From 07f18d9ef5f03e011e49442ffb52d28bf1bc4196 Mon Sep 17 00:00:00 2001 From: Scott Duensing Date: Sat, 26 Sep 2026 21:02:41 -0500 Subject: [PATCH] Docs! Getting ready for more platforms. --- .gitattributes | 1 + .gitignore | 5 +- AGI-SESSION-HANDOFF.md | 217 ----- IIGS_PERF_PLAN.md | 294 ------ LLVM816-ASKS.md | 447 --------- Makefile | 20 +- NATIVE-PERF-PLAN.md | 1164 ----------------------- PERF-AUDIT-PLAN.md | 750 --------------- PERF-AUDIT.md | 1425 ----------------------------- PERF.md | 1208 ------------------------ README.md | 8 +- assets/joeylibLogo.svg | 9 + assets/joeylib_logo_-_320x200.png | 3 + docs/ApiAudio.adoc | 532 +++++++++++ docs/ApiCore.adoc | 268 ++++++ docs/ApiDebug.adoc | 104 +++ docs/ApiDraw.adoc | 226 +++++ docs/ApiFile.adoc | 151 +++ docs/ApiInput.adoc | 257 ++++++ docs/ApiPalette.adoc | 120 +++ docs/ApiSerial.adoc | 212 +++++ docs/ApiSprite.adoc | 406 ++++++++ docs/ApiSurface.adoc | 229 +++++ docs/ApiTile.adoc | 328 +++++++ docs/Appendix.adoc | 197 ++++ docs/Assets.adoc | 336 +++++++ docs/BuildIntegration.adoc | 190 ++++ docs/GettingStarted.adoc | 204 +++++ docs/Manual.adoc | 61 ++ docs/Overview.adoc | 118 +++ docs/Portability.adoc | 278 ++++++ docs/ProgramLayout.adoc | 359 ++++++++ examples/uber/uber.c | 8 +- include/joey/input.h | 2 +- include/joey/palette.h | 2 +- include/joey/platform.h | 2 +- include/joey/present.h | 2 +- include/joey/sprite.h | 2 +- include/joey/surface.h | 2 +- make/x68000.mk | 2 +- scripts/bench-dos.sh | 2 +- scripts/bench-iigs.sh | 2 +- scripts/build-docs.sh | 57 ++ scripts/check-millis.sh | 2 +- scripts/pulseRecordLib.sh | 53 ++ scripts/verify-amiga-audio.sh | 12 +- scripts/verify-audio-all.sh | 110 +++ scripts/verify-dos-audio.sh | 12 +- src/amiga/hal.c | 2 +- src/codegen/spriteCompile.c | 4 +- src/core/assetLoad.c | 2 +- src/core/debug.c | 2 +- src/core/init.c | 4 +- src/core/sprite.c | 6 +- src/core/spriteInternal.h | 4 +- src/core/surface.c | 2 +- src/dos/hal.c | 2 +- src/iigs/hal.c | 8 +- src/iigs/joeyDraw.s | 4 +- src/x68000/hal.c | 2 +- src/x68000/halPlanar.c | 2 +- src/x68000/x68kPlanar.h | 4 +- tests/goldens/uber/amiga.txt | 15 + tests/goldens/uber/atarist.txt | 419 +++++---- tests/goldens/uber/dos.txt | 419 +++++---- tests/goldens/uber/iigs.txt | 15 + tests/host/millisHost.c | 2 +- tools/traceLogo.py | 173 ++++ 68 files changed, 5526 insertions(+), 5964 deletions(-) delete mode 100644 AGI-SESSION-HANDOFF.md delete mode 100644 IIGS_PERF_PLAN.md delete mode 100644 LLVM816-ASKS.md delete mode 100644 NATIVE-PERF-PLAN.md delete mode 100644 PERF-AUDIT-PLAN.md delete mode 100644 PERF-AUDIT.md delete mode 100644 PERF.md create mode 100644 assets/joeylibLogo.svg create mode 100644 assets/joeylib_logo_-_320x200.png create mode 100644 docs/ApiAudio.adoc create mode 100644 docs/ApiCore.adoc create mode 100644 docs/ApiDebug.adoc create mode 100644 docs/ApiDraw.adoc create mode 100644 docs/ApiFile.adoc create mode 100644 docs/ApiInput.adoc create mode 100644 docs/ApiPalette.adoc create mode 100644 docs/ApiSerial.adoc create mode 100644 docs/ApiSprite.adoc create mode 100644 docs/ApiSurface.adoc create mode 100644 docs/ApiTile.adoc create mode 100644 docs/Appendix.adoc create mode 100644 docs/Assets.adoc create mode 100644 docs/BuildIntegration.adoc create mode 100644 docs/GettingStarted.adoc create mode 100644 docs/Manual.adoc create mode 100644 docs/Overview.adoc create mode 100644 docs/Portability.adoc create mode 100644 docs/ProgramLayout.adoc create mode 100755 scripts/build-docs.sh create mode 100644 scripts/pulseRecordLib.sh create mode 100755 scripts/verify-audio-all.sh create mode 100755 tools/traceLogo.py diff --git a/.gitattributes b/.gitattributes index 4f39750..3e2885f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,3 +3,4 @@ *.spc filter=lfs diff=lfs merge=lfs -text *.spr filter=lfs diff=lfs merge=lfs -text *.png filter=lfs diff=lfs merge=lfs -text +*.jpg filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore index 62cf4ed..fdc3286 100644 --- a/.gitignore +++ b/.gitignore @@ -59,9 +59,12 @@ nvram/ # Crap I added stuff/* -docs/* __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) examples/agi/gamedata/ diff --git a/AGI-SESSION-HANDOFF.md b/AGI-SESSION-HANDOFF.md deleted file mode 100644 index 28bb0c6..0000000 --- a/AGI-SESSION-HANDOFF.md +++ /dev/null @@ -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 ` [game] [room]` and forwards -the room to DOS via dosbox `-c "AGI.EXE . "`. 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. diff --git a/IIGS_PERF_PLAN.md b/IIGS_PERF_PLAN.md deleted file mode 100644 index 0388cf9..0000000 --- a/IIGS_PERF_PLAN.md +++ /dev/null @@ -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. diff --git a/LLVM816-ASKS.md b/LLVM816-ASKS.md deleted file mode 100644 index 7c15e12..0000000 --- a/LLVM816-ASKS.md +++ /dev/null @@ -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 # ; 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 ) -> 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`). diff --git a/Makefile b/Makefile index 53a0643..0bc8290 100644 --- a/Makefile +++ b/Makefile @@ -7,6 +7,8 @@ # make amiga builds Commodore Amiga only # make atarist builds Atari ST 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 # # The build references cross tools exclusively from toolchains/, set up @@ -35,7 +37,7 @@ ifeq ($(HAVE_DOS),1) ALL_TARGETS += dos 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 ifeq ($(ALL_TARGETS),) @@ -69,6 +71,20 @@ dos: blank-check: @$(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: rm -rf $(REPO_DIR)/build @@ -83,4 +99,6 @@ help: @echo "Targets: iigs amiga atarist dos all clean status help" @echo " iigs-disk iigs-verify (IIgs MAME visual check)" @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)." diff --git a/NATIVE-PERF-PLAN.md b/NATIVE-PERF-PLAN.md deleted file mode 100644 index d2eefc6..0000000 --- a/NATIVE-PERF-PLAN.md +++ /dev/null @@ -1,1164 +0,0 @@ -# NATIVE-PERF-PLAN: match hand-coded native, per port - -Directive (Scott, 2026-07-06): floor parity against the IIgs is not the -bar anymore -- "still way too slow across the board." The new target is -**each port within ~1.5x of what hand-coded native technique achieves on -that hardware**, proven by cycle-math ceilings and workload benchmark -rows, not vibes. The IIgs-floor rule remains a secondary invariant -(never ship a 68k port slower than the 65816). - -Why the current table lies about games: UBER measures best-case single -ops (aligned sprite x, one tile, one call). Real games pay (a) the -alignment cliff -- only aligned-x sprite draws are compiled; other x -falls to interpreters/per-pixel walkers 3-20x slower -- and (b) a -measured ~35-50% per-call wrapper tax (dispatch + validation + -dirty-mark) around cores that are already at silicon parity, and (c) -have no batch paths, so a tilemap repaint pays that tax per tile. - -Frame-budget reality check (2026-07-06 table): at 25% CPU and 50/60 Hz, -~3 sprites/frame on ST/Amiga/IIgs; a 40x25 tilemap repaint costs -~0.44-0.64s on the 68k ports. Hand-coded games on these machines did -far better. Close that gap. - -Ground rules (inherited from PERF-AUDIT-PLAN, all still binding): -- Source toolchains/env.sh before any make; delete stale binaries after - .s edits; Scott owns git; style rules apply. -- Every phase is hash-gated: existing goldens stay bit-identical - (diff-uber-hashes 45/45 x4) unless a phase EXPLICITLY adds rows, in - which case new goldens are frozen only after 4-port cross-identity. -- Every phase ends with a 4-port bench + PERF.md regeneration. -- Cycle models are hypotheses; the emulator measurement decides (lesson: - the fillCircle asm probe and the 17% ST clock bug). - -## Phase 0 -- Ceilings + honest workload rows [items 1+2 DONE 2026-07-06] - -### Item 1 RESULT -- native-technique ceilings (cycle-math, emulator-class) - -Ops/sec, hand-coded best technique per port. "Meas" = current UBER -(2026-07-06, millis clock). Gap = ceiling / measured. Full derivations -with instruction-level arithmetic are in the Phase-0 workflow reports -(session scratchpad); re-derive on demand -- the arithmetic styles were -validated where models met measurements (ST present model 53/sec vs 56 -measured; IIgs 29/sec present ceiling already in PERF.md). - -| Op (16x16 sprite, 8x8 tile) | ST ceiling | meas | Amiga ceiling | meas | IIgs ceiling | meas | DOS ceiling | meas | -|---|---|---|---|---|---|---|---|---| -| Sprite draw, arbitrary x | ~2,235-2,750 (pre-shifted movem RMW) | 585 aligned / far less unaligned | ~3,600-4,300 (blitter cookie-cut; CPU-compiled ~1,350) | 903 aligned only | 4,300 aligned / 2,490 odd (compiled stores) | 907 | ~11,000 | 6,094 | -| Sprite save or restore | ~5,300 (move.l window) | 703/553 | ~6,300-7,400 (A->D blit) | 567/553 | ~2,520 (MVN) | 543/568 | ~14,500-18,000 | 8,964/5,744 | -| Tile paste 8x8 | ~12,700-15,500 | 1,574 | ~10,700 | 2,253 | ~5,830 | 1,803 | ~57,000 | 9,454 | -| 40-tile row repaint | 352-880 | -- | ~316 | -- | ~190 | -- | ~1,900 | -- | -| Present (native answer) | page flip ~free + dirty bands (full copy ~53/s) | 56 | page flip ~free (8 copper pokes; full blit ~110/s) | 362* | dirty bands (full PEI 20-29/s; real HW ~14-32) | 68* | dirty bands (real ISA full blast 10-31 fps!) | 354* | - -*Present measurement artifacts found: Amiga/DOS measured presents -EXCEED their physical full-copy ceilings because UBER's timed present -loop never re-dirties the stage -- after the first copy it measures the -dirty-union idle early-out, not a copy. IIgs 68 is the known SEI -inflation. The gameFrame workload row (below) fixes this by dirtying -every call. - -Headline: the wrapper tax is universal and dominates -- tile paste runs -3-6x (up to ~10x ST, ~90% overhead DOS) below hand-coded ceilings on -EVERY port with cores already at parity; IIgs C glue measured at -~800-4,000 cycles per call (jlDrawPixel = 820 cycles for a 1-byte op). -Sprite ceilings are 2.5-4x above current aligned numbers (ST via -movem long-RMW + all 16 phases; Amiga via the BLITTER, which also -frees the CPU -- its compiled-CPU path caps at ~1,350). - -**ERRATUM 2026-07-07: every ST "meas" number above (and every ST -number anywhere dated before 2026-07-07) was captured with ~49.5% of -the CPU consumed by the idle 12.3 kHz Timer-A audio ISR that -jlpAudioInit started unconditionally (fixed in src/atarist/audio.c: -demand-driven pump; PERF.md artifact #4). Honest ST values are ~2x the -table above: tile paste 3,203/s (gap to ceiling 4.0-4.8x, not ~10x), -aligned sprite draw 1,188/s (gap 1.9-2.3x), gameFrame 19 fps. The -probe also measured the true ST layer split for tile paste: port op -~1,094 cyc + dirty mark ~804 + wrapper/call ~590 = 2,484 cyc/call, -within ~11% of instruction-count models (the remainder is ST 4-cycle -bus rounding) -- the cycle-model methodology is validated. Amiga -layer split from the same probe: full call ~3,120 cyc, port op alone -~1,719 (chip-RAM contention), steady-state mark ~970. Amiga/DOS/IIgs -audited for the same bug class: clean (~1.8% / ~0.5% / 0% idle).** - -### Item 2 RESULT -- alignment-cliff code truth (supersedes all memory) - -SPRITE_SHIFT_INDEX (spriteInternal.h:24-30): Amiga x&7, ST -(x&7)?2:((x>>3)&1), chunky (IIgs/DOS) x&1. Emitters: - -| Port | DRAW compiled | SAVE/RESTORE compiled | Fallback at NOT_COMPILED | -|---|---|---|---| -| IIgs | shifts 0+1 = EVERY x | shifts 0+1 = every x | (n/a -- no cliff) | -| DOS | shifts 0+1 = EVERY x | shifts 0+1 = every x | (n/a -- no cliff) | -| ST | 0,1 only (x%8==0) | NEVER (stubs) | draw: byte-wide shifted c2p walker (hal.c:1815+); save/restore: asm byte copy when x%8==0 else per-pixel pair walker | -| Amiga | shift 0 ONLY (x%8==0) | NEVER (stubs) | draw: shifted c2p walker (hal.c:1491); save/restore: plane-major copy when x%8==0 else pair walker | - -So the cliff is 68k-ONLY: 7/8 x positions run walkers on ST/Amiga, and -jlSpriteSaveAndDraw's combined fast path NEVER fires on 68k (SAVE never -compiled). IIgs/DOS need nothing in Phase 1. - -Phase-1 design constraints discovered: (a) ST's shift-2 bucket cannot -hold compiled code -- ST needs SPRITE_SHIFT_INDEX changed to x&7 (a -dispatcher-contract change) plus emitter support; (b) routineOffsets -are uint16_t so one sprite's compiled bytes must stay < 64KB -- 8 DRAW -variants of a mixed 32x32 (~8-16KB each) blow it; needs selective -emission or offset-scheme change (16x16 sprites fit fine); (c) the -jlSpriteRestoreUnder gate accepts only copyBytes == bytesPerRow or +1 -(spriteCompile.c:547-552) -- planar shifted saves record +4, so the -gate must widen or 7/8 alignments stay interpreted even after emitters -land; (d) stale doc: sprite.h:32-40 contradicts spriteInternal.h:16-23 -(fix during Phase 1); (e) uber.c:972 comment claims Amiga has 8 -variants -- aspirational, false today. - -### Item 3 -- workload rows [IN PROGRESS] - -Four rows appended AFTER the surfaceMarkDirtyRect timeOp (cumulative- -hash safety: correctness checks clear the stage first, so the frozen 45 -stay bit-identical; Phase-7 precedent): -- "jlSpriteDraw unaligned" -- x=71,y=120 (odd AND x%8==7: worst case on - all four ports simultaneously). -- "jlSpriteDraw sweep16" -- all 16 x phases (208..223) inside ONE call - so the op is idempotent regardless of per-port iteration counts. -- "jlTilePaste map 10x5" -- 50 pastes of two SNAPPED patterned tiles - (tile pixels are platform-private; only snap round-trips portably). -- "gameFrame composite" -- 256x96 repaint + 16 HUD tiles + 3 sprite - saveAndDraw + 3 restores + present, net-idempotent per call; all - sprite x mod-16-aligned so this row isolates wrapper tax + present - (rows above own the alignment signal). Also fixes the present-row - artifact (dirties every call). -Golden contract goes 45 -> 49. Refreeze procedure: regression gate -first (each port: exactly 45 match + 4 extra vs OLD golden), then -cross-identity (49/49 IIgs vs each), only then freeze + regenerate -PERF.md. -Gate: ceilings documented (DONE above); new rows golden'd x4; existing -45 untouched. - -## Phase 1 -- Kill the alignment cliff - -All-shift compiled sprites everywhere. ST: emit shifts covering all -x%16 phases in spriteEmitInterleaved68k (pre-shifted plane bytes + -masks spanning byte halves/groups; the Amiga 8-variant planar emitter -is the proven template). IIgs/DOS: odd-x nibble-shifted DRAW variants -(chunky). Save/restore already widen via shift where applicable -- -verify per port. Arena sizing: 8x DRAW variants (Amiga precedent: -8-32KB arena; re-check DEFAULT_CODEGEN_BYTES per port). -Gate: unaligned-sprite UBER rows reach ~parity with aligned rows on -every port; goldens hold. - -## Phase 2 -- Strip the per-op tax - -Plane-pointer caching (kill per-draw jlpSurfacePlanePtr x4), flattened -hot-op dispatch, inline dirty marks for the remaining hot ops (extend -the 2026-07-06 surfaceMarkDirtyRows inline that bought 2-5%), remove -redundant validation layers (core wrapper already proves s/bounds). -Gate: small-op rows (tileFill/Paste/Copy, sprite ops, fillRect small) -move measurably toward Phase-0 ceilings on all ports; goldens hold. - -## Phase 3 -- Batch APIs - -jlTileMapPaste (N tiles, one validation, one dirty union -- the -jlDrawText pattern generalized) + sprite batch draw/save/restore. -API shape needs Scott's sign-off before landing in public headers. -Gate: tilemap workload row approaches ceiling (per-tile overhead ~0); -goldens hold (new API rows get their own goldens). - -## Phase 4 -- Port-native heavy weapons - -Amiga: blitter cookie-cut sprite draw for larger-than-16x16 objects; -blitter tile/tilemap paste evaluation. ST: evaluate movem-based -row engines + restore-from-clean-buffer strategy for backgrounds. -IIgs: PEI-slam variants for batch paths where applicable. -Gate: per-op measurements vs ceilings; keep only what beats the -compiled-CPU path on the emulator (fillCircle lesson: no dead asm). - -## Phase 5 -- Acceptance - -Every workload row within ~1.5x of its Phase-0 ceiling per port, or a -documented cycle-proof that the remaining gap is structural (e.g. ST -interleaved-planar RMW). PERF.md gets a ceilings column. Update the -perf-directive memory with the outcome. - -## Progress tracker - -- [x] Phase 0 COMPLETE 2026-07-06. Ceilings + code-truth in this file - (above); four workload rows landed in uber.c and golden'd: regression - gate exactly 45 match + 4 extras on every port, cross-identity 49/49 - x4 (bonus: sweep16 validated the ST/Amiga shifted c2p walkers pixel- - perfect at all 14 untested phases). Golden contract is now 49 lines. - BASELINE the later phases must move (ops/sec, millis clock): - jlSpriteDraw unaligned: ST 40 (5% of floor!) | Amiga 75 (9%) | - IIgs 842 | DOS 5115 -- vs aligned 607/903/902/6218 = the - 15x/12x 68k cliff, Phase 1's gate. - jlSpriteDraw sweep16 (x16 = draws/sec): ST 32 | Amiga 80 | - IIgs 896 | DOS 5648 -- real arbitrary-x sprite rate. - jlTilePaste map 10x5 (x50 = tiles/sec): ST 1600 | Amiga 2300 | - IIgs 1750 | DOS 10000 -- Phase 2/3's gate (ceilings say - 10-15k on 68k, 57k DOS). - gameFrame composite: ST 9 | Amiga 18 | IIgs 10 | DOS 18 fps -- - the bottom-line number; the program ends when this is at/near - the port's refresh-limited ceiling or a documented structural - bound. -- [~] Phase 1 DRAW SIDE COMPLETE 2026-07-06 (steps 1-4 of the - adversary-reconciled order; save/restore = step 5 still open): - step 1 fused per-port shift ladder (ST 16/x&15, Amiga 8, chunky 2; - IIgs cache fields #ifdef'd) -- gated 49/49 x4 bit-identical; - step 2 plan-based jlSpriteCompile (measure-all, uint16-cap greedy, - draws tier 1 / save+restore atomic pairs tier 2, arena-full degrade - to SPRITE_DEGRADE_DRAW_MASK, 68k-only even-size guard); step 3 Amiga - all-shift JIT (accumulator merge, shift-0 byte-identical, host- - verified); step 4 ST 16-phase draw (spriteWalk.s masked-movem walker - + per-phase 22B thunk + 12B/group/row pre-shifted tables). GATE: - 49/49 x4; unaligned ST 40->500 (12.5x), Amiga 75->803 (10.7x); - sweep16 ST 2->32, Amiga 5->50; unaligned now ~82-84% of aligned on - both. BUGS FOUND BY THE GATES: even-size guard demoted odd-sized - IIgs/DOS routines to the interpreter (fixed: 68k-only); Amiga bench - task stack was the shell 4KB default -- the deeper compile path - overflowed it nondeterministically (fixed: stack 32768 in - bench-amiga.sh + emitter accumulators to file statics). - Steps 5+6 COMPLETE 2026-07-06 -- **PHASE 1 DONE**, final gate 49/49 - x4. Step 5: group-window save/restore emitters both ports (2 width - classes/sprite, IIgs-MVN-style; ST raw-span move.l chains, Amiga - plane-run move.l/move.w), format-law gates (SPRITE_SAVE_CLASS / - SPRITE_RESTORE_CLASS / SPRITE_BACKUP_PTR_OK in spriteInternal.h; - restore derives class from RECORDED geometry, (bx&15)==0 term is - correctness-critical), ST HAL raw-span interpreted branch (save + - restore switched atomically incl. the 16x16 asm reroute), planar - metadata-probe widening, JOEY_SPRITE_BACKUP_BYTES capacity macro + - all 4 example owners migrated and 2-aligned. SaveAndDraw's combined - path fires on 68k at every x for the first time. Step 6: - DEFAULT_CODEGEN_BYTES 64KB Amiga/ST; doc sweep done. RESULTS - (floor-relative): Amiga Save 276% / Restore 236% / SaveAndDraw 160%, - ST 203% / 163% / 107%; Amiga gameFrame 18 -> 23 fps. Host-verified - emitted streams (objdump); every intermediate gate 49/49 x4. -- [x] INTERLUDE 2026-07-07 (pre-Phase-2, found by the Phase-2 V1 - probe): ST idle-audio ISR tax discovered and fixed -- see the - Phase-0 ERRATUM above and PERF.md artifact #4. All four ports - re-gated 49/49 after a power-failure clean-rebuild recovery AND - after the audio fix (pixels untouched). The Phase-2+ gate baselines - are now (millis clock, post-fix): jlSpriteDraw unaligned ST 992 / - Amiga 803 / IIgs 845 / DOS 5118; sweep16 ST 64 / Amiga 50; jlTilePaste - single ST 3203 / Amiga 2253 / IIgs 1741 / DOS 9806; map 10x5 (x50 = - tiles/s) ST 3100 / Amiga 2300 / IIgs 1750 / DOS 10000; gameFrame - ST 19 / Amiga 23 / IIgs 10 / DOS 18 fps. Floor parity: the ST has - ZERO below-floor cells for the first time; remaining below-floor is - Amiga-only (circle family + unaligned 95% / sweep16 89%) + DOS - audioFrameTick (gates nothing). -- [x] Phase 2 COMPLETE 2026-07-07 (final gate 49/49 x4 + iigs-verify; - detail below). Steps D3 closed it: R6 TMASK_BYTE macro inline - (copyMasked +9%, loop-back needed brl past 8-bit branch range) and - R10 iigsDrawPixelMark fused plot+mark (jlDrawPixel 3,179 -> 5,576, - +75%; batching plotters keep the unfused inner). EXIT NUMBERS: - tilemap tiles/s ST 4,050 / Amiga 3,750 / IIgs 2,950 / DOS 12,900 - (from 1,600 / 2,300 / 1,750 / 10,000); gameFrame ST 21 / Amiga 25 / - IIgs 10 / DOS 19; IIgs tilePaste gap-to-ceiling 2.15x. Remaining - per-call floor = public wrapper + one JSL/call -> Phase 3's job. - Optional non-gating leftovers: R7 saveAndDraw consolidation, R12 - IIgs C-residue reshapes. Recon: 6-agent workflow + - merged synthesis (12 ranked items R1-R12) + calibrated-probe layer - split (see ERRATUM above). Step A LANDED (gate 49/49 x4): - plane-pointer inlining via new src/amiga/amigaPlanar.h + - src/atarist/stPlanar.h internal headers (jlpSurfacePlanePtr stanza - deleted -- its only callers were spriteCompile.c's 68k dispatchers), - dead-validation cleanup in both 68k HALs. Amiga sprite ops +10-23% - (SaveUnder 1503->1853), ST +3.5-5.2%; Amiga unaligned draw now ABOVE - floor (903 vs IIgs 845). Step B LANDED (gate 49/49 x4): whole-tile - dirty mark specialized in tile.c (band = [bx*2, bx*2+1] with - compile-time proof, 8-row widen unrolled ~480 cyc vs 804 measured; - IIgs keeps its asm marker). Tile rows: ST +9-15%, Amiga +13-18%, - DOS +24-29%, map row ST 72 / Amiga 53 / DOS 258; tileSnap (no mark) - flat everywhere = clean control. Step C LANDED (gate 49/49 x4): - jlDrawRect raw-edge fills + ONE mark (band-interval proof in - draw.c) -- ST +25% / Amiga +40% / DOS +22% on the drawRect row; the - x2 widen unroll in surfaceMarkDirtyRows is PER-PORT by measurement: - it lifted Amiga sprite rows +4-6% but cost the ST -5% in BOTH the - pointer-walking and counted forms (gcc-mint materializes 4 live - pointers either way; simple loop re-measured exactly at ST - baseline; DOS flat) -- so Amiga unrolls, everyone else keeps the - simple loop (documented at the #if in surface.c). - Step E1 LANDED (gate 49/49 x4): R4a = ST/Amiga tile ops moved to - always-inline per-port headers (stTile.h / amigaTile.h; jlpTile* - macro-aliased in port.h before the dispatch tail, headers included - at its END so the portData==NULL fallbacks see the jlpGenericTile* - prototypes; ST_BYTES_PER_ROW/GROUP moved to stPlanar.h; - surface68kStTileFill8x8 extern moved to stTile.h) -- ST tiles - +9-14%, Amiga +5-11%, DOS flat (already single-layer). R9 = - ST/Amiga/DOS presents consume gStageScbDirty/gStagePaletteDirty - instead of memcmp'ing 512-712-byte cached mirrors (mirrors + Amiga - memcmpLongs deleted; stageAlloc re-arms both flags for - jlShutdown/jlInit cycles) -- gameFrame ST 19->21 / Amiga 23->25 / - DOS 18->19 fps. Step E2 LANDED (gate 49/49 x4): R8 Amiga 8-row - d16(An) unrolls in amigaTile.h (fill/copy/paste/snap; - copyMasked/pasteMono stay rolled) -- tilePaste +36%, tileSnap +52%, - tileFill +32%, tileCopy +28%, map row 56->75. SESSION NET (from - honest post-audio-fix baselines): map row tiles/s ST 3,100->4,000 / - Amiga 2,300->3,750 / DOS 10,000->12,900; gameFrame ST 19->21 / - Amiga 23->25 / DOS 18->19 (and vs the pre-audio-fix world: ST - gameFrame 9->21). PERF.md table regenerated from the E2-gate logs. - Step D1 LANDED (IIgs gate 49/49 + make iigs-verify framebuffer - check; other ports covered by the D1-gate C-side pass): R2 fused - stage-tile entries iigsTileFillMark/PasteMark/CopyMark in - joeyDraw.s (.text.iigsFusedTile -- ONE section for entries + the - stageTileMark helper; a cross-section 16-bit jsr wild-jumped into - $00CFxx twice before the section-atomicity rule was learned, see - the cross-section-jsr feedback memory). One JSL now does - derive($01:2000 + gRowOffsetLut[by*8] + bx*4) + op via long,X + - band widen. IIgs: tileFill +64% (3,251), tilePaste +61% (2,710), - tileCopy +38%, map row 35->59 (+69%), gameFrame 10->11 fps; - tilePaste gap to ceiling 3.5x -> 2.15x. port.h stage-gates the - three macros; tile.c's tileMarkDirtyFused knob keeps - copyMasked/pasteMono on the separate marker. Step D2 LANDED (gate - 49/49 x4): R5 spriteCompiled* dispatchers moved to - src/core/spriteDispatch.h as always-inline, consumed only by - sprite.c (extern decls removed from spriteInternal.h; compile-time - machinery stays in spriteCompile.c). Sprite rows: IIgs +6-13% - (SaveAndDraw 423), ST SaveUnder +15%, Amiga +5-11%, DOS +2-16%. - Measurement notes recorded in PERF.md: gameFrame quantizes +/-1 - fps run-to-run; DOS unaligned-draw row is bimodal (~3,800/~5,100) - across identical binaries -- don't chase it. REMAINING: R6 - copyMasked tmaskByte de-layering (32 jsr/call = 384 cyc + BSS - scratch, 13-20% of the op; + optional CopyMaskedMark fusion), R10 - fused drawPixel+mark asm entry (~820 -> ~300 cyc), optional R7 - saveAndDraw consolidation, optional R12 IIgs C-residue reshapes. -- [x] Phase 3 LANDED 2026-07-07 (goldens refrozen 49 -> 50 per the - Phase-0 procedure; final gate 50/50 x4). jlTileMapPaste in - include/joey/tile.h + tile.c: tileset + row-major uint8_t index map, - TILE_MAP_SKIP=0xFF, one validation + one dirty union (68k/DOS); - IIgs loop specialized (hoisted stage test, base + (idx<<5) tileset - indexing with a sizeof proof -- tiles[idx] cost a 32-bit multiply - per cell on the 65816 and initially made the batch SLOWER than the - loop). New golden'd row `jlTileMapPaste 10x5` (cross-identity - 52B9646B x4). RESULTS tiles/s batch vs loop: ST 8,150 (+106%, - gap 1.6-1.9x) / Amiga 7,650 (+104%, gap 1.40x = INSIDE the 1.5x - target) / DOS 22,500 (+74%) / IIgs 3,000 (+2%; per-tile cost is - ~93% inner JSL post-Phase-2 -- whole-map asm walker is Phase 4's - lever there). SPRITE BATCHES DESCOPED: gate measures only the - tilemap row and post-R5 sprite dispatch overhead is ~3-6%; re-add - only against a measured need + Scott's API sign-off. API shape - SIGNED OFF by Scott 2026-07-07 (tileset + row-major uint8_t map, - TILE_MAP_SKIP=0xFF, caller-owned index validity) -- the public - header contract in include/joey/tile.h is final. -- [x] Phase 4 COMPLETE 2026-07-07 (all four arms resolved; ST landed - below). IIgs WHOLE-MAP WALKER LANDED - (gate 50/50 + iigs-verify): iigsTileMapPasteMark in joeyDraw.s -- - one JSL walks the index map with an incrementally-advanced dst - offset (ONE gRowOffsetLut read; +4/column, +1280/tile-row), a - 22-byte stacked D-frame for the register-starved inner (map ptr, - tile-src ptr, bases, counters -- X survives the 16-store body and - carries the dst offset; Y is clobbered so the map index lives in - the frame), bank-carry-safe idx<<5 tileset add, brl on all three - wrapping branches, ONE band union tail. RESULT: IIgs jlTileMapPaste - 10x5 60 -> 140 maps/s = 7,000 tiles/s (+133%), ~400 cyc/tile -- - BELOW the 480-cyc single-tile hand-coded ceiling (the walker - amortizes what per-tile calls still pay). Batch row across ports: - ST 8,150 / Amiga 7,650 / IIgs 7,000 / DOS 22,500 tiles/s. - AMIGA BLITTER COOKIE-CUT SPRITE: recon complete, DEFERRED with the - design recorded -- the source side is net-new (sprite data is - fast-RAM chunky + CPU immediates; a chip-RAM pre-shift subsystem - is required), the synchronous per-call WaitBlit contract forfeits - the parallelism win below ~32x32 (all current UBER sprite rows are - 16x16 = at/below the crossover), and the honest experiment needs a - new >=32x32 golden'd row measured with AND without the fence. Full - register program (BLTCON0 0x0F00|shift|0xCA cookie-cut, per-plane B, - shared A mask) + risk list in the recon (session 2026-07-07). - ST ARM LANDED 2026-07-07 (4-agent recon + adversary, then C-first - per the binding feedback): the "movem row engine" hypothesis is - DEAD -- the tile dst is a byte scatter movem cannot address, and - byte-pair word fusion loses on a barrel-shifterless 68000 (lsl.w #8 - = 22 cyc > the move.b it replaces). The C incremental pair walker - (stTileMapPaste, stTile.h) measured +17% (adversary predicted the - +15-23% cap: gcc already hoists the row mulu; the 632-cyc copy body - dominates) -- that measured C ceiling justified asm, and the real - weapon is MOVEP: stTileMapPasteMovep (src/atarist/tileMap.s) does - move.l (a0)+ / movep.l d0,d16(a1) = one tile row's 4 plane bytes in - 36 cyc. ST batch row 163 -> 346 ops/s = 17,300 tiles/s (2.12x, 462 - cyc/tile), ABOVE the Phase-0 12,700-15,500 ceiling (it assumed - byte-move bodies). C walker stays as the odd-tileset-pointer - fallback (move.l needs an even src; jlTileT is align-1). Gate: ST - 50/50 (52B9646B held) + 12-case on-emulator TILETEST proof of the - head/tail/w1/h1/skip/odd-ptr paths the golden never exercises, each - with border-ring containment. RESTORE-FROM-CLEAN-BUFFER: EXPERIMENT - RUN AND PASSED 2026-07-07 (Scott approved same day; sign-off also - ratified the jlTileMapPaste shape). jlSurfaceCopyRect is PUBLIC API - (include/joey/surface.h): same-position rect copy, clip + outward - 16-px group snap UNIFORM on every port (uniform snap is what keeps - cross-port hashes identical), pixels only, dirty-marked via - surfaceMarkDirtyRect; generic chunky row-memcpy engine (DOS + both - IIgs flavors -- the IIgs stage IS plain-C writable through its - bank-$01 pixels pointer, adversary-verified), ST = strided - interleaved row memcpys, Amiga = per-plane CPU row copies (blitter - modulo variant recorded as its upgrade; CopyMemQuick unusable at - 16-px grain). Documented caller hazards: snapped erase can touch - content up to 15 px outside the rect and invalidates overlapping - live sprite backups -- erase-all-then-draw-all, one mechanism per - region. STRATEGY row `gameFrameClean composite` golden'd (contract - 50 -> 51, cross-identity C51FE171 x4): vs gameFrame ST 48/22 = - 2.18x (bar was 1.4x) / DOS 195/19 = 10.3x / IIgs 18/10 = 1.8x / - Amiga 34/25 = 1.36x (least, as the adversary predicted: blitter - fillRect meant less repaint to delete + chip-chip copy vs fast-RAM - backup restore). A library-transparent variant stays REJECTED (the - ST present source is the composited shadow, sprites corrupt it - pre-present; a clean source means a third 32KB buffer + routing - API by another name). IIGS - PEI-SLAM: evaluated and REJECTED -- models ~+28% on the map walker - but holds SEI for the whole map (~5.6 ms at 10x5; audio ISR jitter) - and the VBL-driven bench clock cannot honestly measure SEI ops - (SEI-inflation artifact); the landed walker already beats the - 480-cyc per-tile ceiling. Batch row after Phase 4: DOS 22,500 / ST - 17,300 / Amiga 7,650 / IIgs 7,000 tiles/s. -- [ ] Phase 5. REVISED SCOPE per the 2026-07-07 four-agent levers - recon (gap audit + composite forensics + IIgs sprite autopsy + - Amiga/audio; full reports in the session workflow transcripts): - (a) the `jlStagePresent full` row is an ARTIFACT on all four ports - (idle early-out; IIgs also SEI-inflated) -- there is NO honest - standalone present number; Phase 5 must add probe rows P1-P4 - (present96 / present3win / idle present / frameNoPresent + copied- - bytes counters, IIgs cross-checked against the MAME Lua clock) - BEFORE gating composites. Forensics already attribute the composite - gap to dirty-present costs: DOS gameFrame is AT ceiling - (present-bound, ~1.7us/VGA-byte honest); Amiga present collapses - per-row bands to ONE bounding box (hal.c:761-801) and over-copies - ~5x on scattered sprites (per-row bands like the ST = the fix); ST - pays ~7-9ms FIXED per dirty present in two 200-row C scans (+ - possible byte-looped row memcpy -- probe decides); IIgs promotes - any >=32-word band to a full 160-byte row slam with 6 slow softswitch - toggles per row (~27-32ms for the gameFrame band) and its SEI - freezes the bench clock, so IIgs gameFrame reads HIGH -- true ~7.5- - 8.5 fps, and gameFrameClean carries ~30-40ms/frame unattributed - (llvm-mos far-memcpy byte loops suspected). (b) Ceilings needing - re-derivation before gating: ST tile (movep-honest ~350 cyc/tile), - Amiga tile pair (two Phase-0 numbers disagree 18%), IIgs sprite - (paper 4,300/s assumed no derive/mark/wrapper; honest full-op - ceiling ~2,500-2,650/s with per-row marks -- autopsy modeled the - measured 2,900 cyc within 2%: body 23%, MARK PATH 30%, derive+call - 17%, validation 22%). (c) Decide whether drawLine rows gate - acceptance (~3-4x everywhere, wrapper-dominated, small absolute). -- [~] Phase 6 APPROVED by Scott 2026-07-07 -- "everything except the - audio" (candidate #5 audio-active tax is PARKED pending discussion - with Scott; do NOT start audio work). Execution waves: - W0 probes (P1-P4 + counters + IIgs Lua cross-clock; baselines). - W0 ST BASELINE (2026-07-07, probe build = -DUBER_PROBE scratch UBER; - counters landed in surfaceInternal.h/surface.c + the 3 C presents): - P1 present96 = 37 ops/s = 27.0 ms(!) for the 96-row dirty present -- - copied exactly 12,288 B (no over-copy), scanned 496 rows (200 union - + 96 copy + 200 snapshot); P2 present3win = 80 ops/s = 12.5 ms for - 3 tiny windows => ST dirty-present FIXED cost ~12.5 ms (suspects: - per-present SCB flatten + the two 200-row scans); copy rate ~9.5 - cyc/B (~4x slower than move.l memcpy should be -- byte-loop suspicion - CONFIRMED, W2 target); P4 frameNoPresent = 54 ops/s = 18.5 ms == - component sum 18.15 (components HONEST, no interleaving mystery). - Ledger: P4 + P1 = 45.5 ms vs gameFrame measured 45.0 -- CLOSED. - W0 AMIGA BASELINE: P1 = 50 ops/s = 20.0 ms (copied 12,288 B = solid - band, no over-copy possible); P2 = 71 ops/s = 14.1 ms with copied - 5,309 B vs ~1,536 truly dirty = the bounding-box union 3.5x - OVER-COPY CONVICTED by its own counter (48-row x 26-B enclosing - rect); fixed dirty-present cost ~12 ms (same class as the ST's); - P4 = 18.9 ms == component sum 18.92 -- honest. Ledger P4 + P1 = - 38.9 vs measured 40.0 -- CLOSED. Both 68k ports: the ~12 ms FIXED - per-dirty-present cost is W2's biggest single target. - W0 DOS BASELINE: P1 = 23 ops/s = 43.5 ms (copied 24,576 VGA bytes = - exactly the 96x256 expand -- hardware-rate LUT write, matches the - forensic 40-44 ms model); P2 = 305 ops/s = 3.3 ms, copied 768 B = - zero over-copy (per-row bands); P4 = 9.2 ms ~= component sum. - Ledger P4 + P1 = 52.7 vs measured 52.6 -- CLOSED PERFECTLY. DOS is - present-bound at hardware rate: NO W2 work on DOS. (Probe-run - gotchas recorded: djgpp probe EXEs need $DOS_EMBED_DPMI or they - hang; a case-colliding UBER.exe/UBER.EXE pair breaks the DOSBox - mount.) - W0 IIGS BASELINE + CLOCK VERDICT (heartbeat-window Lua cross-clock, - probe-iigs.sh in the session scratchpad): the GetTick bench clock is - HONEST post-SEI-chunking -- tick-loss ~1.00 on 40 of 42 rows - INCLUDING the dirty-present slam (the 40-row SEI chunks + CLI - windows let the latched VBL IRQ catch up); only jlSurfaceClear - still loses (1.21x, mildly inflated row). The old SEI-inflation - reading of `jlStagePresent full` 67/s was actually the same - IDLE-present early-out artifact as the other ports. HONEST dirty - present: P1 present96 = 17 ops/s = ~59 ms(!) for a 96-row band -- - worse than the 34 ms full-screen PEI floor, confirming the - >=32-word full-row promotion + 6-softswitch-per-row waste; P2 - present3win = 48/s = ~21 ms fixed; P4 frameNoPresent = 26/s = - 38.5 ms. Ledger P4 + P1 = 97 ms vs gameFrame measured ~95 -- - CLOSED. IIgs W2 target = the slam fixed cost + row promotion; W1 - fusions own the P4 side. TOOLCHAIN FLAG for Scott: a -DUBER_PROBE - uber.c whose probe block reads extern uint32_t globals through a - fn-pointer wrapper (probeOp) dies before main on llvm-mos -- - bisected (trivial-op probe launches, wrapper variants do not, - optnone does not help, segment count 5 vs 6 irrelevant); worked - around by compiling the wrapper out on IIgs (uber.c PROBE_ATTR - block). W0 COMPLETE: baselines x4, ledgers closed x4, goldens - re-gated 51/51 x4 after the gameFrameBody refactor. - [Power failure after W0; recovery gate re-passed 2026-07-07: clean - rebuild + iigs-disk, 51/51 x4 (Amiga solo TIMEOUT=600), headline - rows match W0 baselines exactly (IIgs draw 966 / gameFrame 10, ST - 21, DOS 19, Amiga 25 ops/s). Only loss: scratchpad probe-iigs.sh - (recreatable). W1 design in progress.] - W1 ITEM "sprite-mark specialization" LANDED 2026-07-07 (design + - 4-agent audit; sprite.c only): spriteMarkDirty Step-B unrolled - 16/8-row widen + spriteMarkDirtyDraw/Restore fusion-seam spellings. - Gates: IIgs OBJECT-CODE BYTE-IDENTICAL (llvm-mos -O2 old-vs-new - sprite.o cmp -- provably inert there); new P5 spriteBandSum probe - row (djb2 fold of both band arrays after compiled/interpreted/ - clipped/unaligned marks) IDENTICAL pre/post AND cross-port - (1345495317 on ST/Amiga/DOS); P1/P2/gameFrame copied+scanned - counters byte-identical; 51/51 hashes x3 (IIgs exempt by object - proof; DOS needed a solo re-run -- parallel-load TIMEOUT truncation - again). Bench: save control EXACTLY flat on all 3 (2611/2103/10743); - restore ST 1781->1900 (+6.7%) Amiga 1453->1603 (+10.3%) DOS - 6218->7825 (+25.8%); draw ST 1283->1317 Amiga 1103->1153 DOS - 6404->8147 (+27%); saveAndDraw ST 934 Amiga 803 DOS 5480. - New probe rows also added: P6 fillBandSum (fused fillRect battery, - all 4 ports -- cross-port identity gates item 2's asm mark) and a - probeOp-wrapped gameFrame counters row. - W1 ITEM "IIgs fused fillRect" LANDED 2026-07-07 (audit fixes C5 - adc frm_w, C6 fillCircle spans -> NoMark twin, C7 .a16/.i16): - iigsFillRectInner converted in place to iigsFillRectMark/NoMark in - .text.iigsFusedRect with shared stageRectMark helper (packed-band - ABI: A=(max<<8)|min, X=yStart, Y=rows -- item 3's sprite entries - jsr it too), row-invariant slam-dispatch hoists (~64 -> ~30 - cyc/row), fused band widen in friExit; port.h jlpFillRect(Mark) + - jlpFillRectNoMark + JL_FILL_RECT_MARK_FUSED seam; draw.c compiles - the C mark out on IIgs, jlDrawRect edges + fillCircle spans take - the NoMark twin. Gates: draw.o BYTE-IDENTICAL on ST/Amiga/DOS - (item is a proven no-op there; item-1 hash gates stand); P6 - fillBandSum 2155901235 IDENTICAL IIgs-fused-asm vs all 3 C-mark - ports (odd-x/odd-w edges, x=319 column, midBytes==1, NoMark - batching all covered); IIgs 51/51; P1/P2 unchanged, P4 26 -> 28 - ops/s (38.5 -> ~35.7 ms); iigs-verify PASS. IIgs bench: fillRect - 16x16 602 -> 785 (+30%, floor was 740), 80x80 110 -> 133, markRect - row 323 -> 399, drawRect 100x100 73 -> 110 (+51%, way past the +1% - model -- extra win unmodeled per-call ABI+hoists on the 1px edges), - drawLine H 1142 -> 1322, fillCircle 42 FLAT (C6 NoMark proof), - gameFrame 10 -> 11. No row regressed. - W1 ITEM "IIgs fused sprite entries" LANDED 2026-07-07; the R12 - fnAddr cache was BUILT, MEASURED, AND REMOVED (the emulator - decides). Landed shape: iigsSpriteDrawMark / iigsSpriteRestoreMark - in .text.iigsFusedRect (push-target-1+rtl long-call into the arena - routine, then jsr stageRectMark; args re-read from the dp-parked - JSL frame post-call -- callee preserves D/DBR per the emitter - contract); C glue spriteCompiledDrawMark / spriteCompiledRestoreMark - derive fnAddr the old 3-load way; jlSpriteSaveAndDraw pairs the - unchanged compiled save with the fused draw (the pair's ONE mark - moved into asm); save path byte-reverted to baseline. - CACHE AUTOPSY (2 variants benched): v1 epoch-checked per-(shift,op) - cache inlined at the dispatch arms -- save (cache-only, no fusion) - REGRESSED 602 -> 543 and saveAndDraw 423 -> 399; v2 moved the arms - into small noinline helpers (setupPalette precedent) -- WORSE still - (draw 1142 -> 1026: llvm-mos arg re-marshalling through a JSL - boundary costs more than the spills it saves). Conclusion recorded - in spriteDispatch.h: on llvm-mos the epoch hit path costs as much - as the 3-far-load derive it replaces; do NOT resurrect without a - compiler-level win (LLVM816-ASKS candidate: cheaper far loads / - better 32-bit value handling). Arena epoch counter removed with it. - v3 (landed) IIgs numbers: draw 966 -> 1142 (+18%), unaligned - 907-ish -> 1022, restore 545 -> 662 (+21.5%), save 602 FLAT - (baseline restored exactly), saveAndDraw 423 FLAT (llvm-mos frame - effects suspected of eating the pair's fused-mark saving -- one - follow-up candidate), gameFrame 10 -> 11, gameFrameClean 18 flat. - Plan's draw 1,700-2,200 / restore 1,600+ targets assumed the cache - and validation trims: measurement says fusion alone buys +18-22% - and the remaining fat is the C validation/wrapper chain inside the - register-starved public wrappers -- next lever is moving validation - into the fused asm (contract change, W2+ candidate), not caching. - Gates: 51/51 x4; P5 spriteBandSum 1345495317 and P7 compactEpoch - hash CA9CDB56 / bandSum 3922093832 IDENTICAL IIgs vs ST/DOS - (Amiga P7 SKIPPED -- its 8-variant arena cannot fit the two probe - sprites; pre-existing sizing, ST+DOS carry the reference); P6 - unchanged; new P7 probe row added (victim/survivor compaction - content+band test, all ports). -- [x] W2 COMPLETE 2026-07-08 (all five items landed + gated x4 - ports; final IIgs golden: 51/51, iigs-verify PASS, gameFrame 11 -> - 12, gameFrameClean 18 -> 20, jlStagePresent full 67 -> 73 as - flagged). Wave scoreboard vs W0: ST P1/P2 +97%/+206%, gameFrame - +52%, gameFrameClean +88%; Amiga P2 +27% (over-copy 4992 -> 384), - gameFrameClean +9%; IIgs P1 +18%, P2 +8%, idle base -1.3 ms; DOS - untouched by design. Details per item below. - (2026-07-08, 3-designer + audit workflow; audit caught a - display-corrupting IIgs Stage-A mis-park [C-1], an ST run-close bug - [B-1], a probe-link #ifdef hole [B-2], and the first-iteration - union carry-in that would fail exact counter gates on CORRECT code - [A-1] -- steady-delta protocol added). Implementation order: item0 - probes -> Amiga bands -> ST alignment -> ST slam asm -> IIgs Stage - A -> Stage B. - ITEM 0 LANDED (probe scaffolding + IIgs Stage-0 counters): P8 - presentIdle row (2 warm-ups; isolates the per-present FIXED cost), - P9 IIgs shrParity (full display-vs-stage byte parity incl. both - predicate-flip arms), P10 ST displayMismatch, P11 Amiga - presentPageSum (copper-poke-derived front planes vs shadow, incl. - settle + true-idle arms), steady-delta second pass in probeOp + the - IIgs direct-log block, counters now incremented by the IIgs present - asm (slam +160/row, MVN +count+1, scanned +200/present; stale - .a8/.i8 at peiAllDone fixed). ALL display gates PASS on the - UNCHANGED presents (probes trusted): ST 0/0/0, Amiga fails=0, IIgs - badRows=0. Steady baselines: ST P1 12288/496 P2 384/448; Amiga P1 - 12288/400 P2 4992/400 (the convicted bounding-box over-copy, now - measured); DOS P1 24576/200 P2 768/200; IIgs P1 15360/200 P2 - 384/200 P8 copied=0 + 67 ops/s (~15 ms fixed base DIRECTLY measured - -- confirms the FITTED write model: ~444 us/promoted row, so the - honest IIgs Stage A/B target is P1 ~18.5-20 ops/s, NOT the paper - 32; ~85% of a 96-row band is irreducible shadowed-write cost). - ITEM "Amiga per-row bands" LANDED: merged 200-row scan with - exact-span run coalescing feeding amigaPresentFlushRun (THIN/FAT - threshold kept -- P1's single run is geometry-identical to the old - bbox), counters honest per flush. Gates: P2 steady 4992 -> 384 - EXACT + scanned 400, P1 steady 12288/400 EXACT, P11 PASS on the new - present, P5/P6 identical, presentIdle steady 0/200. Bench: P2 71 -> - 90 ops/s (+27%), gameFrameClean 34 -> 37 (+9%), gameFrame 25 FLAT, - idle golden row 434 vs 437 (noise). ACCEPTED DEVIATION: probe row - P1 50 -> 46 (-8%, outside the audit's +-5%) -- the added - per-dirty-row scan cost (union fold + span derive + run compare, - gcc-amigaos register pressure) exposed by the present-only tight - loop; every GOLDEN row is flat or up, and the recorded follow-up is - an Amiga fused-scan asm on the ST presentSlam pattern once item 3 - lands. - ITEM "ST alignment" LANDED (surface.c + ST prev arrays aligned(2) - -- NOT the designed aligned(4): the mint a.out object format caps - alignment at 2, and 2 is all the 68000 needs for move.l; own - relink commit per audit X-2, 51/51 x4 re-gated). - ITEM "ST present fixed-cost + copy rate" LANDED (presentSlam.s, - audit fix B-1 applied: run-close lives in the fast path after both - clean-compares, NOT the group head -- full-screen stays ONE memcpy): - ONE 200-row asm pass fuses union scan + copy + snapshot; 4-row - long-compare fast path on clean groups; partial rows copy via a - jsr'd 20-group move.l chain (no memcpy dispatch under 256 B); - full-width runs still batch through mintlib memcpy's movem path. - Gates: P10 displayMismatch 0/0/0 on the new asm; steady P1 - 12288/200 P2 384/200 EXACT (scanned collapsed 496/448 -> 200); - fingerprints identical; 51/51; objdump checklist (no jsr _memcpy in - jlpPresent, symbolic movem masks, chain entry 0x50(a6,d1.w) - verified). Bench: P1 37 -> 73 ops/s (+97%), P2 80 -> 245 (+206%), - gameFrame 21 -> 32 (+52%), gameFrameClean 48 -> 90 (+88%), - present-full 415 vs 403 (+3%, inside the +-5% gate), presentIdle - 452 ops/s. The ~12.5 ms ST fixed cost is DEAD (P2 now ~4.1 ms incl. - marks+copy). - ITEM "IIgs slam hoist + threshold" LANDED in two bisectable stages - (audit C-1 fix: Stage A forces the run entry whole-row -- runMax=79 - park, 160 B/row, table offset 0 -- so hoist correctness and - entry-math correctness gate separately under P9). Landed shape: - 8-bit-M TOUCHED-test scan (min==0xFF <=> clean; sentinel pin #47 - updated), two-term promotion predicate W >= 8 && (max+1) <= 1.5W - (replaces >= 33 words; fixes both UNDER-promoted left-anchored - mid-width bands and OVER-promoted right-anchored ones), run - gathering clipped at the 40-row SEI chunk budget, ONE softswitch - set/teardown per RUN (descending row slam; full-width rows leave SP - on the next row's top -- zero re-park), computed-entry PEI jump - table (Stage B: pushes words runMax..0), MVN rows reload DBR from - the phb copy at 3,s (frame-layout dependency commented), cli-only - chunk teardown, $E1:9DFE reused as the switches-immune SP-offset - park (old DBR stash deleted; gPeiTempRowBase/gPeiMvnSrc + two dead - rect-blit externs deleted). Design's `bcs peiAllDone` was OUT OF - RANGE (+700 B) -- fixed with a bcc/brl pair (the one design bug the - audit missed). - Gates: P9 shrParity badRows=0 at BOTH stages (incl. both - predicate-flip arms, row 0/199 runs, chunk-edge clip, mixed - slam+MVN presents); Stage A P1 copied stayed EXACTLY 15360 (the - C-1 mis-park would have read 13824 and faked Stage B); Stage B P1 - copied dropped to EXACTLY 13824 (96x144, runMax=71 -- the counter - is the over-push instrument); P2 384/200 and P8 0/200 at every - stage; fingerprints identical throughout. Bench: P1 17 -> 18 - (Stage A) -> 20 ops/s (Stage B, +18%, ~59 -> ~50 ms; the fitted - write model's prediction EXACTLY -- ~85% of the band is - irreducible shadowed-write cost, the paper-model 34ms floor is - refuted by three independent W0 rows); P2 48 -> 52 (+8%); P8 - fixed base 67 -> 73 ops/s (~15 -> 13.7 ms). EXPECTED GOLDEN-ROW - DELTA (audit C-4): jlStagePresent full (the idle-present artifact - row, was 67/s) improves to ~73-74/s -- this is the same honest - idle-scan cheapening P8 measures, NOT a regression; the plan's old - "67/s artifact" figure is superseded. Lua cross-clock re-run SOFT - SKIPPED: every SEI hold strictly SHRANK (worst chunk 18.3 -> ~14.6 - ms modeled) and GetTick honesty was proven at the LONGER holds; - the recreated harness adds nothing the P8/P9 gates don't cover. -- [x] W3 COMPLETE 2026-07-08 (all four items landed + gated; final - Amiga probe: steady counters exact, P11 PASS, fingerprints - identical). Wave scoreboard: ST tile singles +5..+31% (snap 8359), - DOS map row 2.7x (gap to ceiling 1.24x), Amiga gameFrameClean 37 -> - 65 (+76%; 34 -> 65 = +91% across W2+W3) and map row +25%. - PHASE-6 ACCEPTANCE ARTIFACTS DONE 2026-07-08: clean rebuild x4 + - 51/51 x4 + PERF.md regenerated (new baseline table + the - re-derived-ceilings section, arithmetic adversarially checked -- 25 - corrections applied before landing; the old ~29 ops/s IIgs - full-screen note superseded in place). Directive scorecard from the - ceilings section: PASS at <= 1.5x -- DOS map/sprite-draw/gameFrame/ - P1, ST gameFrame/P1/P2 + fillRect class, Amiga tile paste/snap + - gameFrame, IIgs gameFrame/P1/P2/map. STILL OUTSIDE the bar with - recorded levers: IIgs tile/sprite singles (~2.2-3.4x -- per-call - wrapper/validation; lever = batch APIs which already PASS, or - moving validation into the fused asm), 68k sprite save/restore - (~1.9-2.3x -- Phase-1 two-class window contract; lever = 16-phase - save emitters), Amiga presents (~2.8-5.3x -- CPU copy vs blitter; - lever = blitter present, needs the async contract from plan item - 8), and the UNKNOWN-bound DOS/gameFrameClean class (fixed present - base never modeled). These are the honest Phase-7 candidates. -- [~] PHASE 7 APPROVED by Scott 2026-07-08 ("Phase 7. We'll do STAXI - after the main library fixes") -- scope = the ceilings scorecard's - out-of-bar library rows; the STAXI title-anim bug is deferred - behind them. Items (design round first, W1-W3 playbook): - P7-1 68k sprite save/restore (ST 2611/1865, Amiga 2053/1603 vs the - ~5.3k / 6.3-7.4k ceiling class -- find where the ~2x actually - lives: wrapper, backup metadata, window size, or dispatch). - PROBE ANSWERED 2026-07-09 (difference-method layer split; three - hash-inert UBER_PROBE rows in uber.c: spriteSaveVal = fully- - offscreen save exits via clip-fail = wrapper+validation only; - spriteRestoreVal = odd-bx backup fails the LAST validation term = - full validation, zero body; spriteSave g16 = x%16==0 class-0 - window vs the golden row's x=40 class-1 = pure window-size - delta). MEASURED cyc/call -- ST (8.0 MHz): save full 3,064, g16 - 2,408, saveVal 1,088; restore full 4,211, restoreVal 422. Amiga - (7.09): save full 3,372, g16 2,890, saveVal 1,524; restore full - 4,423, restoreVal 672. VERDICTS: (1) the BODY is at the machine - floor -- the window delta measures the copy rate directly: ST - 656 cyc/128 B = 5.1 cyc/B (move.l model 5.0), Amiga 482/128 = - 3.8 cyc/B; window size and body are NOT the problem. (2) the - whole gap is the C chain: wrapper+validation ~1,100 (ST) / - ~1,500 (Amiga) cyc + dispatch/backup-metadata ~660-880 cyc per - call, i.e. ~55-70% of every save/restore call is glue around a - floor-speed body -- the same shape as the IIgs W1 autopsy. - (3) HONEST CEILINGS (supersede the unrevised blitter paper - 6,300-7,400 class): ST save class-1 ~5,300/s (1,509 cyc = body - 1,310 + call ~200), class-0 ~9,400/s, restore ~3,880/s (body + - mark ~550 + call); Amiga save ~4,400-6,100/s (body 960-1,900 - depending on contention), restore ~2,900-3,700/s. True gaps are - ~2.0x (ST save 2,611 vs 5,300; restore 1,900 vs 3,880), NOT the - paper 3.1-3.6x. LEVER (needs a design round + it edits shared - core sprite.c): trim/fuse the 68k wrapper-validation chain (the - W1 IIgs fused-entry pattern, or a lean fully-on-surface fast - test + metadata writes moved into the compiled routine); - potential ~+70-100% on these rows if glue drops to ~300 cyc. - ATTEMPT 1 (2026-07-11): MACRO TIGHTENING -- REVERTED, NET LOSS. - Both class macros (SPRITE_SAVE_CLASS / SPRITE_RESTORE_CLASS) were - rewritten to their algebraically-minimal form (save class = - (x&15)+((widthPx+15)&15) >= 16; restore class = copyBytes>>3 - - GROUPS0 with an unsigned <=1 range test), PROVEN value-identical - over the entire input space (scratch classcheck.c: 102k save + - 1.1M restore cases, 0 mismatches) and gated 51/51 on both ports. - Result: save bit-identical on ST AND Amiga (2611/2103 unchanged), - restore neutral on ST (1900) but REGRESSED Amiga 3% (1603->1553) - -- gcc-amigaos lowered the "simpler" C to +3 instructions - (objdump confirmed pre-bench: save total 120->117 with -3 frame - spills but restore 181->184). The wrapper cost is gated by the - compiler's register allocation (movem of 10-11 regs + the - interleaved isFullyOnSurface/clip validation), NOT by arithmetic - op count -- same compiler-bound wall as the IIgs fnAddr-cache - autopsy. CONCLUSION: no C-level micro-opt moves these rows; the - only real levers are STRUCTURAL and out of "trim" scope -- - (a) a trusted fast-path entry that skips revalidation for a - caller that already proved on-surface (new/opt-in API surface), - or (b) fusing the restore dirty-mark into the emitted asm body - (emitter change, the Amiga/ST analogue of iigsSpriteRestoreMark). - Both are larger efforts to be scoped separately, not landed as - part of P7-1. A left-in NOTE comment in spriteInternal.h warns - against re-attempting the macro form. - - SCOPE: RESTORE-MARK ASM FUSION (lever b), measured 2026-07-11. - ---------------------------------------------------------------- - MEASUREMENT (uber.c UBER_PROBE "probe restoreWork": an identical - class-1 restore to a NON-stage surface, where spriteMarkDirty - early-returns on s != gStage -- so it runs body+dispatch MINUS - the 16-row dirty-band widen; differenced against the golden - jlSpriteRestoreUnder row): - ST restore 4,211 cyc (1900/s), restoreWork 2,996 (2670/s) - -> MARK = 1,215 cyc = 29% of the restore call. - Amiga restore 4,423 cyc (1603/s), restoreWork 3,293 (2153/s) - -> MARK = 1,130 cyc = 26% of the restore call. - Both hash-inert (golden 51/51 held; P11 PASS / ST - displayMismatch=0 -- the work surface is never presented). - DECOMPOSITION (from the gcc -S of spriteMarkDirty.part, Amiga): - - CALL PACKAGING ~330 cyc = movem of 7 regs (prologue+epilogue - ~230) + 4-arg load off stack (~50) + the caller's 4-arg push - (~48). THIS is what fusion removes. - - derive minWord=bx>>2 / maxWord=(bx+bw-1)>>2 / minPtr / maxPtr - ~60 cyc -- fusion KEEPS (still needed), maybe slightly cheaper - with values already in regs. - - the 16-row conditional-RMW widen loop ~740 cyc (2 byte - cmp+maybe-store per row x16) -- IRREDUCIBLE; fusion KEEPS it - verbatim. No 68000 vectorization (per-byte conditional min/max, - no SIMD; minWord/maxWord constant across rows doesn't help). - CEILING: fusion recovers ~the call packaging => ~+330 cyc => - ST 1900 -> ~2,060 (+8%), Amiga 1603 -> ~1,730 (+8%). A REAL - positive win (unlike attempt 1's 0%), but bounded at ~8% because - the widen loop -- 3/4 of the mark -- stays. NOT a path to the - 1.5x target on its own (restore is ~2.0x). - FEASIBILITY: HIGH. Both compiled restore bodies (emitPlaneRunCopy - Amiga / emitGroupSpanCopy ST, in src/m68k/) end in `rts` and - clobber ONLY a0/a1 (Amiga saves a2-a4 itself, touches no data - reg; ST touches no callee-saved reg at all). So a shim can hold - bx/by/bw/bh + the band walk state in d2-d7/a2-a6 ACROSS the body - jsr for free. Direct 68k port of iigsSpriteRestoreMark. - DESIGN: - - Two hand-rolled asm shims (CANNOT share -- different body - ABIs): src/amiga/spriteRestoreMark.s and - src/atarist/spriteRestoreMark.s. (src/m68k/*.s compiles into - BOTH builds, so port-specific asm must live in the port dir.) - - Low-arg entry like the IIgs one: pass fnAddr + backup ptr; - the shim reads backup->{x,y,width,height,bytes}, derives the - screen dst from the PINNED stage planar base (gated on - s==gStage, so the stage's plane pointers/base are the single - source of truth -- Amiga amigaSurfacePlanar(gStage)->planes, - ST stSurfacePlanar(gStage)->base), pushes the body's cdecl - args (Amiga: p0..p3+buf; ST: buf+rowStart), jsr's the body, - cleans the stack, then widens gStageMinWord/gStageMaxWord for - rows by..by+bh-1 to [bx>>2, (bx+bw-1)>>2]. - - A shared widen tail is possible (the IIgs stageRectMark model) - but the two shims differ before it; simplest is to open-code - the ~16-row loop in each (or a .macro in src/m68k included by - both). Match SURFACE_WORD_INDEX EXACTLY (x>>2 grain). - - C glue: spriteCompiledRestoreMark analogues in the Amiga/ST - branches of spriteDispatch.h (derive fnAddr, call the shim); - externs beside the IIgs ones; a fused `#elif AMIGA/ST` arm in - jlSpriteRestoreUnder's s==gStage block mirroring the IIgs arm - (sprite.c:829-838). Non-stage restores keep the current - split path (body + separate spriteMarkDirtyRestore). - RISK: the widen is a HASH-INVISIBLE contract -- an under-mark - corrupts presents while the stage hashes stay green (the exact - class of bug the phantom-row-200 present fix hit). Gates that - DO catch it already exist and must all pass: P11 presentPageSum - (Amiga), P10 displayMismatch (ST), the P5 spriteBandSum - fingerprint, and the gameFrame/gameFrameClean golden rows - (only ops that restore+present). Bit-exact arithmetic vs - SURFACE_WORD_INDEX is mandatory. - BUNDLING: the DRAW mark is the same shape and cost; a - spriteDrawMark shim sharing the widen (the IIgs iigsSpriteDrawMark - pattern) is a cheap add-on that helps the gameFrame composite - (draw+save+restore+present per frame) more than restore alone. - Scope draw fusion together if this is greenlit. - RECOMMENDATION: viable, well-understood, measured ~+8%/row -- but - LOWER ROI than this session's Amiga present asm (+45% for - comparable effort) because 3/4 of the mark is the irreducible - widen. Do it only when specifically pushing the sprite - restore/draw + gameFrame rows toward 1.5x, and bundle draw+restore - to justify the two-port asm cost. The higher-CEILING alternative - is lever (a) trusted-entry (skips the ~422-672 cyc validation - via opt-in API) -- different target, needs an API-design round. - - BUILT + LANDED 2026-07-11 (restore + draw bundled). The scope's - ASM design above was WRONG about the mechanism; the win came from - a different place. Three variants were built and measured: - 1. Full body+mark asm shim (per-port, IIgs-pattern): REGRESSED - both ports (ST -3%, Amiga -6%). The old body dispatch - (spriteCompiledRestoreUnder) is ALWAYS-INLINE and shares its - backup-field loads with the validation; a separate asm shim - re-loads them and adds a call layer, outweighing the mark it - fuses. - 2. Lean rolled-loop asm widen call (cdecl, keeps body inlined): - REGRESSED (ST -6%, Amiga -3%). The rolled dbra loop lost to - gcc's UNROLLED indexed spriteMarkDirty; the ~380-cyc loop - overhead exceeded the ~130-cyc 7-reg movem it removed. - 3. INLINE the unrolled 16-row mark directly into jlRestore for - the h==16 stage window (SPRITE_MARK_STAGE_ROWS16 macro, C - only, NO asm): WON. ST restore 1900->1996 (+5%), Amiga - 1603->1703 (+6%). Both 51/51 + all parity gates. - KEY LESSON: the recoverable win was the CALL BOUNDARY itself - (jsr + frame), not the frame size -- and only INLINING removes - it. Every asm approach KEPT a call (asm can't inline into C), so - all lost. The winning change is pure C: an unrolled inline mark - guarded to s==gStage && h==16, byte-identical band arithmetic - (SURFACE_WORD_INDEX). All fusion asm (src/m68k/spriteMark68k.s + - the two port shims) was DELETED. Draw was bundled with the same - inline (jlSpriteDraw + jlSpriteSaveAndDraw): Amiga draw +4%, - saveAndDraw +6%, unaligned +5%; ST draw NEUTRAL (its masked - draw body dwarfs the mark). DOS untouched (guarded Amiga/ST). - gameFrame composite flat both ports (present-bound). Net: a - C-only, zero-new-file change; restore +5-6% both ports, Amiga - draw/saveAndDraw +4-6%, ST draw harmless. Final +8% ceiling not - reached (inline mark still runs the full unrolled widen; only - the ~call-boundary slice was recovered). - - LEVER (a) TRUSTED FAST-PATH ENTRY -- BUILT + LANDED 2026-07-11, - the BIGGEST 68k sprite win yet. New opt-in public API - (jlSpriteSaveUnderTrusted / jlSpriteRestoreUnderTrusted / - jlSpriteSaveAndDrawTrusted) that SKIPS the per-call - geometry-validation chain (~11 range/alignment checks + NULL / - slot / PTR_OK gates) for a caller that pre-compiles its sprites - and keeps them on-screen. The existing safe entries are - UNCHANGED; trusted is opt-in with a documented UB-if-violated - contract, and each trusted entry FALLS BACK to its safe sibling - for anything the fast path doesn't recognise (uncompiled shift, - non-stage / non-16-row window) so a conservative caller degrades - safely. Amiga/ST-only fast path (IIgs/DOS/generic just call the - safe entry). MEASURED (uber.c probe rows vs the golden safe rows, - same geometry), ALL SIX WIN: - save ST 705->817 (+16%) Amiga 657->753 (+15%) IIgs 602->665 (+10.5%) - restore ST 529->609 (+15%) Amiga 545->609 (+12%) IIgs 662->722 (+9%) - saveAndDraw ST 257->289 (+12.5%) Amiga 273->289 (+6%) IIgs 423->455 (+7.6%) - (Amiga saveAndDraw is the smallest because its heavy masked draw - body makes validation a smaller fraction; IIgs wins are smaller - because 65816 validation is a smaller fraction of the call.) The - 68k wins EXCEED the raw validation cost (~422/672 cyc) because - dropping the checks also shrank gcc's frame/spills; IIgs routes the - trusted restore/saveAndDraw into the safe FUSED MVN entries - (spriteCompiledRestoreMark / spriteCompiledDrawMark) minus - validation. Parity gates PASS on all three (P10 displayMismatch=0 - ST, P11 presentPageSum Amiga, shrParity badRows=0 IIgs); DOS 51/51 - + IIgs 51/51 (safe entries UNCHANGED, golden rows identical). - Contract in joey/sprite.h. This is the expert inner-loop path - (trusted saveAndDraw this frame, trusted restore next) and the - BIGGEST sprite win of the P7-1 program, now on all 3 compiled - ports. [P7-2 IIgs "move validation into fused asm" is effectively - satisfied by this -- validation skipped at the C entry.] - P7-2 IIgs per-call wrapper (tile singles 2.2-2.5x, sprite singles - 2.2-3.4x -- lever: validation/derive into the fused asm - entries; the W1 autopsy buckets are the map). SPRITE HALF DONE - 2026-07-12 via the trusted API (see the P7-1 trusted block: - IIgs save/restore/saveAndDraw +7.6-10.5%). Tile singles remain. - P7-5 ST copyRect move.l chain -- LANDED 2026-07-12 (#3). jlpSurfaceCopy - Rect did a per-row memcpy CALL; the clean-buffer windows are 8 - bytes/row (2 longs) x 16 x 3 = 48 memcpy(8) calls where mintlib - memcpy's frame dwarfs the copy. src/atarist/copyRect.s - (stCopyRectSpans68k, rolled move.l + per-row lea stride) replaces - the short-span case (long rows keep memcpy past a 64-byte - crossover). gameFrameClean 90 -> 120 ops/sec (+33%), 51/51 + P10 - displayMismatch=0; gameFrame unchanged (save/restore, not - copyRect). The rolled asm wins here (vs the mark-fusion rolled - loop that LOST) because it deletes a CALL boundary, not a loop - body. ST-only (Amiga uses the blitter copyRect). - P7-6 IIgs copyRect override -- LANDED 2026-07-12. Same call-overhead - lever on the chunky IIgs, where llvm-mos memcpy is a slow far - byte-loop (#79). An IIgs jlpSurfaceCopyRect (src/iigs/hal.c, - JL_HAS_SURFACE_COPY_RECT) does an inline WORD copy for short spans - -- clang compiles it to `lda [dp],y / sta`, no far-memcpy call - (verified in -S). gameFrameClean 18 -> 24 ops/sec (+33%), 51/51 + - shrParity. FIRST tried in the shared generic copyRect but that - REGRESSED DOS -12% (x86 rep-movs memcpy beats an inline word loop - for 8-byte rows) -> reverted to IIgs-only; DOS keeps generic - memcpy. The lesson: this call-overhead lever is inherently - PER-PORT (depends on that port's memcpy speed vs inline stores). - P7-3 Amiga blitter present + the deferred-fence async contract - (plan item 8 revival; P1 46 vs the ~245 CPU model and beyond; - needs the fence-completeness audit + a debug force-fence - gate; P11/P8 are the safety net). - P7-4 DOS present fixed base: model it first (P8 exists now; the W0 - ledger closed the COPY at hardware rate -- the ~2 ms fixed - scan/glue was never attributed), then fix only if fat shows. - MEASURED 2026-07-08 (pre-design capture): DOS P8 presentIdle = - 1,137 ops/s = 0.88 ms fixed base (copied 0 / scanned 200 - steady). First-pass re-verdict: P2 ceiling ~455 ops/s (0.88 + - 768 B x 1.7 us) -> measured 305 = ~1.49x, PASSES the bar; - gameFrameClean needs the designer's component model. - P7-3 STAGE A+B LANDED 2026-07-09 (src/amiga/hal.c only; baseline - re-confirmed green first -- DOS/ST/Amiga 51/51 on the uncommitted W3 - tree before any edit). Forensic (Amiga probe, pre-change): P8 - presentIdle 661/s = 1.51 ms pure fixed scan; P2 present3win 93/s = - 10.75 ms with scanned=400 (TWO 200-row passes: main scan + separate - snapshot), copied 384 B -> scan-bound; P1 present96 46/s = 21.7 ms, - copied 12,288 B -> ~11 ms CPU copy on top. STAGE A (fuse snapshot - into the scan): first attempt put the snapshot in the HOT clean-clean - path and REGRESSED (present3win 93->87, gameFrameClean 65->59) -- the - gcc-amigaos register-pressure trap the plan already recorded, now - re-confirmed with fresh numbers. FIX: snapshot only on the COLD - union-dirty rows (a row clean in both frames already holds the clean - sentinel in gPrev; hot path stays 2 loads like the original scan), - which eliminates the second 200-row pass -- scanned 400->200 exact, - present3win 93->100, present96 46->50, P11 PASS. STAGE B (blitter - present flush): route amigaPresentRectInner's copy through the - existing W3 amigaBlitterCopyPlanes (offset+modulo A->D) for runs whose - height x aligned-words >= AMIGA_PRESENT_BLIT_MIN_WORDS (128); the - 96-row present96/gameFrame bands take the blitter (both plane sets are - CHIP RAM), the scattered present3win windows stay on the CPU THIN path. - Synchronous (WaitBlit before the display flip) -- NO async fence, so - the deferred-fence contract stays deferred. RESULTS (A+B combined, - vs the W3 baseline): present96 46->71 (+54%, blitter copy offload, - copied still 12,288), present3win 93->103 (+11%, scan fusion), - gameFrame 25->32 (+28%), gameFrameClean 62->71 (+9-15%), - jlStagePresent full 584->609. GATES: Amiga 51/51 x2 (Stage A, Stage - B); P11 presentPageSum PASS (fails=0) BOTH stages -- exhaustive - displayed-page-vs-shadow byte parity, the gate for the hash-invisible - present; scanned 400->200 and copied 384/12,288 exact; P8 idle - unchanged. DOS/ST byte-identical this session (hal.c is Amiga-only); - IIgs untouched (not re-gated). REMAINING Amiga-present lever: the - fused-scan present asm (ST presentSlam analogue, 4 plane buffers) for - the scan-bound P2/gameFrameClean class -- C fusion is now empirically - capped by register pressure, so the scan floor needs walking-pointer - asm; the async-fence blitter contract stays parked behind a >=32x32 - row. gameFrame gap ~2.8x -> ~2.2x; still the largest present item but - materially closed. ADVERSARIAL AUDIT (2026-07-09, 3-lens + verify - workflow: blitter/DMA-race, 2-buffer-convergence/snapshot, edge/BLTSIZE - -- all 3 verify verdicts NOT_A_BUG): change correct by construction -- - shadow is read-only during present (separate MEMF_CHIP alloc, not - aliased to gPlanes/B), blitter self-drains before the copper flip, - runs are row-disjoint so mixed blitter+CPU presents are safe, the - clean-clean skip is sound because min==0xFF<=>max==0x00 propagates into - gPrev, and the modBytes!=0 modulo blit is arithmetically in-bounds - (offset/modBytes even, blitWords<=20, height<=200, stays within - AMIGA_PLANE_SIZE=8000). NOTE: P11 already exercises the modulo blit -- - present96 marks words 8..71 = a PARTIAL span (modBytes=8, blitWords=16, - height=96), not full-width as two lenses assumed. AUDIT FOLLOW-UPS - BOTH LANDED 2026-07-09 (gate 51/51 + P11 PASS fails=0): (1) the P11 - battery gained a narrow-tall arm (16px x 96-row column -> ONE run, - blitWords 2, height 96 = 192 >= threshold, modBytes 36) exercising the - narrow modulo-blit variant present96 (wide, modBytes 8) did not -- P11 - now empirically covers it, no longer correct-by-construction only; (2) - a compile-time typedef guard amigaBltSizeFieldsFit in hal.c asserts - SURFACE_HEIGHT <= 1024 (BLTSIZE 10-bit height field) and width <= 64. - P7-3 FUSED-SCAN ASM LANDED 2026-07-09 (Scott approved; KEPT after a - measure-and-decide). Hypothesis: the union scan was the Amiga present's - register-pressure-bound dominant fixed cost (four band walkers + run - state spilling on gcc-amigaos), so hand-rolling it would win big. - Executed in TWO bisectable stages: (1) C run-list refactor -- the scan - emits coalesced runs into gPresentRuns[] and a separate loop flushes - them (keeps the blitter/CPU flush + copied counter); gated 51/51 + P11 - PASS, perf-neutral, proves the architecture. (2) src/amiga/presentScan.s - = hand-rolled 68000 _amigaPresentScan68k walking the 4 band arrays in - a0-a3 with NO spills (lastRow derived as y-1 at each run close to save a - register; moveq-zero-extend per the partial-register-write hazard; - 4-constant + 8-byte-record compile-time ABI guards in hal.c). GATE - (all THREE correctness signals): 51/51 hashes, copied/scanned counters - BYTE-EXACT vs the C (asm produces identical runs), and P11 presentPageSum - PASS fails=0 incl. the narrow-tall arm. FIRST verdict looked marginal -- - the plain asm (no fast path) moved present96 71->75 and present3win - 103->106 (within noise), composites flat -- but that was a WRONG read of - WHY. A present-component skip-mask probe (temporary gAmigaPresentSkip in - hal.c + p3win noScan/noFlush/noFlip/bare rows, since removed) split - present3win's 9.4 ms: SCAN ~4.9 ms, FLUSH ~3.3 ms (chip->chip CPU copy - of the 3 tiny windows), FLIP ~0.4 ms, residual (copper-check/reset/ - WaitBlit/marks/harness) ~1.2 ms; the 3 marks alone are 0.64 ms. So the - scan IS ~half the present -- the plain asm just wasn't faster than gcc - because the scan is INSTRUCTION-COUNT-bound (~16 instr x 200 rows), NOT - register-spill-bound as hypothesized. The fix is the ST presentSlam - 4-row fast path my asm LACKED: at even row indices one move.l compares 4 - clean-sentinel min bytes (cur AND prev) and skips the whole group when - all clean (0xFFFFFFFF+1==0), ~4x fewer instructions on the clean-row - majority (needs gPrevStageMin/MaxWord aligned(2) for the move.l). - A FIRST cut of it PASSED pixel gates (present3win 106->128 +21%, P11, - 50/51) but BROKE the gameFrameClean golden row -- a bisect revert test - was definitive (fast-path build: gCleanSurface = jlSurfaceCreate() fails - 5/5 incl. at 2 MB chip and low load; removed -> 51/51). ROOT CAUSE - (found on a re-audit, NOT by the first "memory-clean" read which was - WRONG): the routine's loop-exit check (cmp #200,d2) lived only in the - single-row tail .rowNext; the fast path did 'addq #4,d2 / bra .rowLoop', - so a clean group ending at row 199 landed d2 == 200, re-entered - .rowLoop, and -- being >= 197 -- fell into .oneRow and processed a - PHANTOM out-of-bounds row 200: read gStageMinWord[200] and, when that - garbage looked dirty, WROTE gPrevStageMin/MaxWord[200] one byte past the - arrays (and could push a phantom run to gPresentRuns[200]). That - corrupts a neighbouring global -- INVISIBLE to P11/hashes/counters (it - is not the stage) but it breaks a later AllocMem. LESSON worth keeping: - display/hash gates miss a heap corruption that a downstream allocation - catches; gameFrameClean (the only op allocating a 2nd surface) was an - accidental memory-integrity canary. FIX: move the 'cmp #200,d2 / bge - .scanEnd' exit to the TOP of .rowLoop so it guards BOTH re-entry paths; - .rowNext just 'addq #1,d2 / bra .rowLoop'. LANDED + GATED: 51/51 (incl. - gameFrameClean C51FE171), P11 PASS fails=0, present3win 106->128 (+21%), - present96 ->78, gameFrame 32->34, gameFrameClean 62->82, counters exact. - Correction to the W2/PERF forensic: the "~11-12 ms fixed scan" - attribution was roughly right that the scan is big, but it is - instruction-count-bound, not the spill it was assumed to be. - FLUSH LEVER LANDED 2026-07-09: the ~3.3 ms chip->chip CPU copy of the - scattered small windows was contention-bound, so the fix was simply to - lower AMIGA_PRESENT_BLIT_MIN_WORDS 128 -> 16 (the copyRect break-even) so - those runs (present3win/gameFrameClean windows are ~32 plane-words) blit - chip->chip in parallel instead of the CPU eating display-DMA contention. - The old 128 was a conservative guess, not a measurement; the component - probe justified re-testing. GATE: 51/51, P11 PASS fails=0, present3win - 128 -> 153 (+20%), gameFrameClean 82 -> 90 (+10%); present96/gameFrame - flat (their bands already blit); copied/scanned exact. - OWN-ONCE FLUSH POLISH LANDED (correct, but perf-flat -- kept for the - right blitter discipline, not a speedup): the flush now OwnBlitters ONCE - per present (lazy on the first blitter run in amigaPresentRectInner, - DisownBlitter in jlpPresent's flush tail) instead of once per window, so - present3win pays 1 Own/Disown, not 3. amigaBlitterCopyPlanes split into - a body (amigaBlitterCopyPlanesOwned, no Own/Disown, used by the flush) - and a wrapper (Own+body+Disown, used by the single-blit copyRect - callers); a bool *blitOwned threads through amigaPresentFlushRun -> - RectInner; CPU-path runs interleave safely (the Owned body ends with - WaitBlit, so the blitter is idle for a following CPU copy or the flip). - GATE: 51/51, P11 PASS fails=0, present3win 153 -> 154 (WITHIN NOISE), - gameFrameClean/present96/gameFrame flat, counters exact. The gain is - negligible because after the threshold drop the flush is blitter-DMA- - bound (the copy), so the per-window Own/Disown OS calls it removes were - already a tiny fraction -- the earlier "few %" estimate was too - optimistic. No known remaining flush lever. SESSION present3win arc: 106 - (asm scan) -> 128 (4-row fast path) -> 153 (flush blitter) -> 154 - (own-once) = +45%. - (2026-07-08, 4-designer + audit workflow; audit fixed wrong - expected-value rows in two designs [A1/B1], merged the DOS arm into - a shared jlpTileMapPaste seam so tile.c spells the union mark - exactly twice [B3 overturn], corrected impossible Amiga probe gates - [C1/D1], and verified the ST design's dodge of the recorded - per-call-asm 4x loss against the real 632-cyc compiled bodies). - Order: ST singles -> DOS walker+seam -> Amiga blitter copyRect -> - Amiga map hoist (rider). - ITEM "ST movep singles" LANDED: three register-args leaf entries in - tileMap.s (zero movem, zero stacked args, pointers in a0/a1 via GNU - register-variable jsr shims; the shared MTR_*_ROW macros also - rebuild MP_CELL byte-identically -- map row EXACTLY 346 flat); - paste/snap guard their tile-buffer side (even; all real sources - are), copy is movep both sides (no guard); copyMasked's snap - scratch aligned(2) keeps its arm hot [audit A2]. Bench: paste 4033 - -> 4773 (+18%), copy 3445 -> 3626 (+5%; the one model shortfall, - staging-bound), snap 6403 -> 8359 (+31%), copyMasked 1632 -> 1749, - map-singles 80 -> 94 (+18%); gameFrame/Clean flat per the corrected - A1 expectation; 51/51. - ITEM "DOS map walker + seam" LANDED: dosTile.h dword-ladder engine - (16 movs/cell, hoisted pixels check + row derive), dispatched via - the NEW shared tile.c arm `#elif defined(jlpTileMapPaste)` with - port.h aliases (ST stTileMapPaste / DOS dosTileMapPaste); ST and - Amiga tile.o proven BYTE-IDENTICAL pre/post (the seam is inert). - Bench: jlTileMapPaste 450 -> 1224 ops/s (2.7x; 22,500 -> 61,200 - tiles/s, gap to the ~76k ceiling now 1.24x, inside the 1.5x bar); - singles/composites flat; 51/51 with the map row's own hash - 52B9646B; objdump clean (no memcpy/imul/rep-movs in the walker). - ITEM "Amiga blitter copyRect" LANDED: amigaBlitterCopyPlanes gains - offsetBytes/modBytes params (whole-plane call passes 0/0 -- - bit-identical program); jlpSurfaceCopyRect = modulo-driven A->D - blit per plane (mod = 40 - rowBytes, masks $FFFF by the 16-px snap - contract, BLTSIZE one blit always legal) above the 16-plane-word - break-even, CPU word-store walk below (replaces the 64-memcpy path - entirely -- BOTH arms beat it). Gates: 51/51 in BOTH arm-forced - builds (threshold 16 blitter / 0xFFFF CPU -- byte-equal arms - proven); no new blitter race (every path drains via - WaitBlit-before-Disown, the W2 present hoist stays defensive). - Bench: gameFrameClean 37 -> 65 ops/s (+76%!! -- the plan's +13-25% - and the design's 43-45 both UNDERSHOT because the old 64x2-byte - memcpy overhead was worse than modeled; CPU-arm-only build gives - 56); gameFrame 25 flat. - ITEM "Amiga map C-hoist" LANDED (GO as rider per its own evidence + - audit): amigaTileMapPaste in amigaTile.h (resident a0-a3 plane - pointers, 32 move.b/cell off compile-time displacements) behind the - shared jlpTileMapPaste seam. Bench: jlTileMapPaste 153 -> 191 - ops/s (+25%, above its 176-186 model); paste 3803 EXACT, - map-singles 78 flat, gameFrame flat, 51/51. - W1 IIgs fused sprite/fillRect entries + fnAddr cache + sprite-mark - specialization (all ports), W2 present-path fixes (Amiga per-row - bands, ST scan cost, IIgs slam threshold -- NOTE: present bugs are - INVISIBLE to stage hashes; each W2 change needs visual verification - per port + probe deltas), W3 ST movep single-tile ops + DOS tilemap - walker + Amiga map walker + Amiga blitter copyRect, then Phase-5 - acceptance artifacts (re-derived ceilings, PERF ceilings column). - Async blitter contract (#8) stays deferred until a >=32x32 row - exists. Original ranked list follows: - 1. IIgs fused sprite entries (iigsSpriteDrawMark etc., the Phase-2 - D1 tile pattern): draw est 966 -> ~1,700-1,850/s (+80-90%), - crosses the 1.5x-of-honest-ceiling bar alone; + resolved-fnAddr - cache in jlSpriteT (R12 flavor, epoch-invalidated on arena - compaction) -> ~2,000-2,200/s. Same fusion for save/restore - (restore 545 -> ~1,600+). Feeds IIgs gameFrame directly. - 2. IIgs fillRect fused entry (fillRect 16x16 is 6.9x off: 4,651 - cyc/call vs ~676 hand; the +60-75% fusion precedent applies) -- - gameFrame's 256x96 fill inherits it. - 3. Present-path fixes per forensics: Amiga per-row bands (kills the - 5x over-copy), ST scan-cost reduction (dirty-row bitmap/list to - skip the 2x200-row scans; check row memcpy isn't byte-looping), - IIgs narrow-band slam threshold revisit + softswitch hoisting. - DOS is done (present-bound at hardware). - 4. Sprite-window dirty mark specialization (Step-B pattern for - sprites; explains the cross-port restore-lags-save asymmetry - 0.58-0.91) -- one shared fix, all four ports. - 5. AUDIO-ACTIVE TAX (bench measures idle; games play music!): a - playing MOD on stock ST costs >130% CPU (12.3kHz ISR ~49.5% + - libxmp LINEAR mix ~850-1,050 cyc/output sample = 130-160% -- - NOT VIABLE AT ALL); even NEAREST @6144Hz ~= 65-78%. Real fix = - YM-register-native music (~1-3%). DOS mix = 40-70% of the 386SX - (NEAREST @11025 -> ~17-30%; OPL2 FM ~1-2%, adlib hooks already - in dos/audio.c). Amiga ~2-4% (Paula DMA) and IIgs ~0% mixing - (Ensoniq DOC) are fine. Retro-authentic priority: chip-native - music formats are period-correct AND fast. - 6. Amiga jlSurfaceCopyRect on the blitter (mods = 40-wBytes; ~10x - the op, +13-25% gameFrameClean; WaitBlit is free at this size -- - NO async contract needed). Cheap interim: inline word stores - capture ~70% in 10 lines of C. - 7. ST movep for single-tile paste/copy/snap (route per-call ops - through the movep body; ~600-750 cyc/call saved, tilePaste est - +15-20%) + DOS native tilemap walker (only port without one; - batch row 2.5-3.4x off). - 8. Async blitter contract (deferred-fence, internal only) -- pays - ONLY on full-surface copies (~4.5ms/call) and future >=32x32 - cookie-cut sprites; needs a fence-completeness audit + debug - force-fence gate. Keep per-call sync until a >=32x32 row exists. - 9. Amiga whole-map tile walker C-hoist (+15-20%, already inside - 1.5x -- optional polish). diff --git a/PERF-AUDIT-PLAN.md b/PERF-AUDIT-PLAN.md deleted file mode 100644 index c77a2dc..0000000 --- a/PERF-AUDIT-PLAN.md +++ /dev/null @@ -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=` - (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_) #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. diff --git a/PERF-AUDIT.md b/PERF-AUDIT.md deleted file mode 100644 index 98f0984..0000000 --- a/PERF-AUDIT.md +++ /dev/null @@ -1,1425 +0,0 @@ -# JoeyLib Core Audit -- 2026-07-04 - -Deep audit of src/core/, src/generic/, include/joey/ (plus codegen and -hot spots the completeness critic pulled in from src/iigs, src/m68k, -src/atarist). Method: 7 lens-specific finder agents + a 3-gap critic -round, every finding adversarially verified against the actual file on -three lenses (factual accuracy, fix validity on real hardware, -materiality). 77 raw findings -> 71 confirmed, 6 refuted. Where the -verifier tightened a claim, the correction is included -- read it before -acting on a finding. - -Severity: critical = correctness bug or >2x slowdown on a hot op; -high = measurable per-op win (>10%) or real bug on a cold path; -medium = clear cleanup or small win; low = style-level. - -Confirmed: 2 critical, 22 high, 32 medium, 15 low - -## Read this first: the benchmark inflates every small-op number - -The UBER loop (examples/uber/uber.c:66) calls jlFrameCount() between every -op iteration. On the IIgs that is a THREE-layer chain -- jlFrameCount -> -jlpFrameCount -> iigsGetTickWord -> Misc Toolset GetTick ($2503) via the -$E10000 tool dispatcher. The toolbox dispatch alone is several hundred -cycles; with the forwarding layers, the indirect op() call, and the op -wrapper, each benchmark iteration carries roughly 600-800 cycles of pure -harness tax. The uber.c comment claiming the poll costs "~10-30 cyc per op" -is wrong by more than an order of magnitude on the IIgs. - -Cross-check: jlKeyDown is a bounds check plus an array read (~80 cycles -including the call), yet it measures 3382 ops/sec = ~830 cycles/iter. The -missing ~750 cycles are the harness. The same constant sits inside EVERY -small-op number in PERF.md: jlDrawPixel's honest cost is roughly half of -what the table implies, jlSamplePixel/jlPaletteSet/input predicates are -3-10x faster than their entries suggest, and the cross-port ratios for fast -ops partly measure each port's clock-read cost, not library code. - -Fix in uber.c, not in the library: poll the clock every N iterations (e.g. -run the op 8-16 times per while-check and count in batches), or calibrate -an iteration count first and time a fixed batch. Then re-baseline PERF.md. -Until that lands, treat full-screen ops (which amortize the tax) as honest -and every op above ~500 ops/sec as significantly under-reported. Finding -16 (jlFrameCount double pass-through) trims the library-side share; the -toolbox dispatch cost itself is only avoidable by polling less often. - -## Index - - 1. [FIXED 2026-07-05] [bug] src/core/draw.c:549 -- jlDrawPixel (non-IIgs) marks dirty with unclipped x/y, causing out-of-bounds writes to the dirty-band arrays - 2. [CRITICAL] [perf] src/core/sprite.c:193 -- Interpreted sprite inner loop re-derives dst byte address and parity per pixel via a real function call - 3. [HIGH ] [perf] include/joey/platform.h:98 -- IIgs (perf reference) silently runs the generic C nibble loop for jlTilePasteMono -- no JL_HAS_TILE_PASTE_MONO, no port.h macro - 4. [HIGH ] [perf] include/joey/platform.h:205 -- Atari ST defines neither JL_HAS_FLOOD_WALK_PLANES nor JL_HAS_FLOOD_SCAN_ROW_PLANES -- flood fill decodes 4 bitplanes per pixel via a function call - 5. [FIXED 2026-07-05: compact-and-retry + coreSetError] [perf] src/codegen/spriteCompile.c:202 -- jlSpriteCompile ignores the arena's compact-and-retry contract: one fragmented alloc failure silently demotes the sprite to the interpreter forever - 6. [HIGH ] [perf] src/core/audioSfxMix.c:83 -- SFX mixer does a full 32x32 multiply per output sample; on 68000 this is a __mulsi3 library call - 7. [HIGH ] [perf] src/core/audioSfxMix.c:110 -- 64-bit posFp arithmetic executed per sample in the mixer inner loop - 8. [FIXED 2026-07-05] [bug] src/core/codegenArena.c:236 -- IIgs 64KB attrNoCross cap on the codegen arena is unenforced; codegenBytes > 64KB hard-fails jlInit (adventure.c passes 256KB) - 9. [HIGH ] [perf] src/core/draw.c:484 -- jlDrawLine Bresenham fallback accumulates the dirty bounding box with 4 compares per pixel although it equals the endpoint box -10. [HIGH ] [perf] src/core/draw.c:533 -- IIgs stage-identity test is a cross-TU jlStageGet() function call per primitive (per pixel on the hottest op) and is re-checked again inside surfaceMarkDirtyRect -11. [FIXED 2026-07-05, incl. the #65 h==2 companion] [bug] src/core/draw.c:591 -- jlDrawRect general path wraps w/h > 32767 through (int16_t) casts, drawing spurious edges on-surface -12. [FIXED 2026-07-05; bonus: HEAD also INFINITE-LOOPED at exactly r==32767 (int16 tautology), fixed by the same rewrite] [bug] src/core/draw.c:656 -- jlFillCircle (and jlDrawCircle fallback) cast r to int16_t: r > 32767 draws nothing; r in [~380, 32767] iterates up to 32k clipped spans -13. [HIGH ] [perf] src/core/draw.c:703 -- jlFillRect runs 32-bit clip math on every call; the common on-surface case needs only 16-bit compares -14. [HIGH ] [perf] src/core/draw.c:776 -- jlSamplePixel pays a second cross-TU function hop for a ~30-cycle nibble read on chunky ports -15. [HIGH ] [perf] src/core/init.c:148 -- jlFrameCount is a double pass-through on IIgs: jlFrameCount -> jlpFrameCount -> iigsGetTickWord, paid every benchmark iteration and every game-loop pacing check -16. [HIGH ] [perf] src/core/palette.c:106 -- jlPaletteSet re-masks all 15 caller entries against the documented $0RGB contract instead of memcpy + forcing color 0 -17. [HIGH ] [perf] src/core/port.h:116 -- jlStageGet() function call buried in hot-path port.h macros and jlDrawPixel costs a cross-TU hop per primitive -18. [HIGH ] [perf] src/core/sprite.c:189 -- No opaque-pair whole-byte fast path; 5 conditional branches per pixel in the interpreted draw loop -19. [HIGH ] [perf] src/core/sprite.c:524 -- Save/restore interpreted fallbacks bypass SURFACE_ROW_OFFSET and pay two software multiplies per row -20. [HIGH ] [perf] src/core/surface.c:234 -- surfaceMarkDirtyRect is a cross-TU function hop on every drawing op, re-deriving stage-ness the dispatch macro just computed -21. [HIGH ] [perf] src/core/surface.c:317 -- stageDirtyClearAll runs a 200-iteration byte loop every present while its sibling uses memset for the same arrays -22. [HIGH ] [perf] src/core/tile.c:54 -- jlDrawText pays the full public jlTileCopyMasked wrapper plus a multi-row dirty-mark call per glyph -23. [FIXED 2026-07-05; check-millis.sh is the regression gate] [bug] src/generic/genericPort.c:40 -- jlpGenericMillisElapsed wraps backward every ~18 minutes and does a 32-bit multiply+divide per call on IIgs/Amiga -24. [HIGH ] [perf] src/generic/genericSurface.c:55 -- jlpGenericSamplePixel pays a software multiply (y*160) instead of the SURFACE_ROW_OFFSET LUT the codebase built for exactly this -25. [MEDIUM ] [multi-source-of-truth] PERF.md:38 -- PERF.md IIgs baseline predates the Phase-2 jlDrawPixel/dirty-mark rewrite; all cross-port ratios are computed against a stale floor -26. [MEDIUM ] [multi-source-of-truth] include/joey/platform.h:100 -- Eleven IIgs JL_HAS flags are decorative -- the port.h macro overrides bypass the registry, so entries can be added/removed with zero effect -27. [MEDIUM ] [dead-code] src/codegen/spriteCompile.c:246 -- Amiga compiled save/restore dispatchers and the shift-alias loop are dead code contradicted by the emitters -28. [MEDIUM ] [perf] src/codegen/spriteCompile.c:311 -- IIgs spriteCompiledDraw still pays the 2D-array multiply helper the same file dodges everywhere else -29. [MEDIUM ] [duplication] src/codegen/spriteCompile.c:423 -- Dispatcher gate and spriteCompiled* callees each re-derive shift, route offset, and function address per call -30. [MEDIUM ] [multi-source-of-truth] src/core/assetLoad.c:41 -- assetLoad.c re-spells TILE_BYTES and the 40x25 tile-grid limits as private literals -31. [FIXED 2026-07-04 during the stabilization cluster] [bug] src/core/assetLoad.c:167 -- jlSpriteBankLoad allocates up to 32000-byte cels with malloc, violating core.h's own IIgs allocator contract -32. [MEDIUM ] [duplication] src/core/assetLoad.c:180 -- jlSpriteBankLoad re-implements spriteInitFields field by field, and the spriteInternal.h comment contradicts it -33. [MEDIUM ] [perf] src/core/audioSfxMix.c:131 -- Mixer re-reads slot fields through the pointer every sample because dst stores are char-typed and may alias -34. [FIXED 2026-07-05 via the sprite.c registry + spriteSystemShutdown] [bug] src/core/codegenArena.c:294 -- codegenArenaShutdown frees every ArenaSlotT while live sprites still hold pointers; shutdown -> re-init -> jlSpriteDestroy corrupts the heap -35. [MEDIUM ] [perf] src/core/draw.c:168 -- floodFillInternal C fallback evaluates the loop-invariant matchEqual ternary and a jlpSamplePixel function call per pixel -36. [MEDIUM ] [unnecessary-conditional] src/core/draw.c:345 -- jlDrawCircle fallback runs 8 bounding-box compares per iteration that are provably dead after the first iteration -37. [MEDIUM ] [unnecessary-conditional] src/core/draw.c:484 -- Bresenham fallbacks accumulate a bounding box per pixel that is statically known before the loop -38. [MEDIUM ] [perf] src/core/draw.c:668 -- jlFillCircle C span loop pays surfaceMarkDirtyRect (plus IIgs jlStageGet) per span instead of one bounding-box mark -39. [MEDIUM ] [perf] src/core/init.c:20 -- DEFAULT_CODEGEN_BYTES (8KB) is sized to IIgs code density; a single 32x32 sprite's ST/Amiga DRAW routines nearly fill or overflow it, silently forcing the interpreter -40. [MEDIUM ] [perf] src/core/input.c:92 -- jlKeyDown's real cost is ~5x its body: cross-TU call overhead around a two-load predicate -41. [MEDIUM ] [perf] src/core/port.h:130 -- port.h comment says 'the asm derives the fill word' but the jlpTileFill/jlpFillCircle macros compute (c & 0x0F) * 0x1111 in C -- a software multiply helper per call on the 65816 -42. [MEDIUM ] [perf] src/core/port.h:179 -- DOS pays real cross-TU calls to no-op planar hooks on every sprite draw/save/restore and surface copy -- port.h's own comment admits it -43. [MEDIUM ] [perf] src/core/random.c:23 -- jlRandom's 32-bit shift triple (13/17/5) is a worst case for the 16-bit 65816; bit-identical 16-bit-half rewrite available -44. [MEDIUM ] [perf] src/core/sprite.c:103 -- isFullyOnSurface uses 32-bit adds/compares on every hot sprite entry gate, against the file's own stated convention -45. [MEDIUM ] [perf] src/core/sprite.c:179 -- spriteDrawInterpreted row setup redoes a per-row multiply and recomputes loop-invariant column math every row -46. [FIXED 2026-07-05] [bug] src/core/sprite.c:346 -- IIgs/DOS draw fast path never checks SPRITE_NOT_COMPILED before jumping into the arena -47. [MEDIUM ] [multi-source-of-truth] src/core/surfaceInternal.h:54 -- Four divergent implementations of the 'row is dirty' test; the STAGE_DIRTY_ROW_CLEAN macro that should be the single truth is unused -48. [MEDIUM ] [perf] src/core/tile.c:79 -- tile.c wrappers derive pixel coords with * TILE_PIXELS_PER_SIDE multiplies where port.h deliberately uses shifts to dodge the ORCA-C multiply helper -49. [MEDIUM ] [perf] src/generic/genericDraw.c:42 -- jlpGenericFillRect recomputes loop-invariant span bytes/edge masks and the row offset every row -50. [MEDIUM ] [duplication] src/generic/genericTile.c:27 -- Identical 4-byte row-copy inner loop triplicated byte-at-a-time across TileCopy, TilePaste, and TileSnap -51. [MEDIUM ] [perf] src/generic/genericTile.c:59 -- jlpGenericTileCopyMasked does a read-modify-write merge even when both source nibbles are opaque -52. [MEDIUM ] [dead-code] src/iigs/hal.c:298 -- iigsBlitStageToShr's SCB/palette MVN upload paths are dead, yet jlpPresent still marshals both far pointers into the asm every present -53. [MEDIUM ] [perf] src/iigs/joeyDraw.s:1416 -- iigsDrawPixelInner carries dead PHB/PLB framing and a long-absolute scratch round-trip in the hottest inner op -54. [MEDIUM ] [multi-source-of-truth] src/iigs/spriteEmitIigs.c:3 -- spriteEmitIigs.c top-of-file contract describes the deleted self-modifying-stub design; the code below it emits the opposite (C-ABI prologue) -55. [MEDIUM ] [dead-code] src/m68k/spriteEmit68k.c:179 -- spriteEmit68k.c (chunky 68k emitter) is compiled into Amiga and ST builds but nothing calls it -56. [MEDIUM ] [duplication] src/m68k/spriteEmitInterleaved68k.c:83 -- c2pColumn/putWord duplicated byte-for-byte between the two 68k emitters; shiftedByteAt/spriteSourceByte duplicated between IIgs and x86 emitters -57. [LOW ] [cleanup] include/joey/surface.h:66 -- surface.h documents jlSurfaceHash as FNV-1a but the implementation is a 31/251 two-stream product hash -58. [FIXED 2026-07-05, incl. the jlpTileSnap twin] [unnecessary-conditional] src/atarist/hal.c:1410 -- ST jlpTilePasteMono dereferences dst before its own NULL check, and both the dst and duplicate pd NULL checks are dead -59. [LOW ] [perf] src/codegen/spriteCompile.c:362 -- SPLIT_BACKUP_CACHED global single-entry cache thrashes and costs more than it saves with multiple backup buffers -60. [LOW ] [multi-source-of-truth] src/core/assetLoad.c:28 -- assetLoad.c comment claims file-IO lives in a named IIgs segment, but no segment directive exists anywhere -61. [LOW ] [unnecessary-conditional] src/core/draw.c:276 -- plotPixelNoMark re-checks s == NULL per pixel although every caller has already validated it -62. [LOW ] [duplication] src/core/draw.c:574 -- isFullyOnSurface predicate duplicated between sprite.c helper and jlDrawRect's inline test -63. [LOW ] [multi-source-of-truth] src/core/sprite.c:344 -- jlSpriteDraw's fast-path comment claims the IIgs never takes it, contradicting the codegen default -64. [LOW ] [duplication] src/core/sprite.c:409 -- routineOffsets shift*3+op byte-offset idiom hand-expanded at three sites in sprite.c -65. [LOW ] [unnecessary-conditional] src/core/surface.c:245 -- surfaceMarkDirtyRect re-validates w/h that its contract already guarantees positive -66. [LOW ] [dead-code] src/core/surfaceInternal.h:36 -- SURFACE_PIXELS_PER_WORD is defined and never used anywhere in the repo -67. [LOW ] [magic-number] src/core/surfaceInternal.h:125 -- gRowOffsetLut declared with magic literal 200 instead of SURFACE_HEIGHT -68. [LOW ] [dead-code] src/core/tile.c:17 -- Stale comment in tile.c still claims the inner row copies are spelled out in this file -69. [LOW ] [unnecessary-conditional] src/generic/genericTile.c:84 -- fg/bg colors are masked to 4 bits twice on the TilePasteMono path -70. [LOW ] [duplication] src/generic/genericTile.c:142 -- jlpGenericTileFill re-derives the tile row-0 address with its own math instead of the file's GENERIC_TILE_ROW0 macro -71. [LOW ] [cleanup] src/iigs/hal.c:358 -- Stale 15-line comment on IIgs jlpFrameCount documents a deleted $C019-polling/gFrameCount implementation; body is one GetTick call -72. [FIXED 2026-07-05] [bug] src/core/draw.c:417 -- jlDrawLine H/V fast paths clamp the span to the surface size BEFORE clipping, so a line with a far-off-surface start draws nothing (found during Phase 0 harness work) -73. [CRITICAL] [bug] src/dos/hal.c:321 -- DOS jlpFrameCount edge-polled $3DA and lost every frame whose ~1ms retrace pulse fell between calls; the ENTIRE old DOS PERF.md column was inflated 5-40x on slow ops (FIXED during Phase 0: now derived from the millis clock) -74. [FIXED 2026-07-05: pass 2 now emits into the roomy scratch and memcpys into the arena, so sizing and emit agree by construction; DOS jlSpriteDraw jumped 284 -> 6094 ops/sec on the compiled path with bit-identical pixels] [bug] src/codegen/spriteCompile.c:149 -- jlSpriteCompile returns false on DOS (pre-existing at HEAD; UBER logs the failure), so every DOS sprite benchmark and game has been running the interpreter; root cause not yet isolated -75. [FIXED 2026-07-05: both planar overrides decoded the mono tile as their PRIVATE plane-major layout instead of the contract's chunky nibble-pairs; both now fold each 4-byte chunky row into the plane shape mask] [bug] src/generic/genericTile.c:77 -- jlTilePasteMono produces THREE different pixel outputs on chunky (DOS/IIgs generic) vs Amiga vs ST overrides; caught by the Phase 0 harness the first time the op was ever hashed cross-port -76. [CRITICAL] [bug] src/iigs/hal.c:348 -- IIgs jlpWaitVBL polled $C019's VBL bit, which never reads high under MAME's apple2gs; every program pacing with jlWaitVBL hung forever in emulation, and UBER (the only in-tree caller) could never complete headless (FIXED during Phase 0: now waits on a GetTick change) -77. [CRITICAL] [bug] src/iigs/spriteEmitIigs.c + src/codegen/spriteCompile.c -- IIgs sprite codegen corrupts memory under the clang build: with JOEY_IIGS_SPRITE_CODEGEN=1 (the default), UBER's stage/surface state is corrupted between setupSprite and drawShowcase and the app dies in a wild memset over bank 0; with codegen disabled UBER runs. The old "#19 rewrite fixed it" claim was validated on the ORCA build, not clang -78. [FIXED 2026-07-05: the ST fillCircle asm read its color argument at a 6-arg function's stack offset (copy-paste from lineSpan.s) and filled with a garbage color; its span math was also rewritten from midpoint-Bresenham to the exact-disk reference. jlTileFill was EXONERATED -- simulator-proven pure fallout of the fillCircle corruption] -- ST jlFillCircle and jlTileFill produce different pixels than the chunky reference (DOS): first-ever DOS-vs-ST hash comparison diverges at those two ops while Amiga matches DOS on every timed op -79. [HIGH ] [perf] toolchains/iigs/llvm-mos/runtime/src/libc.c:191 -- clang-era IIgs links byte-at-a-time memset/memcpy (~30 cyc/byte) where the ORCA baseline used MVN (~7 cyc/byte); jlScbSetRange measures 163 ops/sec vs the ORCA-era 1005 -- every memset/memcpy-based core path regressed ~4x on the reference platform (toolchain-owned; flag to the llvm816 session) -81. [CRITICAL] [bug] src/core/surface.c:279 + toolchain libc -- the IIgs C heap is 665 bytes of unreserved bank-0 leftover, and at exhaustion its malloc/calloc returns 1 instead of NULL (the libc's own "over-heap miscompile" comment, still live). stageAlloc's calloc(~720) returned 1; the whole port ran on a phantom stage struct at bank-0 zero page whose pixels field ALIASED the real framebuffer address by coincidence -- rendering worked while scb/palette writes scribbled over system bank 0. Root cause of the divergent IIgs hashes, the phase-5 hang, and (pending re-test) #77 (FIXED JoeyLib-side during Phase 1: surface/sprite structs + owned tileData moved to jlpBigAlloc/jlpBigFree -- finding #31 executed; toolchain asks filed) -82. [CRITICAL] [bug] src/core/debug.c (IIgs) -- per-line log writes cost ~8 emulated seconds each through the floppy FST and can wedge indefinitely in SmartPort firmware retries (PC-sampled: slot-5 ROM spin stalled UBER 18+ emulated minutes). FIXED during Phase 1: IIgs logging now writes a RAM ring (gJoeyLogRing, drained live by the MAME Lua probes); jlLogFlush writes the ring to disk in one open/write/close pass -83. [HIGH ] [bug] src/iigs/joeyDraw.s -- iigsDrawLineInner and iigsFillCircleInner stashed the pixels pointer with a DBR store but dereferenced it D-relative (bank 0): every plot went through a garbage pointer -- deterministic wrong pixels vs the chunky reference (FIXED during Phase 1 with the drawPixel-pattern D-relative stash) -89. [LOW ] [bug] src/core/draw.c diagonal Bresenham -- dx/dy computed in int16_t, so endpoints more than 32767 apart (e.g. x0=-32768, x1=100) corrupt the walk and can infinite-loop. Pre-existing and latent (such inputs draw nothing useful and the H/V fast paths and endpoint-box marking are unaffected); noted during the Phase 3 review. Fix opportunistically when the fallback is next touched -88. [LOW ] [bug] cross-port jlTileT semantics -- jlTileSnap on planar ports stores platform-private plane-major bytes in jlTileT.pixels, but jlTilePasteMono (per contract) decodes the same struct as chunky nibble-pairs: a tile snapped on ST/Amiga then mono-pasted decodes differently than on DOS/IIgs. Latent wart flagged during the #75 fix; consider a contract note or a chunky-normalizing snap in Phase 5 -87. [MEDIUM ] [bug] src/iigs/joeyDraw.s -- the odd-column nibble mask in the M=16 plot RMW was BYTE-SWAPPED ($F0FF instead of $FFF0 on the little-endian 65816): it never cleared the target nibble and erased the adjacent byte's low nibble instead. 8 sites in iigsDrawCircleInner's octant plots + 1 latent in iigsDrawLineInner. Simulation-validated root cause (Bresenham geometry was identical to C; first diff at the octant-7/8 pole pairs); fix = the one-constant change at all 9 sites, byte-identical to the C reference across 20 simulated cases (FIXED during Phase 1, pending MAME re-verification with the Wave-1 batch) -86. [CRITICAL] [bug] src/iigs/joeyDraw.s -- the #83 garbage plot pointer is layout-dependent: when a memory shift (e.g. the #85 reservation) changed what it aliased, line plots landed ON the dln* Bresenham state, mutating the endpoint mid-line and hanging the walk in an infinite coordinate wrap that scribbles via out-of-bounds gRowOffsetLut offsets -- a wild-write generator and plausible contributor to the pre-#81 corruption (FIXED with #83) -84. [HIGH ] [bug] src/core/sprite.c + IIgs codegen-off path -- IIgs interpreted sprite ops are pathologically slow (jlSpriteSaveUnder 1 iter/65 frames, jlSpriteDraw 1 iter/368 frames -- ~6 SECONDS for one 16x16 interpreted draw) and pixel-divergent (sprite-clip-roundtrip FAILs; all post-sprite hashes fall out). Needs its own hunt: even finding #2's per-pixel call overhead cannot explain three orders of magnitude -85. [CRITICAL] [bug] src/iigs/hal.c -- the pinned stage framebuffer ($01:2000-$9CFF) was never reserved from the GS/OS Memory Manager, so NewHandle legally placed jlpBigAlloc blocks INSIDE the display: the sprite struct landed at $01:8B04 and every surface clear repainted its fields (widthTiles read as the fill color -- 0x44 after clear(4)); clipRect then clamped draws to near-full-screen, producing finding #84's ~1000x sprite slowness, the 32KB backup-buffer overrun, and the BSS carnage (FIXED during Phase 1: jlpStageAllocPixels now claims the range via NewHandle attrAddr, and all JoeyLib NewHandle sites pass attrNoSpec) -80. [MEDIUM ] [bug] src/core/debug.c:47 -- per-line log durability does not hold on the clang IIgs runtime: jlLogF+fflush lines do not reach the disk image until a clean fclose/exit (GS/OS FST or libc buffering), so a hung run leaves a joeylog.txt that dramatically under-reports progress -- the exact failure debug.c was designed to prevent. The SHR mailbox in uber.c is the reliable progress channel under emulation; fflush-commit semantics are queued as a toolchain question (LLVM816-ASKS.md) - -## Findings - -### 1. jlDrawPixel (non-IIgs) marks dirty with unclipped x/y, causing out-of-bounds writes to the dirty-band arrays - -- **Where:** `src/core/draw.c:549` -- **Severity:** critical | **Category:** bug - -The IIgs branch of jlDrawPixel bounds-checks and returns before marking (draw.c:529), but the #else branch (DOS/Amiga/ST) calls surfaceMarkDirtyRect with the RAW coordinates after plotPixelNoMark silently clipped the plot. surfaceMarkDirtyRect's contract (surface.c:230, 'already clipped to surface bounds') is violated: for s == stage and y outside [0,199], the row loop calls widenRow(y,...) which reads/writes gStageMinWord[y] and gStageMaxWord[y] with no clamp -- e.g. jlDrawPixel(stage, 5, 250, c) writes gStageMinWord[250], corrupting the adjacent gStageMaxWord array or other .bss. Negative x with valid y is also broken: SURFACE_WORD_INDEX(-5) = 0xFE widens gStageMaxWord[y] to 254 (rows are only 80 words), so the next jlStagePresent slams ~3 rows of bytes past the intended row band. Off-surface pixel draws are documented-legal ('Out-of-bounds draws are silent no-ops', draw.h:3) and routine in games, so this fires in normal use on DOS/Amiga/ST. - -**Fix:** Mirror the IIgs branch: in the #else path do `if ((uint16_t)x >= SURFACE_WIDTH || (uint16_t)y >= SURFACE_HEIGHT) { return; }` after the NULL check, then call jlpDrawPixel directly (skipping the plotPixelNoMark layer and its duplicate checks) and mark. Optionally also add a debug-build assert/clamp in surfaceMarkDirtyRect to catch future contract violations. - -**Impact:** Memory corruption (dirty-band arrays + neighboring globals) and present-time over-blit on DOS/Amiga/ST for any off-surface stage pixel; fix also removes one redundant function call + NULL check per on-surface pixel - -### 2. Interpreted sprite inner loop re-derives dst byte address and parity per pixel via a real function call - -- **Where:** `src/core/sprite.c:193` -- **Severity:** critical | **Category:** perf - -For every opaque pixel the loop computes (dx + col), then writeDstNibble (lines 87-96) redoes x >> 1 to find the byte and branches on x & 1 for parity -- all rederivable state the loop could carry incrementally, exactly as it already carries the src inTileX phase (lines 195-199). Worse, ORCA/C 2.2.1 performs no function inlining, so on the IIgs this is a genuine JSL/RTL + 3-arg stack frame per opaque pixel (~60-90 cycles of pure call overhead on top of the ~30-cycle merge). The in-file comment at lines 343-345 states jlSpriteCompile leaves sp->slot NULL on IIgs, so this loop is the ONLY IIgs sprite-draw path (bench: jlSpriteDraw 438 ops/sec = ~6400 cycles/op total). writeDstNibble also masks nibble & 0x0F twice (lines 92, 94) even though the caller's extract at lines 190-191 already yields a 4-bit value. - -**Fix:** Before the col loop set dst = dstRow + ((uint16_t)dx >> 1) and dstOdd = (uint8_t)(dx & 1). Per opaque pixel merge inline: if (dstOdd) { *dst = (uint8_t)((*dst & 0xF0) | nibble); dst++; } else { *dst = (uint8_t)((*dst & 0x0F) | (nibble << 4)); } then dstOdd ^= 1 (advance dst only after the odd half). Drop the redundant & 0x0F masks. Keep writeDstNibble only if a cold path still needs it; otherwise delete it. - -**Impact:** Saves ~70-95 cycles per opaque pixel on 65816 (call overhead + add + shift + redundant masks). An 8x8 mostly-opaque sprite saves ~5000 of its ~6400 cycle budget: roughly 2-3x on IIgs jlSpriteDraw, the perf-floor reference op. DOS gains the per-pixel address arithmetic even where Watcom inlines. - -**Verifier correction:** The per-pixel writeDstNibble call and redundant masks are real, and the fix is valid, but this is the clip-edge/uncompiled FALLBACK path, not every IIgs sprite draw: IIgs sprite codegen is ON by default (spriteCompile.c:144-146) and UBER's 438 ops/sec measured the compiled path. Expected win is 2-3x on the interpreted draw itself (edge-clipped sprites, pre-compile draws), not on the headline bench number. Bonus: the comment at sprite.c:341-345 is stale (contradicts spriteCompile.c #19 note) -- a multiple-sources-of-truth item worth fixing in the same pass. - -### 3. IIgs (perf reference) silently runs the generic C nibble loop for jlTilePasteMono -- no JL_HAS_TILE_PASTE_MONO, no port.h macro - -- **Where:** `include/joey/platform.h:98` -- **Severity:** high | **Category:** perf - -The IIgs table jumps from TILE_PASTE (line 98) to TILE_SNAP (line 99) with no JL_HAS_TILE_PASTE_MONO, and the IIgs macro block in src/core/port.h (lines 37-192) has no jlpTilePasteMono macro, while Amiga (platform.h:146) and ST (platform.h:201) both override it. So jlTilePasteMono (src/core/tile.c:140) dispatches to jlpGenericTilePasteMono (src/generic/genericTile.c:77), a cross-TU call running a 32-iteration per-byte colorize loop ('hi = (srcByte >> 4) ? fgColor : bgColor' etc., lines 86-91) before finally reaching the asm jlpTilePaste. This is a hot path: Space Taxi repaints all 1000 level tiles through jlTilePasteMono (examples/spacetaxi/stRender.c:425) and re-pastes 103 logo cells every 4th frame on the title screen (stRender.c:527). Every sibling tile op on the IIgs is hand asm; this is the one silent miss the flag/override split hides. - -**Fix:** Add JL_HAS_TILE_PASTE_MONO to the IIgs block in platform.h and a jlpTilePasteMono macro in port.h's IIgs section calling a new iigsTilePasteMonoInner in joeyDraw.s that LUT-expands mono bytes (16-entry nibble-pair table) directly into the surface rows, skipping the intermediate jlTileT + separate paste pass. - -**Impact:** Generic path ~= 100 cyc call + ~1500-2200 cyc colorize + ~2530 cyc paste (jlTilePaste = 1106 ops/s) => ~600 ops/s; fused asm ~2x faster per op. Title-screen logo repaint alone is ~450K cycles every 4 frames (~2.5 full 60Hz frame budgets at 2.8MHz). - -**Verifier correction:** All substantive claims hold. Two nits: (1) the '~2.5 full 60Hz frame budgets' figure is the amortized per-frame cost ? the 484K-cycle logo repaint is ~10 frame budgets per 4-frame period at 46.7K cyc/frame, so the impact is understated, not overstated; (2) the '~2530 cyc paste' component uses the full jlTilePaste API cost (1106 ops/s) while the generic path pays only the jlpTilePaste macro/asm inner (the jlTilePasteMono wrapper + dirty mark supply the rest), so the breakdown misattributes but the ~600 ops/s total is still about right. - -### 4. Atari ST defines neither JL_HAS_FLOOD_WALK_PLANES nor JL_HAS_FLOOD_SCAN_ROW_PLANES -- flood fill decodes 4 bitplanes per pixel via a function call - -- **Where:** `include/joey/platform.h:205` -- **Severity:** high | **Category:** perf - -The ST table goes straight from SPRITE_RESTORE (204) to SURFACE_COPY_PLANES (205); the Amiga table has JL_HAS_FLOOD_WALK_PLANES / JL_HAS_FLOOD_SCAN_ROW_PLANES between them (platform.h:150-151, implemented at src/amiga/hal.c:2131 and 2181). Without them, floodFillInternal (src/core/draw.c) takes the portable tier: the walk-out loop calls 'pix = jlpSamplePixel(s, (int16_t)(leftX - 1), y);' (draw.c:166) and the row scan calls 'pix = jlpSamplePixel(s, (int16_t)(leftX + i), scanY);' (draw.c:237) once per pixel. ST surfaces are pure planar (pixels==NULL, atarist/hal.c:2131/2159), so each call lands in jlpSamplePixel (atarist/hal.c:2013) -> stPlanarGetPixel: y*160 address math plus 4 interleaved plane-word tests -- per PIXEL, through a function call. Each filled pixel is visited ~3x (walk + scan above + scan below). Flood fill is about to become per-frame-hot: the planned AGI interpreter draws every pic resource with flood fills. - -**Fix:** Add the two flags to the ST block and implement jlpFloodWalkPlanes / jlpFloodScanRowPlanes in src/atarist/hal.c against the word-interleaved StPlanarT layout (walk runs 16 pixels per 4-word group by combining plane words into a match mask, same structure as the Amiga versions at amiga/hal.c:2131/2181, adjusted for ST_BYTES_PER_GROUP interleave). - -**Impact:** ~180-220 cycles per sampled pixel today; a 100x50 flood ~= 5000 px x 3 visits x ~200 cyc = ~3M cycles = ~0.4s at 8MHz. Plane-word walkers process 16 px per 4 word-reads: estimated 10-20x speedup on ST flood fill. - -**Verifier correction:** Directionally and factually right. Minor additions: port.h:153's comment claiming the planar flood hooks are 'Amiga-only' must be updated with the fix, and the realistic speedup is ~5-15x rather than the quoted 10-20x since the run-edge markBuf walk in draw.c stays per-pixel C either way. - -### 5. jlSpriteCompile ignores the arena's compact-and-retry contract: one fragmented alloc failure silently demotes the sprite to the interpreter forever - -- **Where:** `src/codegen/spriteCompile.c:202` -- **Severity:** high | **Category:** perf - -codegenArenaInternal.h:41-44 states the contract: 'Returns NULL if no free slot is large enough -- the caller should run codegenArenaCompact and retry, or surface the failure.' The only caller does neither: it bails, sp->slot stays NULL, and every subsequent jlSpriteDraw/SaveUnder/RestoreUnder for that sprite runs the interpreter (benched at 0.13-0.22x of the compiled path -- the #1 perf gap). codegenArenaCompact is only reachable via the public jlSpriteCompact (src/core/sprite.c:435), which nothing calls automatically, so a game that creates/destroys sprites across levels accumulates holes until compiles fail even with ample total free space. jlSpritePrewarm additionally swallows the false return ('(void)jlSpriteCompile(sp);', sprite.c:360), so the failure is invisible. - -**Fix:** On the NULL return, call codegenArenaCompact() and retry codegenArenaAlloc(totalSize) once before giving up (failure-path only, zero hot-path cost). Optionally coreSetError on the final failure so prewarm failures are diagnosable. - -**Impact:** Prevents a permanent 5-8x slowdown on the hottest op after arena fragmentation; ~10 lines - -**Verifier correction:** jlSpriteCompile technically satisfies the contract's second arm (it surfaces the failure via the documented false return, sprite.h:86), so 'ignores the contract' is overstated. The real defect: no code anywhere acts on that failure -- jlSpritePrewarm and both example apps swallow it, and nothing ever calls jlSpriteCompact -- so arena fragmentation silently and permanently demotes sprites to the interpreter. Fix as proposed: on codegenArenaAlloc returning NULL, call codegenArenaCompact() and retry the alloc once before returning false; failure-path only, zero hot-path cost. - -### 6. SFX mixer does a full 32x32 multiply per output sample; on 68000 this is a __mulsi3 library call - -- **Where:** `src/core/audioSfxMix.c:83` -- **Severity:** high | **Category:** perf - -interp() runs once per output sample per active slot inside audioSfxOverlayMix. delta is at most +/-255 and frac at most 65535, but both are int32_t, so on the ST (compiled -m68000 per make/atarist.mk line 10, no mulu.l) gcc emits a __mulsi3 library call: 3x mulu.w plus shifts/adds plus call overhead, roughly 250-350 cycles, versus ~74 cycles for a single muls.w. At the ST's 12288 Hz MIX_RATE with one active slot that is ~2.5-4M wasted cycles/sec, ~30-45% of the 8 MHz CPU, and the ST HAL already warns (src/atarist/audio.c:413) that audioSfxOverlayMix can approach the 83 ms refill budget -- this multiply is the single biggest reason. - -**Fix:** Reformulate as a 16x16->32 signed multiply that matches gcc's mulhisi3 pattern: `sample = (int)s0 + (int)(((int32_t)(int16_t)delta * (int32_t)(int16_t)(uint16_t)(frac >> 1)) >> 15);` -- delta fits int16, frac>>1 fits a positive int16, and the result differs from the current value by at most 1 LSB of 8-bit audio (inaudible). Emits one muls.w on 68000; DJGPP is unaffected. - -**Impact:** ~200-280 cycles/sample saved; roughly halves audioSfxOverlayMix cost on ST (~30% of ST CPU while SFX plays) - -**Verifier correction:** Everything as stated, except the proposed one-line expression does not deliver the muls.w on the shipped toolchain (still compiles to __mulsi3). Write it with explicit int16_t locals so gcc matches mulhisi3: 'int16_t delta = (int16_t)(s1 - s0); int16_t fracH = (int16_t)(uint16_t)(frac >> 1); return (int)s0 + (int)(((int32_t)delta * fracH) >> 15);' -- verified to emit a single muls.w with m68k-atari-mint-gcc 15.2.0 at the real ST flags. - -### 7. 64-bit posFp arithmetic executed per sample in the mixer inner loop - -- **Where:** `src/core/audioSfxMix.c:110` -- **Severity:** high | **Category:** perf - -pos is uint64_t (48.16 fixed point), so every sample pays a 64-bit >>16, a 64-bit mask, and a 64-bit add (`pos += (uint64_t)step;` line 142). On the 68000 each of these is a multi-register sequence (~40-70 cycles combined vs ~10 for 32/16-bit ops). The uint64_t exists only to lift the old 65535-input-sample cap (header comment, audioSfxMixInternal.h:37-41), but a split representation gives the same range without any 64-bit ops: a uint32_t sample index covers 4G input samples, far beyond the 48-bit rationale. - -**Fix:** Replace posFp with `uint32_t posIdx` + `uint16_t posFrac`, and split stepFp once per slot into stepInt = stepFp >> 16 and stepFrac = stepFp & 0xFFFF. Inner loop becomes: `frac += stepFrac; idx += stepInt + (frac carry);` (carry via `if (frac < stepFrac) idx++;` after 16-bit wrap, or a uint32_t accumulator for frac). Update audioSfxSlotArm/ArmStream/streamRefill resets accordingly. Output is bit-identical. - -**Impact:** ~30-60 cycles/sample on 68000, ~10-15% of the mixer inner loop - -**Verifier correction:** Impact is ~25-50 cycles/sample (~5-10% of the loop today, ~10-17% after the multiply fix), not a guaranteed 30-60. Use the carry-compare formulation (uint16_t frac; frac += stepFrac; if wrap then idx++) or keep frac in a 16-bit register -- the suggested uint32_t-accumulator '(fracAcc >> 16)' add compiles to a swap chain on m68k-atari-mint-gcc that eats most of the win. Part of the benefit is indirect: freeing the second pos register un-spills step from the stack (add.l 50(%sp) becomes add.l Dn). - -### 8. IIgs 64KB attrNoCross cap on the codegen arena is unenforced; codegenBytes > 64KB hard-fails jlInit (adventure.c passes 256KB) - -- **Where:** `src/core/codegenArena.c:236` -- **Severity:** high | **Category:** bug - -attrNoCross means the block may not cross a 64KB bank boundary, so any totalBytes > 0x10000 is unsatisfiable and NewHandle returns NULL; codegenArenaInit returns false and jlInit (src/core/init.c:99-105) aborts with the generic message 'failed to allocate codegen arena'. Nothing in the arena, init.c, or joey/core.h documents or clamps the cap. examples/adventure/adventure.c:2273 sets `config.codegenBytes = 256UL * 1024`, which would make the app refuse to start on the reference platform even though clamping to one bank would work fine (routineOffsets are uint16_t and per-sprite fnAddr math already fits a bank). - -**Fix:** In codegenArenaInit, under JOEYLIB_PLATFORM_IIGS, clamp: `if (totalBytes > 65536ul) { totalBytes = 65536ul; }` (with a comment naming the attrNoCross bank cap), or fail with a specific error string so the caller knows to shrink codegenBytes. - -**Impact:** Whole-app init failure on IIgs for any config >64KB; one concrete caller (adventure) already trips it - -**Verifier correction:** One refinement: clamping to exactly 65536 may still fail at runtime -- a 64KB attrFixed+attrNoCross+attrPage block requires a completely free bank, which GS/OS may not have. The clamp should be paired with a specific error string (so failure is diagnosable) and/or adventure.c should set codegenBytes per-platform (#ifdef: ~16KB on chunky ports as its own comment notes, 256KB only on Amiga). Also document the IIgs cap at the jlConfigT.codegenBytes declaration in include/joey/core.h. - -### 9. jlDrawLine Bresenham fallback accumulates the dirty bounding box with 4 compares per pixel although it equals the endpoint box - -- **Where:** `src/core/draw.c:484` -- **Severity:** high | **Category:** perf - -Bresenham walks monotonically from (x0,y0) to (x1,y1) and terminates exactly at (x1,y1), so the set of visited points has bounding box [min(x0,x1),max(x0,x1)] x [min(y0,y1),max(y0,y1)] -- exactly what the JL_HAS_DRAW_LINE fast path already computes once up-front at lines 459-462 (bbx/bby/bbw/bbh). The in-loop accumulation therefore recomputes a value that is fully known before the loop, at 4 compare+branch pairs per plotted pixel. This fallback is the ONLY diagonal-line path on DOS (no JL_HAS_DRAW_LINE) and runs for every clipped diagonal line on IIgs/Amiga/ST. - -**Fix:** Before the loop, save the original endpoints and compute minX/maxX/minY/maxY from them (same 4 ternaries as the fast path), then delete the 4 in-loop compares and keep the existing post-loop clamp+mark unchanged. Output is bit-identical. - -**Impact:** ~40-50 cycles/pixel on 68000 (4x cmp.w+bcc) vs a ~150-250 cycle per-pixel plot: ~15-25% off the fallback diagonal-line inner loop; ~20-30 cycles/pixel on 65816 - -### 10. IIgs stage-identity test is a cross-TU jlStageGet() function call per primitive (per pixel on the hottest op) and is re-checked again inside surfaceMarkDirtyRect - -- **Where:** `src/core/draw.c:533` -- **Severity:** high | **Category:** perf - -gStage is `static` in surface.c (line 27), so the stage-identity test everywhere outside surface.c must go through jlStageGet() -- a real cross-segment JSL + ORCA-C prologue/epilogue. The IIgs jlDrawPixel (measured 1755 ops/sec ~= 1600 cyc/call) pays this call per pixel just to compare two pointers. The IIgs macros jlpFillCircle, jlpSurfaceClear, and jlpFillRect in src/core/port.h (lines 97, 106, 116: `((_s) == jlStageGet() ?`) pay it once per invocation -- i.e. once per SPAN in jlFillCircle/jlDrawLine/jlDrawRect span loops via fillRectOnSurface. Worse, fillRectOnSurface then calls surfaceMarkDirtyRect, which re-derives the same fact with its own `s != gStage` compare -- the stage test is evaluated twice per span through two different mechanisms (function call vs direct global), a duplication of the same truth. - -**Fix:** Make gStage non-static, declare `extern jlSurfaceT *gStage;` in surfaceInternal.h (gStageMinWord/gStageMaxWord are already extern there), and replace jlStageGet() in the port.h macros and draw.c hot paths with a direct `(_s) == gStage` compare (two LDA-long + CMP, ~12-16 cycles vs ~40-70 for the call). Keep jlStageGet() as the public accessor. - -**Impact:** ~30-60 cycles per primitive invocation on IIgs: ~3-4% of jlDrawPixel per call, multiplied per-span in fillCircle/drawRect/line loops; also removes the double stage check per span - -### 11. jlDrawRect general path wraps w/h > 32767 through (int16_t) casts, drawing spurious edges on-surface - -- **Where:** `src/core/draw.c:591` -- **Severity:** high | **Category:** bug - -In the clipped (general) path, the bottom-edge row is computed as y + (int16_t)h - 1 and the right-edge column as x + (int16_t)w - 1 (line 595). h and w are uint16_t, so any value > 32767 goes negative in the cast and the edge coordinate wraps by -65536. When the true edge coordinate lands in [65536, 65855+], the wrapped value falls back inside [0, 319]/[0, 199] and a phantom edge is drawn ON-surface: jlDrawRect(s, 100, 5, 10, 65533, c) computes bottom row (int16_t)(5 + (-3) - 1) = 1 and draws a horizontal line at y=1 that should be at y=65537 (off-surface, i.e. nothing). Same for the right edge with large w (e.g. x=300, w=65530 draws a vertical edge at x=293). The on-surface fast path above is safe because it is gated by the 32-bit test at lines 574-576. - -**Fix:** Compute the edge coordinates in int32_t in the general path (int32_t bottom = (int32_t)y + h - 1; skip the edge if bottom > SURFACE_HEIGHT-1 or < 0, else cast), or early-clip the outer rect in 32-bit like jlFillRect does and derive edges from the clipped box. - -**Impact:** Wrong pixels rendered for w or h > 32767 on all four ports; cold path, ~4 extra 32-bit ops only in the already-slow clipped case - -**Verifier correction:** Details are accurate as stated. Worth noting the realistic trigger is unsigned-wrap in caller width math, not literal 32k-wide rects. - -### 12. jlFillCircle (and jlDrawCircle fallback) cast r to int16_t: r > 32767 draws nothing; r in [~380, 32767] iterates up to 32k clipped spans - -- **Where:** `src/core/draw.c:656` -- **Severity:** high | **Category:** bug - -r is uint16_t. For r >= 32768, (int16_t)r is negative, so the span loop never executes and jlFillCircle draws NOTHING -- but a disk of radius 40000 centered anywhere near the surface mathematically covers the entire 320x200 surface, so the correct output is a full-surface fill. jlDrawCircle's fallback has the same wrap at line 338 (`x = (int16_t)r;` makes `while (x >= y)` false immediately), which silently drops perimeter pixels that can legitimately cross the surface (e.g. cx=-39900, cy=100, r=40000 passes through x~100). Separately, for r in [~380, 32767] with the circle not fully on-surface, the loop runs r+1 iterations emitting two clipped jlFillRect calls each even though at most 200 rows can intersect the surface -- 32767 iterations of 32-bit while-loop math plus 65k clipped fill calls is multiple seconds at 2.8 MHz. - -**Fix:** Before the span loop: 32-bit test whether the farthest surface corner from (cx,cy) satisfies dist2 <= r*r; if so, jlFillRect(s, 0, 0, SURFACE_WIDTH, SURFACE_HEIGHT) and return (this also fixes all r >= 32768 full-coverage cases). Then clamp the y loop to rows that can intersect the surface (y limited by cy vs [0,199] bounds, computed in int32 once) and use a uint16_t loop variable. - -**Impact:** Correctness: whole-surface fills silently dropped for huge r; perf: worst case ~32k wasted iterations (seconds per call) reduced to <=200 - -**Verifier correction:** Fix must seed yy=y0*y0 when clamping the y range (the incremental (y+1)^2 chain breaks on a jump), and the initial x walk-down from r is still O(r) once per call unless an integer sqrt seeds x; the wasted-iteration window is any r >= 200 with clipping, not just r >= ~380. - -### 13. jlFillRect runs 32-bit clip math on every call; the common on-surface case needs only 16-bit compares - -- **Where:** `src/core/draw.c:703` -- **Severity:** high | **Category:** perf - -The clip (draw.c:703-717) does 4 sign/zero extensions to int32_t, two 32-bit adds, four 32-bit clamps, and two 32-bit compares on a 16-bit-native CPU where every int32 op is a multi-instruction carry-chained sequence -- roughly 120-200 cycles -- even when the rect is fully on-surface (the overwhelmingly common case: 16x16 HUD fills, tile-sized fills, benchmark ops). The 32-bit width is only needed to survive x<0 with huge w, or w>32767. The library already recognizes this cost elsewhere: jlDrawLine (draw.c:425-427), jlDrawRect (draw.c:574-576), and jlFillCircle (draw.c:664-667) all grew on-surface pre-tests specifically to 'skip jlFillRect's 32-bit clip'. jlFillRect itself never got the fast path, so direct API users always pay it. - -**Fix:** Add a 16-bit fast path before the 32-bit clip: 'if (x >= 0 && y >= 0 && w > 0 && h > 0 && w <= SURFACE_WIDTH && h <= SURFACE_HEIGHT && x <= (int16_t)(SURFACE_WIDTH - w) && y <= (int16_t)(SURFACE_HEIGHT - h)) { fillRectOnSurface(s, x, y, (int16_t)w, (int16_t)h, colorIndex); return; }' -- all uint16/int16 ops -- and keep the 32-bit clip as the slow path. This also lets the three callers above drop their duplicated pre-tests (one source of truth for the fast path). - -**Impact:** ~100-180 cycles per on-surface jlFillRect: ~10-25% of an honest 16x16 fill (450 ops/sec measured), plus dedupes three caller-side on-surface tests - -**Verifier correction:** Add the 16-bit on-surface fast path to jlFillRect, but keep the three span emitters calling fillRectOnSurface directly (do not reroute spans through the public jlFillRect). Realistic impact: ~100-180 cycles per on-surface call on the 65816, ~2-3% of the measured 16x16 fill (more, proportionally, for smaller rects); severity medium, not high. - -### 14. jlSamplePixel pays a second cross-TU function hop for a ~30-cycle nibble read on chunky ports - -- **Where:** `src/core/draw.c:776` -- **Severity:** high | **Category:** perf - -IIgs and DOS define no JL_HAS_SAMPLE_PIXEL, so jlpSamplePixel resolves to the out-of-line jlpGenericSamplePixel function (port.h:394-400). The chain is: public jlSamplePixel call (~60-80 cyc marshalling s/x/y) -> NULL+bounds checks -> a SECOND full call into jlpGenericSamplePixel (~60-80 cyc re-marshalling the exact same three args) -> a 3-line nibble extract. Measured 1916 ops/sec = ~1460 cyc/call for what is fundamentally a ~30-40 cycle indexed read; the inner hop alone is 30-50% of the honest post-fix cost. This is the exact wrapper tax port.h:44-46 documents ('a C wrapper would cost ~60-80 cyc/call of pure overhead') and eliminated for the draw ops via macros -- the read op was left behind. - -**Fix:** On chunky builds (JOEYLIB_NATIVE_CHUNKY without JL_HAS_SAMPLE_PIXEL), inline the read directly in jlSamplePixel after the bounds check: 'uint8_t b = s->pixels[SURFACE_ROW_OFFSET((uint16_t)y) + ((uint16_t)x >> 1)]; return (x & 1) ? (uint8_t)(b & 0x0F) : (uint8_t)(b >> 4);' (draw.c already includes surfaceInternal.h). Equivalently, make jlpGenericSamplePixel a static inline in a shared internal header so all chunky call sites collapse the hop. - -**Impact:** ~80-150 cycles per call, 30-50% of jlSamplePixel; also speeds the flood-fill C walk-out loops on ports without asm flood tiers (DOS/blank) which call it per pixel - -**Verifier correction:** Win on IIgs is larger than stated per-mechanism (inner call ~60-80 cyc PLUS the y*160 software-multiply helper the generic pays because it skips the gRowOffsetLut), but is ~10-17% of the measured op, not 30-50%. To also speed the flood-fill walk loops, the read must be exposed as a macro (not static inline -- ORCA/C 2.2.1) in a shared internal header, since floodFillInternal calls jlpSamplePixel, not the public jlSamplePixel. - -### 15. jlFrameCount is a double pass-through on IIgs: jlFrameCount -> jlpFrameCount -> iigsGetTickWord, paid every benchmark iteration and every game-loop pacing check - -- **Where:** `src/core/init.c:148` -- **Severity:** high | **Category:** perf - -core.h tells callers to poll jlFrameCount faster than 2*jlFrameHz, and UBER polls it once per op iteration. On IIgs the call chain is three deep: the init.c wrapper JSLs to jlpFrameCount in src/iigs/hal.c:372-374, whose entire body is `return iigsGetTickWord();` (asm in peislam.s). Two of the three layers are pure forwarding, ~150-250 cycles per call on the 65816. This is also where a large slice of the 'jlKeyDown = 3382 ops/sec' mystery goes: 2.8 MHz / 3382 is ~830 cycles/iter, of which the frame-count chain in the bench loop is ~250-350 cycles -- it systematically inflates the measured cost of every fast op. - -**Fix:** Use the existing `#if !defined(jlpFrameCount)` hook (src/core/port.h:495): in the IIgs port header declare iigsGetTickWord and `#define jlpFrameCount() iigsGetTickWord()`, deleting the hal.c wrapper. Same treatment for jlpFrameHz (body is `return gFrameHz;`). That leaves one unavoidable public-ABI layer instead of three. - -**Impact:** ~100-170 cycles per jlFrameCount call on IIgs; corrects downward bias in every fast-op UBER number - -**Verifier correction:** Directionally right, three details need fixing. (1) Savings are ~60-80 cycles per call (one removed forwarding layer), not ~100-170: the public jlFrameCount ABI layer remains, and the dominant cost of the chain is the Misc Toolset GetTick dispatch inside iigsGetTickWord (peislam.s:29-35, jsl $E10000), which no wrapper removal touches. The benchmark bias is real but only partially removable this way. (2) The jlpFrameHz 'same treatment' is unsafe as proposed: gFrameHz is static in hal.c (line 59); exporting it for direct reads from CORESYS-segment code risks the documented cross-load-segment DBR/absolute-addressing trap (hal.c:363-371) if the compiler emits DBR-relative addressing, and jlpFrameHz is cold (once per bench op, not per iteration) -- leave it a function or verify long-addressing codegen first. (3) Also remove the now-stale JL_HAS_FRAME_COUNT at include/joey/platform.h:108 and fix the wrong '~10-30 cyc per-iter poll' comment at examples/uber/uber.c:12-14; a cheaper long-term option for the residual toolbox cost is having the asm cache the tick in a long-addressed word updated by an edge-detect, but that changes timing semantics and is a separate decision. - -### 16. jlPaletteSet re-masks all 15 caller entries against the documented $0RGB contract instead of memcpy + forcing color 0 - -- **Where:** `src/core/palette.c:106` -- **Severity:** high | **Category:** perf - -include/joey/palette.h:15-16 states the contract: 'colors16 must point to exactly 16 uint16_t values in $0RGB format', and the only documented silent rewrite is color 0 forced to $000. The & 0x0FFF on entries 1-15 is undocumented defensive validation of already-guaranteed input, paid on every call of an op measured at only 678 ops/sec (~4100 cyc). Under ORCA/C the loop costs two far-pointer indirect accesses, two 32-bit pointer increments, and SEP/REP churn from the uint8_t counter per iteration (~45-60 cyc x 15). The read-side twin jlPaletteGet (line 82) already does a plain 32-byte memcpy of the same row, which lowers to MVN (~7 cyc/byte) on IIgs. - -**Fix:** memcpy(row, colors16, SURFACE_COLORS_PER_PALETTE * sizeof(uint16_t)); -row[0] = 0x0000; -This keeps the documented color-0-black behavior bit-exact for contract-conforming input and mirrors jlPaletteGet. (If the defensive mask must be kept, at minimum change `uint8_t i` to `uint16_t i` to stop the 8-bit-counter SEP/REP churn on the 65816.) - -**Impact:** ~400-600 cyc/call, roughly 10-15% of jlPaletteSet on IIgs - -**Verifier correction:** Directionally and quantitatively right; two mechanism details are off: ORCA/C keeps M=16 and handles the uint8_t counter via AND #$00FF masking (plus 32-bit far-pointer post-increments), not SEP/REP churn per iteration; and ORCA/C memcpy is a library call whose inner loop uses MVN, not an inlined MVN. The proposed fix (memcpy row, then row[0] = 0x0000) is correct as written ? palette.c's own paletteInitDefault comment already treats the re-masking as avoidable overhead. - -### 17. jlStageGet() function call buried in hot-path port.h macros and jlDrawPixel costs a cross-TU hop per primitive - -- **Where:** `src/core/port.h:116` -- **Severity:** high | **Category:** perf - -The IIgs jlpFillRect (port.h:115-120), jlpSurfaceClear (port.h:105-109), and jlpFillCircle (port.h:96-101) macros, plus jlDrawPixel itself (draw.c:533 'if (s == jlStageGet())'), each call the jlStageGet() accessor to answer 'is this the stage?'. gStage is a static in surface.c (surface.c:27), so every jlDrawPixel, every jlFillRect, every fill-circle span (81 spans for r=40), every H/V line, and every flood-fill span pays a JSL+RTL plus a far-pointer load (~25-60 cyc) to fetch a pointer that changes only at init/shutdown. The repo already fixed this exact pattern for the codegen arena: codegenArenaInternal.h:62-66 says 'Direct extern access (instead of a getter function) so per-frame hot paths ... skip the JSL/PHB/RTL/PLB the wrapper would impose.' On the post-Phase-2 jlDrawPixel chain (~400-500 cyc) this call is ~8-12% of the whole op. - -**Fix:** Declare 'extern jlSurfaceT *gStage;' in surfaceInternal.h (renaming or keeping jlStageGet() as the public accessor shim, same as codegenArenaBase()), and change the port.h macros and draw.c:533 to compare '(_s) == gStage' directly. Read-only cross-TU global reads are safe on 65816 (only '++' on globals is the DBR trap). - -**Impact:** ~25-60 cycles removed per jlDrawPixel/jlFillRect/jlSurfaceClear/jlFillCircle-span call; ~8-12% on jlDrawPixel, multiplied 81x inside one jlFillCircle r=40 C-span fallback - -**Verifier correction:** Direct-extern gStage fix is correct and safe, but: (1) drop the flood-fill claim ? IIgs flood spans go through iigsFloodWalkAndScansInner and never reach jlpFillRect, so flood is unaffected on every port; (2) impact today is ~25-50 cyc per call = ~1.5-3% of the measured jlDrawPixel (1755 ops/sec ? 1600 cyc) and ~0.5-1% of jlFillRect16x16; the 8-12% figure only holds for the hypothetical post-optimization ~400-500 cyc chain. Severity: medium, not high. The 81x-per-fill-circle multiplier applies only to the off-stage/off-surface C span fallback. - -### 18. No opaque-pair whole-byte fast path; 5 conditional branches per pixel in the interpreted draw loop - -- **Where:** `src/core/sprite.c:189` -- **Severity:** high | **Category:** perf - -Each pixel executes 5 conditional branches: src-parity ternary (line 190), transparency test (line 192), dst-parity branch inside writeDstNibble (line 91), tile-boundary test (line 196), and the loop bound (line 185). Src parity (sx + col) & 1 and dst parity (dx + col) & 1 differ by the constant (dx ^ sx) & 1 for the whole draw, so the common aligned case (even x, no odd clip) can be specialized to a byte-wise loop: read one src byte = two pixels; byte == 0 skips both; both nibbles opaque = one whole-byte store instead of two read-modify-write merges. The first review round flagged this exact missing fast path in genericTile.c (the byte-merge structure at src/generic/genericTile.c lines 52-67 is the template, itself still missing the both-opaque single store at line 59) but not here, where it matters more: this loop is every IIgs sprite draw and the IIgs/DOS partial-clip fallback. - -**Fix:** Hoist alignedPhase = (((dx ^ sx) & 1) == 0) out of the row loop. Aligned case: loop over src bytes -- b = *src++; if (b == 0) { dst++; continue; } if ((b & 0xF0) && (b & 0x0F)) { *dst++ = b; } else merge the single opaque nibble; handle one odd leading/trailing pixel outside the loop. Keep the current per-pixel walker only for the misaligned phase. - -**Impact:** Aligned opaque pair drops from ~2 RMW merges (~50-60 cycles even after inlining) to one load+test+store (~15 cycles) on 65816; branch count falls from 10 to ~3 per pair. Combined with the dst-pointer fix this is ~3-5x on the aligned-opaque inner loop, the dominant case for typical sprites. - -**Verifier correction:** Valid missing fast path, but on the interpreted fallback (edge-clipped and uncompiled draws) rather than 'every IIgs sprite draw' -- IIgs codegen is on by default. Implementation must chunk the byte loop per tile column (4 contiguous bytes, then advance srcTileRow by TILE_BYTES), which the sketched *src++ loop glosses over. - -### 19. Save/restore interpreted fallbacks bypass SURFACE_ROW_OFFSET and pay two software multiplies per row - -- **Where:** `src/core/sprite.c:524` -- **Severity:** high | **Category:** perf - -The restore fallback (line 524) and the save fallback twin (line 632: srcRow = &s->pixels[(dy + row) * SURFACE_BYTES_PER_ROW];) compute row addresses with a raw * 160 instead of SURFACE_ROW_OFFSET -- the macro surfaceInternal.h:124-130 exists precisely because ORCA/C emits a ~150-cycle multiply helper for this (surfaceInternal.h:72 comment), and spriteDrawInterpreted at line 173 already uses it. Both loops additionally recompute the backup index per row with a variable multiply ((uint16_t)row * (uint16_t)copyBytes, lines 526 and 633), another ~30-50 cycle helper call on 65816. Since sp->slot is NULL on IIgs (comment lines 343-345), these fallbacks ARE the per-sprite-per-frame IIgs save/restore path. This is also a multiple-sources-of-truth violation: row addressing has a blessed single mechanism and these two loops re-derive it inline. - -**Fix:** Hoist pointers and strength-reduce both loops: dst = &s->pixels[SURFACE_ROW_OFFSET((uint16_t)by) + byteStart]; src = backup->bytes; then per row memcpy(dst, src, (size_t)copyBytes); dst += SURFACE_BYTES_PER_ROW; src += copyBytes;. Apply identically in jlSpriteSaveUnder lines 631-636. - -**Impact:** ~180-200 cycles per row saved on IIgs (both multiply helpers replaced by two pointer adds). An 8-row sprite saves ~1500 cycles per save or restore call -- plausibly 30-50% of each interpreted save/restore op. 68000 ports unaffected (chunky loop skipped); DOS gains the variable multiply. - -**Verifier correction:** Fix and mechanism are correct, but on IIgs these loops are the clipped/uncompiled fallback, not the primary per-frame path (compiled MVN save/restore is on by default per spriteCompile.c). The cycle savings apply to the fallback op; the SURFACE_ROW_OFFSET bypass is independently a concrete multiple-sources-of-truth cleanup. - -### 20. surfaceMarkDirtyRect is a cross-TU function hop on every drawing op, re-deriving stage-ness the dispatch macro just computed - -- **Where:** `src/core/surface.c:234` -- **Severity:** high | **Category:** perf - -Every primitive tail pays a full cross-TU call (~60-80 cyc of push/JSL/prologue) into surfaceMarkDirtyRect just to run 's != gStage' (surface.c:242) -- a test jlpFillRect's macro already evaluated via jlStageGet() one line earlier (two computations of the same fact per op) -- plus two shifts and a branch, before either the inline h==1 widen or a second JSL into iigsMarkDirtyRowsInner for h>1. Call sites on the hot five: fillRectOnSurface (draw.c:54), jlTilePaste (tile.c:161), jlTileCopy/Masked/Fill (tile.c:83/102/122), jlSpriteDraw via sprite.c:351 and spriteDrawInterpreted (sprite.c:203). For jlTilePaste the mark chain (~150-200 cyc: call + checks + JSL + 8-row widen) rivals the 32-byte paste itself; Phase 2.1 of IIGS_PERF_PLAN.md fixed this for jlDrawPixel only. - -**Fix:** Move surfaceMarkDirtyRect into surfaceInternal.h as a static inline (it needs only the already-extern gStageMinWord/gStageMaxWord, a widenRow inline, and the extern gStage from the previous finding). h==1 widens fully inline; h>1 keeps the single JSL to iigsMarkDirtyRowsInner. Non-stage surfaces then cost one inline compare instead of a wasted function call. - -**Impact:** ~60-100 cycles per jlFillRect/jlTile*/jlSpriteDraw call (10-25% of an honest 8x8 tile op); also removes the dead call entirely for non-stage surfaces - -**Verifier correction:** Impact is ~60-120 cycles per op, which is ~2-4% of the MEASURED per-call times (jlTilePaste 1106 ops/s = ~2530 cyc, jlFillRect16x16/jlSpriteDraw ~6200-6400 cyc); the 10-25% figure only holds against the op's intrinsic cost with harness/API overhead excluded. Requires making gStage extern (it is static in surface.c). Note the redundant stage-ness applies to the jlpFillRect/jlpSurfaceClear/jlpFillCircle macros (which call jlStageGet(), itself a ~60-cyc cross-TU call worth converting to an extern-gStage compare in the same pass); the jlpTile* macros never compute stage-ness. On IIgs the sprite path hits this via sprite.c:203 (interpreted), since sp->slot is always NULL there; sprite.c:351/420 apply to the 68k compiled path. One caveat: if any IIgs build still uses ORCA/C 2.2.1, its 'inline' is parsed but not honored, making the win 68k-only under that compiler. - -### 21. stageDirtyClearAll runs a 200-iteration byte loop every present while its sibling uses memset for the same arrays - -- **Where:** `src/core/surface.c:317` -- **Severity:** high | **Category:** perf - -jlStagePresent calls stageDirtyClearAll after every frame (src/core/present.c:25), so this loop is on the flagship hot op. Yet surfaceMarkDirtyAll, 90 lines up in the same file (lines 225-226), already switched to `memset(gStageMinWord, 0, SURFACE_HEIGHT); memset(gStageMaxWord, STAGE_DIRTY_FULL_MAX, SURFACE_HEIGHT);` with a comment explaining memset 'lowers to a tight fill (MVN-seeded on IIgs) and drops the 200-iter indexed-store loop'. The clear-all variant was left on the slow form: ~200 iterations of two 8-bit indexed stores plus loop overhead under ORCA/C is roughly 5000-8000 cycles per present (the per-present budget at the honest ~29-42 ops/sec is ~66-96k cycles), and the byte-at-a-time loop also runs per-frame on the 68k ports. - -**Fix:** void stageDirtyClearAll(void) { - memset(gStageMinWord, STAGE_DIRTY_CLEAN_MIN, SURFACE_HEIGHT); - memset(gStageMaxWord, STAGE_DIRTY_CLEAN_MAX, SURFACE_HEIGHT); -} -(constant fills 0xFF / 0x00, same values the loop stores; deletes the row variable). - -**Impact:** ~5000-8000 cyc per jlStagePresent on IIgs (~7-10% of a present); every frame on all four ports - -**Verifier correction:** Net saving is the loop cost (~4000-6000 cyc) minus the two memsets (~2800 cyc for 400 bytes), i.e. roughly 2000-4000 cyc per present on IIgs (~3-6% of the honest ~66-96k cycle present budget), not the full 5000-8000. Still a clear every-frame win on all four ports and removes a duplicated slow variant of logic the author already converted to memset in surfaceMarkDirtyAll. - -### 22. jlDrawText pays the full public jlTileCopyMasked wrapper plus a multi-row dirty-mark call per glyph - -- **Where:** `src/core/tile.c:54` -- **Severity:** high | **Category:** perf - -Per character, jlDrawText re-enters the public wrapper which re-checks dst/src for NULL (already checked once at tile.c:43), re-validates cx/cy against block bounds (guaranteed by the cursor-wrap logic at lines 57-62; only srcBx/srcBy from asciiMap actually need validation), and then calls surfaceMarkDirtyRect for an 8-row rect. On IIgs an h=8 mark takes the JSL-to-iigsMarkDirtyRowsInner path (surface.c:262), so every glyph costs a wrapper prologue (~60-80 cyc) plus a cross-segment dirty-mark call that re-widens the same 8 rows the previous glyph just widened. - -**Fix:** In jlDrawText, validate srcBx/srcBy inline, call jlpTileCopyMasked(...) directly, and accumulate a bounding block rect (minCx/maxCx/minCy/maxCy) across the string; issue one surfaceMarkDirtyRect for the union after the loop (over-approximation on wrap is safe). - -**Impact:** ~300-400 cycles saved per glyph on IIgs (~10-15% of text throughput); a 20-char HUD string saves ~7000 cycles per redraw - -**Verifier correction:** The fix must also bounds-check the caller's bx/by once at jlDrawText entry (e.g. return, or clamp, when bx >= TILE_BLOCKS_PER_ROW || by >= TILE_BLOCKS_PER_COL): the wrap logic at tile.c:56-62 only guarantees in-range cx/cy after the first wrap, so with a direct jlpTileCopyMasked call an out-of-range initial by would produce up to 40 out-of-bounds tile writes (past the 200-entry gRowOffsetLut on IIgs) that the current wrapper's validation silently absorbs. Note this is a tiny behavior change for garbage inputs (chars before the first wrap were previously skipped, not drawn); the header documents bx in [0,39], by in [0,24], so rejecting them is fine. With that added, the rest of the fix (inline srcBx/srcBy validation from asciiMap, direct jlpTileCopyMasked, one unioned surfaceMarkDirtyRect after the loop) is sound on all four ports. - -### 23. jlpGenericMillisElapsed wraps backward every ~18 minutes and does a 32-bit multiply+divide per call on IIgs/Amiga - -- **Where:** `src/generic/genericPort.c:40` -- **Severity:** high | **Category:** bug - -jlpFrameCount() returns uint16_t, so the product wraps to 0 after 65536 frames: 18.2 minutes at 60Hz, 21.8 at 50Hz. The uint32_t return type advertises a monotonic millisecond clock, so any game computing `now - last` deltas sees a huge underflow at the wrap and stalls or skips. Only ST and DOS override JL_HAS_MILLIS_ELAPSED (platform.h lines 218/248); the comment at line 37 confirms "This is the IIgs/Amiga path" -- the reference platform is affected. Each call also pays a 32-bit software multiply and a 32-bit divide (hundreds of cycles on the 65816). - -**Fix:** Keep a static uint32_t accumulated frame count: read jlpFrameCount(), extend by tracking the uint16_t delta since the previous call ((uint16_t)(now - last) handles wrap), add to the 32-bit total, then convert. Cache jlpFrameHz() once; for the common 50/60Hz values replace the generic divide with *20 (50Hz) or *50/3 (60Hz) fast cases, keeping the general path as fallback. - -**Impact:** correctness: eliminates the 18-min timer wrap on IIgs/Amiga; perf: drops a 32-bit div per call except in cold generic case - -**Verifier correction:** All core claims stand. Refinements to the fix: (1) do not convert an accumulated total frame count (totalFrames*1000/hz re-overflows uint32 at ~19.9h @60Hz) -- accumulate milliseconds incrementally per uint16_t frame delta, carrying the fractional-frame remainder (e.g. ms += delta*20 at 50Hz; at 60Hz ms += delta*50/3 with mod-3 remainder carry), which pushes the wrap to the natural 2^32 ms (~49.7 days) where uint32_t delta arithmetic is exact for callers; (2) the fix only observes every wrap if called at least once per 65535 frames (~18 min) -- true for any per-frame caller, but document it; (3) on ORCA-C update the statics with explicit load/add/store (the gFrameCount pattern, src/iigs/hal.c:363-371), never ++ on file-scope data (inc abs DBR trap); (4) nuance on failure mode: pure 'delta >= threshold then reset' callers self-heal with one spurious fire -- the severe damage is accumulator-style callers like agi.c:787-789 (freeze + v11 script-clock jump); (5) related cleanup: src/iigs/hal.c:382-386 is a dangling comment for the removed IIgs jlpMillisElapsed acknowledging the old wrap limitation -- delete it, and core.h:61's 'decoupled from frame rate' claim is also currently false on IIgs/Amiga (frame-derived), which the accumulator fix does not change and the doc should note. - -### 24. jlpGenericSamplePixel pays a software multiply (y*160) instead of the SURFACE_ROW_OFFSET LUT the codebase built for exactly this - -- **Where:** `src/generic/genericSurface.c:55` -- **Severity:** high | **Category:** perf - -port.h has no IIgs macro override for jlpSamplePixel, so every IIgs jlSamplePixel call (measured 1916 ops/sec, ~1460 cyc/call) lands here and pays the ORCA/C software multiply helper (~100-150 cyc) for the constant *160. surfaceInternal.h (included by this TU at line 12) defines SURFACE_ROW_OFFSET precisely to dodge this: on IIgs it is a single ASL + indexed read of gRowOffsetLut, and on gcc/Watcom ports it compiles to the same shift+add chain as the raw multiply. The same multiply is also paid per-pixel by the flood-fill C fallback walks in draw.c (lines 151, 166, 177, 237) on DOS. - -**Fix:** uint8_t byte = s->pixels[SURFACE_ROW_OFFSET((uint16_t)y) + ((uint16_t)x >> 1)]; -- identical output on all ports, drops the multiply helper on IIgs. - -**Impact:** ~100-150 cyc/call saved on IIgs, roughly 7-10% of jlSamplePixel; zero cost/change on other ports - -**Verifier correction:** Directionally and numerically correct as written. Small precision fix: the per-pixel DOS flood-walk mention is not a real cost (x86 compilers strength-reduce the constant multiply, and the fix expands to the identical expression there); the concrete IIgs beneficiaries are jlSamplePixel itself (~8-10%/call), the once-per-call flood seed reads, and the per-pixel sprite-capture path in src/core/sprite.c:296-297. - -### 25. PERF.md IIgs baseline predates the Phase-2 jlDrawPixel/dirty-mark rewrite; all cross-port ratios are computed against a stale floor - -- **Where:** `PERF.md:38` -- **Severity:** medium | **Category:** multi-source-of-truth - -IIGS_PERF_PLAN.md declares Phase 2 DONE ('jlDrawPixel fully inlined on IIgs ... This is the 5-10x path', plan line ~28-31), yet PERF.md (regenerated 2026-06-30) still shows 1755 ops/sec -- the exact figure the plan's pre-fix analysis derived its '~1595 cyc/call' from (plan line 196: 'jlDrawPixel costs ~1595 cyc/call (2.8e6 / 1755)'). PERF.md's own preamble admits the IIgs column is 'unchanged from the last IIgs capture'. So the reference floor that the whole perf directive ('every target must meet or beat the IIgs') is measured against does not describe the shipped code: the current draw.c:521-542 chain models at ~400-500 cyc (~5600-7000 ops/sec). Amiga's 2.04x and ST's 1.18x on jlDrawPixel are therefore probably violations, not passes -- the same class of measurement lie the plan's Phase 0 was created to kill for SEI ops. The ~1500 'missing' cycles in the task brief are roughly half this stale baseline and half the jlStageGet/asm-framing findings. - -**Fix:** Re-capture the IIgs UBER column on MAME (make iigs-verify path) before any further cross-port tuning, and mark stale-capture columns explicitly in PERF.md (date per column) so a ratio can never silently mix captures from different code revisions. - -**Impact:** Scope: every IIgs row and every Amiga/ST/DOS ratio in PERF.md for the pixel-family ops; misdirects which ports are 'passing' the perf floor - -**Verifier correction:** The IIgs PERF.md column is genuinely one stale capture predating the whole IIGS_PERF_PLAN implementation (identical since commit 087bc77, 2026-05-27), so a recapture with per-column capture dates is warranted. But the jlDrawPixel impact is ~7%, not 3-4x: draw.c:524-527 records the shipped inlined form measured at 1875 ops/sec on hardware (vs 1672 for an all-C inline), so Amiga (~1.91x) and ST (~1.10x) remain passes against the honest floor. The rows where staleness plausibly changes conclusions are the fillRect/clear family: Phase 0.1 removed the fillRect SEI (table still shows the inflated 60), Phase 3's computed-jump slam should raise fillRect 16x16/80x80, and Phase 5.1's PHA-slam should raise surfaceClear -- meaning some 68k 'passes' near 1.0-1.3x on those rows (e.g. ST fillRect 1.11x/1.33x, Amiga 0.95x) could flip to violations after recapture, not the pixel rows. - -### 26. Eleven IIgs JL_HAS flags are decorative -- the port.h macro overrides bypass the registry, so entries can be added/removed with zero effect - -- **Where:** `include/joey/platform.h:100` -- **Severity:** medium | **Category:** multi-source-of-truth - -For every IIgs op that port.h implements as a macro (SURFACE_CLEAR, DRAW_PIXEL, FILL_RECT, TILE_FILL, TILE_COPY, TILE_COPY_MASKED, TILE_PASTE, TILE_SNAP, SPRITE_DRAW, SPRITE_SAVE, SPRITE_RESTORE), the dispatch stanza's '#if !defined(jlp)' guard (e.g. port.h:205, 275) short-circuits before the JL_HAS flag is ever tested, and no core gate consults them (core only gates on JL_HAS_DRAW_LINE/DRAW_CIRCLE/FILL_CIRCLE/FLOOD_* -- draw.c:111,313,456,630). So the registry that platform.h line 81-86 declares authoritative is silently non-authoritative for the reference machine: deleting JL_HAS_TILE_PASTE from the IIgs table changes nothing, and forgetting to add a flag (the TILE_PASTE_MONO gap above) produces no error. This is the systemic mechanism behind silent-generic-fallback rot. Full sweep result: all other flags in all four tables do have matching jlp symbols, and no port defines a jlp function without its flag -- the drift today is confined to the IIgs macro set and the missing entries reported separately. - -**Fix:** In port.h's IIgs macro block, pair each macro with a consistency check ('#if !defined(JL_HAS_TILE_PASTE) #error "IIgs macro override without registry flag" #endif' or one grouped check), so the table and the macro set can never diverge silently in either direction. - -**Impact:** Compile-time guard; zero runtime cost. Closes the exact hole that let jlTilePasteMono run generic C on the perf-reference machine unnoticed. - -**Verifier correction:** Accurate version: the 11 IIgs flags are decorative and the registry comment (platform.h:84-86) is a second, false source of truth; pairing each IIgs port.h macro with '#if !defined(JL_HAS_) #error #endif' (covering all 15 macro ops, including DRAW_LINE/DRAW_CIRCLE/FILL_CIRCLE/FLOOD_WALK_AND_SCANS whose flags stay load-bearing via core gates) prevents future macro/flag drift and keeps the table trustworthy as documentation. It does NOT close the finding-0 class of hole: an op missing from both the macro set and the registry (TILE_PASTE_MONO) triggers no check and silently takes the generic ? catching that class requires a deliberate per-port audit of which generic-fallback ops are intentional (DOS relies on generics by design), not a mechanical guard. - -### 27. Amiga compiled save/restore dispatchers and the shift-alias loop are dead code contradicted by the emitters - -- **Where:** `src/codegen/spriteCompile.c:246` -- **Severity:** medium | **Category:** dead-code - -The comments here (lines 174-178, 241-245) claim Amiga 'SAVE/RESTORE emit only shift 0 and shift 1' and that this loop aliases slots 2..7 -- but spriteEmitSavePlanar68k and spriteEmitRestorePlanar68k both just `return 0u;` ('Save/restore are not compiled on the Amiga yet', spriteEmitPlanar68k.c lines 223-235). So every routineOffsets[*][SAVE/RESTORE] entry is SPRITE_NOT_COMPILED, the alias loop copies sentinel onto sentinel, and the entire Amiga spriteCompiledSaveUnder/spriteCompiledRestoreUnder bodies (lines 557-623) are unreachable: the sprite.c gates require a non-sentinel offset. Every Amiga sprite save/restore therefore runs the interpreted C walkers in amiga/hal.c -- consistent with the measured 0.13-0.22x sprite gap -- while this file reads as if compiled save/restore ships. Note also that when the emitters do land, jlSpriteRestoreUnder's chunky-only gate (sprite.c lines 496-497: copyBytes must equal spriteBytesPerRow or spriteBytesPerRow+1) will reject the planar shifted width (backup->width = widthPx+8 gives copyBytes = spriteBytesPerRow+4), silently keeping 7 of 8 x-alignments on the interpreted path. - -**Fix:** Either delete the alias loop plus the dead Amiga dispatcher bodies until the emitters exist (leaving a one-line comment), or land the emitters; in either case make the restore gate platform-aware (accept the planar shifted copyBytes, e.g. via a SPRITE_RESTORE_SHIFT_FROM_BACKUP macro next to SPRITE_SHIFT_INDEX in spriteInternal.h) so the compiled path is reachable for shifted x. - -**Impact:** ~70 lines of unreachable dispatch code; comments actively misdirect the Amiga sprite-perf work that memory notes flag as the number-one gap - -**Verifier correction:** Core claim correct. Fix adjustment: replace the Amiga dispatcher bodies with ST-style empty linker stubs (sprite.c calls them unconditionally, so full deletion breaks the link), delete or clearly mark the alias loop and the lines 172-178/241-245 comments as aspirational, and when the emitters land make the restore gate accept the planar copyBytes = spriteBytesPerRow+4 case. - -### 28. IIgs spriteCompiledDraw still pays the 2D-array multiply helper the same file dodges everywhere else - -- **Where:** `src/codegen/spriteCompile.c:311` -- **Severity:** medium | **Category:** perf - -The save and restore dispatchers in this same file rewrite routineOffsets[shift][op] as byte-pointer arithmetic specifically because 'uint16_t arr[N][M] indexing' and 'shift * SPRITE_OP_COUNT ... can lower to a multiply helper call' on ORCA/C (comments at lines 419-422 and 426, and sprite.c lines 559-561). But spriteCompiledDraw -- the single hottest dispatch, behind jlSpriteDraw at 438 ops/sec -- still uses the plain 2D index. DRAW is the one op where this was not applied. - -**Fix:** Match the save/restore pattern: `routineOffset = *(uint16_t *)((uint8_t *)sp->routineOffsets + (((((uint16_t)shift << 1) + shift) + SPRITE_OP_DRAW) << 1));` then add routineOffset into fnAddr (or take it from the caller per the dedup finding). - -**Impact:** ~30-50 cycles per jlSpriteDraw on IIgs (0.5-0.8% of the op), zero risk - -### 29. Dispatcher gate and spriteCompiled* callees each re-derive shift, route offset, and function address per call - -- **Where:** `src/codegen/spriteCompile.c:423` -- **Severity:** medium | **Category:** duplication - -The hot chain does the same derivations twice per op. jlSpriteSaveUnder computes shift, routeIdx = (shift<<1)+shift+SPRITE_OP_SAVE, and loads routeOffset to gate the fast path (sprite.c lines 565-567); spriteCompiledSaveUnder then recomputes shift (line 392), cacheIdx (line 423), and re-loads routineOffset (line 427). Restore is worse: sprite.c lines 487-501 compute spriteBytesPerRow, copyBytes, shift, routeIdx, and routeOffset, then spriteCompiledRestoreUnder calls spriteChunkyRestoreShift which recomputes copyBytes and spriteBytesPerRow (widthTiles*4 again) and re-derives shift (lines 471-482). jlSpriteSaveAndDraw triples it: it computes saveIdx/drawIdx and loads both offsets (sprite.c 408-413), then both callees redo everything, and both callees also independently compute the screen row pointer from SURFACE_ROW_OFFSET(y) (lines 398 and 308) even though sprite.h line 134 promises 'the destination ptr is computed once'. widthPx/heightPx are likewise recomputed from tiles at every entry (sprite.c 338-339, 395-396, 551-552; spriteCompile.c 394-395) when they are create-time constants. - -**Fix:** Pass the already-derived values through: change spriteCompiledDraw/SaveUnder/RestoreUnder signatures (internal, spriteInternal.h) to take uint8_t shift and uint16_t routineOffset from the gate; in SaveAndDraw compute the row pointer once and hand it to both. Additionally precompute widthPx, heightPx, and per-shift copyBytes into jlSpriteT at spriteInitFields time. - -**Impact:** ~40-100 cycles per compiled sprite op on IIgs (0.6-1.5% of jlSpriteDraw, more for SaveAndDraw); removes three copies of the same index-math truth - -**Verifier correction:** Directionally right; one detail off: plain jlSpriteDraw's gate (sprite.c:346) does NOT derive shift/routeOffset today (only save/restore gates do), so the draw-path saving is just the widthPx/heightPx precompute (~10-20 cyc); the real wins are RestoreUnder (~100+ cyc: kills the spriteChunkyRestoreShift call) and SaveAndDraw (row pointer once). Refactor must touch all four platform dispatcher variants and keep Amiga/ST's SPRITE_NOT_COMPILED delegate path working. - -### 30. assetLoad.c re-spells TILE_BYTES and the 40x25 tile-grid limits as private literals - -- **Where:** `src/core/assetLoad.c:41` -- **Severity:** medium | **Category:** multi-source-of-truth - -The file already includes joey/tile.h, which is the declared single source of truth for tile geometry ('one source of truth for the 4bpp packed layout' per sprite.c:18-19): TILE_BYTES == 32, TILE_BLOCKS_PER_ROW == 40, TILE_BLOCKS_PER_COL == 25. ASSET_TILE_DATA_BYTES is the fread size written straight into outTiles[i].pixels (assetLoad.c:238), whose real capacity is TILE_BYTES -- if tile geometry ever changed, the load would silently read the wrong byte count into the struct. The 40/25 caps would likewise silently diverge from the real surface grid. - -**Fix:** #define ASSET_TILE_DATA_BYTES TILE_BYTES, ASSET_MAX_TILES_W TILE_BLOCKS_PER_ROW, ASSET_MAX_TILES_H TILE_BLOCKS_PER_COL (or use the tile.h names directly and delete the local defines). - -**Impact:** Zero runtime cost; removes three shadow constants that must currently be kept in sync with tile.h by hand - -### 31. jlSpriteBankLoad allocates up to 32000-byte cels with malloc, violating core.h's own IIgs allocator contract - -- **Where:** `src/core/assetLoad.c:167` -- **Severity:** medium | **Category:** bug - -tileBytes can reach ASSET_MAX_TILES_W * ASSET_MAX_TILES_H * 32 = 32000 bytes per cel, and a bank loads up to maxCels of them. include/joey/core.h:30-36 documents that on the IIgs the C heap lives in bank 0, is tiny, caps a single malloc at ~32 KB, and that 'multi-KB caches and frame buffers must come from the IIgs Memory Manager' via jlAlloc. Loading even a modest sprite bank (e.g. 16 cels of 32x32 = 4 KB each) exhausts the bank-0 heap and the loader silently returns a short count mid-file. src/core/sprite.c (lines 215/255/306) has the same pattern, so a fix should cover both and free with the matching jlFree. - -**Fix:** Route cel pixel buffers (and jlSpriteT headers if desired) through jlAlloc/jlFree instead of malloc/free, after confirming the IIgs sprite emitter has no bank-0 assumption on tileData; update jlSpriteFree/ownsTileData teardown to use jlFree. - -**Impact:** Prevents silent short-loads of sprite banks beyond ~4-8 cels on IIgs - -**Verifier correction:** Directionally correct; three details to fix in the writeup: (1) the teardown function is jlSpriteDestroy (sprite.c:316-328, frees tileData with free()), not 'jlSpriteFree'; (2) jlSpriteCreateFromSurface's buf (sprite.c:255) and both jlSpriteT header mallocs (sprite.c:215/306, assetLoad.c:175) must switch in the same commit because jlSpriteDestroy cannot distinguish malloc'd from jlAlloc'd tileData; (3) on IIgs the heap is ~32KB TOTAL (0x4000-0xBF00), not just per-allocation capped, and the 8KB codegen arena mallocs from the same pool, so exhaustion arrives even sooner than the finding's 16-cel estimate. - -### 32. jlSpriteBankLoad re-implements spriteInitFields field by field, and the spriteInternal.h comment contradicts it - -- **Where:** `src/core/assetLoad.c:180` -- **Severity:** medium | **Category:** duplication - -assetLoad.c lines 180-190 duplicate the exact body of spriteInitFields (sprite.c lines 117-127), including all four memsets with their subtle 0xFF-vs-0 sentinel rules. Two sources of truth for jlSpriteT initialization means the next field added to the struct (this already happened once with cachedSizeBytes) must be remembered in both places; missing the loader copy leaves bank-loaded sprites with garbage in a dispatch-critical field -- e.g. an uninitialized routineOffsets entry of 0 is a VALID arena offset and would dispatch into stale code, exactly the failure mode the 0xFF fill exists to prevent (sprite.c lines 113-116). Separately, spriteInternal.h line 39 documents tileData as 'NULL for loaded sprites', which this code disproves (loaded sprites own a malloc'd copy) -- and if it were true, jlSpriteCompile would reject every loaded sprite (spriteCompile.c line 165). - -**Fix:** Make spriteInitFields non-static, declare it in spriteInternal.h, and call `spriteInitFields(sp, buf, widthTiles, heightTiles, true);` from jlSpriteBankLoad. Fix the spriteInternal.h line 39 comment. - -**Impact:** Removes an 11-line duplicate of sentinel-sensitive init code; closes a future stale-dispatch/crash class for bank-loaded sprites - -### 33. Mixer re-reads slot fields through the pointer every sample because dst stores are char-typed and may alias - -- **Where:** `src/core/audioSfxMix.c:131` -- **Severity:** medium | **Category:** perf - -The loop caches pos and step in locals but reads slot->length (line 127), slot->sample, and slot->streamLen (lines 114, 123-124) through the slot pointer on every sample. Because `dst[i] = ...` (line 141) writes through a (volatile) uint8_t pointer, C aliasing rules force the compiler to assume the store may modify *slot and reload those fields each iteration -- 3-4 extra memory operands per sample on the 68000 (~40-60 cycles). The per-sample `if (isStream)` branch also runs 12288 times/sec even though isStream is loop-invariant. - -**Fix:** Hoist `const uint8_t *smp = slot->sample; uint32_t length = slot->length; uint16_t streamLen = slot->streamLen;` into locals before the inner loop (re-snapshot streamLen after streamRefill), and split the fixed-buffer and streaming cases into two separate inner loops so the isStream test happens once per slot instead of once per sample. - -**Impact:** ~10-15% of the mixer inner loop on ST - -**Verifier correction:** Real but the per-sample overhead is 2 slot-field reloads (length, sample ptr; streamLen on the stream path) plus the isStream test -- about 30-40 cycles/sample, not 3-4 operands/40-60: gcc already hoists slot->fill into a register and reads length only once per iteration, reusing it for both bounds checks. Hoisting into locals plus splitting the fixed/stream loops is the right fix and was verified to keep the fields in registers across the volatile store; expect ~7-9% of the loop today, ~10-13% after the __mulsi3 fix. - -### 34. codegenArenaShutdown frees every ArenaSlotT while live sprites still hold pointers; shutdown -> re-init -> jlSpriteDestroy corrupts the heap - -- **Where:** `src/core/codegenArena.c:294` -- **Severity:** medium | **Category:** bug - -jlShutdown (init.c:129) calls this with no way to clear sp->slot in user-owned sprites. While the arena stays down, codegenArenaFree's `gCodegenArenaBase == NULL` guard (line 207) saves a later jlSpriteDestroy -- but after a second jlInit the guard passes and lines 210-214 read/write the freed struct (`slot->used = false; gUsedBytes -= slot->size;`) and coalesce through freed next/prev pointers: heap corruption. Drawing the old sprite instead executes stale offsets inside the NEW arena (silent garbage execution). Relatedly, the double-free 'ignore' at lines 210-212 is false safety: per codegenArenaInternal.h:46-49 the struct 'may be freed (if it coalesced into a neighbor)', so the `!slot->used` check itself can be the use-after-free. (Compaction, by contrast, was audited safe: all four emitters are genuinely position-independent -- 68k d16(An)-only, IIgs abs,Y with DBR from the argument and MVN bank operands that are data banks moving intact with the memmove, x86 [esi+disp] -- so the cachedDstBank/cachedSrcBank cache stays valid across moves.) - -**Fix:** Keep a small registry (or intrusive list) of compiled jlSpriteT in sprite.c; have a spriteSystemShutdown hook walk it from jlShutdown, calling codegenArenaFree and NULLing sp->slot + routineOffsets before codegenArenaShutdown runs. - -**Impact:** Eliminates heap corruption / wild execution across any shutdown/re-init cycle with surviving sprites - -**Verifier correction:** Accurate as written; note the exploit window is specifically shutdown -> re-init -> destroy/draw (destroys while the arena stays down are already safe via the line-207 guard, and every current example shuts down only at exit). A documentation-only fix (sprites are invalidated by jlShutdown; destroy them first) would also close it at zero code cost if the author prefers. - -### 35. floodFillInternal C fallback evaluates the loop-invariant matchEqual ternary and a jlpSamplePixel function call per pixel - -- **Where:** `src/core/draw.c:168` -- **Severity:** medium | **Category:** perf - -matchEqual is fixed for the whole fill, but the walk-left loop (line 168), walk-right loop (line 179), and the markBuf fill (lines 239-241) re-branch on it per pixel. Each walked pixel also pays a full jlpSamplePixel function call that re-derives the row base (SURFACE_ROW_OFFSET(y), a *160 multiply on non-IIgs generic) even though y is constant across the entire walk and scan. On the ST this is the worst case: it has no JL_HAS_FLOOD_* hooks at all (platform.h lines 189-241), so every walked/scanned pixel is a function call into the planar samplePixel that decodes 4 plane bits -- flood fill on ST will be far below the IIgs reference, violating the perf floor. Flood fill is a declared hot op for the planned AGI/Sierra picture playback. - -**Fix:** Split the walk and markBuf loops into matchEqual/boundary variants selected once per call (compile-time-style if/else duplication, no dispatch tables), and give the chunky generic a row-walk helper that takes a precomputed row pointer and steps bytes (2 px/byte) instead of calling jlpSamplePixel per pixel; add the analogous ST plane-row walker (JL_HAS_FLOOD_WALK_PLANES / JL_HAS_FLOOD_SCAN_ROW_PLANES) like Amiga has. - -**Impact:** Removes 1-2 branches plus a ~100-300 cycle call per walked pixel on DOS/ST; ST flood fill is currently per-pixel planar-decode calls and stands to gain several x - -**Verifier correction:** The generic samplePixel's y*160 is strength-reduced to shifts/adds by gcc on 68000/DOS, so the per-pixel cost there is the function call + (on ST) the 4-bit plane decode, not a multiply; the ST plane-walker hook is where the multiple-x gain lives. - -### 36. jlDrawCircle fallback runs 8 bounding-box compares per iteration that are provably dead after the first iteration - -- **Where:** `src/core/draw.c:345` -- **Severity:** medium | **Category:** unnecessary-conditional - -On the first iteration x == r and y == 0, so the accumulation immediately sets maxX = cx+r, minX = cx-r, maxY = cy+r, minY = cy-r. Every subsequent candidate uses offsets x,y <= r, so none of the 8 conditionals can ever fire again -- the box is a compile-time-derivable constant of (cx, cy, r), and the accumulation (unlike the plots) does not depend on which pixels were on-surface since it folds off-surface reflections too. All 8 compare+branch pairs per iteration are pure dead work. - -**Fix:** Delete the 8 in-loop conditionals; before the loop compute the box once in int32 (minX = cx-r, maxX = cx+r, minY = cy-r, maxY = cy+r, then the existing clamp). The clamped result is bit-identical, including the fully-off-surface collapse to no-op. - -**Impact:** 8 compare/branch pairs (~50-80 cycles) per iteration, ~0.7*r iterations per circle: ~5% of the fallback circle path (every circle on DOS, every clipped circle on IIgs/Amiga/ST) - -### 37. Bresenham fallbacks accumulate a bounding box per pixel that is statically known before the loop - -- **Where:** `src/core/draw.c:484` -- **Severity:** medium | **Category:** unnecessary-conditional - -A line's dirty box is exactly min/max of its two endpoints -- the JL_HAS_DRAW_LINE fast path already computes precisely that in four ternaries BEFORE drawing (draw.c:459-462: 'int16_t bbx = (x0 < x1) ? x0 : x1;' etc.), yet the fallback re-derives it with 4 compare+branches on EVERY plotted pixel. Same pattern in jlDrawCircle (draw.c:345-352): 8 folds per iteration whose result is fully determined after the first iteration, because iteration one runs with x==r, y==0 and immediately sets the box to cx-r..cx+r / cy-r..cy+r; every later fold is dead work. DOS defines neither JL_HAS_DRAW_LINE nor JL_HAS_DRAW_CIRCLE, so every diagonal line and every circle outline there pays this; IIgs/Amiga/ST pay it on every clipped line/circle. - -**Fix:** jlDrawLine: hoist the endpoint min/max computation above the loop (reuse the exact 4-ternary block from the fast path) and delete the in-loop folds. jlDrawCircle: set minX=cx-x, maxX=cx+x, minY=cy-x, maxY=cy+x once before the loop (x==r there; keep the existing post-loop clamp, which already handles fully-off-surface circles). - -**Impact:** ~20-40 cycles/pixel removed from the clipped line path (4 compare+branch on 65816/68000) and ~8 folds/iteration from circle outlines; DOS pays this on every diagonal line and circle - -**Verifier correction:** Valid, but entirely redundant with findings 3 and 5; treat as one fix. For the circle, hoist in int32 (per finding 5) rather than int16 so centers near int16 limits do not under-mark. - -### 38. jlFillCircle C span loop pays surfaceMarkDirtyRect (plus IIgs jlStageGet) per span instead of one bounding-box mark - -- **Where:** `src/core/draw.c:668` -- **Severity:** medium | **Category:** perf - -In the onSurface branch every one of the 2r+1 scanline spans routes through fillRectOnSurface, which is jlpFillRect + surfaceMarkDirtyRect. That is 2 extra function calls per span (3 on IIgs, where the jlpFillRect macro also calls jlStageGet() per span), yet the exact final dirty rect is already known: the (cx-ir, cy-ir, 2ir+1, 2ir+1) square the JL_HAS_FILL_CIRCLE path marks once at line 634. For non-stage surfaces surfaceMarkDirtyRect is a pure wasted call per span (immediate `s != gStage` return). This path is hit for every on-surface fill circle on DOS, and on IIgs/Amiga/ST whenever jlpFillCircle returns false (IIgs: any non-stage surface). - -**Fix:** In the onSurface branch call jlpFillRect(...) directly per span (no fillRectOnSurface), and after the loop mark once: surfaceMarkDirtyRect(s, (int16_t)(cx - ir), (int16_t)(cy - ir), (int16_t)(2 * ir + 1), (int16_t)(2 * ir + 1)). Identical dirty coverage, since the y=0 span already spans cx-r..cx+r. - -**Impact:** Eliminates 2r+1 surfaceMarkDirtyRect calls (+2r+1 jlStageGet calls on IIgs): ~100-160 cycles/span on 65816, r=30 saves ~6-10k cycles (~15-25% of the C-fallback fill circle) - -**Verifier correction:** Savings are the surfaceMarkDirtyRect + fillRectOnSurface wrapper calls per span only; the per-span jlStageGet inside the jlpFillRect macro remains unless gStage is also made directly comparable (finding 4). Dirty coverage is not identical: the single square mark over-marks rows away from the center exactly like the JL_HAS_FILL_CIRCLE asm path already does (safe; slightly more present-time slam on DOS stage fills, pure win for non-stage surfaces where marks are no-op calls). - -### 39. DEFAULT_CODEGEN_BYTES (8KB) is sized to IIgs code density; a single 32x32 sprite's ST/Amiga DRAW routines nearly fill or overflow it, silently forcing the interpreter - -- **Where:** `src/core/init.c:20` -- **Severity:** medium | **Category:** perf - -The '~3-4 KB per 32x32 sprite' claim is IIgs math (5-9 bytes/destbyte, opaque pairs 3 bytes). The 68k emitters are 4-8x less dense: ST emits 2 DRAW shifts at 4 planes * 6 bytes per opaque column-row (spriteEmitInterleaved68k.c:191-196) = ~3.2KB/shift for an all-opaque 32x32 (~6.4KB for both shifts), and mixed columns cost 12-16 bytes/plane (line 198), up to ~8KB per shift. Amiga is ~3.1KB (shift 0 only) opaque, ~8KB mixed. So with the default (used by examples draw, hello, keys, joy, pattern, audio), the second sprite -- or even the first partially-transparent one -- fails codegenArenaAlloc and runs interpreted with no error. This matches the benched 'compile fails -> interpreted' symptom on the 68k ports. - -**Fix:** Make the default per-platform, e.g. `#if 68k/DOS: 32KB, IIgs: 8KB` (68k heaps are large; only the IIgs justification in the comment holds there), and correct the comment to state per-target densities. - -**Impact:** Removes silent interpreter fallback (5-8x sprite slowdown) for every app using the default on Amiga/ST - -**Verifier correction:** Directionally right, two details wrong. (1) The named examples (draw/hello/keys/joy/pattern/audio) set codegenBytes=8*1024 explicitly and create no sprites; every sprite-using example sets 32KB+ ? so nothing in the repo currently hits this. It is a latent trap only for user apps that pass codegenBytes=0. (2) The 68k bench fallback is NOT this bug: uber uses 32KB; its interpreter fallback comes from shift coverage (ST compiles only byte-aligned x, Amiga only shift 0; save/restore emit 0 on both). Accurate math: ST all-opaque 32x32 needs ~6.4KB (both shifts, one slot) so the FIRST fits the 8KB default and the second fails; a substantially-mixed 32x32 (~10-16.6KB) fails alone. Amiga: opaque ~3.1KB, fully-mixed ~8.2KB fails alone. Fix: make the default per-platform (e.g. 32KB on 68k/DOS, 8KB IIgs), rewrite the comment with per-target densities, and consider setting an error string or documenting jlSpriteCompiledSize()==0 as the failure probe so the fallback is diagnosable. - -### 40. jlKeyDown's real cost is ~5x its body: cross-TU call overhead around a two-load predicate - -- **Where:** `src/core/input.c:92` -- **Severity:** medium | **Category:** perf - -The function body is one compare plus one long-addressed byte load (~30 cycles on 65816), but every call pays JSL/RTL plus prologue/epilogue plus parameter marshalling (~120-180 cycles). Games are told to call the predicates per key per frame (include/joey/input.h header comment), so a typical 6-10 key poll burns ~1-1.5K cycles/frame in pure call overhead. Combined with the jlFrameCount forwarding finding, this fully accounts for the 3382 ops/sec bench: jlKeyDown itself is a minority of the measured 830 cycles/iter. - -**Fix:** The state arrays are already extern (src/core/inputInternal.h). Move the declarations into joey/input.h (documented as read-only) and make jlKeyDown/jlKeyPressed/jlKeyReleased and the mouse predicates `static inline` (or macros) in the header so the compare+load inlines at the call site; keep the out-of-line definitions for ABI/linkage compatibility. - -**Impact:** ~5x per predicate call (~150 -> ~30 cycles); ~1K cycles/frame in a typical input handler on IIgs - -**Verifier correction:** Directionally correct, but the fix must not rely on 'static inline' for the IIgs: ORCA/C 2.2.1 accepts at best but never performs inlining, so under the current baseline compiler header inlines are a no-op (or a parse error). Use macro-form predicates in joey/input.h (like the existing jlp macros in port.h), or guard true 'static inline' behind #if defined(__clang__)/__GNUC__ for the clang-IIgs/m68k-gcc builds while ORCA keeps the extern functions. Keep the out-of-line definitions for linkage. Note the macro caveat: args are evaluated 2-3 times, so a public macro jlKeyPressed(keys[i++]) would misbehave ? either document plain-lvalue args or restrict macros to the internal build. Realistic win: ~120-150 -> ~30-35 cyc per predicate call on 65816 (~4x, not quite 5x), roughly 1K cyc/frame in a typical handler. - -### 41. port.h comment says 'the asm derives the fill word' but the jlpTileFill/jlpFillCircle macros compute (c & 0x0F) * 0x1111 in C -- a software multiply helper per call on the 65816 - -- **Where:** `src/core/port.h:130` -- **Severity:** medium | **Category:** perf - -Line 126 documents 'tileFill: takes colorIndex; the asm derives the fill word ((c&0x0F) * 0x1111)' -- but the macro body performs the multiply in C and passes the finished fillWord to iigsTileFillInner (prototype at port.h:62 takes 'uint16_t fillWord'). jlpFillCircle does the same at line 99. The comment and code disagree (second source of truth), and the code picked the losing option: when the caller's colorIndex is a variable (the normal case -- core jlTileFill passes the API argument straight through), ORCA-C lowers *0x1111 to its 16x16 software multiply helper. Meanwhile the surrounding comments (port.h:125, surfaceInternal.h:117-120) document dodging that exact helper for *160. - -**Fix:** Make the comment true: change iigsTileFillInner / iigsFillCircleInner to accept the raw nibble and derive the fill word in asm (ASL x4 + ORA, twice: ~12 cycles), and pass '(uint16_t)((_c) & 0x0F)' from the macros. Constant-color call sites lose nothing (the asm expansion is still cheaper than a helper call). - -**Impact:** ~100-300 cycles saved per variable-color jlTileFill / jlFillCircle call on the reference machine (roughly 5-10% of a tileFill-class op), plus removes a comment/code contradiction. - -**Verifier correction:** Directionally right with three detail fixes: (1) the asm derivation costs ~20-30 cycles (pha / 4x asl / ora 1,s gives n*0x11, then sta/xba/ora doubles it into a word), not ~12; still 5-10x cheaper than the multiply helper. (2) 'Constant-color call sites lose nothing' is moot ? the only call sites of these macros (tile.c:121, draw.c:633) pass runtime variables, so nothing constant-folds today anyway. (3) Under the in-progress clang/llvm-mos migration the *0x1111 may be strength-reduced inline (~30-80 cyc with 65816 register spills), shrinking but not eliminating the win; on the ORCA/C baseline the helper-call estimate stands. Also update the fillCircle comment at port.h:90-93, which asserts the C multiply is 'still cheaper than three sequential ORs/shifts' ? false by the repo's own ~150 cyc multiply figure. - -### 42. DOS pays real cross-TU calls to no-op planar hooks on every sprite draw/save/restore and surface copy -- port.h's own comment admits it - -- **Where:** `src/core/port.h:179` -- **Severity:** medium | **Category:** perf - -The ((void)0) elision block for jlpSurfaceCopyPlanes / jlpSprite{Draw,Save,Restore}Planes (port.h:185-190) is scoped to JOEYLIB_PLATFORM_IIGS only. DOS defines no JL_HAS_SPRITE_* flags, so those hooks resolve to jlpGenericSpriteDrawPlanes/SavePlanes/RestorePlanes -- empty bodies in src/generic/genericSprite.c:10-22 -- called for real: jlSpriteSaveAndDraw's compiled fast path makes two no-op calls per sprite (sprite.c:417-418, since COMPILED_SPRITE_WRITES_PLANES is 0 on DOS per sprite.c:36-39), jlSpriteRestoreUnder one (sprite.c:505/530), jlSpriteDraw one (sprite.c:355), and jlSurfaceCopy one (surface.c:70). All args are pushed, the call executes, nothing happens. - -**Fix:** Move the four planar-hook elision macros out of the IIgs-only block into a '#if defined(JOEYLIB_NATIVE_CHUNKY)' block (covers IIgs, DOS, and the blank template, which is chunky by default); genericSprite.c then compiles to dead code on every shipping chunky port and can be dropped from those builds. - -**Impact:** 3 wasted far-ish calls (~30-60 cycles each with 4-6 pushed args in real-mode x86) per sprite save+draw+restore cycle -- a few percent of DOS per-sprite cost, and deletes an entire misleading TU from chunky builds. - -**Verifier correction:** Mechanism and fix are correct, but the cost model is wrong: DOS builds with DJGPP (i586-pc-msdosdjgpp-gcc, 32-bit protected mode per toolchains/env.sh:61-62), not real mode ? there are no far calls. An empty cdecl call with 4-6 pushed 32-bit args is ~10-25 cycles on 486/Pentium class, not 30-60, so the per-sprite win is ~1-3%, on the port that is already the fastest. The real value is the cleanup: gate the four elision macros on JOEYLIB_NATIVE_CHUNKY (covers IIgs/DOS/blank), making genericSprite.c + jlpGenericSurfaceCopyPlanes dead on all shipping chunky builds. Note the behavior is documented as intentional at port.h:179-181, so also update that comment when applying the fix. - -### 43. jlRandom's 32-bit shift triple (13/17/5) is a worst case for the 16-bit 65816; bit-identical 16-bit-half rewrite available - -- **Where:** `src/core/random.c:23` -- **Severity:** medium | **Category:** perf - -On the IIgs each 32-bit shift lowers to either a helper call or a per-bit asl/rol loop; 13+17+5 = 35 single-bit 32-bit shifts is ~400-700 cycles per jlRandom call, for a generator explicitly chosen (file header) to avoid multiply cost on the 65816. The same update can be computed on uint16_t halves with far cheaper shifts: x<<13 is hi'=(hi<<13)|(lo>>3), lo'=lo<<13; x>>17 touches only the low half (lo ^= hi>>1, hi unchanged); x<<5 is hi'=(hi<<5)|(lo>>11), lo'=lo<<5. Output stays bit-identical, preserving the cross-port determinism contract in core.h. - -**Fix:** Store gRngState as two uint16_t halves (or unpack/repack locally) and apply the three xors with the 16-bit shift identities above; keep jlRandom returning the recombined uint32_t. Optionally hand-roll the 65816 version per the perf directive -- the >>17 step in particular collapses to a single 16-bit shift and xor. - -**Impact:** ~2-3x per jlRandom call on IIgs (~500 -> ~200 cycles); matters for per-entity per-frame RNG use - -**Verifier correction:** Directionally correct, but make the 16-bit-halves path IIgs-only (or hand-rolled 65816 asm per the perf directive) rather than a portable rewrite: on the 68000, GCC emits native 32-bit shifts (lsl.l/lsr.l at 8+2n cycles plus swap tricks for >>17, ~100 cycles total for all three shifts), so the halves form is a wash-to-slight-loss there. Keep the existing 32-bit C for GCC targets (Amiga/ST/DOS) and gate the decomposed version behind the IIgs platform define; output is bit-identical either way so cross-port determinism is preserved. - -### 44. isFullyOnSurface uses 32-bit adds/compares on every hot sprite entry gate, against the file's own stated convention - -- **Where:** `src/core/sprite.c:103` -- **Severity:** medium | **Category:** perf - -isFullyOnSurface runs on every jlSpriteDraw (line 346), jlSpriteSaveAndDraw (line 403), and jlSpriteSaveUnder (line 562) call -- the hottest per-frame gates -- and does two int32 add+compare sequences (lines 103 and 106). On ORCA/C 65816 each 32-bit add/compare is a multi-instruction software sequence or helper call. The same file explicitly avoids this pattern 370 lines later: jlSpriteRestoreUnder's comment (lines 469-472) says the edge tests 'don't need 32-bit arithmetic (which would invoke a costly long-compare helper)'. The 32-bit cast exists only because x + widthPx can overflow 16-bit int; rearranging the comparison keeps everything in native 16-bit: widthPx is at most 255*8 = 2040, so SURFACE_WIDTH - (int16_t)widthPx fits int16_t. - -**Fix:** Rewrite as pure 16-bit arithmetic: `if (x < 0 || y < 0) { return false; } if (x > (int16_t)(SURFACE_WIDTH - (int16_t)widthPx)) { return false; } if (y > (int16_t)(SURFACE_HEIGHT - (int16_t)heightPx)) { return false; } return true;`. No overflow: widthPx/heightPx <= 2040 so the subtraction stays in [-1720, 319]. - -**Impact:** ~60-200 cycles per sprite op on IIgs (roughly 1-3% of a 6,400-cycle jlSpriteDraw), on all three hot entry points; also drops two 32-bit compares per call on 68k - -**Verifier correction:** Cost is likely at the low end of the claimed range (~50-120 cycles) since llvm-mos inlines 32-bit adds as lda/adc pairs rather than helper calls, but the win is real on all three gates. Equivalent simpler form also works: with x >= 0 and widthPx <= 2040, (uint16_t)x + widthPx cannot wrap 16 bits, so an unsigned 16-bit add+compare is also correct. - -### 45. spriteDrawInterpreted row setup redoes a per-row multiply and recomputes loop-invariant column math every row - -- **Where:** `src/core/sprite.c:179` -- **Severity:** medium | **Category:** perf - -(srcY >> 3) * wTiles is a variable 16-bit multiply executed every row (~30-50 cycle helper on 65816) even though the tile band only changes once every 8 rows, and the column terms at lines 181-183 -- inTileX = sx & 7 and ((sx >> 3) << 5) -- are fully loop-invariant (sx never changes across rows) yet are recomputed per row. srcY itself just counts up from sy, so all of tileRowBase is derivable incrementally. - -**Fix:** Hoist srcColBase = ((uint16_t)sx >> 3) << 5 and inTileXStart = sx & 7 above the row loop. Track inTileY = srcY & 7 and a bandBase pointer: seed bandBase once with the (sy >> 3) * wTiles multiply, then per row srcTileRow = sp->tileData + bandBase + (inTileY << 2) + srcColBase; when ++inTileY wraps to 8, reset it and bandBase += (uint16_t)wTiles << 5. - -**Impact:** ~60-90 cycles per row on 65816 (multiply helper + invariant shifts/adds), ~500-700 cycles per 8-row draw = roughly 8-10% of the measured jlSpriteDraw op on IIgs; also removes a MULU-class op per row on DOS. - -**Verifier correction:** Real per-row redundancy with a valid incremental fix, but impact is ~30-100 cycles/row on the interpreted FALLBACK path only (a few percent of that op, not 8-10% of the benchmarked jlSpriteDraw, which ran the compiled path). Worth doing as part of the same interpreted-loop rework as findings 0/1. - -### 46. IIgs/DOS draw fast path never checks SPRITE_NOT_COMPILED before jumping into the arena - -- **Where:** `src/core/sprite.c:346` -- **Severity:** medium | **Category:** bug - -jlSpriteDraw gates only on sp->slot != NULL, and the IIgs and DOS spriteCompiledDraw variants (spriteCompile.c lines 309-313 and 705-706) add sp->routineOffsets[shift][SPRITE_OP_DRAW] to the slot address with no sentinel check. jlSpriteCompile explicitly stores SPRITE_NOT_COMPILED (0xFFFF) per (shift,op) whenever an emitter returns 0 (spriteCompile.c line 233-234), so if any DRAW shift ever emits 0 bytes while the slot still allocates, fnAddr = arenaBase + slot->offset + 0xFFFF and the CPU JSLs/calls into arbitrary arena bytes -> crash. The sibling dispatchers already guard: jlSpriteSaveUnder checks routeOffset != SPRITE_NOT_COMPILED (sprite.c line 568), and the Amiga/ST spriteCompiledDraw check and delegate to jlpSpriteDrawPlanes (spriteCompile.c lines 543, 658). Today the IIgs/x86 draw emitters happen to emit both shifts, so this is latent, but it is the only dispatch path with no guard. - -**Fix:** In jlSpriteDraw, fold the DRAW offset check into the fast-path gate (read routineOffsets[shift][SPRITE_OP_DRAW] once, compare against SPRITE_NOT_COMPILED, and pass the offset down per the duplication finding below), falling back to spriteDrawInterpreted when absent -- mirroring the SaveUnder/RestoreUnder gates. - -**Impact:** Removes a latent jump-to-garbage crash; combined with the dispatch-dedup fix it costs zero extra cycles - -**Verifier correction:** Not triggerable today: the IIgs/x86 DRAW emitters structurally always emit for shifts 0/1 (the only shifts those dispatchers select), so this is a latent contract violation, not an active crash. The stronger framing: spriteInternal.h:74-76 documents that dispatchers must check routineOffsets != SPRITE_NOT_COMPILED before calling spriteCompiledDraw, and jlSpriteDraw is the only dispatcher that skips it. Hoisting the guard (via SPRITE_SHIFT_INDEX + one table read passed down to spriteCompiledDraw) is cycle-neutral and lets the redundant Amiga/ST in-callee checks be deleted. - -### 47. Four divergent implementations of the 'row is dirty' test; the STAGE_DIRTY_ROW_CLEAN macro that should be the single truth is unused - -- **Where:** `src/core/surfaceInternal.h:54` -- **Severity:** medium | **Category:** multi-source-of-truth - -grep shows this macro has zero uses. Each present hand-rolls its own predicate: DOS `if (minWord > maxWord) { continue; }` (src/dos/hal.c:289), ST `if (gStageMinWord[y] <= gStageMaxWord[y])` (src/atarist/hal.c:665), IIgs asm `cmp gStageMaxWord,x / bcc+beq` (joeyDraw.s:2903-2911), but Amiga tests `if (gStageMinWord[y] != STAGE_DIRTY_CLEAN_MIN)` (src/amiga/hal.c:752 and 845). The Amiga form is semantically different for a band where only maxWord was widened (min still 0xFF, max > 0 -- reachable today via the unclipped jlDrawPixel bug): IIgs/DOS/ST treat that row dirty, Amiga treats it clean. Four expressions of one invariant is exactly the multiple-sources-of-truth drift the cleanup checklist targets. - -**Fix:** Make the C ports use STAGE_DIRTY_ROW_CLEAN(y) (or a positive STAGE_DIRTY_ROW_DIRTY twin) and change Amiga's min!=0xFF test to the min>max form; keep a comment in joeyDraw.s pinning the asm compare to the macro's definition. If unification is declined, delete the unused macro. - -**Impact:** Consistency across 3 C ports + 1 asm port; removes a latent Amiga skip-row divergence - -**Verifier correction:** The multi-source-of-truth cleanup is real, but the 'latent Amiga skip-row divergence' cannot drop real content. (1) Amiga's min!=0xFF tests at hal.c:752/845 are only the idle-frame early-out and the anyDirty snapshot flag; the actual per-row copy decision (hal.c:786) uses rowMin > rowMax, identical semantics to the other ports. (2) The divergent state (min=0xFF, max=0xFF) is reachable only through the non-IIgs jlDrawPixel path (draw.c:549 marks dirty with raw unclipped x,y after plotPixelNoMark discarded the off-surface pixel; x in [-4,-1] gives word index 0xFF) ? a state where no pixel was written, so Amiga treating the row clean is correct and the other ports merely do wasted copy work. widenRow (surface.c:47-54) guarantees any in-bounds write sets min<=79<0xFF, so Amiga never misses real content. Amiga's comment at hal.c:742-743 documents this invariant deliberately. Right fix: have DOS/ST use STAGE_DIRTY_ROW_CLEAN (or a dirty-positive twin), keep Amiga's cheaper single-array test but name it as a second macro (e.g. STAGE_DIRTY_ROW_TOUCHED) documented as equivalent under the clipped-marks invariant, and pin the joeyDraw.s compare to the macro with a comment. Separately worth flagging: the real bug this exposes is draw.c:549 passing raw x,y into surfaceMarkDirtyRect ? an out-of-range y indexes gStageMinWord[y] out of bounds on DOS/Amiga/ST (potential OOB write via widenRow), which is more severe than the predicate drift itself. - -### 48. tile.c wrappers derive pixel coords with * TILE_PIXELS_PER_SIDE multiplies where port.h deliberately uses shifts to dodge the ORCA-C multiply helper - -- **Where:** `src/core/tile.c:79` -- **Severity:** medium | **Category:** perf - -All five public tile wrappers (jlTileCopy 79-80, jlTileCopyMasked 99-100, jlTileFill 117-118, jlTilePasteMono 141-142, jlTilePaste 157-158) compute the dirty-rect pixel coords with a runtime multiply. The IIgs macro layer in port.h uses `(_by) << 3` / `(_bx) << 2` for the same derivation, with the comment "Use SURFACE_ROW_OFFSET (LUT lookup) to dodge a software multiply helper", and surfaceInternal.h documents that ORCA-C emits a multiply helper even for the implicit *2 of uint16_t array indexing. If ORCA-C does not strength-reduce *8 (the surrounding code assumes it does not), each tile-op call pays up to two ~150-cycle helper calls purely for dirty-rect bookkeeping -- against jlTilePaste's measured ~2530 cyc/call that is up to ~12%. Two derivations of the same block-to-pixel mapping (wrapper multiplies vs port.h shifts) is also a mild dual-source-of-truth. - -**Fix:** Replace with shifts: dstPixelX = (uint16_t)((uint16_t)dstBx << 3); (same for the other nine sites), or add a TILE_BLOCK_TO_PIXEL(_b) macro next to TILE_PIXELS_PER_SIDE in tile.h and use it in both places. - -**Impact:** up to ~300 cyc/call (~12% of jlTilePaste) on IIgs under ORCA-C; zero-risk on all other ports - -**Verifier correction:** Impact is 'up to ~300 cyc' contingent on ORCA-C 2.2.1 not strength-reducing *8 (strongly indicated by surfaceInternal.h's documented *2-indexing helper, but worth a one-off disassembly check); on gcc/Watcom ports it is a pure wash. Also note jlpGenericTilePasteMono/jlpGenericTileFill in src/generic/genericTile.c:134-135 repeat the same * TILE_PIXELS_PER_SIDE pattern -- if a TILE_BLOCK_TO_PIXEL(_b) macro is added to tile.h, use it there too for one source of truth (harmless on the gcc ports that compile it, but consistent). - -### 49. jlpGenericFillRect recomputes loop-invariant span bytes/edge masks and the row offset every row - -- **Where:** `src/generic/genericDraw.c:42` -- **Severity:** medium | **Category:** perf - -x and w are constant across the row loop, so pxStart/pxEnd, the odd-left-edge test (line 44), midBytes (line 48), and the odd-right-edge test (line 53) resolve identically for every row -- but all are re-derived per row, including two conditional branches. The row base is also re-derived per row via SURFACE_ROW_OFFSET(y + row) (a LUT read on IIgs, a *160 on DOS if OpenWatcom fails to strength-reduce) instead of advancing one pointer by SURFACE_BYTES_PER_ROW. This is the fill used by every IIgs NON-stage fillRect (the jlpFillRect macro defers to it, port.h:120) and every DOS fillRect. - -**Fix:** Hoist before the loop: compute firstByteIndex, hasLeftNibble, midBytes, hasRightNibble once from x/w; inside the loop keep a single `uint8_t *line` advanced by `line += SURFACE_BYTES_PER_ROW`, doing (optional left RMW) + memset + (optional right RMW) with the precomputed constants. - -**Impact:** ~20-40 cycles/row on 65816 (2 branches + adds + LUT read): ~10% of a 16x16 non-stage fill on IIgs; smaller but free win on DOS - -**Verifier correction:** Directionally right and if anything understated: per-row waste under ORCA-C is ~40-90+ cycles once the SURFACE_ROW_OFFSET LUT read plus the 32-bit pointer rebuild from s->pixels are counted, so the win on an IIgs non-stage 16x16 fill is likely 15-30%, not ~10%. Two scope caveats: (1) this does NOT affect the UBER 450 ops/sec jlFillRect16x16 number, which measures the IIgs stage asm path (iigsFillRectInner); (2) h==1 span fills (circle scanlines / axis-aligned lines routed through fillRectOnSurface on DOS) gain nothing from the hoist since the 'loop' runs once. Beneficiaries are IIgs offscreen-surface fills, all DOS fills, and the rare Amiga/ST portData==NULL fallback. - -### 50. Identical 4-byte row-copy inner loop triplicated byte-at-a-time across TileCopy, TilePaste, and TileSnap - -- **Where:** `src/generic/genericTile.c:27` -- **Severity:** medium | **Category:** duplication - -The exact same statement appears in jlpGenericTileCopy (line 27), jlpGenericTilePaste (line 107), and jlpGenericTileSnap (line 125); the three loops differ only in the two stride constants. Beyond the duplication, the copy is 4 single-byte moves where the surface side is always 4-byte aligned (heap base plus by*8*160 + bx*4). On the 16-bit DOS target (the only port that runs these -- IIgs uses asm macros, Amiga/ST override all five tile ops per platform.h lines 95-99/141-146/196-201) two word moves or an inlined memcpy(d, s, 4) halves the memory operations. jlTileT.pixels is only byte-aligned, so use memcpy(d, s, TILE_BYTES_PER_ROW), which Watcom/gcc inline optimally and which stays safe for a future 68k-family blank port where a misaligned word move is a crash. - -**Fix:** Add one static inline helper, e.g. static inline void genericTileRowsCopy(uint8_t *d, uint16_t dStride, const uint8_t *s, uint16_t sStride) { for (row...) { memcpy(d, s, TILE_BYTES_PER_ROW); d += dStride; s += sStride; } } and call it from all three functions with the appropriate strides. - -**Impact:** ~2x fewer inner-loop memory ops on DOS tile copy/paste/snap; collapses 3 duplicate loops into 1 shared routine - -**Verifier correction:** Two detail fixes: (1) DOS is DJGPP gcc, 32-bit protected mode (env.sh:62), not '16-bit'; memcpy compiles to one 32-bit move per row, so the win is 8->2 memory ops (~4x), better than claimed. (2) The reason the current form is slow is that uint8_t* d/s may alias, which blocks gcc store merging ? memcpy asserts non-overlap and unlocks it. Nit: jlTileCopy documents src==dst surfaces as legal; same-block copy gives d==s, technically UB for memcpy though harmless with the generated dword move (blocks at distinct coords never partially overlap). Keep the helper static inline; it only needs to serve DOS/BLANK. - -### 51. jlpGenericTileCopyMasked does a read-modify-write merge even when both source nibbles are opaque - -- **Where:** `src/generic/genericTile.c:59` -- **Severity:** medium | **Category:** perf - -For a byte whose two nibbles are both non-transparent (the majority of bytes in solid glyph strokes and most tileset art), the result is simply srcByte, yet the code loads d[col], performs two masked merges, and stores. Only the mixed case (exactly one transparent nibble) needs the destination read. This is the DOS masked-copy inner loop (IIgs uses iigsTileCopyMaskedInner, Amiga/ST override with planar functions), running 32 times per tile. - -**Fix:** Restructure the per-byte body: if (srcHi != transHi && srcLo != transLo) { d[col] = srcByte; continue; } then fall through to the existing skip/merge logic for the transparent and mixed cases. - -**Impact:** saves one memory load and ~4 ALU ops per fully-opaque byte (typically 50-90% of non-skipped bytes) in the DOS masked tile copy - -**Verifier correction:** Correct as stated, but scope is DOS/BLANK only (plus unreachable Amiga/ST portData==NULL fallbacks); severity is closer to low-medium since DOS is rarely the bottleneck. IIgs, Amiga, and ST hot paths are untouched by this fix. - -### 52. iigsBlitStageToShr's SCB/palette MVN upload paths are dead, yet jlpPresent still marshals both far pointers into the asm every present - -- **Where:** `src/iigs/hal.c:298` -- **Severity:** medium | **Category:** dead-code - -uploadFlags is hard-coded 0 at the only call site, so the flag-gated SCB (200-byte) and palette (512-byte) MVN blocks in joeyDraw.s (lines 2787-2851, `lda bsFrame+8 ; uploadFlags` gates both) can never execute -- hal.c's own comment (lines 290-292) says 'The asm's now-unreached SCB/palette MVN code is dead -- a removal candidate.' The cost is not just dead bytes in the bank-limited DRAWPRIMS load segment: every present still pushes two 4-byte far pointers plus the flags word, and the asm entry copies all ten bytes into its rebuilt D-frame (joeyDraw.s:2763-2772) before ever looking at a dirty band. - -**Fix:** Change the entry to iigsBlitStageToShr(void): delete the scbPtr/palettePtr/uploadFlags marshalling and D-frame rebuild in joeyDraw.s, delete the two flag-gated MVN blocks, and call it with no args from jlpPresent (uploadScbAndPaletteIfNeeded already owns SCB/palette upload). - -**Impact:** ~50-100 cyc per present of argument marshalling + frame rebuild; frees dead code space in the crowded DRAWPRIMS bank - -**Verifier correction:** Two detail fixes: (1) ABI ? per the clang w65816 cdecl documented at joeyDraw.s:2716-2719, scbPtr (arg0) arrives in A:X and is never pushed; only palettePtr (4 bytes) + uploadFlags (2 bytes) go on the stack, so it is one pushed far pointer plus a word, not two. (2) Fix scope ? when deleting the marshalling (joeyDraw.s:2765-2772) and both flag-gated MVN blocks (2787-2859), KEEP the caller-DBR stash at joeyDraw.s:2780-2784 (sep/lda 3,s/sta 0xE19DFE/rep): the narrow-row MVN path in phase 3 reloads $E1:9DFE at line 3122 after every MVN to restore DBR. Also delete the now-orphaned bsFrame BSS block (joeyDraw.s:4417-4420), change the extern at hal.c:98 and the call at hal.c:298 to zero-arg, and update the stale jlpInit comment at hal.c:270-273 that still says iigsBlitStageToShr uploads SCB/palette. Impact restated: ~90-110 cyc/present (~0.15% of a full-screen present, ~1-2% of an all-clean one) plus ~150 bytes freed in the crowded DRAWPRIMS bank ? value is primarily dead-code cleanup, not measurable perf. - -### 53. iigsDrawPixelInner carries dead PHB/PLB framing and a long-absolute scratch round-trip in the hottest inner op - -- **Where:** `src/iigs/joeyDraw.s:1416` -- **Severity:** medium | **Category:** perf - -The routine (joeyDraw.s:1412-1487) never writes DBR -- every instruction between phb and plb is rep/sep, stack-relative loads, DP stores after tcd, absolute loads, and [pix],y accesses -- so the phb/plb pair (3+4 cyc) is pure dead weight. The offset math also stores the LUT row into memory and immediately re-adds it ('sta dpxlTmp ... lda 8,s / lsr a / clc / adc dpxlTmp', lines 1441-1446) where reordering to 'lda 8,s ; lsr a ; clc ; adc gRowOffsetLut,x' saves the ~11-cycle round-trip; and dpxlTmp/dpxlNibPart live in absolute bss (4-cyc accesses) even though D is already repointed at a bank-0 scratch block, so widening dpxlScratch and addressing them D-relative saves ~2 cyc per access. This is the Phase-2.2 residual from IIGS_PERF_PLAN.md, still unfixed. Total ~25-35 cycles out of a ~140-cycle inner routine that runs once per jlDrawPixel and per Bresenham plot. - -**Fix:** Delete phb/plb (DBR is never modified); fold the row-offset add into 'adc gRowOffsetLut,x' directly instead of via dpxlTmp; extend dpxlScratch to 8 bytes and address tmp/nibPart as D-relative offsets 4/6. - -**Impact:** ~25-35 cycles of ~140 (about 20% of the inner plot, ~6-8% of the full post-Phase-2 jlDrawPixel call); same pattern audit applies to the line/circle inners - -**Verifier correction:** Valid parts: (1) delete phb/plb ? DBR is never written in iigsDrawPixelInner ? saving 7 cycles, and renumber the stack-relative arg offsets from 8/10/12,s to 7/9/11,s (and the comment at lines 1402-1405); (2) fold the row offset directly with 'lda 8,s / lsr a / clc / adc gRowOffsetLut,x' (X still holds y*2), eliminating dpxlTmp and its store/reload for ~10 cycles. Drop the third sub-fix: relocating scratch to D-relative offsets in dpxlScratch saves ~0 cycles (DL != 0 makes dp access 5 cyc, same as absolute) and is hazardous ? D-relative stores land in bank 0 at (dpxlScratch & 0xFFFF), not at the data-bank .bss symbol (the routine's own comment documents this disagreement), so widening the .bss reservation does not reserve the bank-0 bytes actually written. Net win ~17 cycles of a ~150-cycle inner (~11% of the inner, ~1% of the measured 1595-cycle jlDrawPixel call, roughly 1755 -> 1774 ops/sec), not 25-35 cycles / 6-8% of the call. Also: iigsDrawPixelInner runs once per jlDrawPixel and per plot only in the clipped Bresenham C fallback; the on-surface line/circle fast paths plot inline in their own asm inners (which deserve the same phb/plb and round-trip audit, per IIGS_PERF_PLAN.md Phase 4). - -### 54. spriteEmitIigs.c top-of-file contract describes the deleted self-modifying-stub design; the code below it emits the opposite (C-ABI prologue) - -- **Where:** `src/iigs/spriteEmitIigs.c:3` -- **Severity:** medium | **Category:** multi-source-of-truth - -The #19 rewrite (documented in spriteCompile.c:133-146) replaced the self-modifying stub with plain C function pointers, and this same file emits the C-ABI prologue at lines 336-342 (PHB/TAY/SEP/TXA/PHA/PLB) -- directly contradicting its own header ('No stack arg, no prologue', 'DBR = program bank', 'Y = destRow loaded by stub'). spriteEmitter.h:75 repeats the stale claim ('IIgs uses a self-modifying MVN-stub on top of these bytes'), and spriteEmitter.h:6-9 claims 'The planar 68k emitters now return 0 bytes after Phase 11', contradicted by spriteEmitPlanar68k.c emitting real shift-0 DRAW bytes (and by spriteEmitter.h's own lines 94-96). Anyone editing the IIgs emitter against its stated contract would break the real one (e.g., dropping the prologue back out). - -**Fix:** Rewrite the spriteEmitIigs.c header to describe the C-ABI convention (arg in A:X, PHB prologue, PLB/RTL epilogue), and fix spriteEmitter.h lines 5-9 and 75 to match the current emitters. - -**Impact:** Removes actively-misleading contracts on the two files most likely to be hand-edited for perf work - -**Verifier correction:** Finding is accurate as written. One addition for the fix scope: src/codegen/spriteCompile.c:172-178 is a third stale source of truth ? it claims the Amiga planar DRAW 'emits a unique pre-shifted variant per shift in 0..7', but spriteEmitDrawPlanar68k returns 0 for any shift != 0 (only shift 0 is emitted, matching spriteEmitter.h:94-96). The doc rewrite should fix that comment too. Precise conventions for the new header text: DRAW is void draw(uint8_t *dstRow0) with the 24-bit pointer arriving as A = low 16 bits, X = bank; SAVE/RESTORE are void copy(uint32_t packed) with packed = srcOff | (dstOff << 16), arriving as A = srcOff, X = dstOff; all preserve caller DBR via PHB/PLB and return via RTL with M=16. - -### 55. spriteEmit68k.c (chunky 68k emitter) is compiled into Amiga and ST builds but nothing calls it - -- **Where:** `src/m68k/spriteEmit68k.c:179` -- **Severity:** medium | **Category:** dead-code - -spriteCompile.c dispatches exclusively to spriteEmitDrawPlanar68k (Amiga), spriteEmitDrawInterleaved68k (ST), spriteEmitDrawX86, and spriteEmitDrawIigs (lines 52-99). No source file references spriteEmitDraw68k/spriteEmitSave68k/spriteEmitRestore68k (grep across src/, include/, make/ finds only the definitions, the prototypes in spriteEmitter.h lines 64/83-84, and the .o rules), yet make/amiga.mk line 57 and make/atarist.mk line 42 both build and link spriteEmit68k.o. This is the pre-planar chunky emitter that the planar/interleaved rewrites replaced; it also redefines TILE_PIXELS/TILE_BYTES/TILE_BYTES_PER_ROW locally instead of using joey/tile.h. - -**Fix:** Remove spriteEmit68k.c, its prototypes in spriteEmitter.h, and the $(BUILD)/obj/68k/spriteEmit68k.o entries from make/amiga.mk and make/atarist.mk (or move the file to stuff/ if it is wanted as reference for a future chunky-68k target). - -**Impact:** ~330 dead lines removed from two ports' builds and link images; eliminates a stale second copy of the tile-geometry constants - -**Verifier correction:** Correct as a dead-code finding, with two refinements: (1) the 'link images' impact is overstated ? nothing references the symbols, so the static-archive member is never pulled into final binaries; the real cost is ~330 dead source lines, two compile units, and a duplicate copy of the tile-geometry constants. (2) The fix should also sweep stale references the finding missed: the 'spriteEmit68k.c shared with ST/Amiga' comments at make/amiga.mk:45-47 and make/atarist.mk:31-33, the '68k chunky (unused)' sizing comment block in spriteEmitter.h (keep SPRITE_EMIT_MAX_BYTES_PER_DESTBYTE_68K itself ? it still sizes the Amiga/ST scratch), and docs/amiga_planar.md:5 / docs/atarist_planar.md:6 which cite the file under its old src/codegen/ path. - -### 56. c2pColumn/putWord duplicated byte-for-byte between the two 68k emitters; shiftedByteAt/spriteSourceByte duplicated between IIgs and x86 emitters - -- **Where:** `src/m68k/spriteEmitInterleaved68k.c:83` -- **Severity:** medium | **Category:** duplication - -spriteEmitInterleaved68k.c:83-116 (c2pColumn + putWord) is character-identical to spriteEmitPlanar68k.c:89-122; spriteEmitIigs.c:52-113 (shiftedByteAt + spriteSourceByte) is identical to spriteEmitX86.c:168-232 (modulo comments); the dead spriteEmit68k.c carries a third copy. These helpers ARE the transparency/c2p contract that must match the interpreters bit-for-bit (the planar file even says 'Identical pixel walk to jlpSpriteDrawPlanes'), so a fix applied to one copy and not the others produces per-platform sprite corruption that only shows on the un-fixed target. Constants TILE_BYTES/TILE_BYTES_PER_ROW/TILE_PIXELS/TRANSPARENT_NIBBLE are also re-defined per file instead of coming from joey/tile.h as sprite.c does. - -**Fix:** Add src/codegen/spriteEmitCommon.c/.h with c2pColumn, putWord (68k), shiftedByteAt, spriteSourceByte, and take the tile geometry macros from joey/tile.h; each emitter includes it. Emit-time only, so the extra call is free at runtime. - -**Impact:** Removes ~160 duplicated lines and 3-way drift risk in the pixel-walk contract - -**Verifier correction:** Directionally right; refine the fix: (1) split the common code by family instead of one spriteEmitCommon.c -- put c2pColumn/putWord in src/m68k/ (only Amiga/ST build that dir) and shiftedByteAt/spriteSourceByte in src/codegen/ shared by DOS+IIgs -- otherwise the linker's whole-archive-member pull drags 68k c2p helpers into DOS/IIgs binaries; (2) joey/tile.h names the constant TILE_PIXELS_PER_SIDE, not TILE_PIXELS, so emitters need the rename, and TRANSPARENT_NIBBLE is NOT in tile.h (src/core/sprite.c:22 re-defines it locally too) -- hoist it into spriteInternal.h, which every emitter and sprite.c already includes; (3) delete the dead spriteEmit68k.c outright along with its LIB_OBJS lines in make/amiga.mk:57 and make/atarist.mk:42 and its prototypes in spriteEmitter.h:64,83-84, rather than de-duplicating into it; (4) on IIgs, moving statics out of spriteEmitIigs.c changes ORCA-Linker segment packing (known order-sensitive), so rebuild and re-verify link order after the refactor. - -### 57. surface.h documents jlSurfaceHash as FNV-1a but the implementation is a 31/251 two-stream product hash - -- **Where:** `include/joey/surface.h:66` -- **Severity:** low | **Category:** cleanup - -jlpGenericSurfaceHash (src/generic/genericSurface.c:61-90) mixes via SURFACE_HASH_MIX_BYTE, the strength-reduced `lo *= 31 + b / hi *= 251 + b` pair defined in surfaceInternal.h:77-80 -- not FNV-1a (no 0x811c9dc5 offset basis, no xor-then-multiply, different prime). This is not just a stale comment: the hash's whole purpose is bit-exact cross-port comparison against the IIgs golden reference, and Amiga/ST supply their own plane-reading overrides. Anyone writing a new port override from the public header's description would implement real FNV-1a and silently fail every UBER pixel-compare. - -**Fix:** Rewrite the header comment to describe the actual algorithm ('two-stream 16-bit multiplicative hash, streams combined hi<<16|lo; must match SURFACE_HASH_MIX_BYTE in surfaceInternal.h byte-for-byte across ports'), pointing overriders at the macro as the single definition. - -**Impact:** Prevents a future port override from breaking cross-port hash validation; doc-only change - -**Verifier correction:** The header is wrong about more than the algorithm: it also misstates the hashed domain. The real hash covers the packed chunky byte stream (32000 bytes, 2 px/byte -- not 64000 per-pixel indices), then the 200 SCB bytes, then the 256 palette uint16s folded high-byte-then-low, seeded lo=0xACE1/hi=0x1357, returned as (hi<<16)|lo. The comment rewrite should state all of that (algorithm, seeds, domain, combine order) and name SURFACE_HASH_MIX_BYTE in surfaceInternal.h as the single source of truth that any port override must reuse byte-for-byte. - -### 58. ST jlpTilePasteMono dereferences dst before its own NULL check, and both the dst and duplicate pd NULL checks are dead - -- **Where:** `src/atarist/hal.c:1410` -- **Severity:** low | **Category:** unnecessary-conditional - -Line 1410 reads dst->portData, then line 1414 checks 'dst == NULL' -- if dst could be NULL the check is too late; in fact it cannot be (the only caller, jlTilePasteMono at src/core/tile.c:129, validates 'dst == NULL || in == NULL' before dispatch), so lines 1414-1416 are dead either way. Line 1418's 'pd == NULL' recheck is also dead: line 1410 already returned on portData == NULL. The Amiga counterpart (amiga/hal.c:1816-1826) has none of these extra checks. This is on the ST mono-tile hot path (Space Taxi logo repaint, 103 calls every 4th frame). - -**Fix:** Delete lines 1414-1416 and the redundant pd NULL check at 1418-1420; keep only the portData==NULL generic-fallback branch (or drop even that, since ST surfaces always carry portData post-Phase-9 and the generic fallback writes to pixels which is always NULL on ST, i.e. it silently does nothing anyway). - -**Impact:** Removes 2 dead conditionals (~20-30 cycles) per mono tile paste on 68000, and removes a misleading 'fallback works' impression -- the generic fallback branch is actually a silent no-op on ST. - -**Verifier correction:** All details check out. Two refinements: (1) the caller reference should be tile.c:140 (dispatch) with the NULL validation at tile.c:129; (2) the identical dead-check pattern (deref at 1454, dst/chunkyOut NULL check at 1458, pd recheck at 1462) exists in the adjacent jlpTileSnap and should be cleaned in the same pass. - -### 59. SPLIT_BACKUP_CACHED global single-entry cache thrashes and costs more than it saves with multiple backup buffers - -- **Where:** `src/codegen/spriteCompile.c:362` -- **Severity:** low | **Category:** perf - -The cache assumes one backup buffer per program ('effectively never changes after the first call', line 354), but the normal pattern is one jlSpriteBackupT + buffer per moving sprite. With two or more sprites saved/restored per frame the pointer ping-pongs, so every call misses: it pays a 24-bit pointer compare (a multi-instruction 32-bit compare on the 65816) and then does the full SPLIT_POINTER anyway plus three global stores. Even on a hit, the 32-bit compare costs a comparable amount to SPLIT_POINTER itself, which is just four byte loads and two stores. The right home for the cached split is the backup record: save already computes it, and restore reuses the same backup. - -**Fix:** Add `uint16_t bytesLo; uint8_t bytesBank;` to jlSpriteBackupT (or a per-sprite cache beside cachedDstBank), have spriteCompiledSaveUnder store the split it computes, and have spriteCompiledRestoreUnder read it; delete gLastBackupBytes/-Lo/-Bank and SPLIT_BACKUP_CACHED. - -**Impact:** ~20-40 cycles per save/restore call on IIgs whenever a game uses more than one backup buffer (the common case); removes global mutable state - -**Verifier correction:** Impact estimate is right but the fix touches the public jlSpriteBackupT struct; either accept that (source-compatible addition, document the fields as internal) or take the simpler fix: delete gLastBackupBytes/SPLIT_BACKUP_CACHED and use SPLIT_POINTER unconditionally ? it already beats the cache whenever more than one backup buffer is live, which every real example in the repo is. - -### 60. assetLoad.c comment claims file-IO lives in a named IIgs segment, but no segment directive exists anywhere - -- **Where:** `src/core/assetLoad.c:28` -- **Severity:** low | **Category:** multi-source-of-truth - -The comment describes a load-bearing mitigation for the documented ORCA linker 64KB-bank failure mode, but the file contains no #pragma/segment directive and make/iigs.mk assigns none -- the mitigation it documents does not exist. Whoever next hits 'Code exceeds code bank size' will be misled into believing the stdio cluster is already segregated. Either the directive was lost in the ORCA->clang migration or the comment was aspirational. - -**Fix:** Either implement it (a section attribute / link-script placement under the clang toolchain) or delete the comment and note the real current placement; one source of truth between comment and build. - -**Impact:** Debugging-time hazard only; no runtime cost - -**Verifier correction:** Accurate as filed; one refinement: the stdio-bank-bursting failure mode itself was an ORCA-Linker-era problem, and the clang pipeline's multi-segment emitter now handles >64KB overflow automatically, so the comment is doubly stale -- the right fix is almost certainly to delete/rewrite it rather than implement a named segment under link816. - -### 61. plotPixelNoMark re-checks s == NULL per pixel although every caller has already validated it - -- **Where:** `src/core/draw.c:276` -- **Severity:** low | **Category:** unnecessary-conditional - -All three callers -- jlDrawCircle (line 299), jlDrawLine (line 399), and non-IIgs jlDrawPixel (line 518) -- test s == NULL before reaching plotPixelNoMark, so the NULL branch inside it can never be taken; in the Bresenham circle fallback it executes 8 times per iteration. This is the 'validation repeated at multiple levels' pattern: on 68000 a tst.l+beq on a 32-bit pointer is ~10-12 cycles per plotted pixel, on top of the per-pixel function call itself (plotPixelNoMark is not inlined by ORCA/C 2.2.1). - -**Fix:** Delete the NULL check from plotPixelNoMark and document the non-NULL precondition in its comment (its callers are all in this TU and already comply); keep the bounds check, which is the function's actual job. - -**Impact:** ~10 cycles per plotted pixel in the line/circle fallback loops (x8 per circle iteration); pure dead branch removal - -### 62. isFullyOnSurface predicate duplicated between sprite.c helper and jlDrawRect's inline test - -- **Where:** `src/core/draw.c:574` -- **Severity:** low | **Category:** duplication - -This is the exact predicate implemented by the static isFullyOnSurface() in src/core/sprite.c:99-110 (including the same int32_t-widening trick for the right/bottom edges). The same rect-on-surface question is also answered ad hoc in jlDrawLine's H/V span guards (draw.c:425-426, 443-444). Two hand-maintained spellings of one clip invariant is precisely the 'same clip computation in draw.c and sprite.c' class on the cleanup checklist. - -**Fix:** Move isFullyOnSurface into surfaceInternal.h as a static inline (or macro, for ORCA/C which does not inline) named e.g. SURFACE_RECT_ON_SURFACE(x, y, w, h), and use it from both sprite.c (3 call sites) and jlDrawRect. - -**Impact:** One source of truth for the on-surface fast-path gate used by sprites, rects, and spans; no runtime change - -### 63. jlSpriteDraw's fast-path comment claims the IIgs never takes it, contradicting the codegen default - -- **Where:** `src/core/sprite.c:344` -- **Severity:** low | **Category:** multi-source-of-truth - -spriteCompile.c lines 144-146 set JOEY_IIGS_SPRITE_CODEGEN to 1 by default ('Codegen is ON by default'), and the file's #19 block describes the rewritten C-ABI IIgs codegen as live -- so on IIgs sp->slot is NOT left NULL and this fast path IS the production path (jlSpriteDraw 438 ops/sec is the compiled path). A reader trusting this comment would conclude the IIgs compiled branch is dead and could 'clean it up' or skip optimizing it. Related stale docs in the public header compound this: sprite.h line 88 says codegen is 'currently x86/DOS' and lines 17-21 describe jlSpritePrewarm as a no-op under an interpreter-only v1, while all four ports now have emitters wired. - -**Fix:** Rewrite the sprite.c comment to describe current behavior (IIgs compiled path live; interpreter is the clip-edge/uncompiled fallback), and update sprite.h's jlSpriteCompile/jlSpritePrewarm prose to name the actual per-port status. - -**Impact:** Documentation-only, but it misdirects optimization effort on the reference platform's hottest sprite path - -**Verifier correction:** Add a third stale instance the finding missed: spriteInternal.h:74 still describes the IIgs compiled entry points as 'inline asm + self-modifying stub on IIgs', contradicting the rewritten C-ABI dispatch documented in spriteCompile.c:133-143 and :285-293. All three doc sites should be fixed together. - -### 64. routineOffsets shift*3+op byte-offset idiom hand-expanded at three sites in sprite.c - -- **Where:** `src/core/sprite.c:409` -- **Severity:** low | **Category:** duplication - -The multiply-free routineOffsets[shift][op] access ((shift<<1)+shift+op, then <<1 for bytes) is spelled out independently in jlSpriteSaveAndDraw (lines 409-413), jlSpriteRestoreUnder (lines 500-501), and jlSpriteSaveUnder (lines 566-567), each with its own copy of the 'dodges ~MUL4' comment. The encoding must stay in lockstep with SPRITE_OP_COUNT == 3 and with the emitters in src/codegen/spriteCompile.c that fill the same table; a fourth op added to SPRITE_OP_COUNT would require finding and fixing three hand-rolled *3 expansions. - -**Fix:** Add to spriteInternal.h, next to SPRITE_NOT_COMPILED: #define SPRITE_ROUTINE_OFFSET(_sp, _shift, _op) (*(uint16_t *)((uint8_t *)(_sp)->routineOffsets + ((uint16_t)((((uint16_t)(_shift) << 1) + (_shift) + (_op))) << 1))) and use it at all three sites (identical codegen, since it is the same expression). - -**Impact:** Collapses 3 duplicated pointer-math blocks into one macro co-located with the table's layout constants; no runtime change - -**Verifier correction:** Scope is slightly understated: the same shift*3+op idiom also appears twice in src/codegen/spriteCompile.c (:423-427 spriteCompiledSaveUnder, :478-482 spriteCompiledRestoreUnder), where the index additionally feeds the cachedDstBank/cachedSrcBank pointers ? so the shared definition should expose the index computation (e.g. a SPRITE_ROUTINE_INDEX(shift, op) macro) that both the offset-read macro and the IIgs cache-pointer math build on, covering all five sites. - -### 65. surfaceMarkDirtyRect re-validates w/h that its contract already guarantees positive - -- **Where:** `src/core/surface.c:245` -- **Severity:** low | **Category:** unnecessary-conditional - -The function's own contract comment (lines 230-233: 'Drawing primitives pass the rect they actually wrote (already clipped to surface bounds, w and h positive)') makes this check dead for conforming callers, and I verified every call site: fillRectOnSurface is caller-proven positive, the line/circle accumulated boxes only mark after a `minX <= maxX && minY <= maxY` clamp (draw.c:379, 511), flood spans are rightX-leftX+1 >= 1, sprite paths mark only after clipRect/isFullyOnSurface succeed, and tile.c passes constant 8s after tile-coord validation. Two signed compares + branches per draw op (~10-15 cyc on the 65816) on the most-called internal function in the library. Note this check does NOT protect against the real contract violation (finding 1) -- jlDrawPixel passes w=h=1 with a bad y -- so it must not be treated as the safety net; it can be dropped once the jlDrawPixel clip fix lands. - -**Fix:** After fixing jlDrawPixel's unclipped mark, delete the `if (w <= 0 || h <= 0)` early-out (optionally demote to a debug-build assert), leaving only the s != gStage gate. - -**Impact:** ~10-15 cyc on every dirty-marking draw op on IIgs; two branches on 68k ports - -**Verifier correction:** The check is not literally dead today: jlDrawRect(h==2) reaches surfaceMarkDirtyRect with h==0 via fillRectOnSurface (draw.c:582/584). Deleting the check is still behaviorally safe because both the portable mark loop and the top-tested IIgs asm treat empty ranges as no-ops (and iigsFillRectInner is also h==0-safe), but the cleanest fix is to also make jlDrawRect skip the interior-edge fills when h==2, restoring fillRectOnSurface's documented contract before dropping the guard. - -### 66. SURFACE_PIXELS_PER_WORD is defined and never used anywhere in the repo - -- **Where:** `src/core/surfaceInternal.h:36` -- **Severity:** low | **Category:** dead-code - -A repo-wide grep (all .c/.h including src//, src/codegen/, and examples/) finds exactly one occurrence: this definition. The related quantity that code actually uses is baked into SURFACE_WORD_INDEX's '>> 2' shift two macros below, so this name is a stale second spelling of the same fact that nothing reads. - -**Fix:** Delete the define, or -- if the name is worth keeping as documentation -- express SURFACE_WORD_INDEX's shift in terms of it so it has exactly one consumer. - -**Impact:** Dead macro removal; no runtime effect - -**Verifier correction:** Prefer the deletion option. The alternate fix ('express SURFACE_WORD_INDEX's shift in terms of it') is not directly expressible ? a shift needs log2(4)=2, not 4, so you would either divide (risking a helper call on ORCA/C 2.2.1, exactly what the header's lines 39-43 comment engineered around) or introduce a companion SURFACE_WORD_SHIFT 2, which just trades one loose constant for another. Delete the define and let the existing comment carry the documentation. - -### 67. gRowOffsetLut declared with magic literal 200 instead of SURFACE_HEIGHT - -- **Where:** `src/core/surfaceInternal.h:125` -- **Severity:** low | **Category:** magic-number - -Everywhere else in this same header the row count is spelled SURFACE_HEIGHT (e.g. gStageMinWord[SURFACE_HEIGHT] at lines 66-67, and the compile-time band-fits-byte guard). The LUT is indexed by SURFACE_ROW_OFFSET with y in [0, SURFACE_HEIGHT); a raw 200 here means a future height change would compile cleanly against a mis-sized extern while the IIgs hal.c builder fills a differently-sized array. - -**Fix:** extern const uint16_t gRowOffsetLut[SURFACE_HEIGHT]; (and match the definition in src/iigs/hal.c). - -**Impact:** Declaration-only; closes one silent-divergence point for the 320x200 contract - -**Verifier correction:** One factual error in the proposed fix: gRowOffsetLut is not defined in src/iigs/hal.c ? it is asm BSS in src/iigs/joeyDraw.s:269-272 ('.zero 400'), filled at init by the asm row-LUT builder (referenced from hal.c:108's comment). There is no C definition for the extern size to cross-check against, so the change is hygiene/documentation only; a real height change would still require manually resizing the .zero 400 in the asm. Fix is simply: extern const uint16_t gRowOffsetLut[SURFACE_HEIGHT]; plus optionally a comment in joeyDraw.s noting the 2*SURFACE_HEIGHT byte reservation. - -### 68. Stale comment in tile.c still claims the inner row copies are spelled out in this file - -- **Where:** `src/core/tile.c:17` -- **Severity:** low | **Category:** dead-code - -The copies the comment refers to were moved to src/generic/genericTile.c (as lines 24-25 and 29-30 themselves note), so there are no inner copies "below" and the no- rationale no longer applies to this TU -- tile.c no longer touches pixel bytes at all. The vestigial placeholder sections at lines 22-30 ("----- Prototypes -----" and "----- Internal helpers -----" containing only moved-elsewhere notes) are also dead scaffolding. Misleading segment-placement comments are dangerous in this codebase given the ORCA linker bank-packing sensitivities. - -**Fix:** Delete lines 17-19 and collapse the empty Prototypes/Internal-helpers placeholder sections (lines 22-30) into a single one-line pointer to genericTile.c; if the DRAWPRIMS/no-memcpy constraint still matters, move that comment to genericTile.c where the loops now live. - -**Impact:** comment-only; prevents a future editor from applying a stale segment constraint to the wrong file - -### 69. fg/bg colors are masked to 4 bits twice on the TilePasteMono path - -- **Where:** `src/generic/genericTile.c:84` -- **Severity:** low | **Category:** unnecessary-conditional - -jlTilePasteMono in src/core/tile.c already masks both colors before dispatch (lines 135-136: `fgColor &= 0x0Fu; bgColor &= 0x0Fu;`), then jlpGenericTilePasteMono masks them again. Beyond the redundant ANDs, having both layers claim responsibility for masking is a small multiple-sources-of-truth problem: Amiga/ST function overrides receive already-masked values, so the contract should be that jlp receives masked colors and the generic should not re-mask. - -**Fix:** Delete lines 84-85 from jlpGenericTilePasteMono and document the pre-masked contract in the prototype comment in port.h (matching how the IIgs macros own `(_c) & 0x0F` for the ops where the wrapper does not mask). - -**Impact:** 2 redundant ops per call; clarifies which layer owns color masking - -**Verifier correction:** Real but as a low-severity contract-cleanup, not a perf item: the 2 redundant ANDs are noise next to the 32-iteration colorize loop. The valuable part is the port.h contract comment, since masking ownership currently differs op-by-op (TilePasteMono wrapper masks; TileFill wrapper does not and the jlp layer masks). - -### 70. jlpGenericTileFill re-derives the tile row-0 address with its own math instead of the file's GENERIC_TILE_ROW0 macro - -- **Where:** `src/generic/genericTile.c:142` -- **Severity:** low | **Category:** duplication - -Every other function in genericTile.c derives the block's row-0 pointer via GENERIC_TILE_ROW0 (defined at line 12), but jlpGenericTileFill first expands bx/by to pixel coords with two multiplies (lines 134-135: `pixelX = bx * TILE_PIXELS_PER_SIDE`) and then converts back down with a shift -- a second spelling of the same block-to-address mapping that must be kept in sync by hand. The intermediate pixelX/pixelY variables serve no other purpose since this function does not mark dirty (the core wrapper does). - -**Fix:** Delete pixelX/pixelY and use row = GENERIC_TILE_ROW0(s->pixels, bx, by); like the other four functions. Optionally write the four bytes as two uint16_t stores of (doubled<<8)|doubled via the shared row helper. - -**Impact:** removes 2 multiplies + 2 locals and one divergent copy of the address math in the DOS tile fill - -**Verifier correction:** The fix (use GENERIC_TILE_ROW0) is right, but the perf framing is nil: bx*8/by*8 are strength-reduced to shifts by every compiler that runs this generic, so this is purely a single-source-of-truth cleanup. The optional 'two uint16_t stores' suggestion is unnecessary type-punning for zero measured gain on DOS ? skip it and keep the four byte stores or a memset-style fill. - -### 71. Stale 15-line comment on IIgs jlpFrameCount documents a deleted $C019-polling/gFrameCount implementation; body is one GetTick call - -- **Where:** `src/iigs/hal.c:358` -- **Severity:** low | **Category:** cleanup - -The comment block at lines 358-371 explains a $C019 edge-detected counter and why gFrameCount needs an explicit lda+adc+sta instead of '++' (the INC-abs DBR trap) -- but the function body (line 373) is just 'return iigsGetTickWord();' and no gFrameCount variable exists anywhere in src/iigs/ (grep finds it only in this comment). The comment now asserts a timing mechanism ('No IRQ involvement') that is the opposite of reality: GetTick is the VBL-interrupt-driven tick, which is exactly the clock whose SEI artifact already inflated UBER numbers once. The registry-side comment (platform.h:108 'GetTick word, function') is the accurate one; this in-file duplicate is a wrong second source of truth. - -**Fix:** Replace lines 358-371 with a one-liner: '// Frame counter: low word of the VBL-interrupt GetTick (see peislam.s). SEI-holding ops distort it -- see perf notes.' - -**Impact:** Documentation-only, but prevents re-tripping the known GetTick/SEI benchmarking trap and the INC-abs cargo-culting for a variable that no longer exists. - -**Verifier correction:** Minor nit only: the stale block is 14 lines (358-371), not 15. Everything else in the finding is accurate as stated. - -### 72. jlDrawLine H/V fast paths clamp the span to the surface size BEFORE clipping, so a line with a far-off-surface start draws nothing - -- **Where:** `src/core/draw.c:417` -- **Severity:** high | **Category:** bug - -Found while building the Phase 0 verification harness (not part of the -original 71-agent audit; confirmed by direct code trace). The horizontal -fast path computes `span = (int32_t)x1 - (int32_t)x0 + 1` and then clamps -`if (span > SURFACE_WIDTH) { span = SURFACE_WIDTH; }` BEFORE any clipping -against the surface. For a line whose left endpoint is far off-surface -- -jlDrawLine(s, -20000, 50, 20000, 50, c) -- the clamped span (320) is -anchored at x0 = -20000, so the subsequent jlFillRect(s, -20000, 50, 320, -1, c) clips to nothing and the call is a silent no-op. The correct output -is the full visible row [0..319] at y=50 (the line passes through the -entire surface). The vertical path (draw.c:439-441) has the identical bug -for far-off-surface y0. The diagonal Bresenham fallback is unaffected -(per-pixel clipping). Off-surface endpoints are documented-legal input -(draw.h: out-of-bounds draws clip). - -**Fix:** Clip x0 into range before anchoring the clamped span: after -computing the 32-bit span, do `if (x0 < 0) { span += x0; x0 = 0; }` (span -shrinks by the off-surface distance; if span <= 0 the line is entirely -off-surface and returns), THEN clamp span to SURFACE_WIDTH. Mirror for the -vertical path with y0/SURFACE_HEIGHT. The on-surface fast-path test below -is unchanged. - -**Impact:** Correctness: legal H/V lines with a far-off-surface start -silently vanish on every port. The uber.c "edge-lines" check captures the -current (wrong) behavior; its hash changes when this fix lands. - -### 73. DOS jlpFrameCount edge-polled $3DA and lost frames whenever callers polled slower than the retrace pulse - -- **Where:** `src/dos/hal.c:321` (old implementation) -- **Severity:** critical | **Category:** bug -- FIXED during Phase 0 - -The old counter incremented only when a CALL observed the $3DA retrace -bit high after a previous call observed it low. The VGA retrace pulse is -~1ms of the 14.3ms frame, so any caller doing more than ~1ms of work -between jlFrameCount() polls silently lost frames -- including any real -game pacing its main loop this way. Consequence for measurement: UBER's -"16-frame" windows stretched 5-40x in real time for ops slower than -~1ms, inflating every slow-op number in PERF.md's DOS column -(jlSurfaceClear read 449x the IIgs; the honest number is ~2.5x). The -Phase 0 batched timing loop stalled on it outright, which is how it was -found. Fix (landed): jlpFrameCount = millis * 70 / 1000 off the existing -PIT-ISR/uclock millisecond source -- poll-rate-independent, monotonic; -jlpWaitVBL still syncs to real retrace. - -### 74. jlSpriteCompile returns false on DOS -- every DOS sprite bench and game runs the interpreter - -- **Where:** `src/codegen/spriteCompile.c:149` (failure surfaces here; root cause not yet isolated) -- **Severity:** high | **Category:** bug - -Pre-existing at HEAD (verified against a pristine uber.c build): UBER -logs "jlSpriteCompile failed" on DOS, so sp->slot stays NULL and every -jlSpriteDraw/SaveUnder/RestoreUnder runs interpreted. The old PERF.md -DOS sprite rows therefore measured the interpreter (and were ALSO -inflated by finding #73). spriteEmitX86.c emits real code for shifts -0-1, the arena initializes (jlInit succeeds), and the 16KB scratch is a -plain malloc on DJGPP -- the failure is in the sizing/emit/arena-alloc -chain and needs instrumented bisection. Root-cause lands in Phase 1 -(it gates Phase 4's compiled-path work on DOS). - -### 75. jlTilePasteMono renders differently on chunky, Amiga, and ST -- three-way pixel divergence - -- **Where:** `src/generic/genericTile.c:77` + the Amiga/ST jlpTilePasteMono overrides -- **Severity:** high | **Category:** bug - -First-ever cross-port hash of the op (Phase 0 harness, mono pattern -alternating 0xF0/0x0F bytes, fg=5/bg=9 then fg=14/bg=0): DOS(generic) -= 5EE7ED99, ST = 5D8774F9, Amiga = 1C87B4F9 -- after identical hashes -for every preceding check on all three ports. The three implementations -disagree about the mono-tile decode (likely nibble order / bit sense). -The public contract (tile.h: non-zero source nibble -> fgColor) must -pick one behavior; fix the divergent ports in Phase 1 and re-golden the -tilePasteMono hash line. Blocks Phase 7 #3 (the IIgs asm must match the -agreed behavior, not whichever variant it was written against). - -### 76. IIgs jlpWaitVBL polled $C019, which never asserts under MAME -- infinite hang for any VBL-paced program in emulation - -- **Where:** `src/iigs/hal.c:348` (old implementation) -- **Severity:** critical | **Category:** bug -- FIXED during Phase 0 - -The old wait spun until $C019 bit 7 read high ("active scan"), then -until it read low again. Probing MAME's apple2gs at two beam positions -(frame-start and frame-done callbacks, 120 frames each) shows bit 7 -NEVER reads high under the emulator, so the first loop never exits. -Symptom chain that hid it: DRAW and PATTERN (the examples every -verification gate uses) never call jlWaitVBL or jlFrameCount, so -iigs-verify passed for months while UBER -- the only caller -- stalled -right after its red "running" bar (PC probe: a 46-byte call-free spin; -the screen frozen at exactly the 1280 bytes of 0x11 the bar paints). -This also explains why PERF.md's IIgs column was "carried over" rather -than re-captured: UBER on IIgs could not complete under headless MAME -at HEAD. Whether real hardware asserts the bit as the code expected is -unverified; the fix removes the dependence either way. - -**Fix (landed):** jlpWaitVBL waits for the firmware tick to advance -- -`tick = iigsGetTickWord(); while (iigsGetTickWord() == tick) {}`. The -tick increments from the VBL interrupt, so tick-edge granularity IS the -VBL boundary; semantics unchanged on real hardware, and it works under -MAME. Callers must not hold SEI (a masked VBL interrupt freezes the -tick, exactly as it froze the old status bit). - -**Impact:** Unblocks the IIgs UBER baseline capture (bench-iigs.sh) and -fixes jlWaitVBL for every future game on the reference platform under -emulation. - -### 77. IIgs sprite codegen corrupts memory under the clang build - -**ROOT-CAUSED 2026-07-04 (final):** with #81/#85/#86 fixed, forensic -dumps show the residual mechanism exactly: the codegen arena's -NewHandle landed at $08:4200 -- INSIDE the app's own entry-segment -bank, overlapping seg1's BSS span -- because the GS/OS loader only -MM-claims each segment's IMAGE bytes, leaving BSS+heap spans as -MM-free space. The emitted routines were verified BYTE-VALID (correct -C-ABI prologue, correct pixel stores); writing them simply clobbers -live app state. Fix is toolchain-owned (reserve full segment spans in -crt0/loader -- LLVM816-ASKS.md item 5, now with this empirical proof); -codegen stays OFF on IIgs until then. No JoeyLib-side code fault -remains in the emit or dispatch paths. - -**RETEST 2026-07-04 (post-#81 allocator fix):** the original -corrupt-at-first-compile failure is RESOLVED -- a codegen-on UBER run -now completes all 33 timed ops and most checks. Two residues: (a) the -run hangs inside checkAllocator's SECOND/THIRD jlSpriteCompile (arena -churn; sub-marker 112) -- the remaining #77 hunt target; (b) finding -#84 applies to the COMPILED path too (jlSpriteDraw measured 1 op/368 -frames with codegen on, same hash as interpreted), so the compiled -path either never truly executes or shares the interpreter's -pathology. Codegen default stays 0 pending (a). - -- **Where:** `src/iigs/spriteEmitIigs.c` + `src/codegen/spriteCompile.c` (path known; exact write not yet isolated) -- **Severity:** critical | **Category:** bug - -Bisected during Phase 0. With codegen ON (default), UBER on IIgs stalls -at drawShowcase's jlSurfaceClear: MAME stack/DP forensics show -jlpGenericSurfaceClear (the stage check failed -- the stage pointer or -struct was already corrupted) calling memset with a garbage destination -that walked into bank 0 and shredded the direct page. The corruption -happens between setupSprite (jlSpriteCompile) and drawShowcase. With -JOEY_IIGS_SPRITE_CODEGEN=0 the run proceeds normally. The -spriteCompile.c #19 note says the self-modifying-stub corruption was -"rewritten away" -- that was validated on the ORCA build; the clang -build (different ABI, different segment layout, different emitted-code -assumptions in spriteEmitIigs.c's C-ABI prologue) has its own -corruption. Root-cause with MAME write-watchpoints on the stage struct -in Phase 1; until then the IIgs baseline is captured codegen-off and -the IIgs sprite rows measure the interpreter (same situation DOS is in -via finding #74). - -### 78. ST jlFillCircle and jlTileFill draw different pixels than the chunky reference - -- **Where:** `src/atarist/` fillCircle span fill + tile fill overrides -- **Severity:** high | **Category:** bug - -First-ever DOS-vs-ST timed-op hash comparison (Phase 0 goldens): -identical hashes through jlFillRect 320x200, then divergence at -jlFillCircle r=40 and again at jlTileFill. Amiga (also planar) matches -DOS on every timed op, so the chunky semantics are the agreed truth and -the ST implementations are the outliers. Historically only IIgs-vs-68k -comparisons were run, so a DOS/ST disagreement could hide. Fix in -Phase 1 alongside #75 (tilePasteMono three-way divergence), then -re-golden the affected ST hash lines. - -### 79. clang-era IIgs libc memset/memcpy are byte loops -- ~4x regression vs the ORCA baseline - -- **Where:** `toolchains/iigs/llvm-mos/runtime/src/libc.c:191` (toolchain-owned) -- **Severity:** high | **Category:** perf - -jlScbSetRange (a 200-byte memset + flag) measures 163 ops/sec on the -codegen-off clang build vs 1005 ops/sec in the old PERF.md column -- -impossible unless the old column was captured on the ORCA build, whose -memset lowers to MVN (~7 cyc/byte). The clang runtime's memset/memcpy -(libc.c) are `while (n--) *d++ = c;` byte loops through 24-bit -pointers (~30 cyc/byte). Every memset/memcpy-based path in the core -(scb range fill, surface copy, sprite save/restore fallback memcpy, -stage clear fallback, log buffering) regressed ~4x on the reference -platform when the toolchain switched. The library-side plan already -reduces memset/memcpy dependence (findings #9, #19, #21), but the -libc routines themselves are toolchain-owned: FLAG TO SCOTT for the -llvm816 session (MVN-based memset/memcpy in runtime/src/libc.c). -Also note: PERF.md's IIgs column provenance is the ORCA build -- the -clang build's honest numbers are materially different across the -board, which changes every cross-port ratio. - -### 80. IIgs per-line log flushing does not commit to disk under the clang runtime - -- **Where:** `src/core/debug.c:47` (design assumption broken; root cause in the runtime/FST layer) -- **Severity:** medium | **Category:** bug - -debug.c's contract is flush-after-every-line so a crash or hang leaves -every line on disk. Empirically (Phase 1 mailbox vs log comparison): -UBER ran all 33 timed ops -- 33 jlLogF calls, each followed by fflush --- yet only 3 op lines were on the disk image at MAME exit. Lines only -persist after a clean fclose (the completed Jun-30 capture) or when -enough buffered data forces a block write. Either the llvm-mos libc -fflush does not issue an FST commit, or GS/OS caches the blocks until -file close. Consequences: every "the log stops at line N" inference -about IIgs hangs is unreliable (this misdirected the Phase 1 hunt -once); crash forensics on real hardware would hit the same wall. -Workarounds in place: the uber.c SHR mailbox ($E1:9DC8) is the -progress channel under emulation. Fix directions: toolchain-side -fflush->FST flush call (asked in LLVM816-ASKS.md), or debug.c -IIgs-side fclose+reopen-append per line (correct but seconds-per-line -under emulated FST -- only acceptable behind a debug flag). - -### 81. IIgs C heap: 665 bytes, unprotected, and malloc returns 1 (not NULL) at exhaustion -- the port ran on a phantom stage - -- **Where:** `src/core/surface.c:279` (victim); root cause in the toolchain libc/link layout -- **Severity:** critical | **Category:** bug -- JoeyLib side FIXED during Phase 1 - -The full forensic chain (MAME memory probes + in-app mailbox pokes + -link-map correlation + an independent static audit): - -1. The linker defines the C heap as the leftover space in the entry - bank above BSS: `__heap_start=$00:BC67`, `__heap_end=$00:BF00` -- - 665 bytes, in bank 0, and never reserved from the GS/OS Memory - Manager (nothing in crt0Gsos claims it). -2. `stageAlloc`'s `calloc(1, sizeof(jlSurfaceT))` (~720 bytes) cannot - fit. The libc malloc's overflow branch -- whose own comment records - a "historical over-heap miscompile (only manifested for oversized - n)" and assumes the path "is never exercised" -- returns **1** - instead of NULL. -3. `gStage = (jlSurfaceT *)1` passes every NULL check. Its `pixels` - field dereferences $00:0001, which happens to contain $00012000 -- - the exact pinned stage framebuffer address -- so all drawing and - present work flawlessly. Its scb[200] and palette[512] fields alias - bank-0 low memory: every jlScbSetRange/jlPaletteSet scribbled over - system space, and jlSurfaceHash hashed that garbage (the divergent - IIgs hashes in the Phase 0 goldens). -4. Cumulative bank-0 damage + further heap exhaustion during the - sprite churn produced the phase-5 checkAllocator hang, the moving - freeze points, and (pending confirmation) finding #77's - codegen-path corruption. An independent static audit of the - emitters verified the emit-time code itself is clean and separately - flagged that Memory Manager handles can legally be placed over the - unreserved heap (second corruption channel, toolchain-owned). - -**Fix (landed, JoeyLib side):** finding #31 executed ahead of -schedule and extended to surfaces -- ALL core structs now come from -jlpBigAlloc/jlpBigFree (IIgs Memory Manager; plain malloc elsewhere): -the stage struct, jlSurfaceCreate structs, jlSpriteT headers, owned -tileData, and the assetLoad cel buffers, with every error-path free -converted symmetrically. DOS re-benched bit-identical to the frozen -goldens (43/43 hashes). The residual C-heap load is the log FILE plus -arena bookkeeping nodes (well under 665 bytes). - -**Toolchain asks (filed in LLVM816-ASKS.md):** (a) malloc/calloc must -return NULL at exhaustion -- minimal repro is calloc(1, 720) against -the default heap; (b) reserve [__heap_start, __heap_end) from the -Memory Manager in crt0Gsos (or back malloc with an MM handle); -(c) consider attrNoSpec on NewHandle calls and reserving the pinned -stage range $01:2000-$9FFF. - -### 82. IIgs per-line log writes stall the app in floppy firmware -- replaced with a RAM ring - -- **Where:** `src/core/debug.c` (IIgs branch) -- FIXED during Phase 1 -- **Severity:** critical | **Category:** bug - -PC sampling of a "hung" UBER run landed in slot-5 SmartPort firmware -and ROM: the app was inside GS/OS floppy writes. Each jlLogF line cost -~8 emulated seconds of FST/floppy time (the steady ~4-ops-per-2000- -frames crawl), and the end-of-run logging wedged in a firmware retry -loop for 18+ emulated minutes. Combined with #80 (per-line fflush never -commits anyway), the flush-per-line-to-floppy design was slow, lossy, -AND hang-prone under emulation. Fix (landed): on IIgs, jlLog/jlLogF -append to a RAM ring exported as gJoeyLogRing/gJoeyLogRingHead; -scripts/bench-iigs.sh's Lua drains the ring live (magic-word-gated -mailbox handshake) and reconstructs joeylog.txt on the host; -jlLogFlush writes the ring to disk once (open/write/close -- fclose is -the only committing operation) for real-hardware persistence. Result: -the first complete IIgs UBER capture (33 ops + 17 checks, green -screen at frame ~29000). - -### 83. IIgs diagonal-line asm diverges from the portable Bresenham - -**ROOT-CAUSED AND FIXED 2026-07-04 (with #86):** iigsDrawLineInner (and -iigsFillCircleInner) stashed the pixels pointer with a DATA-BANK store -(`sta dlnScratch` under DBR) but dereferenced it D-relative (`[pix],y` -reads bank 0 at D+0) -- the exact DBR-vs-D disagreement that -iigsDrawPixelInner's and iigsDrawCircleInner's comments record fixing, -never applied to line/fillCircle. Every plot went through whatever -garbage pointer lived at the bank-0 alias of the scratch symbol: -deterministic wrong pixels (this finding), and -- when a memory-layout -shift changed what the garbage pointed at -- the plots could land on -the dln* walk state itself, mutating the endpoint mid-line and hanging -the Bresenham in an infinite wrap (#86; PC-sampled at dlnLoop/dlnStep -during the Phase 1 hunt). The wrap-walk also makes the routine a wild- -write generator (gRowOffsetLut indexed far out of bounds, stores -through garbage row offsets), a plausible contributor to the original -pre-#81 corruption soup. Both functions now use drawPixel's canonical -pattern (phx/pha, tcd, D-relative stores; arg offsets unchanged). -iigsBlitStageToShr has the same stale pattern in its bsFrame stash but -those dereferences are the #52 dead paths -- deleted by that cleanup -rather than fixed. - -- **Where:** `src/iigs/joeyDraw.s` (iigsDrawLineInner) + the JL_HAS_DRAW_LINE gate in draw.c -- **Severity:** high | **Category:** bug - -First complete IIgs capture: hashes match DOS exactly through the H/V -lines, then diverge at `jlDrawLine diag` (DOS and Amiga agree with each -other). The on-surface diagonal goes through the asm fast path on IIgs, -so either iigsDrawLineInner's Bresenham differs from the C reference -(step order, error seeding, endpoint handling) or the gate feeds it -different values. The drawRect/circle/fillRect hashes after it also -diverge until the full-screen fill wipes the state and hashes -RECONVERGE (samplePixel through tileSnap match DOS again) -- so the -damage is contained to the line/circle output pixels, and the circle -asm needs the same scrutiny. Hunt: compare asm vs C pixel-by-pixel for -the UBER diag line (0,0)-(319,199) in a MAME framebuffer dump. - -### 84. IIgs interpreted sprite ops: ~6 seconds per 16x16 draw and wrong pixels - -- **Where:** `src/core/sprite.c` interpreted paths as compiled by the clang w65816 backend -- **Severity:** high | **Category:** bug - -With codegen off (finding #77 mitigation), the sprite ops measure: -jlSpriteSaveUnder 1 iter / 65 frames, jlSpriteDraw 1 iter / 368 frames -(~6.1 SECONDS for one fully-on-surface 16x16 interpreted draw at -2.8MHz -- three orders of magnitude beyond any plausible cost; finding -#2's writeDstNibble overhead accounts for ~2x, not 1000x). And the -output is wrong: sprite-clip-roundtrip FAILs (save/draw/restore does -not restore the pre-save pixels) and every post-sprite hash diverges. -Suspects: a clang-w65816 codegen pathology in the inner loop (e.g. a -software-multiply or long-pointer sequence per pixel plus something -quadratic), or memory-layout aliasing that makes the loop rewalk. -jlSpriteRestoreUnder (memcpy rows) runs at a sane 4023 ops/sec, so the -slowness is specific to the nibble-walking loops. Needs disassembly of -the compiled spriteDrawInterpreted + a MAME cycle profile. - -### 85. The pinned framebuffer was never reserved from the Memory Manager -- MM handles landed inside the display - -- **Where:** `src/iigs/hal.c` (jlpStageAllocPixels / all NewHandle sites) -- FIXED during Phase 1 -- **Severity:** critical | **Category:** bug - -The empirical chain that resolved finding #84's "1000x slow sprites": -a diagnostic poke showed the interpreted draw looping over w=280-320, -h=100-170 with sp->widthTiles == sp->heightTiles == 0x44 (= 68 tiles, -i.e. a 544x544 sprite clamped to the surface by clipRect). 0x44 is not -data -- it is COLOR 4 DOUBLED, the fill byte of the jlSurfaceClear(4) -that precedes UBER's sprite section. A follow-up watch of the sprite -struct's address showed it living at $01:8B04 -- INSIDE the pinned -stage framebuffer ($01:2000-$9CFF) -- with its bytes tracking each -successive clear's fill pattern ($AA during the showcase). Mechanism: -jlpStageAllocPixels handed out the bare constant $01:2000 without ever -claiming the range from the Memory Manager (the #77 static audit's -candidate 2), so NewHandle considered the framebuffer free and placed -the #81-fix's jlpBigAlloc blocks inside it. Every surface clear then -rewrote the structs: garbage sprite dimensions (the #84 slowness and -wrong pixels), a full-surface save through the 256-byte gBackupBytes -(the BSS carnage: smashed gBackup, the impossible op-counter values, -sprite-clip-roundtrip FAIL), and plausibly the residual #77 -second-compile hang (arena bookkeeping in painted-over memory). - -**Fix (landed):** jlpStageAllocPixels claims $01:2000 (SURFACE_PIXELS_SIZE -bytes) via NewHandle attrAddr|attrFixed|attrLocked and holds the handle -(released in jlpStageFreePixels); failure to claim leaves a -coreSetError breadcrumb. Additionally every JoeyLib NewHandle site -(jlpBigAlloc, the codegen arena, all three audio handles) now passes -attrNoSpec so no JoeyLib handle can land in banks $00/$01 at all. -Toolchain note appended to LLVM816-ASKS.md item 5: the runtime should -consider the same reservation for any of its own MM usage. - -## Refuted findings (for the record) - -- `src/codegen/spriteCompile.c:202` -- jlSpriteCompile never compacts-and-retries on arena exhaustion, silently demoting sprites to the interpreter forever - - Why refuted: Accuracy: FAILS. Quoted code exists (spriteCompile.c:202-206), but the claim 'jlSpriteCompile does neither' misreads the contract. codegenArenaInternal.h:42-44 offers two sanctioned options: compact-and-retry OR surface the failure ? and jlSpriteCompile surfaces it by returning false. The public API (include/joey/sprite.h:85-87) explicitly documents 'Returns false if the arena is full (caller may -- `src/core/input.c:79` -- jlWaitForAnyKey makes 59 cross-TU predicate calls per poll instead of scanning the arrays it already owns - - Why refuted: Accuracy: PASS ? line 79 does call jlKeyPressed for all 59 keys per spin iteration, both arrays are module globals in this same file, the bounds check is loop-dead, and ~9K cyc/iteration on 65816 is a fair estimate. Fix: technically valid (identical semantics, early return preserved). Materiality: FAIL ? jlWaitForAnyKey is a blocking busy-wait whose sole purpose is to burn cycles until a human pre -- `src/core/sprite.c:155` -- Planar ports run clipRect in spriteDrawInterpreted solely for the dirty mark, then jlpSpriteDrawPlanes re-clips the same sprite - - Why refuted: Accuracy: mostly right -- on Amiga/ST s->pixels is NULL so spriteDrawInterpreted reduces to clipRect + surfaceMarkDirtyRect, jlSpriteDraw:354-355 then calls jlpSpriteDrawPlanes which performs its own full clip (verified amiga/hal.c:1673-1685, atarist/hal.c:1749-1764), and 68k sprite compile currently fails so this is the live path. Fix-validity: workable but weaker than claimed -- moving surfaceMa -- `src/core/sprite.c:65` -- clipRect negation of dstX/dstY overflows int16_t at INT16_MIN - - Why refuted: Accuracy: technically correct on paper -- at sprite.c:65/70, negating INT16_MIN is formal UB where int is 16-bit (ORCA/C, Watcom) and truncating impl-defined conversion on 32-bit-int m68k gcc, and the analysis that all other extents are wrap-safe checks out (dstX+w max 2359, w<=2040). Fix-validity: the early rejection guard is correct and overflow-free, though 'zero cost' is slightly off (two extr -- `src/core/codegenArena.c:113` -- codegenArenaAlloc does no size rounding: 68000 code alignment is guaranteed only by the accident that every current 68k routine happens to be word-sized - - Why refuted: Accuracy: the mechanics check out (no rounding at codegenArena.c:113, back-to-back compaction packing at 165-168, both 68k emitters emit exclusively via 2-byte putWord, odd-address 68000 code fetch is an address error), but the core risk claim is wrong: even-ness is NOT 'the accident that every current 68k routine happens to be word-sized' -- every 68000 instruction is architecturally a multiple o -- `src/codegen/spriteCompile.c:185` -- emitTotalSize performs a full throwaway emit of every routine, doubling sprite-compile cost on 2.8MHz/8MHz targets - - Why refuted: ACCURACY: factually correct -- emitTotalSize (spriteCompile.c:108-130) runs all 24 (shift,op) emitters to completion into scratch and the emit loop (210-240) runs them all again; the IIgs draw emitter does real per-destbyte work (shiftedByteAt at spriteEmitIigs.c:346/367, c2pColumn on 68k), so compile cost genuinely ~doubles and the ~2000-extra-calls estimate for a 32x32 sprite is the right order. diff --git a/PERF.md b/PERF.md deleted file mode 100644 index 786266c..0000000 --- a/PERF.md +++ /dev/null @@ -1,1208 +0,0 @@ -# JoeyLib cross-port performance (UBER benchmark) - -Regenerated 2026-07-05 (PERF-AUDIT-PLAN.md Phase 8, #25b) from -same-day captures of all four ports. This supersedes the 2026-07-04 -Phase 0 baseline; every phase of the audit fix plan (0-8) has landed, -so these are the numbers the library actually ships with. - -**2026-08-06 update -- Sharp X68000 moved to the 256-colour graphics -plane.** The port previously drew native-planar into the TEXT plane, which has a -single 16-entry hardware palette and therefore could not honour the SCB. To make -SCB per-band palettes work it now presents into the 256-colour GRAPHICS plane -(`_iocs_crtmod(13)`), expanding the four 1bpp planes to one byte per pixel in -GVRAM with the DOS port's encoding, `(scb[y] << 4) | nibble`, against the -256-entry palette at `$E82000`. Storage is UNCHANGED -- still -`JOEYLIB_NATIVE_PLANAR`; only the present target moved. No raster interrupt is -involved. All 51 golden hashes are unchanged (jlSurfaceHash hashes the surface -planes, not GVRAM) and the port stays at 51/51. (The "total wall time is -identical at 15945 ms" originally written here was an artifact of the broken -frame clock -- see the clock note at the end of this entry.) - -Measured 2026-08-06 on the passing 256-colour run with both clocks fixed. -Honest UBER total wall time is **40890 ms (2249 frames)**. Two earlier figures -in this entry's history were wrong and are corrected below: 15945 ms came from a -broken frame clock, and 108309 ms was real but predated the present rewrite. - -The "pre-LUT" column is the straightforward per-pixel expansion the graphics-plane -switch first shipped with; "now" is after the word-store expansion tables. - -| op | pre-LUT | now | IIgs | now / IIgs | -|---|---|---|---|---| -| `jlSurfaceClear` | 30 | 106 | 33 | 3.21x | -| `jlPaletteSet` | 16175 | 58516 | 1561 | 37.49x | -| `jlScbSetRange` | 5946 | 21206 | 163 | 130.10x | -| `jlDrawPixel` | 10225 | 36220 | 5576 | 6.50x | -| `jlDrawLine H` | 2632 | 9370 | 1322 | 7.09x | -| `jlFillRect 16x16` | 687 | 2414 | 785 | 3.08x | -| `jlFillRect 80x80` | 84 | 306 | 133 | 2.30x | -| `jlFillRect 320x200` | 29 | 103 | 22 | 4.68x | -| `jlDrawCircle r=16` | 165 | 591 | 303 | 1.95x | -| `jlDrawCircle r=80` | 35 | 125 | 70 | 1.79x | -| `jlSpriteDraw` | 2059 | 7397 | 1142 | 6.48x | -| `jlSpriteSaveUnder` | 3570 | 12803 | 602 | 21.27x | -| `jlTileMapPaste 10x5` | 295 | 1055 | 140 | 7.54x | -| `jlStagePresent full` | 392 | 1373 | 73 | 18.81x | -| `gameFrame composite` | 2 | 12 | 12 | 1.00x | -| `gameFrameClean composite` | 2 | 137 | 20 | 6.85x | - -**All 39 shared ops now meet or exceed the Apple IIgs reference; median 7.54x, -slower on none.** The two composite rows are the ones that mattered -- they are -the closest thing UBER has to a real game frame, and they were the reason the -port missed the "within ~1.5x of the native ceiling" directive at 0.17x and -0.10x. `gameFrame composite` is now exactly at parity and `gameFrameClean` is -6.85x. - -The fix was NOT a port of DOS's gExpandLut. DOS fuses two ADJACENT output bytes -into one 16-bit store (mode 13h pixels are neighbouring bytes); here each pixel -is a 16-bit WORD, so its bytes are two apart and that fusion is impossible. -Inverting it works: write whole words, and one 32-bit store covers exactly two -pixels. The high byte is free because the 256-colour index reads only -`$000F | $00F0` (x68k_v.cpp:339-340). Expansion tables indexed by plane and -nibble are pre-interleaved into word positions, so four planes OR together with a -replicated palBase and the inner loop issues four long stores per eight pixels -with no shifting at all -- against roughly 64 operations and eight byte-stores -before. Verified three ways, because each layer misses what the others catch: -51/51 golden (surface correct), a GVRAM dump under PATTERN.X byte-identical to -the pre-rewrite run (pixel encoding correct), and an earlier framebuffer capture -(the display actually renders the bands). - -**Two clock bugs had to be fixed before any of these numbers meant anything, and -every X68000 figure published before 2026-08-06 was wrong.** The port had no -`JL_HAS_MILLIS_ELAPSED` override, so milliseconds derived from the frame counter; -and that counter was POLLED (`vdispPoll`), so an op spanning more than a frame -lost the V-DISP edges in between. The undercount scaled with the op's cost and -cancelled it, making every op report ~10,000 ops/sec regardless of workload -- -`jlFillRect 320x200` (9735) matched `jlFillRect 16x16` (10505) despite 250x the -work. The fix chains Human68k's MFP Timer C, reading its rate from TCDCR/TCDR -rather than assuming it, and derives the frame counter from that tick. -`UBER-CLK` now agrees frame-vs-millisecond on every row, which it could not -before because both figures came from the one broken source. - -**2026-07-06 update:** the IIgs sprite column is now COMPILED. Finding -#77 (the loader/segment-BSS + malloc-heap reservation that forced the -codegen arena into the app's own BSS) was fixed toolchain-side and -JOEY_IIGS_SPRITE_CODEGEN was re-enabled; the four IIgs sprite rows were -re-captured (bench-iigs.sh) and jumped 5-25x (SaveUnder 104->543, -Draw 48->907, RestoreUnder 110->568, SaveAndDraw 15->376 ops/sec), -bit-identical to the interpreter goldens (diff-uber-hashes 45/45). The -ST and DOS columns are unchanged from the 2026-07-05 capture. - -Also 2026-07-06: the Amiga `jlpFillRect` partial-width path was moved -onto the blitter (BLTAFWM/BLTALWM hardware edge masks + masked minterm), -which lifted `jlFillRect 16x16` 567->1153, `80x80` 71->269 (both from -below-floor to well above it), and -- because they build on fillRect -- -`jlDrawLine V` 144->269 and `jlDrawRect 100x100` 122->203, all -hash-identical (Amiga 45/45). The one remaining Amiga below-floor cell -is `jlFillCircle r=40` (74%); a whole-circle asm routine was tried and -did NOT help (per-span dispatch is not the cost -- the 8-way-symmetry -overlap fill dominates), so it stays on the C span walker for now. - -**2026-07-06, NATIVE-PERF Phase 0:** four WORKLOAD rows were added (see -NATIVE-PERF-PLAN.md) and the goldens refrozen at **49 lines** after a -clean regression gate (45 match + exactly 4 extras per port) and full -4-port cross-identity. The new rows measure what games actually do, and -they expose the real problem the per-op rows hid: `jlSpriteDraw -unaligned` runs at **5% (ST) / 9% (Amiga)** of the IIgs floor -- a -15x/12x cliff vs the aligned row, because only x%8==0 draws are -compiled on the 68k ports (see the plan's code-truth table). The -`gameFrame composite` row is the bottom line: **ST 9 / Amiga 18 / -IIgs 10 / DOS 18 frames/sec** for a modest game frame. Bonus finding: -cross-identity of the sweep16 row validated the ST/Amiga shifted c2p -walkers as pixel-perfect at all 14 previously-untested x phases. -(Re-capture note: IIgs input/audio rows wobbled vs the prior MAME run --- e.g. jlAudioFrameTick 50223 -> 27612 -- run-to-run MAME variance on -cheap ops; hashes are unaffected and those rows gate nothing.) - -**2026-07-06, NATIVE-PERF Phase 1 (draw side) LANDED:** all-shift -compiled sprite DRAW on both 68k ports. Amiga emits pre-shifted JIT -variants for all 8 bit phases; the ST emits per-phase thunk + data -tables driven by a shared masked-movem walker (spriteWalk.s) for all -16 intra-group phases, with the dispatcher's ST shift contract changed -to x & 15. jlSpriteCompile is now plan-based: it measures every -(shift, op) variant, greedily fits them under the uint16 offset cap -(draws first, save/restore as atomic pairs), and degrades to the -byte-aligned phases when the arena is too small instead of losing the -sprite. RESULT -- the alignment cliff is CLOSED: `jlSpriteDraw -unaligned` ST 40 -> 500 ops/sec (12.5x), Amiga 75 -> 803 (10.7x); -sweep16 ST 2 -> 32 sweeps/sec (512 draws/sec), Amiga 5 -> 50 (800 -draws/sec). Unaligned now performs like aligned on both ports (ST 82% / -Amiga 84% of their aligned rows), and every one of the 49 golden -hashes still matches -- the compiled paths are bit-identical to the -interpreted walkers they replace, on all four ports. (Found along the -way: the Amiga bench task stack was ~4 KB -- one deep call chain from -overflow -- now 32 KB in bench-amiga.sh; the emitters' accumulators are -file statics.) - -**Phase 1 step 5 (save/restore) ALSO LANDED, completing Phase 1:** -compiled save/restore on both 68k ports operate on the 16-px-group -window (two width classes per sprite, like the IIgs MVN pair), the -backup format is a pure function of the recorded geometry (identical -for compiled and interpreted producers/consumers -- the ST HAL gained a -raw-span interpreted branch, switched atomically with restore), and the -gates route by SPRITE_SAVE_CLASS / SPRITE_RESTORE_CLASS with an odd- -buffer-pointer demotion (68000 word ops need even addresses). The -combined jlSpriteSaveAndDraw path now fires on 68k at EVERY x for the -first time. RESULTS (floor-relative): Amiga SaveUnder 104% -> 276%, -RestoreUnder 98% -> 236%, SaveAndDraw 94% -> 160%; ST SaveUnder -158% -> 203%, RestoreUnder 122% -> 163%, SaveAndDraw 87% -> 107%; -Amiga gameFrame 18 -> 23 fps. Backup capacity contract: -JOEY_SPRITE_BACKUP_BYTES in sprite.h (window worst case; all in-repo -examples migrated + 2-aligned). DEFAULT_CODEGEN_BYTES is 64 KB on -Amiga/ST (full variant sets). All 49 golden hashes held throughout. - -**2026-07-07, THE ST COLUMN DOUBLED -- idle-audio ISR tax found and -fixed:** every previous ST capture ran with ~49.5% of the CPU silently -consumed by the 12.3 kHz Timer-A PWM ISR that jlpAudioInit started -unconditionally -- it streamed buffer silence to the YM even with -nothing playing, and UBER inits audio before its timed run. Found by -a calibrated probe (known-cycle dbra spin + per-layer direct-call rows -under the bench Hatari config): every probe row slowed by exactly -1.98x once audio was initialized, and the probe's un-taxed tilePaste -cycles/call matched the instruction-count model that UBER's number -mysteriously doubled. Fix: src/atarist/audio.c Timer A is now -demand-driven -- installed on the first PlayMod/PlaySfx, parked again -once the mix buffer drains back to silence with nothing live -(drain-to-silence handshake in jlpAudioFrameTick; StopMod parks -immediately when no SFX is riding the pump). Probe-verified: idle rows -match the never-initialized rows exactly, the tax correctly returns -while an SFX drains, and it vanishes after the drain. Full ST -re-bench: 49/49 hashes bit-identical (pixels untouched) and EVERY row -~2x: gameFrame 9 -> 19 fps, tilePaste 1,574 -> 3,203/s, sprite draw -607 -> 1,188/s, unaligned draw 508 -> 992/s. The other three ports -were audited for the same bug class and are clean: Amiga ~1.8% idle -tax (PTPlayer rides Paula DMA), DOS ~0.5% (SB auto-init DMA, ~11 Hz -half-buffer IRQ), IIgs 0% (no handler installed until PlayMod). ALL -pre-2026-07-07 ST comparisons -- including the Phase-0 ceiling gaps -and the Phase-1 relative results -- understated the ST by ~2x. - -**2026-07-07, NATIVE-PERF PHASE 4 (IIgs arm) LANDED -- whole-map asm -walker:** iigsTileMapPasteMark (joeyDraw.s) replaces the IIgs -stage-arm per-tile loop of jlTileMapPaste with ONE JSL that walks the -index map: dst offset advances incrementally (+4/column, +1280/tile -row) from a single gRowOffsetLut read, the register-starved inner -lives in a 22-byte stacked D-frame (X survives the 16-store body and -carries the dst offset; the map index sits in the frame because Y is -clobbered), the idx << 5 tileset add carries into the bank byte, all -three wrapping branches are brl, and ONE band union closes the call. -Gated 50/50 + make iigs-verify. RESULT: IIgs batch row 60 -> 140 -maps/s = 7,000 tiles/s (+133%), ~400 cyc/tile -- BELOW the 480-cyc -single-tile hand-coded ceiling, because the walker amortizes the -JSL/derive/mark that even a hand-coded per-tile call pays. Batch row -across all ports: ST 8,150 / Amiga 7,650 / IIgs 7,000 / DOS 22,500 -tiles/sec (the morning's per-call loop numbers were 1,600 / 2,300 / -1,750 / 10,000). The Amiga blitter cookie-cut sprite was recon'd and -DEFERRED with a full design on record: the synchronous WaitBlit the -hash contract requires erases the parallelism win at <= 16x16 (every -current sprite row), and the chip-RAM pre-shifted source data is a -net-new subsystem -- the honest experiment is a >= 32x32 golden'd row -measured with and without the fence. - -**2026-07-07, NATIVE-PERF PHASE 4 CLOSED -- clean-buffer experiment -PASSED, jlSurfaceCopyRect is public API:** Scott signed off the -jlTileMapPaste shape and approved the clean-buffer experiment; both -landed same-day. jlSurfaceCopyRect(dst, src, x, y, w, h) is a -SAME-POSITION rect copy (clip, then snap outward to the 16-px group -grid on every port -- uniform snap keeps cross-port hashes -byte-identical): core wrapper + dirty mark in surface.c, generic -chunky row-memcpy engine (serves DOS and both IIgs surface flavors -- -the IIgs stage pixels pointer reaches bank $01 through ordinary -large-model stores, recon-verified against the sprite-restore -precedent), ST override = strided interleaved row memcpys (the -raw-span restore recipe), Amiga override = per-plane CPU row copies -(CopyMemQuick is unusable at 16-px grain: word alignment only; -the modulo-driven blitter variant is the recorded production -upgrade). The STRATEGY row `gameFrameClean composite` (erase 3 -sprite windows from a pristine clean surface + 3 draws + present; NO -per-frame background repaint, NO save-under -- the static-background -pattern, not an op-for-op gameFrame speedup) measures, vs gameFrame: -**ST 48 vs 22 = 2.18x (acceptance bar was 1.4x), DOS 195 vs 19 = -10.3x, IIgs 18 vs 10 = 1.8x, Amiga 34 vs 25 = 1.36x** (Amiga gains -least, exactly as the adversarial recon predicted: its fillRect was -already blitter-fast, and clean->stage is a chip-to-chip CPU copy -against display DMA -- the blitter rect variant is its lever). -GOLDEN CONTRACT now **51 lines**: regression gate exactly 50 match + -1 extra per port, 4-port cross-identity of the new row (hash -C51FE171 x4), then freeze. Found along the way: `make all` does NOT -regenerate joey.2mg, so bench-iigs.sh silently benched the previous -UBER (clean 50/50, new row absent) until `make iigs-disk` -- recorded -in the stale-binary memory. - -**2026-07-07, NATIVE-PERF PHASE 4 (ST arm) LANDED -- movep.l map -engine:** the ST jlTileMapPaste arm now dispatches to -stTileMapPasteMovep (src/atarist/tileMap.s): per tile row, one -`move.l (a0)+` + `movep.l d0,d16(a1)` writes all 4 interleaved plane -bytes in 36 cycles vs ~80 for the four compiled move.b -- movep IS -the half-group byte-scatter pattern (the classic ST c2p trick), where -movem is useless (byte-granular dst) and byte-pair word fusion loses -(lsl.w #8 costs 22 cyc on a barrel-shifterless 68000, priced during -recon). Cells walk in even/odd pairs sharing one group pointer -(+8/pair, all row offsets compile-time d16); odd bx = head cell, odd -width = tail cell. The C pair walker (stTileMapPaste in stTile.h, -+17% alone -- adversarial recon correctly predicted the pure-C cap at -+15-23% because gcc already hoists the mulu and the 632-cyc copy body -dominates) stays as the odd-tileset-pointer fallback: `move.l (a0)+` -needs an even source, jlTileT is align-1, and the guard is one AND -per call. RESULT: ST batch row 163 -> 346 ops/sec = **17,300 -tiles/s** (2.12x, 462 cyc/tile) -- ABOVE the Phase-0 12,700-15,500 -"hand-coded ceiling", which under-counted by assuming byte-move -bodies. Gate: ST 50/50 bit-identical (hash 52B9646B held through -both engines) + a 12-case on-emulator proof (TILETEST, session -scratchpad) covering odd-bx head, odd-width tail, w=1 columns, h=1, -checkerboard/end/all skips, and both odd-pointer fallback paths, -each with a border-ring containment check. Batch row across ports: -DOS 22,500 / ST 17,300 / Amiga 7,650 / IIgs 7,000 tiles/s. IIgs -PEI-slam map variant was EVALUATED and REJECTED: models ~+28% -(~312 cyc/tile) but requires SEI for the whole map (~5.6 ms -interrupts-off at 10x5, audio ISR jitter) AND the VBL-driven bench -clock cannot honestly measure SEI ops (PERF artifact: SEI inflation) --- the walker already beats the 480-cyc per-tile ceiling without it. - -**2026-07-07, NATIVE-PERF PHASE 3 LANDED -- jlTileMapPaste batch API:** -one public entry pastes a wTiles x hTiles rectangle from a tileset + -row-major uint8_t index map (TILE_MAP_SKIP = 0xFF leaves a cell -untouched): one validation, one dirty union (68k/DOS; the IIgs -stage-fused entries mark per pasted tile, and its loop hoists the -stage test + does tileset indexing as base + (idx << 5) -- the -natural tiles[idx] spelling cost a 32-bit indexed multiply per cell -on the 65816 and erased the batch win until specialized). GOLDEN -CONTRACT now **50 lines**: refreeze followed the Phase-0 procedure -(regression exactly 49 match + 1 extra per port, then 4-port -cross-identity of the new `jlTileMapPaste 10x5` row -- all four hash -52B9646B -- then freeze). RESULTS, tiles/sec batch vs per-call loop: -ST 8,150 vs 4,000 (+106%, gap-to-ceiling 1.6-1.9x); Amiga 7,650 vs -3,750 (+104%, gap 1.40x -- INSIDE the ~1.5x program target); DOS -22,500 vs 12,900 (+74%, 2.5x); IIgs 3,000 vs 2,950 (+2% -- its -Phase-2 fused entries already deleted the per-tile wrapper, so the -remaining cost is ~93% inner JSL; a whole-map IIgs asm walker is the -Phase-4 lever). Sprite batch APIs were DESCOPED from Phase 3: the -phase gate measures only the tilemap row, and post-R5 sprite dispatch -overhead is ~3-6% -- not worth new public surface without a measured -need. - -**2026-07-07, NATIVE-PERF PHASE 2 COMPLETE -- Steps D3 (R6 + R10) -close the phase:** (R6) the IIgs masked-copy inner's 4 x 8 jsr/rts -round-trips to tmaskByte were inlined via the TMASK_BYTE assembler -macro (identical body; the row loop-back became brl -- the expanded -body exceeds 8-bit branch range): jlTileCopyMasked 625 -> 681 (+9%, -exactly the 384-cycle model). (R10) the stage arm of jlDrawPixel is -one fused JSL (iigsDrawPixelMark: single-row widen + nibble RMW plot -against the pinned stage base): jlDrawPixel 3,179 -> 5,576 (+75%, 502 -cyc/call incl. wrapper vs the measured 880 before); the batching -plotters (lines/circles, plotPixelNoMark) keep the unfused inner and -were flat, as were tiles/sprites. Final gate 49/49 x4 + make -iigs-verify. PHASE-2 EXIT vs its gate ("small-op rows move measurably -toward Phase-0 ceilings on all ports; goldens hold"): tilemap row -tiles/sec ST 1,600 -> 4,050 / Amiga 2,300 -> 3,750 / IIgs 1,750 -> -2,950 / DOS 10,000 -> 12,900; gameFrame ST 9 -> 21 / Amiga 18 -> 25 / -IIgs 10 / DOS 18 -> 19; IIgs tilePaste ceiling gap 3.5x -> 2.15x, ST -2.6x. The remaining per-call floor is the public wrapper + one -JSL/call -- Phase 3 batching territory. Optional leftovers R7 -(saveAndDraw consolidation) and R12 (IIgs C-residue reshapes) are -recorded in the plan, not phase-gating. - -**2026-07-07, NATIVE-PERF Phase 2 Step D2 LANDED -- sprite dispatch -flattening (R5):** the spriteCompiled* runtime dispatchers moved from -spriteCompile.c into spriteDispatch.h as always-inline functions -consumed only by sprite.c, deleting the cross-TU call per compiled -sprite op and letting the compiler share the slot/field loads the -caller already did. Gated 49/49 x4. Sprite rows: IIgs +6-13% -(SaveAndDraw 376 -> 423, Draw 906 -> 966), ST SaveUnder +15% (2,611), -Amiga +5-11% (SaveUnder 2,053), DOS SaveUnder +16% (10,743). Two -measurement footnotes: gameFrame wobbles +/-1 fps between runs -(single-digit iters per 16-frame window -- quantization, not signal; -ST has bounced 21/22 across three consecutive gates), and DOS -`jlSpriteDraw unaligned` is BIMODAL across runs of identical binaries -(~3,800 vs ~5,100; seen 07-06 and again today; the all-phase sweep16 -row is stable at ~362, so per-phase behavior is fine -- likely DOSBox -dynamic-core state; do not chase single-run deltas on that row). - -**2026-07-07, NATIVE-PERF Phase 2 Step D1 LANDED -- IIgs fused tile -entries (R2):** jlTileFill/Copy/Paste on the stage now run ONE JSL -each: new joeyDraw.s entries (iigsTile*Mark, section -.text.iigsFusedTile) derive the destination in asm from the pinned -$01:2000 stage base + gRowOffsetLut, write via long,X with the stage -literal folded into each store, and widen the dirty band via a shared -stageTileMark helper -- replacing C-side pointer math plus two JSLs -(op + marker). Gated 49/49 (pixels) AND make iigs-verify (framebuffer --- the mark half is hash-blind, so the display check is load-bearing). -RESULTS: IIgs tileFill 1,981 -> 3,251 (+64%), tilePaste 1,687 -> 2,710 -(+61%), tileCopy 1,441 -> 1,988 (+38%), map row 35 -> 59 (+69% = 2,950 -tiles/s), gameFrame 10 -> 11 fps; unfused controls (copyMasked, snap, -sprites) flat. IIgs tilePaste is now ~1,030 cyc/call vs the 480-cyc -hand-coded ceiling (gap 2.15x, was 3.5x). Toolchain landmine found en -route: a 16-bit jsr between .section blocks can cross OMF segment -banks (no link error, wild jump into $00CFxx) -- helpers must share -their callers' section or be jsl/rtl; see the cross-section-jsr -feedback memory. - -**2026-07-07, NATIVE-PERF Phase 2 Steps A-E LANDED (per-op tax):** -five hash-gated steps, each 49/49 x4. (A) plane-pointer access -inlined into the 68k compiled-sprite dispatchers (amigaPlanar.h / -stPlanar.h; the jlpSurfacePlanePtr cross-TU call -- ~190 cyc x4 per -Amiga dispatch -- deleted): Amiga sprite ops +10-23%. (B) whole-tile -dirty marks specialized (band = [bx*2, bx*2+1] with compile-time -proof, unrolled 8-row widen): tiles +9-28% on 68k/DOS. (C) jlDrawRect -raw-edge fills + ONE band-identical mark (+22-40%); the multi-row -widen unroll is Amiga-only by measurement (gcc-mint compiled it WORSE, -ST -5% -- see the per-port #if in surface.c). (D=R9) presents consume -the core gStageScbDirty/gStagePaletteDirty flags instead of -memcmp'ing 512-712-byte cached mirrors: gameFrame +1-2 fps on -ST/Amiga/DOS. (E) ST/Amiga tile ops moved into always-inline per-port -headers (stTile.h / amigaTile.h), deleting the second cdecl layer -(ST tiles +9-14%, Amiga +5-11%), then the Amiga row loops unrolled to -d16(An) form (tilePaste +36%, tileSnap +52%). NET SESSION MOVEMENT on -the tile gate row (tiles/sec, map 10x5): ST 1,600 -> 4,000; Amiga -2,300 -> 3,750; DOS 10,000 -> 12,900. gameFrame: ST 9 -> 21, Amiga -23 -> 25, DOS 18 -> 19 fps (IIgs untouched at 10 -- its fused-asm -glue is Phase 2 Step D, still open). The present-row jumps (ST 467, -DOS 1,137) are the dirty-union idle early-out running flag-gated; -gameFrame is the honest present signal. - -Correctness statement backing this table: all FIVE ports produce -BYTE-IDENTICAL pixels on every measured op and every correctness -check -- **51/51** surface hashes match the frozen goldens in -tests/goldens/uber/ (39 timed ops + 12 hashed checks; the X68000 has no -golden of its own and is diffed straight against iigs.txt; the PASS/FAIL -checks -- palette round-trip, PRNG golden sequence, sprite clip -round-trip, arena churn, sprite-from-surface -- pass everywhere). -The Phase 0-era divergences (#75 tilePasteMono three-way, #78 ST -fillCircle/tileFill, #87 IIgs circle outline) are all fixed. - -**2026-07-09, Phase 7 P7-3 -- Amiga present sped up (src/amiga/hal.c -only).** The Amiga present was Phase 6's single largest out-of-bar item -(2.8-5.3x). A probe forensic split it: a fixed per-present scan (P8 idle -= 1.51 ms) plus, for large presents, a ~11 ms CPU copy (P1 present96 -copied 12,288 B). Two bisectable changes, each P11-gated (presentPageSum -= exhaustive displayed-page-vs-shadow byte parity, the gate for the -hash-invisible present -- PASS on both): (A) the per-present snapshot -pass was folded into the scan's COLD union-dirty path (a row clean in -both frames already holds the clean sentinel in gPrev, so the hot path -stays 2 loads -- a first attempt that touched every row REGRESSED and -re-confirmed the recorded gcc-amigaos register-pressure trap), dropping -the present from two 200-row passes to one (scanned 400 -> 200); (B) the -large-run copy now runs on the blitter via the W3 amigaBlitterCopyPlanes -(A->D, offset+modulo) above a 128-plane-word threshold, so present96 / -gameFrame's 96-row bands move off the CPU while present3win's scattered -windows stay on the CPU THIN path. RESULTS: present96 46 -> 71 (+54%), -present3win 93 -> 103 (+11%), gameFrame 25 -> 31 (+28%), gameFrameClean -62 -> 71, jlStagePresent full 584 -> 609; Amiga 51/51 golden hashes hold -(present changes are hash-invisible -- pixels unchanged). Remaining -Amiga-present lever: a fused-scan present asm (ST presentSlam analogue -over 4 plane buffers) for the scan-bound rows -- C fusion is now -empirically register-pressure-capped, and the async-fence blitter -contract stays parked behind a >=32x32 row. - -**2026-07-09 -- fused-scan present asm + a component probe that located -the real cost.** src/amiga/presentScan.s (hand-rolled 68000, the 4 band -arrays walked in address registers) replaces jlpPresent's C union scan. -The plain asm (no fast path) barely moved present3win (103->106), which -first read as "the scan isn't the bottleneck" -- but a present-component -skip-mask probe corrected that. It split present3win's 9.4 ms: SCAN -~4.9 ms, FLUSH ~3.3 ms (chip->chip CPU copy of the 3 tiny windows), FLIP -~0.4 ms, residual (copper-check/reset/WaitBlit/marks/harness) ~1.2 ms; -the 3 marks alone are 0.64 ms. So the scan IS ~half the present -- the -plain asm just wasn't faster than gcc because the scan is -INSTRUCTION-COUNT-bound (~16 instr x 200 rows), not the register spill it -was assumed to be. The ST presentSlam 4-row fast path (one move.l -compares 4 clean-sentinel min bytes, cur AND prev, and skips the group; -needs gPrev arrays aligned(2)) LANDED: present3win 106->128 (+21%), -present96 ->78, gameFrame 32->34, gameFrameClean 62->82, P11 PASS, 51/51. -A first cut passed all PIXEL gates but broke the gameFrameClean golden -row -- a bisect proved the fast path's 'bra .rowLoop' skipped the -loop-exit check, so a clean group ending at row 199 landed the row index -on 200 and processed a PHANTOM out-of-bounds row 200 (writing -gPrevStageMin/MaxWord[200] one byte past the arrays). That corrupts a -neighbouring global invisibly to P11/hashes/counters yet breaks a later -jlSurfaceCreate AllocMem -- gameFrameClean (the only op allocating a 2nd -surface) was an accidental memory-integrity canary. FIX: the exit check -now lives at the TOP of .rowLoop, guarding both re-entry paths. The FLUSH -lever also LANDED: the ~3.3 ms chip->chip CPU copy of the small windows -was contention-bound, so lowering AMIGA_PRESENT_BLIT_MIN_WORDS 128 -> 16 -routes them to the blitter (parallel chip->chip DMA) -- present3win -128 -> 153 (+20%), gameFrameClean 82 -> 90, 51/51 + P11. An OWN-ONCE -flush polish (the present OwnBlitters once instead of per window) also -landed correct (51/51 + P11) but PERF-FLAT (present3win 153 -> 154, -within noise): after the threshold drop the flush is blitter-DMA-bound, -so the per-window Own/Disown it removes was already negligible -- kept -for the right blitter discipline, not a speedup. Session present3win arc -106 -> 128 -> 153 -> 154 = +45%. No known remaining flush lever. - -**2026-08-05, FIFTH PORT: Sharp X68000 -- the hashes PASS, the clock -does NOT.** src/x68000 + make/x68000.mk (JOEYLIB_PLATFORM_X68000) is on -the golden gate: tools/diff-uber-hashes reports OK: 51/51 ops match. The -port carries NO frozen golden of its own -- it is diffed straight -against tests/goldens/uber/iigs.txt, which it matches exactly. Shape: -10 MHz 68000, NATIVE PLANAR storage (4 x 1bpp planes, 40 bytes/row, -X68K_PLANE_SIZE 8,000), byte-for-byte the Amiga surface layout -- but -the DISPLAY is not the surface. It draws into the TEXT plane (four 1bpp -planes at $E00000/$E20000/$E40000/$E60000, 128-byte stride), NOT -graphics VRAM, so jlpPresent scatters the compact 40-byte rows into that -stride: 32,000 bytes/frame against 128,000 bytes of GVRAM word writes on -the chunky path it replaced (src/x68000/x68kPlanar.h:20-33). The 320x200 -window is CENTRED in a 512x512 mode (_iocs_crtmod 12) at x=96 / y=156, -byte-aligned by construction (96/8 = 12). Palette is the 16-entry -PCG/text palette at $E82200 -- NOT the $E82000 graphic palette -- in -GGGGGRRRRRBBBBBI order, green and red SWAPPED versus RGB555 plus ONE -SHARED intensity LSB; and because the text plane has exactly one -16-entry palette, per-scanline SCB palettes cannot be honoured (palette -0 always wins) -- unlike the ST, which really does emulate per-band SCB -palettes from an MFP Timer B ISR, so this port stands alone in ignoring -the SCB on screen. [SUPERSEDED 2026-08-06: the port moved to the -256-colour graphics plane (_iocs_crtmod 13, palette $E82000) precisely to -fix this -- SCB per-band palettes now work, DOS-style. See the -2026-08-06 entry at the top. The rest of this paragraph still describes -the port accurately.] Sprites COMPILE (shared 68k planar emitter, see the -Reading notes). Toolchain is elf2x68k at toolchains/x68000/m68k-xelf -(gcc 13.4.0, ELF -- so no leading-underscore asm symbols, and -src/m68k/*.s does not link there); disks are built by tools/xdftool.py -(Human68k FAT12, 1024-byte sectors, .X executables); the emulator is a -patched MAME 0.264 single-driver build (patches/mame-0.264-x68k-*.patch). -The only recorded rate-card input so far is from those patches: TVRAM -costs ~1.92 extra cycles/access and GVRAM ~1.03 versus main memory, -applied only at a 10 MHz CPU clock (docs/x68000_mame_crtc_timing.md:307-309, -328) -- which is why the 4x traffic cut to the text plane wins despite -TVRAM's higher per-access cost. NO ceilings are derived for this port -anywhere in this file, and none should be until the clock is fixed. - -THE COLUMN BELOW IS NOT COMPARABLE TO THE OTHER FOUR. It is recorded raw -because the capture is expensive and the log is not kept in-tree, not -because the numbers mean what they say -- see measurement artifact 5. -Capture route: UBER built with the serial log tee -(EXTRA_CFLAGS=-DJOEY_LOG_SERIAL_TEE, which now reaches every port via -make/common.mk:26; src/core/debug.c calls jlpLogTee, implemented at -src/x68000/hal.c:353) against scripts/x68kSerialPeer.py, which MUST be -run as `python3 -u` or a killed peer loses the whole capture to stdio -buffering. This is the only route that gets a full log off the machine: -the disk route (scripts/verify-x68000-golden.sh) forces -DUBER_FRAMES=1u -and cannot produce timings at all -- and in practice cannot produce the -log either, because joeylog.txt never commits its size to the Human68k -image. Run header as reported: total wall time 15,945 ms, 877 frames -@ 55 Hz (both from the suspect clock). - -| Op | X68000 (reported ops/sec) | -| --- | --- | -| jlSurfaceClear | 8775 | -| jlPaletteSet | 14353 | -| jlScbSetRange | 8305 | -| jlDrawPixel | 8580 | -| jlDrawLine H | 8855 | -| jlDrawLine V | 8800 | -| jlDrawLine diag | 8195 | -| jlDrawRect 100x100 | 8470 | -| jlDrawCircle r=16 | 8305 | -| jlDrawCircle r=80 | 7316 | -| jlFillRect 16x16 | 9295 | -| jlFillRect 80x80 | 22182 | -| jlFillRect 320x200 | 6986 | -| jlFillCircle r=40 | 9240 | -| jlSamplePixel | 10395 | -| jlTileFill | 8195 | -| jlTileCopy | 8525 | -| jlTileCopyMasked | 8745 | -| jlTilePaste | 9405 | -| jlTileSnap | 8690 | -| jlSpriteSaveUnder | 8580 | -| jlSpriteDraw | 8250 | -| jlSpriteRestoreUnder | 8886 | -| jlSpriteSaveAndDraw | 8305 | -| jlStagePresent full | 572 | -| jlInputPoll | 8250 | -| jlKeyDown | 53006 | -| jlKeyPressed | 53611 | -| jlMouseX | 125529 | -| joeyJoyConnected | 53006 | -| jlAudioFrameTick | 23756 | -| jlAudioIsPlayingMod | 84472 | -| surfaceMarkDirtyRect (via jlFillRect 32x32) | 8965 | -| jlSpriteDraw unaligned | 8030 | -| jlSpriteDraw sweep16 | 8470 | -| jlTilePaste map 10x5 | 8305 | -| gameFrame composite | 938 | -| jlTileMapPaste 10x5 | 8030 | -| gameFrameClean composite | 1245 | - -The tell is in the table itself, no model needed: 26 of the 39 rows land -inside a single 7,316-9,405 band -- a 1.29x spread across ops whose real -work differs by orders of magnitude. `jlDrawPixel` (8,580), a 100x100 -rect outline (8,470) and a 16x16 sprite draw (8,250) cannot all cost the -same; `jlFillRect 80x80` (22,182) cannot be 2.4x FASTER than `jlFillRect -16x16` (9,295); `jlSurfaceClear` (8,775) cannot land within 1.2x of -`jlSamplePixel` (10,395). The physics check, in the style of artifact 2: -the stage is 4 x 8,000 = 32,000 bytes, so 8,775 clears/sec is 114 -us/clear = ~1,140 cycles at 10 MHz = 28 bytes PER CYCLE. Even at the ST -rate card's write-only fill rate of ~2.2 cyc/B, 32,000 bytes is ~70k -cycles = ~7.0 ms = ~140 ops/sec, so that row reads ~60x high. Same for -the composite: `gameFrame` at 938/s is 1.07 ms = ~10,700 cycles for a -256x96 fill + 16 tile pastes + 3 saveAndDraw + 3 restores + a 96-row -band present, when the fill ALONE is 12,288 B x 2.2 = 27k cycles (the -very figure this file uses for the ST gameFrame ceiling) -- 2.5x the -whole measured frame. Only the cheap rows survive: at ~8 us/call -`jlMouseX` (125,529), `jlKeyDown` (53,006) and `jlAudioIsPlayingMod` -(84,472) poll the clock far more often than once per frame and read -honestly. - -For calibration once the clock is fixed, this port sits between the two -68k ports already in the table: a 10 MHz 68000 with NO blitter -- the -ST's situation with 25% more clock -- writing a compact 4-plane surface -in plain malloc'd main RAM (no Chip RAM constraint, nothing but the CPU -ever reads it) and then scattering 32,000 bytes/frame into TVRAM at its -~1.92-cycle access penalty. So CPU-bound rows should be expected ABOVE -the 8.0 MHz ST roughly on clock ratio, while the present should land -between the ST (bus-floor CPU copy, 8 MHz) and the Amiga (7.09 MHz but a -blitter and display-DMA contention). None of that is measured yet, and -none of it can be until artifact 5 is closed. - -## Measurement honesty (read before trusting any number) - -Five measurement artifacts have shaped this table's history. Three are -fixed, one is inherent, and one -- the X68000 frame clock -- is OPEN and -invalidates that port's entire ops/sec column: know them before comparing -rows: - -1. **Per-iteration poll tax -- FIXED (Phase 0).** The pre-audit bench - loop called jlFrameCount() between every op; on the IIgs that was a - GetTick toolbox chain (~600-800 cycles), so every small-op number - carried the harness cost, not the op cost. Ops now run in adaptive - batches per poll. Small-op numbers jumped 2-10x on every port when - this landed; never compare against pre-Phase-0 captures. -2. **SEI tick loss -- INHERENT on IIgs, rows tagged below.** The - benchmark clock is jlFrameCount = the VBL-interrupt-driven GetTick. - An op that holds SEI while it runs suppresses those interrupts, the - clock loses ticks, and the op over-reports. The three SEI-holding - rows -- `jlSurfaceClear`, `jlFillRect 320x200`, and - `jlStagePresent full` -- are CLOCK-UNRELIABLE in the IIgs column. - The physics check: a full-screen op moves 32000 bytes at ~6 - cyc/word, so ~29 ops/sec is the hard ceiling at 2.8 MHz. The - present row's 68 and the clear row's 35 sit above it -- inflated; - the honest values are at or below the ceiling. -3. **ST frame-rate miscalibration -- FOUND and FIXED 2026-07-06; the - ST column below is the corrected (millis-clock) capture.** Before the - fix, ops/sec was `iters * jlFrameHz() / actualFrames` and the ST's - `jlpFrameHz()` hardcoded 50 -- but Hatari runs the emulated ST at - ~60 Hz VBL, so every ST number was scaled by 50/60 ~= 0.83 (~17% - pessimistic). Caught by cross-checking against UBER's independent - Timer-C 200 Hz millis clock (refresh-independent), which ran ~16-22% - faster; the Amiga's two clocks agreed exactly (FS-UAE = PAL 50 Hz), - confirming it was ST-only. Two fixes landed: (a) UBER now computes - ops/sec from `jlMillisElapsed` (its own hardware clock on the ST and - DOS; derived from the frame counter on the IIgs -- identical to the - old formula -- and on the Amiga and X68000, neither of which - overrides jlpMillisElapsed; on the X68000 that counter is POLLED, so - both UBER clocks carry the same error and the frame-vs-millis - cross-check cannot see it -- artifact 5), and (b) ST `jlpFrameHz()` now reads the shifter - resolution + sync-mode registers to return the true 50/60/71 Hz. The - per-op `UBER-CLK:` log line reports frame-vs-millis as a standing - cross-check (they now agree). ST cells rose ~17% (e.g. jlSpriteDraw - 503->585, jlFillRect 80x80 97->115); DOS/IIgs/Amiga unchanged. -4. **ST idle-audio ISR tax -- FOUND and FIXED 2026-07-07; the ST - column below is the post-fix capture.** jlpAudioInit started the - 12.3 kHz Timer-A PWM ISR at init and left it running forever, even - silent (~320 cycles/tick = ~49.5% of the CPU), and UBER inits audio - before timing anything. Every ST capture before 2026-07-07 is ~2x - pessimistic. Timer A is demand-driven now (runs only while a mod or - SFX is audible), so the benches measure the op and an idle game - gets the whole CPU. Amiga/DOS/IIgs audited clean for the same bug - class (~1.8% / ~0.5% / 0% idle cost). - -5. **X68000 polled frame clock -- OPEN; that port's whole ops/sec - column is invalid.** _iocs_vdispst HANGS the machine when handed a - plain C handler, so the V-DISP interrupt is never installed - (src/x68000/hal.c:85-92; gVdispInstalled stays false) and - jlpFrameCount instead edge-detects the MFP GPIP V-DISP bit each time - something calls it (hal.c:310-323). A polled edge detector loses - ticks exactly when the work is heavy, and the HAL says so in its own - comment: "Measured: 400 audio refills across ~19 frames reported ZERO - elapsed frames, because nothing polled in between" (hal.c:275-279). - UBER polls once per 16-op batch (uber.c:201-219), so any op whose - batch stretches past a V-DISP period makes the counter under-count, - and ops/sec = iters * 1000 / millis over-reports by that same factor. - The frame-vs-millis cross-check built for artifact 3 cannot catch it: - the X68000 has no jlpMillisElapsed override, so millis comes from - jlpGenericMillisElapsed, which is computed FROM jlpFrameCount - (src/core/port.h:753-758, src/generic/genericPort.c:52-97) -- every - UBER-CLK line on that port compares a number with itself, which is - why they all agree to within 0.5%. Cheap rows (jlMouseX, jlKeyDown, - jlAudioIsPlayingMod) poll often enough to catch every edge and read - honestly; everything else is inflated, ~60x on the full-screen rows. - THE FIX is recorded in the HAL: an asm thunk for _iocs_vdispst (save - d0-d1/a0-a1, call, restore, correct return) so the interrupt owns the - counter (hal.c:87-91, 298-300). Until that lands the X68000 gates on - HASHES ONLY -- it is a correctness column, not a performance column. - -## Baseline (2026-07-08): absolute ops/sec per port - -The IIgs is the reference and the perf floor (every other port must -meet or beat it -- project directive). Non-IIgs cells show absolute -ops/sec plus percentage of the IIgs number; sub-100% cells are bolded -as below-floor. `tools/uber-perf-table` emits this table directly; -regenerate after any re-capture (Amiga needs TIMEOUT=600 -- the -default cuts off the final checks). The tool still knows only the -original four ports (its PORTS list, tools/uber-perf-table:40-45) and -there is no bench-x68000.sh, so an X68000 column has to be added by -hand from a serial-tee capture -- see the 2026-08-05 entry. Capture provenance: all four -ports 2026-07-08 from a full `make clean` rebuild (Phase 6 W1+W2+W3 -landed: fused IIgs sprite/fillRect entries, sprite-mark -specialization, per-row/fused presents on Amiga/ST/IIgs, ST movep -tile singles, DOS map walker, Amiga blitter copyRect + map hoist); -51/51 golden hashes x4 on this exact build; Amiga solo TIMEOUT=600; -IIgs from a fresh `make iigs-disk` image. - -| Op | IIgs (ops/sec) | Amiga (ops/sec, % of IIGS) | Atari ST (ops/sec, % of IIGS) | DOS (ops/sec, % of IIGS) | -| --- | --- | --- | --- | --- | -| jlSurfaceClear | 33 | 75 (227%) | 88 (267%) | 66 (200%) | -| jlPaletteSet | 1561 | 11503 (737%) | 10388 (665%) | 29828 (1911%) | -| jlScbSetRange | 163 | 4003 (2456%) | 4833 (2965%) | 8491 (5209%) | -| jlDrawPixel | 5576 | 5903 (106%) | 6522 (117%) | 9009 (162%) | -| jlDrawLine H | 1322 | 1753 (133%) | 2670 (202%) | 5682 (430%) | -| jlDrawLine V | 104 | 303 (291%) | 261 (251%) | 456 (438%) | -| jlDrawLine diag | 42 | **40 (95%)** | 88 (210%) | 109 (260%) | -| jlDrawRect 100x100 | 110 | 285 (259%) | 279 (254%) | 470 (427%) | -| jlDrawCircle r=16 | 303 | **285 (94%)** | 359 (118%) | 461 (152%) | -| jlDrawCircle r=80 | 70 | **63 (90%)** | 83 (119%) | 98 (140%) | -| jlFillRect 16x16 | 785 | 1153 (147%) | 1225 (156%) | 3017 (384%) | -| jlFillRect 80x80 | 133 | 285 (214%) | 240 (180%) | 412 (310%) | -| jlFillRect 320x200 | 22 | 66 (300%) | 77 (350%) | 59 (268%) | -| jlFillCircle r=40 | 42 | **31 (74%)** | 155 (369%) | 156 (371%) | -| jlSamplePixel | 4797 | 7253 (151%) | 9784 (204%) | 46266 (964%) | -| jlTileFill | 3239 | 3753 (116%) | 3855 (119%) | 11824 (365%) | -| jlTileCopy | 1981 | 2903 (147%) | 3626 (183%) | 10910 (551%) | -| jlTileCopyMasked | 681 | 1003 (147%) | 1749 (257%) | 3391 (498%) | -| jlTilePaste | 2700 | 3753 (139%) | 4685 (174%) | 12575 (466%) | -| jlTileSnap | 2520 | 6103 (242%) | 8516 (338%) | 19218 (763%) | -| jlSpriteSaveUnder | 602 | 2053 (341%) | 2611 (434%) | 10718 (1780%) | -| jlSpriteDraw | 1142 | 1153 (101%) | 1341 (117%) | 8146 (713%) | -| jlSpriteRestoreUnder | 662 | 1603 (242%) | 1865 (282%) | 7826 (1182%) | -| jlSpriteSaveAndDraw | 423 | 803 (190%) | 934 (221%) | 5478 (1295%) | -| jlStagePresent full | 73 | 609 (834%) | 407 (558%) | 1132 (1551%) | -| jlInputPoll | 229 | 4003 (1748%) | 2788 (1217%) | 3148 (1375%) | -| jlKeyDown | 10191 | 39753 (390%) | 44448 (436%) | 77688 (762%) | -| jlKeyPressed | 11432 | 38003 (332%) | 44448 (389%) | 74903 (655%) | -| jlMouseX | 20138 | 97703 (485%) | 117920 (586%) | 155583 (773%) | -| joeyJoyConnected | 10191 | 38903 (382%) | 44260 (434%) | 77697 (762%) | -| jlAudioFrameTick | 27612 | 56503 (205%) | 39885 (144%) | **11490 (42%)** | -| jlAudioIsPlayingMod | 11029 | 56453 (512%) | 60441 (548%) | 127856 (1159%) | -| surfaceMarkDirtyRect (via jlFillRect 32x32) | 397 | 853 (215%) | 774 (195%) | 1479 (373%) | -| jlSpriteDraw unaligned | 1022 | **953 (93%)** | 1089 (107%) | 6316 (618%) | -| jlSpriteDraw sweep16 | 67 | **58 (87%)** | 70 (104%) | 463 (691%) | -| jlTilePaste map 10x5 | 59 | 75 (127%) | 94 (159%) | 257 (436%) | -| gameFrame composite | 12 | 31 (258%) | 32 (267%) | 19 (158%) | -| jlTileMapPaste 10x5 | 140 | 191 (136%) | 346 (247%) | 1224 (874%) | -| gameFrameClean composite | 20 | 71 (355%) | 90 (450%) | 192 (960%) | - -> **IIgs full-screen ceiling (~29 ops/sec).** A full-screen op moves -> 32000 bytes = 16000 word-stores x 6 cyc (STA long,X / PEI, both -> 3 cyc/byte) / 2.8 MHz ~= 34 ms ~= 29 ops/sec maximum. So -> `jlSurfaceClear`, `jlFillRect 320x200`, and `jlStagePresent full` -> cannot legitimately exceed ~29; a higher reading means the benchmark -> clock was perturbed. The clock is `jlFrameCount` = the VBL-interrupt- -> driven GetTick, so an op that holds `SEI` while it runs can over- -> report its ops/sec. Treat full-screen IIgs numbers above ~29 as -> inflated and cross-check against the cycle model. - -> **IIgs full-screen writes: the paper ceiling is refuted in MAME -> (Phase 6 W2).** The old note here derived ~34 ms / ~29 ops/sec for -> any full-screen op from 3 cyc/byte stores. The W2 fitted model -- -> which closes P1, P2, and the idle-present row simultaneously -- -> measured a shadowed push write at ~1.73 us (~4.8 fast cycles), so -> the honest MAME full-screen write floor is ~55 ms of writes alone; -> on real hardware (datasheet ~0.98 us/write) it is ~31 ms. The -> `jlStagePresent full` row is NOT a full-screen copy either way: it -> is the all-clean idle present (early-out scan only), which is why -> it reads far above any copy ceiling on every port. The GetTick -> clock itself is proven honest post-SEI-chunking (Lua cross-clock, -> tick-loss ~1.00); `jlSurfaceClear` remains the one mildly inflated -> row (1.21x). - -## Re-derived native-technique ceilings (NATIVE-PERF Phase 6 close-out, 2026-07-08) - -The Phase-0 ceilings were paper models. Three measurement waves (W0 -probes + ledgers, W1 IIgs sprite autopsy + fusion, W2 present fixes + -fitted IIgs write model, W3 tile engines) invalidated or refined -several of them; this section re-derives every headline ceiling from -the MEASURED models and gates each row against the directive bar -(**measured within ~1.5x of hand-coded native technique for the row's -exact work**). "Ceiling" = the ops/sec a hand-coded native routine -would achieve doing the row's exact work -- pixel/byte traffic at the -measured machine rate, plus a minimal call and the dirty-band -bookkeeping the present contract requires. Gap = ceiling / measured. -Measured values: golden rows from the 2026-07-08 PERF.md capture; -probe rows (P1/P2/P8) from the Phase-6 W0/W2 tracker entries. Gaps > -1.5x are bolded. Cells marked UNKNOWN have no recorded model and no -honest derivation -- they are bounds, not ceilings. - -**Superseded by this section:** (a) the "IIgs full-screen ceiling -(~29 ops/sec)" callout above -- the 34 ms full-screen "PEI floor" is -REFUTED in MAME: under the fitted write model (below), 32,000 shadowed -bytes cost ~55.4 ms of writes alone, so the honest MAME full-present -ceiling is ~14.5/s (~22/s real-HW variant), and the golden -`jlStagePresent full` reading (73/s) is the idle-early-out artifact, -not a copy; (b) the Phase-0 ST tile ceilings (12,700-15,500/s -- -assumed byte-move bodies; the landed movep engine beats them); (c) the -Phase-0 IIgs sprite-draw ceiling (4,300/s -- assumed no -derive/mark/wrapper; W1 autopsy honest ceiling is 2,500-2,650/s); -(d) the Phase-0 Amiga/ST/DOS "present full copy" ceilings (the timed -row never re-dirtied; the P1/P2 probe models below are the real ones). - -Row shapes (exact UBER geometry the ceilings are computed for): -tile ops are one 8x8 tile; the map row is 10x5 = 50 tiles; sprite -per-op rows are a 16x16 sprite at (40,30) -- x&15 = 8, so the 68k -save/restore window is TWO 16-px groups = 256 B (IIgs/DOS chunky -window is 128 B); gameFrame = 256x96 fill + 16 tile pastes + 3 -saveAndDraw + 3 restores (sprites mod-16 aligned = 1-group windows) + -a 96-row x 256-px dirty present; gameFrameClean = 3 16x16 copyRect -erases + 3 draws + present; P1 = the 96-row x 256-px band alone -(12,288 shadow bytes; 24,576 VGA bytes on DOS); P2 = three 16x16 -windows (384 B; 768 VGA bytes). Clocks: ST 8.0 MHz, Amiga 7.09 MHz, -IIgs 2.8 MHz; DOS has no cycle model -- only recorded rates are used. - -### Measured rate cards (replace the Phase-0 paper inputs) - -* **ST (68000):** bus floor 4.0 cyc/B copied; move.l inline 5.0 cyc/B; - movem streams ~4.6-4.8 cyc/B (write-only fill ~2.2 cyc/B); landed - movep tile bodies 36 cyc/tile-row (paste/snap) and 48 (copy); - specialized whole-tile mark ~480 cyc; measured per-present idle base - 0.87 ms (hunt-3 refresh: jlStagePresent full 1150/s post the - group-skip landings; the old 2.2 ms / P8 452/s figure predates them - -- every ceiling row below that adds "+ 2.2 ms base" is pessimistic - by ~1.3 ms and ST P2's honest re-derived ceiling is ~775/s, which - RE-OPENS that row). P1 machine ceiling ~100 ops/s (session model) - carries the same staleness. -* **Amiga:** blitter A->D ~2 chip cyc/word + ~230 cyc/plane setup + - ~240 cyc Own/Disown; display-DMA contention ~2x on chip traffic - (Phase-0 full-blit 110/s precedent); measured contended CPU tile - body ~700 cyc/cell (backed out of the landed map walker, 191 maps/s - = 742 cyc/tile incl. walk); P8 idle base 0.87 ms (hunt-3 refresh: - jlStagePresent full 1143/s -- the old NEVER RECORDED note is - resolved by the golden idle-dominated row post group-skips). -* **DOS:** the W0 "~1.7 us/VGA byte -- pure hardware rate" model is - REFUTED (hunt-3): the dword-fused expand measured probe present96 at - 29 ops/s = ~1.40 us/B over 24,576 B, EXCEEDING the old ceiling (~24) - -- under DOSBox's instruction-counting core the write loop was - software-bound, not hardware-bound. DOS present ceilings should be - re-fit from ~1.40 us/B; gameFrameClean is PASS-at-floor (245/s vs - the probe-split-derived ~244 floor); - CPU-side recorded models only: single-tile paste ~57,000/s (Phase-0, - unrevised) and map walker ~76,000 tiles/s (W3: ~26 measured vs 18-20 - hand instructions/cell). -* **IIgs:** MAME-fitted write model (closed the P1/P2/P8 ledgers - simultaneously): shadowed SHR push write wSh ~1.73 us/B, slow-I/O - softswitch access ~1.0 us; measured per-present fixed base 2.75 ms - (hunt-3 probe: presentIdle 363/s; the original 13.7 ms figure died - with the band-memset fix -- any ceiling below still adding "+13.7" - is pessimistic by ~11 ms). Real-HW datasheet variant: wSh ~0.977 - us/B (fixed base assumed unchanged -- unverified on hardware). Tile hand-call floor - 480 cyc; W1 sprite autopsy: honest full-op draw ~1,060-1,120 cyc - (body ~670 + minimal per-row mark ~390 + 3-far-load derive + JSL; - the fnAddr cache was built, measured a LOSS, and removed). - -### Atari ST (8.0 MHz 68000) - -| Row | Ceiling (ops/s) | Derivation | Measured | Gap | Bar | -| --- | --- | --- | --- | --- | --- | -| jlTilePaste | ~9,200 | movep body 8x36=288 + mark ~480 + call ~100 = 868 cyc | 4,685 | **2.0x** | FAIL | -| jlTileCopy | ~8,300 | movep body 8x48=384 + 480 + 100 = 964 cyc | 3,626 | **2.3x** | FAIL | -| jlTileSnap | ~20,600 | body 288 + call ~100 = 388 cyc (no mark; measured overhead is ~651) | 8,516 | **2.4x** | FAIL | -| jlTileMapPaste 10x5 | ~440 | 50 x ~350 cyc/tile (movep-honest) + one mark 480 + one validation ~200 = 18.2k cyc | 346 | 1.27x | PASS | -| jlSpriteDraw | 2,235-2,750 | Phase-0 pre-shifted movem-RMW model, 2,909-3,580 cyc -- UNREVISED paper | 1,341 | **1.7-2.1x** | FAIL | -| jlSpriteSaveUnder | ~5,300 | 2-group window 256 B x 5.0 cyc/B + row stride ~230 = 1,510 cyc. P7-1 probe split (2026-07-09): full call 3,064 cyc = wrapper+validation 1,088 + dispatch/metadata ~660 + body 1,310; the window delta (g16 row) measured the body at 5.1 cyc/B = AT the move.l floor -- the whole gap is C glue | 2,611 | **2.0x** | FAIL (lever: 68k wrapper trim/fusion) | -| jlSpriteRestoreUnder | ~4,000 | save copy 1,510 + sprite mark ~480 = 1,990 cyc. P7-1 probe: full 4,211 cyc, validation alone 422 (odd-bx row) | 1,900 | **2.0x** | FAIL (same lever) | -| gameFrame | ~58 fps | fill 12,288 B x 2.2 = 27k + 16 pastes 13.9k + 3 saves 2.6k + 3 draws 9.3k (mid-range of the paper draw model) + 3 restores 4.1k + present96 80k = 136.9k cyc = 17.1 ms | 32 | **1.8x** | FAIL | -| gameFrameClean | ~245 | 3 copyRect x ~750 cyc + 3 draws 9.3k + P2-class present 20.8k = 32.4k cyc = 4.0 ms | 90 | **2.7x** | FAIL | -| P1 present96 | ~100 | model: 12,288 B x 4.0 cyc/B = 6.1 ms + P8 base 2.2 ms + snapshot traffic in one fused 200-row scan/snapshot pass ~= 10 ms | 73 | 1.37x | PASS | -| P2 present3win | ~385 | measured idle base 2.2 ms (P8 452/s) + 384 B copy+snapshot x ~8 cyc/B ~= 2.6 ms | 245 | **~1.6x** | FAIL (marginal) | - -### Amiga (7.09 MHz 68000 + blitter) - -| Row | Ceiling (ops/s) | Derivation | Measured | Gap | Bar | -| --- | --- | --- | --- | --- | --- | -| jlTilePaste | ~5,500 | measured contended body ~700 + mark 480 + call ~100 = 1,280 cyc (uncontended instruction model gives ~6,100-6,400 -> gap 1.6-1.7; the Phase-0 18% pair discrepancy persists as this band) | 3,753 | ~1.48x | PASS (at bar) | -| jlTileCopy | ~4,800-5,300 | chip-to-chip body ~750-900 + 480 + 100 cyc | 2,903 | **1.7-1.8x** | FAIL (marginal) | -| jlTileSnap | ~8,900 | body ~700 + call ~100 = 800 cyc (no mark; writes go to fast RAM) | 6,103 | ~1.45x | PASS (at bar) | -| jlTileMapPaste 10x5 | ~200 | 50 x ~700 + mark 480 + validation ~200 = 35.7k cyc; the landed walker already beat its own design model (176-186 -> 191) | 191 | ~1.05x | PASS | -| jlSpriteDraw | ~1,350 | Phase-0 compiled-CPU ceiling; the 3,600-4,300/s blitter cookie-cut is DEFERRED by recon -- sync WaitBlit forfeits the win at <= 16x16 (this row's size) | 1,153 | 1.17x | PASS | -| jlSpriteSaveUnder | ~4,400-6,100 | SUPERSEDES the blitter paper (6,300-7,400): P7-1 probe (2026-07-09) measured the CPU body at 3.8 cyc/B via the window delta (482 cyc/128 B), so body ~960-1,900 cyc (contention band) + call ~200. Full call 3,372 cyc = wrapper+validation 1,524 + dispatch/metadata ~880 + body -- the gap is C glue, not the copy | 2,103 | **~2.1-2.9x** | FAIL (lever: 68k wrapper trim/fusion) | -| jlSpriteRestoreUnder | ~2,900-3,700 | body + mark ~480 + call (same probe-derived band); full 4,423 cyc, validation alone 672 (odd-bx row) | 1,603 | **~1.8-2.3x** | FAIL (same lever) | -| gameFrame | ~70 fps | fill blit 13.4k x2 contention = 27k + 16 pastes 20.5k + 3 saves 3k + 3 draws 15.8k + 3 restores 4.4k (sprite components interpolated from the paper models) + present96 29k = 99.7k cyc = 14.1 ms | 31 | **2.3x** | FAIL | -| gameFrameClean | ~230 | 3 blit copyRects ~6.8k contended + 3 draws 15.8k + P2 present ~8k = 30.6k cyc = 4.3 ms | 71 | **3.2x** | FAIL | -| P1 present96 | ~245 | 4-plane modulo blit 4x(230 + 1,536 w x 2) + 240 = 13.4k cyc, x2 contention + union scan ~2k = 29k cyc = 4.1 ms (the landed P7-3 blitter flush now takes this path; CPU-copy variant was 12,288 B x 4 cyc/B + scan ~= 7.5 ms -> ~133/s) | 78 | **~3.1x** | FAIL | -| P2 present3win | UNKNOWN (write-only bound ~890-1,300) | 12 plane-blit setups 2.8k + 192 w x2 contention + Own/Disown + scan ~2k ~= 5.4-8k cyc = 890-1,310 ops/s; honest ceiling needs the Amiga P8 idle base = 661/s = 1.51 ms. Component split (2026-07-09 skip-mask probe): scan ~4.9 ms, flush ~3.3 ms (chip->chip CPU copy, below the 128-word blitter threshold), flip ~0.4 ms, residual ~1.2 ms. TWO levers landed: (1) presentScan.s 4-row fast path cut the scan (106 -> 128, +21%, after fixing a phantom-row-200 OOB); (2) the flush moved to the blitter by lowering AMIGA_PRESENT_BLIT_MIN_WORDS 128 -> 16 (chip->chip DMA vs contention-bound CPU copy): 128 -> 153 (+20%). Both 51/51 + P11 | 153 | **<= ~6-8x vs bound** | FAIL/UNKNOWN | - -### DOS (AT-class VGA; no cycle model -- recorded rates only) - -| Row | Ceiling (ops/s) | Derivation | Measured | Gap | Bar | -| --- | --- | --- | --- | --- | --- | -| jlTilePaste | ~57,000 | Phase-0 single-tile model -- UNREVISED (W3 walker data is consistent with it) | 12,575 | **4.5x** | FAIL | -| jlTileCopy | ~57,000 class | same 64-B traffic as paste; no separate model recorded | 10,910 | **~5.2x** | FAIL | -| jlTileSnap | >= ~57,000 class | paste class minus the mark; no separate model recorded | 19,218 | **~3.0x** | FAIL | -| jlTileMapPaste 10x5 | ~1,520 | recorded W3 model: ~76k tiles/s (walker at 61,200 x 26/[18-20] hand instructions/cell) / 50 | 1,224 | 1.24x | PASS (residue structural: map indirection + mark) | -| jlSpriteDraw | ~11,000 | Phase-0 model -- UNREVISED | 8,146 | 1.35x | PASS | -| jlSpriteSaveUnder | 14,500-18,000 | Phase-0 model -- UNREVISED | 10,718 | 1.35-1.68x | AT BAR (range straddles) | -| jlSpriteRestoreUnder | 14,500-18,000 | Phase-0 model -- UNREVISED (add mark: real ceiling somewhat lower) | 7,826 | **1.9-2.3x** | FAIL | -| gameFrame | ~22 fps | present writes 24,576 B x 1.7 us = 41.8 ms + components ~2.5 ms ~= 44.3 ms (component figure not derivable from recorded rates -- no DOS fill rate; verdict insensitive: even at the measured P4 9.2 ms the ceiling is ~19.6 fps, gap ~1.03); W0 verdict: present-bound AT hardware | 19 | 1.2x | PASS | -| gameFrameClean | UNKNOWN (upper bound ~500) | erases+draws ~0.7 ms + present writes 768 B x 1.7 us = 1.3 ms; per-present fixed scan (~2.0 ms observed) never modeled | 192 | **<= ~2.6x vs bound** | FAIL/UNKNOWN | -| P1 present96 | ~24 | 24,576 VGA B x 1.7 us = 41.8 ms -- pure hardware rate | 23 | 1.04x | PASS (AT hardware) | -| P2 present3win | UNKNOWN (upper bound ~766) | 768 B x 1.7 us = 1.3 ms writes only; fixed base unmeasured (W0 closed DOS present work) | 305 | **<= ~2.5x vs bound** | FAIL/UNKNOWN | - -### Apple IIgs (2.8 MHz 65816; MAME-fitted write model) - -| Row | Ceiling (ops/s) | Derivation | Measured | Gap | Bar | -| --- | --- | --- | --- | --- | --- | -| jlTilePaste | ~5,830 | recorded 480-cyc hand per-tile call | 2,700 | **2.2x** | FAIL | -| jlTileCopy | ~4,860 | 480 + ~96 cyc extra far-src reads = 576 cyc | 1,981 | **2.5x** | FAIL | -| jlTileSnap | ~5,800 | 480-cyc class (mark savings ~= far-read tax) | 2,520 | **2.3x** | FAIL | -| jlTileMapPaste 10x5 | ~140 | 50 x ~390 cyc/tile floor (no tracker source; within noise of the landed ~400-cyc/tile walker) + one mark ~= 19.9k cyc; the landed walker already runs BELOW the 480-cyc per-call ceiling | 140 | ~1.0x | PASS | -| jlSpriteDraw | 2,500-2,650 | W1 autopsy honest full-op model: ~1,060-1,120 cyc = body ~670 + per-row mark ~390 + 3-far-load derive + JSL (fnAddr cache rejected by measurement) | 1,142 | **~2.2-2.3x** | FAIL | -| jlSpriteSaveUnder | ~2,050 | MVN window body ~1,111 cyc (128 B x ~7 cyc/B + row stepping; = Phase-0 2,520/s) + derive/JSL ~250 = 1,360 cyc | 602 | **3.4x** | FAIL | -| jlSpriteRestoreUnder | ~1,600 | save body 1,111 + fused mark ~390 + derive/JSL ~250 = 1,750 cyc (matches the W1 "restore 1,600+" honest target) | 662 | **2.4x** | FAIL | -| gameFrame | ~16.5 fps (real HW ~20) | P4 components ~20.4 ms (fill 6,144 w x 6 cyc = 13.2 + pastes 2.7 + saves 1.5 + draws 1.2 + restores 1.9) + present96 40.3 ms = 60.7 ms | 12 | 1.37x | PASS | -| gameFrameClean | ~60 | 3 stage-copy erases ~1.2 ms + 3 draws ~1.2 ms + P2 present 14.4 ms ~= 16.7 ms | 20 | **3.0x** | FAIL | -| P1 present96 | ~25 MAME (real HW ~35) | 15,360 B x 1.73 us = 26.6 ms writes + 13.7 ms measured base = 40.3 ms (landed Stage-B copies 13,824 B -> 37.6 ms -> ~27/s, gap 1.33x); minimal-copy variant (12,288 truly dirty B) 35.0 ms -> ~29/s, gap 1.43x -- PASS every way | 20 | 1.24x | PASS | -| P2 present3win | ~70 (real HW ~71) | 384 B x 1.73 us = 0.7 ms + 13.7 ms base = 14.4 ms | 52 | 1.34x | PASS | - -IIgs real-HW-datasheet variants (wSh ~0.977 us/B, MAME-measured 13.7 ms -base assumed unchanged -- unverified on hardware): P1 15.0 + 13.7 = -28.7 ms -> ~35/s; P2 ~14.1 ms -> ~71/s; full 200-row present 31.3 + -13.7 = 45.0 ms -> ~22/s (vs ~14.5/s MAME-fitted); gameFrame ~49 ms -> -~20 fps. The MAME-fitted column is what the bench harness can verify; -the real-HW column is the datasheet claim. - -### Rows still outside the 1.5x bar -- reasons and recorded levers - -* **Tile singles, all four ports (1.7x-5.2x).** Bodies are at machine - floor (probe-validated splits, restated against the 2026-07-08 - capture: ST paste overhead = 1,708 - 288 = 1,420 cyc, snap overhead - 651 cyc with no mark; the W3-landing probe read 1,388/669); the gap - is the per-call public wrapper + validation + whole-tile mark - (Phase-2 exit finding: "remaining per-call floor = public wrapper + - one JSL/call"). Recorded answer is the batch API, which PASSES on - every port (1.0x-1.27x); no further singles lever is recorded -- - the singles gap is accepted per-call API tax (absolute cost <= ~0.3 - ms/call everywhere; IIgs tileCopy ~0.3 ms and tileSnap ~0.22 ms are - the worst cases, all other rows <= ~0.2 ms). -* **IIgs sprite rows (2.2-2.3x/3.4x/2.4x).** Autopsy buckets: - validation ~22% + wrapper residue in the register-starved public - wrappers; fusion already landed (+18-22%), the fnAddr cache measured - a loss and was removed. VALIDATION LEVER LANDED 2026-07-12 via the - trusted fast-path API (jlSprite{SaveUnder,RestoreUnder,SaveAndDraw} - Trusted, extended from the 68k P7-1 work): the trusted entries skip the - ~22% validation and route into the existing fused MVN entries. IIgs - save 602->665 (+10.5%), restore 662->722 (+9%), saveAndDraw 423->455 - (+7.6%); shrParity badRows=0, 51/51 golden (safe entries unchanged). - Smaller than the 68k wins because 65816 validation is a smaller - fraction. Do not resurrect the fnAddr cache without a compiler-level - win (LLVM816-ASKS candidate). -* **ST sprite rows (~2.0x) / Amiga save-restore (~1.8-2.9x) / DOS - restore (1.9x-2.3x).** The P7-1 layer-split probe (2026-07-09) - RESOLVED where the 68k gap lives: the copy bodies are AT the machine - floor (window-delta-measured 5.1 cyc/B ST / 3.8 cyc/B Amiga), and - ~55-70% of every call is the C wrapper+validation (1,088 cyc ST / - 1,524 Amiga for save; 422/672 for restore's validation alone) plus - dispatch/backup-metadata glue (~660-880 cyc). The old paper ceilings - (Amiga blitter 6,300-7,400) are superseded by probe-derived CPU - ceilings in the tables above; true gaps ~2x. LEVER ATTEMPT 1 (macro - tightening, 2026-07-11): REVERTED, net loss. Both class macros were - rewritten to their algebraically-minimal form, proven value-identical - (102k save + 1.1M restore cases, 0 mismatches) and gated 51/51 -- - benched save bit-identical on both ports, restore neutral on ST but - -3% on Amiga (1603->1553; gcc-amigaos lowered the "simpler" C to +3 - instructions). The wrapper is gated by register allocation (movem of - 10-11 regs + interleaved validation), not arithmetic op count -- no - C micro-opt moves these rows, same compiler-bound wall as the IIgs - fnAddr-cache autopsy. LEVER ATTEMPT 2 (restore+draw mark fusion, - 2026-07-11): LANDED, +5-6% on restore. Isolated the mark at ~26-29% of - a 68k restore (probe: restore to a non-stage surface skips it). Three - builds: a per-port body+mark asm shim REGRESSED both ports (broke the - always-inline body's shared loads + added a call layer); a lean - rolled-loop asm widen call REGRESSED (rolled dbra lost to gcc's - unrolled indexed mark); INLINING the unrolled 16-row mark into - jlSpriteRestoreUnder for the h==16 stage window (C only, no asm) WON -- - ST restore 1900->1996, Amiga 1603->1703, both 51/51 + P11/P10/P5 - parity. The recoverable win was the CALL BOUNDARY (only inlining - removes it; asm always keeps a jsr). Draw bundled via the same inline - (jlSpriteDraw + SaveAndDraw): Amiga draw +4% / saveAndDraw +6% / - unaligned +5%; ST draw neutral (masked body dwarfs the mark); DOS - untouched (guarded). gameFrame flat (present-bound). LEVER ATTEMPT 3 - (TRUSTED fast-path entry, 2026-07-11): LANDED, the biggest 68k sprite - win yet. New opt-in public API (jlSpriteSaveUnderTrusted / - RestoreUnderTrusted / SaveAndDrawTrusted) that skips the per-call - geometry-validation chain for a caller that pre-compiles its sprites - and clamps positions on-screen (UB-if-violated contract; falls back to - the safe sibling for anything unrecognised; Amiga/ST fast path, - DOS just calls the safe entry). WINS ON ALL THREE COMPILED PORTS: - save ST 705->817 (+16%) / Amiga 657->753 (+15%) / IIgs 602->665 - (+10.5%); restore ST 529->609 (+15%) / Amiga 545->609 (+12%) / IIgs - 662->722 (+9%); saveAndDraw ST 257->289 (+12.5%) / Amiga 273->289 - (+6%) / IIgs 423->455 (+7.6%). On 68k the win EXCEEDS the raw ~422/672 - cyc validation because dropping the checks also shrank gcc's - frame/spills; IIgs routes into the fused MVN entries minus validation. - Parity gates pass (P10 ST, P11 Amiga, shrParity IIgs); DOS 51/51 + - IIgs 51/51 (falls back / safe entries unchanged). Batch APIs stay - descoped (dispatch alone ~3-6%); the blitter cookie-cut stays deferred - until a >= 32x32 golden row exists. -* **Amiga presents P1/P2 (+ both Amiga composites, 2.8x-5.3x; P2 - bound-only).** W2 fixed the bounding-box over-copy (P2 copied bytes - 4,992 -> 384 exact; W0's conviction figure was 3.5x vs the ~1,536 - truly-dirty estimate) but the ~11 ms per-present fixed scan - survives (P2 = 11.1 ms to move 384 B; P1 even regressed 8% on added - scan cost -- accepted deviation). Recorded levers: fused-scan - present asm on the ST presentSlam pattern (W2 follow-up) and a - modulo-blitter present flush (the landed W3 copyRect blitter arm is - the template). This is the single largest remaining item in the - program. -* **ST P2 (~1.6x, marginal) and ST composites (1.8x/2.7x).** P2 - residue is ~1.5 ms absolute (per-run move.l-chain entry + snapshot - maintenance); W2 closed ST present work and no lever is recorded. - gameFrame's gap is spread across sprite/fill wrapper taxes (no - single lever); gameFrameClean's was dominated by jlSurfaceCopyRect's - strided per-row memcpy dispatch -- the same overhead class the Amiga - W3 blitter item deleted. LANDED 2026-07-12 (#3): an ST move.l-chain - copyRect (src/atarist/copyRect.s, stCopyRectSpans68k) replaces the - per-row memcpy CALL for short spans (the 48 8-byte-row window copies - where mintlib memcpy's frame dwarfs the two move.l; long rows keep - memcpy past the 64-byte crossover). gameFrameClean 90 -> 120 ops/sec - (+33%), 51/51 + P10 displayMismatch=0; gameFrame unchanged (uses - save/restore, not copyRect). Bigger than expected -- the small-window - memcpy call overhead dominated the clean-buffer op. -* **IIgs gameFrameClean (was 3.0x).** Recorded forensic: ~30 ms/frame - unattributed, llvm-mos far-memcpy byte loops in the generic copyRect - erase engine (finding #79 class). LEVER LANDED 2026-07-12: an - IIgs-specific jlpSurfaceCopyRect override (src/iigs/hal.c, gated - JL_HAS_SURFACE_COPY_RECT) does an inline word copy for short spans -- - compiles to `lda [dp],y / sta`, NO far-memcpy call (verified in the -S - output) -- for the 8-byte-row clean-buffer windows; long rows keep - memcpy. gameFrameClean 18 -> 24 ops/sec (+33%), 51/51 + shrParity. - NOTE: this was FIRST tried in the shared generic copyRect but that - REGRESSED DOS -12% (x86 rep-movs memcpy already beats an inline word - loop for 8-byte rows), so it is IIgs-only; DOS stays on generic memcpy. - Remaining IIgs gameFrameClean gap (if any) would want the flagged - llvm816 memcpy fix or an MVN block-copy for the long rows. -* **DOS gameFrameClean / P2 -- RESOLVED 2026-07-13 (perf-hunt-2 ranks - 4/5/6).** The missing display gate now exists: P12 vgaMismatch (an - UBER_PROBE row + dosProbeVgaMismatch in src/dos/hal.c) re-derives - every VGA byte independently of the expand LUT and reads 0/0/0 - across the battery. The per-present scan was cut by the 4-row dword - group skip (P8 idle 1137 -> 3881 ops/s, ~0.26 ms base) and the - expand loop dword-fused (two LUT words per 32-bit store; DOSBox's - instruction-counting core rewarded it directly): gameFrameClean 196 - -> 243 ops/s (+24%), gameFrame 19 -> 23 (+21%), both 51/51. The - probe component split answers the old unknown: gameFrameClean at - 244/s = 4.10 ms/frame = present 3.06 ms + window erases 0.70 ms + - sprite draws 0.34 ms -- the row is present-dominated and the - remaining present cost is expand + per-row glue at the measured - rate, not an unexplained residual. No further DOS present lever is - recorded. (-fomit-frame-pointer also landed for flag parity with - the 68k ports: input rows +28-29%, map paste +7%, zero regressions.) - -### Assumptions - -1. Clocks: ST 8.0 MHz, Amiga 7.09 MHz (PAL), IIgs 2.8 MHz; DOS has no - cycle model, so DOS ceilings use only the recorded rates (1.7 - us/VGA byte, 57k paste, 76k tiles/s walker). -2. Irreducible per-call floors assumed: ~60-100 cyc call/shim on 68k, - ~250 cyc JSL+derive on IIgs; the landed specialized dirty marks - (~480 cyc 68k whole-tile, ~390 cyc IIgs fused) are treated as - irreducible for row shapes that mark per call. -3. Amiga display-DMA contention factor ~2x on chip-RAM traffic, taken - from the Phase-0 full-blit 110/s precedent; uncontended variants - are shown wherever they would change a verdict. -4. The IIgs fixed present base was 13.7 ms MAME-measured (P8) when - the ceilings below were derived. 2026-07-13: ~11 ms of that base - was the dirty-band memset pair (see History) and is now ~2.75 ms - -- ceilings that add "+ 13.7 ms base" are stale and pessimistic; - re-derive with ~2.75 ms before gating new work on them. The - real-HW column assumption (base carries over unchanged) is still - unverified on hardware. -5. Composite ceilings are sums of component ceilings at the rows' - exact geometry (per-op sprite rows at x=40 use the 2-group 256-B - 68k window; gameFrame's mod-16-aligned sprites use the 1-group - 128-B window). Composite sprite components not separately recorded - (ST draws, Amiga saves/restores/copyRects) are interpolations - inside the quoted Phase-0 ranges. -6. Ceilings labeled UNREVISED (ST/Amiga/DOS sprite models, DOS tile - singles) are Phase-0 paper carried forward because no wave - re-measured them; their gaps are quoted as ranges and should be - re-derived before gating any new work on those rows. The ST P1 - ~100 ops/s machine ceiling is a session model consistent with the - P8 base + bus-floor copy, not a tracker-recorded number. -7. Where no model or measurement exists (Amiga/DOS P8 idle bases, DOS - copy/snap singles), the cell is an explicit upper bound or - UNKNOWN -- no ceiling was invented. - -## Reading notes - -* **Sprite rows measure COMPILED code on all five ports** since - NATIVE-PERF Phase 1 (2026-07-06): the 68k ports JIT all-shift draw - variants plus group-window save/restore (previously stubs that fell - to interpreters), IIgs codegen re-enabled via finding #77, DOS via - finding #74. The X68000 joined on the shared 68k PLANAR emitter with - no emitter changes -- it takes the AMIGA arm of - src/core/spriteDispatch.h (4-plane cdecl routines from - src/m68k/spriteEmitPlanar68k.c, one pre-shifted variant per x % 8 - phase, all 8 compiled), because its surface stride is the Amiga's 40 - bytes and the display's 128-byte text-plane stride never reaches the - emitter. The interpreted walkers remain only as arena-full - degrade paths -- and are proven bit-identical to the compiled code - by the 51-hash gate. -* **The below-floor cells are the standing punch list**, and after the - 2026-07-07 audio-tax fix the ST has NONE -- every remaining - below-floor graphics cell is the Amiga's: the circle family (diag - line 95%, circle r=16 94%, r=80 87%, fillCircle 74% -- planar 4-plane - RMW tax) and the two sprite workload rows a hair under floor - (unaligned 95%, sweep16 89%; chip-RAM display-DMA contention that the - fast-RAM-code ST does not pay). DOS jlAudioFrameTick (42%) is the SB - half-buffer silence refill on an otherwise-trivial call -- it gates - nothing. Floor parity is effectively DONE; the open work is - ceiling-relative (NATIVE-PERF-PLAN.md Phase 2+), where every port - still runs 2-5x below hand-coded technique on tile/sprite glue. - Amiga jlFillCircle r=40 (74%) was probed - 2026-07-06 with a full whole-circle asm routine (Bresenham + inline - 4-plane long-fill span): it held 45/45 hashes but did NOT move the - number (34 vs 31, within the coarse 11-iter sample), so the per-span - C dispatch is NOT the bottleneck -- the asm was reverted. The real cost - is the midpoint fill's 8-way symmetry filling each row 2-4x (idempotent - overlap): the true lever is a scanline rewrite (one span per row, no - overlap), which would roughly halve the byte writes and stay - hash-identical. NB: this op is timed at batch=1 (~29 ms each) by the - 50 Hz frame counter, a coarse measurement. (The old note here blaming - a "4-5x model-vs-measured gap" partly on measurement was vindicated - 2026-07-07: on the ST that gap was the idle-audio tax, artifact #4.) -* **jlRandom has no timed row.** Phase 8 (#43) rewrote its xorshift32 - update in 16-bit halves on the IIgs only (~2-3x fewer cycles per - call on the 65816; 32-bit shifts there are per-bit helper loops). - Output is bit-identical -- host-proven over the golden sequence and - a 50M-state sweep, and gated by UBER's random-golden check on all - five ports. -* **Input predicates stay functions** (Phase 8 #40 decision): the - post-clock-fix numbers (12K-77K ops/sec) put call overhead nowhere - near a real frame budget; macro forms in the public header were - rejected as not worth the API exposure. -* **IIgs libc memset/memcpy remain ~4x slow** (finding #79, - toolchain-owned): llvm-mos runtime byte loops vs ORCA's MVN. The - jlScbSetRange and jlPaletteSet IIgs rows carry that tax; library-side - mitigations landed (#16 unrolled word copy, #21), the libc fix is - flagged to the llvm816 session. - -## History - -- 2026-07-20 (later still): music layer v2 = the complete feature - set. jlAudioNoise percussion voice implemented natively on all four - ports (ST YM noise generator on channel C with shared-volume guards - both directions + a Supexec'd PSG all-off in shutdown; DOS OPL2 - rhythm-mode snare on ch 7 with pitch via the hi-hat modulator and - a shared adlibFreqToFnum factored out of jlpAudioVoice; Amiga - 1024-byte LFSR chip sample looped on Paula ch 3, mt_musicmask - re-applied after every mt_end; IIgs resident LFSR sample on SFX - slot 3 with retrigger phase rotation -- and a real guard fix: - jlpAudioVoice bounded by SFX_SLOTS could stamp tone squares over - slots 3-4). Sequencer v2: noise as uniform voice 3, tempo-change - opcode, jlMusicSetAtten fades, jlMusicPause/Resume. songbake v2: - noise column, repeat blocks, mid-song tickms, ST-overlap warning. - AGI's always-parsed-never-sounded SN76489 noise channel is now - audible via psgNoiseApply. Design recon = 10-agent workflow - (5 design + 5 adversarial verify) that caught 3 hardware-level - bugs pre-implementation. Gates: builds x4, AGI host suite 1/1, - sequencer host harness all-checks, golden 51/51 x4 cross-diff. - -- 2026-07-20 (later): portable chip-tracker music layer landed (the - cross-platform answer to the ST MOD tax -- one authored song, four - native chip backends, no CPU mixing anywhere). New: src/core/music.c - jlMusicPlay/Stop/IsPlaying sequencer over the JYM1 event stream - (tools/songbake bakes text notation; pacing = jlMillisElapsed so - tempo holds at 50/60/70 Hz; pumped by jlAudioFrameTick above the - gAudioReady gate). Hunt-3 rank 4 landed as the IIgs backend: - jlpAudioVoice rewritten from synthesize-per-call (~120-150 ms: C - toneFill + forced 2 KB XOR recopy, plus it burned that cost even - with audio uninitialized) to a resident DOC-unsigned square with - freqWord/length/volume struct pokes + one NTPstreamsound JSL per - note (~10 far stores). Amiga jlpAudioVoice implemented for the - first time (was a stub): Paula ch 0-2 looping a 2-byte chip-RAM - square, DMA auto-reload sustains for free. Voices vs MOD stay - mutually exclusive on ST/Amiga (same chip). jlAudioFrameTick gains - a no-song boolean check (musicPump) -- DOS row 57969 ops/s, - unchanged within noise. Gates: 4-port builds clean, DOS/ST/Amiga/ - IIgs golden 51/51 cross-diff OK. - -- 2026-07-20: hunt-3 top ranks landed. Rank 1 probe re-baseline - (IIgs+ST -DUBER_PROBE battery): the IIgs gameFrameClean mystery SOLVED -- - the split rows (newly wired into the IIgs timeOp arm) attributed the - 28.6 ms as erases 15.9 + draws 3.3 + present 9.4, exposing - jlSurfaceCopyRect at ~5.3 ms PER 16x16 call: the Phase-7 C word-copy - override had regressed into the dead-lever-#12 shape (llvm-mos - re-deriving both far pointers per word, ~30+ instr/word, visible in - the current -S). Fix = iigsCopyRectRows asm ([dp],y pairs, ~21 - cyc/word): gameFrameClean 35 -> 56 ops/s (+60%), hash C51FE171 - unchanged, 51/51. Rank 5: jlpInputPoll idle clear memset -> - iigsByteFill: jlInputPoll 228 -> 662 ops/s (+190%). Rank 3: AGI - restoreRectFromBackdrop -> jlSurfaceCopyRect (the stale "planar = - rare (Amiga only)" fallback full-copied 32KB per object erase on - BOTH 68k ports and forced full-dirty presents); builds clean x4, - host AGI suite 1/1 (harness stub rot also repaired). Rank 2: rate - cards refreshed in place (IIgs base 2.75 ms, ST idle 0.87 ms, Amiga - idle 0.87 ms recorded for the first time, DOS 1.7 us/B model REFUTED - -- measured 29/s EXCEEDS the old ceiling, refit ~1.40 us/B; ST P2 - RE-OPENED at ~775/s re-derived ceiling). IIgs probe battery also - refreshed: present96 26/s, present3win 133/s, trusted save 795/s, - drawTrusted 1262/s, shrParity OK, band fingerprints cross-port - identical. -- 2026-07-19: perf-hunt-2 close-out. Ranks 8/9/10/12/13/14/17 landed - (ST draw-JIT group rewrite: jlSpriteDraw 1366 -> 1807 +32%, - saveAndDraw +21%, gameFrameClean +14%; jlSpriteDrawTrusted on all - ports; IIgs fused save: saveUnder 602 -> 722 +20%; ST PS_ROW inline - arm; DOS inline-mark + full trusted family: safe restore +5%, - trusted restore +21%; Amiga fillCircle exact-disk walk 31 -> 40 - +29%; cleanup batch incl. 115 lines of unreachable ST walkers). - Rank 11 v2 landed after a re-probe (presentScan.s 50-fixed-group - restructure; Amiga gameFrameClean 91 -> 97 +6.6%; the re-measured - split: scan 3.24 / flush 1.61 / flip 0.36 / residual 1.33 ms of a - 6.54 ms present3win). Ranks 15/16 killed by their own gates. P7-2 - stage 2 (metadata fills into a 10-arg asm entry) was built, - measured 722 -> 602, and REVERTED -- llvm-mos argument marshalling - costs more than the far stores it replaces (the fnAddr-cache wall; - P7-2 is closed, both directions measured). All landings 51/51 - cross-port + P5/P11/P12 parity. -- 2026-07-13 (later): perf-hunt-2 ranks 2/4/5/6/7 landed. ST+Amiga - idle-present group scan (4 rows per longword vs the 0xFF clean - sentinel): jlStagePresent full ST 407 -> 1150/s, Amiga 609 -> - 1131/s; composites unchanged. DOS: -fomit-frame-pointer (flag - parity; input rows +28-29%), 4-row group skip in jlpPresent (P8 - idle 1137 -> 3881/s), dword-fused VGA expand (gameFrameClean 196 -> - 243, gameFrame 19 -> 23), NEW P12 vgaMismatch display gate - (dosProbeVgaMismatch + UBER_PROBE battery, 0/0/0), and the - gameFrameClean component split (present 3.06 / erases 0.70 / draws - 0.34 ms). All four ports 51/51 cross-diffed after each landing. -- 2026-07-13: IIgs dirty-band memset fix (perf-hunt-2 rank 1+3). The - "13.7 ms irreducible present base" was mostly two libc memset far - calls: stageDirtyClearAll (runs after EVERY present) memset both - 200-byte band arrays, and llvm-mos lowers memset to `jsl memset` -- - libc's `while (n--) *d++ = c` byte loop (the old "MVN-seeded" - comments were wrong; finding-#79 class). Replaced with asm word - fills in joeyDraw.s (iigsStageBandsClearAll / MarkAll, abs,x - descending; a C word-fill emitted ~35 instr/store and was rejected) - plus iigsByteFill ([dp],y) for jlScbSetRange. Measured (MAME): - jlStagePresent full 67 -> 363/s (fixed base ~13.7 -> ~2.75 ms), - jlScbSetRange 163 -> 1322/s, jlSurfaceClear 33 -> 56/s, - gameFrameClean 24 -> 35/s, gameFrame 12 -> 14/s. 51/51 golden vs - Amiga reference; verify-iigs UBER framebuffer checksum byte- - identical to the memset build. NOTE: every IIgs ceiling that bakes - in the 13.7 ms base (P1/P2 rows, assumption 4, the rate-card fixed - base) is now stale on the optimistic side for the library and needs - re-derivation with the ~2.75 ms base; the 2026-07-08 baseline table - rows for the five ops above are superseded by the numbers here. -- 2026-07-08: Phase 6 W1+W2+W3 landed (fused IIgs sprite/fillRect - entries + sprite-mark specialization; per-row/fused presents on - Amiga/ST/IIgs with display-parity probe gates P8-P11; ST movep tile - singles, DOS map walker, Amiga blitter copyRect + map hoist). Table - regenerated from a clean rebuild, 51/51 x4. Ceilings re-derived - from the measured models (fitted IIgs write cost, ST bus floor, - movep/blitter rates) -- the old ~29 ops/s IIgs full-screen note is - superseded in place. - -Pre-2026-07-04 tables (ORCA-era IIgs column, artifact-inflated DOS -numbers, un-batched loops) live in git history only -- every -conclusion drawn from them was re-derived from Phase 0 onward. The -Phase 0 baseline (2026-07-04) and per-phase deltas are recorded in -PERF-AUDIT-PLAN.md's progress tracker; headline gains since Phase 0: -IIgs interpreted spriteDraw 18 -> 48 ops/sec (Phase 4), IIgs -tilePasteMono asm 3.35x (Phase 7 #3), ST flood plane hooks 5.6x -(Phase 7 #4), DOS paletteSet +92% / tileSnap +42% (Phases 2/5), -DOS compiled sprites 284 -> ~6100 ops/sec (#74). diff --git a/README.md b/README.md index 10db500..1624ee8 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ platforms from a single codebase: The Apple IIgs defines the capability ceiling. Stronger platforms coast. 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 @@ -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 shift, caps lock, and the machine's keyboard layout already applied by the backend -- so punctuation like `.` and `:` arrives correctly on -every port. Refilled by the same `jlInputPoll`; see `docs/input.md` +every port. Refilled by the same `jlInputPoll`; see `claudeDocs/input.md` for per-port sources and limitations. ```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 loopback self-test. `scripts/check-serial.sh` unit-tests the portable gate layer against a loopback mock with no hardware. Full reference, per-port -behavior, and porting notes: [`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 @@ -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` round-trips the core against real files; `make iigs-verify-save` boots the IIgs save path under MAME. Full reference, per-port behavior, and porting -notes: [`docs/save.md`](docs/save.md). +notes: [`claudeDocs/save.md`](claudeDocs/save.md). ### Debug logging (`joey/debug.h`) diff --git a/assets/joeylibLogo.svg b/assets/joeylibLogo.svg new file mode 100644 index 0000000..243c693 --- /dev/null +++ b/assets/joeylibLogo.svg @@ -0,0 +1,9 @@ + + + assets/joeylib_logo_-_320x200.png + + + + + + diff --git a/assets/joeylib_logo_-_320x200.png b/assets/joeylib_logo_-_320x200.png new file mode 100644 index 0000000..8edb90a --- /dev/null +++ b/assets/joeylib_logo_-_320x200.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8c0bd6b4cacb53c762b94f43c49a96e9e5f05890b820ca637068d72c96672f80 +size 50531 diff --git a/docs/ApiAudio.adoc b/docs/ApiAudio.adoc new file mode 100644 index 0000000..b893c4a --- /dev/null +++ b/docs/ApiAudio.adoc @@ -0,0 +1,532 @@ +== Audio + +Declared in ``. + +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. diff --git a/docs/ApiCore.adoc b/docs/ApiCore.adoc new file mode 100644 index 0000000..ec58784 --- /dev/null +++ b/docs/ApiCore.adoc @@ -0,0 +1,268 @@ +== Core and Lifecycle + +Declared in ``, included by ``. + +=== 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. diff --git a/docs/ApiDebug.adoc b/docs/ApiDebug.adoc new file mode 100644 index 0000000..fec92ae --- /dev/null +++ b/docs/ApiDebug.adoc @@ -0,0 +1,104 @@ +== Debug Logging + +Declared in ``. + +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; +} +---- diff --git a/docs/ApiDraw.adoc b/docs/ApiDraw.adoc new file mode 100644 index 0000000..8ee333e --- /dev/null +++ b/docs/ApiDraw.adoc @@ -0,0 +1,226 @@ +== Drawing Primitives + +Declared in ``. + +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. diff --git a/docs/ApiFile.adoc b/docs/ApiFile.adoc new file mode 100644 index 0000000..9928a71 --- /dev/null +++ b/docs/ApiFile.adoc @@ -0,0 +1,151 @@ +== Files and Saves + +Declared in ``. + +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"); +} +---- diff --git a/docs/ApiInput.adoc b/docs/ApiInput.adoc new file mode 100644 index 0000000..08c6ac7 --- /dev/null +++ b/docs/ApiInput.adoc @@ -0,0 +1,257 @@ +== Input + +Declared in ``. + +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. +==== diff --git a/docs/ApiPalette.adoc b/docs/ApiPalette.adoc new file mode 100644 index 0000000..b6496ee --- /dev/null +++ b/docs/ApiPalette.adoc @@ -0,0 +1,120 @@ +== Palette and Scanline Control + +Declared in ``. + +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. diff --git a/docs/ApiSerial.adoc b/docs/ApiSerial.adoc new file mode 100644 index 0000000..22c2d10 --- /dev/null +++ b/docs/ApiSerial.adoc @@ -0,0 +1,212 @@ +== Serial (Optional Add-On) + +Declared in ``. + +[IMPORTANT] +==== +This header is *not* pulled in by ``. Include it explicitly: + +[source,c] +---- +#include +#include +---- + +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. diff --git a/docs/ApiSprite.adoc b/docs/ApiSprite.adoc new file mode 100644 index 0000000..9213269 --- /dev/null +++ b/docs/ApiSprite.adoc @@ -0,0 +1,406 @@ +== Sprites + +Declared in ``. + +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. diff --git a/docs/ApiSurface.adoc b/docs/ApiSurface.adoc new file mode 100644 index 0000000..ac76881 --- /dev/null +++ b/docs/ApiSurface.adoc @@ -0,0 +1,229 @@ +== Surfaces and Presenting + +Declared in `` and ``. + +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. +==== diff --git a/docs/ApiTile.adoc b/docs/ApiTile.adoc new file mode 100644 index 0000000..8906e50 --- /dev/null +++ b/docs/ApiTile.adoc @@ -0,0 +1,328 @@ +== Tiles and Text + +Declared in ``. + +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. +==== diff --git a/docs/Appendix.adoc b/docs/Appendix.adoc new file mode 100644 index 0000000..ecccbc5 --- /dev/null +++ b/docs/Appendix.adoc @@ -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. +|=== diff --git a/docs/Assets.adoc b/docs/Assets.adoc new file mode 100644 index 0000000..ce4d0d8 --- /dev/null +++ b/docs/Assets.adoc @@ -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. diff --git a/docs/BuildIntegration.adoc b/docs/BuildIntegration.adoc new file mode 100644 index 0000000..4eebe58 --- /dev/null +++ b/docs/BuildIntegration.adoc @@ -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-.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. +==== diff --git a/docs/GettingStarted.adoc b/docs/GettingStarted.adoc new file mode 100644 index 0000000..0e54fee --- /dev/null +++ b/docs/GettingStarted.adoc @@ -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//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 +#include + +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 ` 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 + +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. +|=== diff --git a/docs/Manual.adoc b/docs/Manual.adoc new file mode 100644 index 0000000..cd2255b --- /dev/null +++ b/docs/Manual.adoc @@ -0,0 +1,61 @@ += JoeyLib Manual +Scott Duensing +: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[] diff --git a/docs/Overview.adoc b/docs/Overview.adoc new file mode 100644 index 0000000..8465282 --- /dev/null +++ b/docs/Overview.adoc @@ -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. diff --git a/docs/Portability.adoc b/docs/Portability.adoc new file mode 100644 index 0000000..92e8452 --- /dev/null +++ b/docs/Portability.adoc @@ -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. diff --git a/docs/ProgramLayout.adoc b/docs/ProgramLayout.adoc new file mode 100644 index 0000000..d821417 --- /dev/null +++ b/docs/ProgramLayout.adoc @@ -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 + +#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. +==== diff --git a/examples/uber/uber.c b/examples/uber/uber.c index 7d46138..6eb2c29 100644 --- a/examples/uber/uber.c +++ b/examples/uber/uber.c @@ -141,7 +141,7 @@ static unsigned char gBackupBytes[JOEY_SPRITE_BACKUP_BYTES(2, 2)] __attribute__( 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 // round-trips portably) and dedicated gameFrame sprite backups so the // 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); } -// ----- 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 // 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 // tools/diff-uber-hashes can compare ports against each other and against // 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 // exactly those lines then, nothing else. // @@ -1729,7 +1729,7 @@ static void __attribute__((noinline)) runAllTests(void) { // Surface mark dirty (via jlFillRect's mark step). 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 // checks clear the stage first, so the frozen lines above stay // bit-identical (Phase-7 golden-extension precedent). diff --git a/include/joey/input.h b/include/joey/input.h index e985608..872d055 100644 --- a/include/joey/input.h +++ b/include/joey/input.h @@ -122,7 +122,7 @@ void jlInputPoll(void); // the keyboard layout are already applied by the backend, so // punctuation and shifted symbols arrive correctly on every port. // (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 // needed. It holds JL_CHAR_QUEUE_SIZE - 1 characters; when full, diff --git a/include/joey/palette.h b/include/joey/palette.h index a39f4ff..ba19536 100644 --- a/include/joey/palette.h +++ b/include/joey/palette.h @@ -3,7 +3,7 @@ // 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 // 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 #define JOEYLIB_PALETTE_H diff --git a/include/joey/platform.h b/include/joey/platform.h index adb30c5..92fcf19 100644 --- a/include/joey/platform.h +++ b/include/joey/platform.h @@ -84,7 +84,7 @@ // 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.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. #define JOEYLIB_CPU_68000 #define JOEYLIB_ENDIAN_BIG diff --git a/include/joey/present.h b/include/joey/present.h index 9283039..7efa3fb 100644 --- a/include/joey/present.h +++ b/include/joey/present.h @@ -5,7 +5,7 @@ // planar platforms (Amiga, Atari ST) this is a chunky-to-planar // conversion. Drawing primitives mark per-row dirty ranges on the // 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 #define JOEYLIB_PRESENT_H diff --git a/include/joey/sprite.h b/include/joey/sprite.h index 1be7777..6dbc134 100644 --- a/include/joey/sprite.h +++ b/include/joey/sprite.h @@ -18,7 +18,7 @@ // variants into the codegen arena and the draw/save/restore calls // dispatch to the compiled routines, falling back to the interpreter // 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 // does not land mid-frame. diff --git a/include/joey/surface.h b/include/joey/surface.h index 675d9cd..b158bc3 100644 --- a/include/joey/surface.h +++ b/include/joey/surface.h @@ -8,7 +8,7 @@ // regardless, so game code does not see the storage format. // // 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 #define JOEYLIB_SURFACE_H diff --git a/make/x68000.mk b/make/x68000.mk index 6ad07e9..947cfd4 100644 --- a/make/x68000.mk +++ b/make/x68000.mk @@ -2,7 +2,7 @@ # # BRING-UP STATE. This builds libjoey.a and the serial example against the # 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 # toolchains/x68000 by the tarball. m68k-xelf-gcc emits ELF; the driver's diff --git a/scripts/bench-dos.sh b/scripts/bench-dos.sh index 21a8b6a..290f495 100755 --- a/scripts/bench-dos.sh +++ b/scripts/bench-dos.sh @@ -7,7 +7,7 @@ # 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 -# matches the PERF.md reference machine. +# matches the claudeDocs/PERF.md reference machine. # # scripts/bench-dos.sh # default 90s timeout # TIMEOUT=120 scripts/bench-dos.sh # override diff --git a/scripts/bench-iigs.sh b/scripts/bench-iigs.sh index 11cd040..cb4d157 100755 --- a/scripts/bench-iigs.sh +++ b/scripts/bench-iigs.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # 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 # disk; every "hashes identical x4" gate needs this. # diff --git a/scripts/build-docs.sh b/scripts/build-docs.sh new file mode 100755 index 0000000..d46bbee --- /dev/null +++ b/scripts/build-docs.sh @@ -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" diff --git a/scripts/check-millis.sh b/scripts/check-millis.sh index 89b8eb5..d84c41c 100755 --- a/scripts/check-millis.sh +++ b/scripts/check-millis.sh @@ -6,7 +6,7 @@ # # 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 -# 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 repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) diff --git a/scripts/pulseRecordLib.sh b/scripts/pulseRecordLib.sh new file mode 100644 index 0000000..8c2cf2a --- /dev/null +++ b/scripts/pulseRecordLib.sh @@ -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 -- 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 -- 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 +} diff --git a/scripts/verify-amiga-audio.sh b/scripts/verify-amiga-audio.sh index 4e58f6d..8bd0074 100755 --- a/scripts/verify-amiga-audio.sh +++ b/scripts/verify-amiga-audio.sh @@ -49,14 +49,18 @@ prog=${1:-Audio} binDir=$repo/build/amiga/bin kickstart=$repo/toolchains/emulators/support/kickstart.rom grader=$repo/tools/gradeAudioPhases.py +pulseLib=$repo/scripts/pulseRecordLib.sh label=verify-amiga-audio [ -f "$kickstart" ] || { echo "$label: missing $kickstart" >&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 command -v "$tool" >/dev/null || { echo "$label: $tool not installed" >&2; exit 2; } 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 progDir=$binDir/$prog @@ -80,7 +84,7 @@ cleanup() { [ -n "$xvfbPid" ] && kill "$xvfbPid" 2>/dev/null # Always unload the sink: leaving null sinks behind on the user's # 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 echo "$label: kept $work" >&2 else @@ -101,9 +105,7 @@ fi printf 'stack 32768\n:%s\n' "$prog" > "$work/hd/s/startup-sequence" sinkName="joeyAudioGate$$" -sinkModule=$(pactl load-module module-null-sink \ - sink_name="$sinkName" \ - sink_properties=device.description="$sinkName" 2>/dev/null) || sinkModule="" +sinkModule=$(pulseCreateSink "$sinkName") || sinkModule="" [ -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 & diff --git a/scripts/verify-audio-all.sh b/scripts/verify-audio-all.sh new file mode 100755 index 0000000..d5fdac3 --- /dev/null +++ b/scripts/verify-audio-all.sh @@ -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 diff --git a/scripts/verify-dos-audio.sh b/scripts/verify-dos-audio.sh index 59f48ba..e779d56 100755 --- a/scripts/verify-dos-audio.sh +++ b/scripts/verify-dos-audio.sh @@ -47,14 +47,18 @@ prog=${1:-AUDIO} binDir=$repo/build/dos/bin conf=$repo/scripts/dosbox-386sx16.conf grader=$repo/tools/gradeAudioPhases.py +pulseLib=$repo/scripts/pulseRecordLib.sh label=verify-dos-audio [ -f "$grader" ] || { echo "$label: missing $grader" >&2; exit 2; } +[ -f "$pulseLib" ] || { echo "$label: missing $pulseLib" >&2; exit 2; } +# shellcheck source=scripts/pulseRecordLib.sh +. "$pulseLib" [ -f "$conf" ] || { echo "$label: missing $conf" >&2; exit 2; } for tool in dosbox Xvfb xdotool parec pactl python3; do command -v "$tool" >/dev/null || { echo "$label: $tool not installed" >&2; exit 2; } 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. if [ -f "$binDir/$prog/$prog.EXE" ]; then @@ -79,7 +83,7 @@ cleanup() { [ -n "$xvfbPid" ] && kill "$xvfbPid" 2>/dev/null # Always unload the sink: a leaked null sink on the user's audio # 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 echo "$label: kept $work" >&2 else @@ -90,9 +94,7 @@ cleanup() { trap cleanup EXIT sinkName="joeyDosGate$$" -sinkModule=$(pactl load-module module-null-sink \ - sink_name="$sinkName" \ - sink_properties=device.description="$sinkName" 2>/dev/null) || sinkModule="" +sinkModule=$(pulseCreateSink "$sinkName") || sinkModule="" [ -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 & diff --git a/src/amiga/hal.c b/src/amiga/hal.c index 32228f0..ee3ba42 100644 --- a/src/amiga/hal.c +++ b/src/amiga/hal.c @@ -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 // 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 // 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 diff --git a/src/codegen/spriteCompile.c b/src/codegen/spriteCompile.c index 0eb8f43..f9bc045 100644 --- a/src/codegen/spriteCompile.c +++ b/src/codegen/spriteCompile.c @@ -29,8 +29,8 @@ * math -- see spriteCompiledDraw + the prologue/epilogue in spriteEmitIigs.c. * Codegen is ON by default; set JOEY_IIGS_SPRITE_CODEGEN to 0 to force the * interpreter. */ -/* 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 +/* claudeDocs/PERF-AUDIT.md #77 -- ROOT-CAUSED 2026-07-04, FIXED toolchain-side + * 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, * 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 diff --git a/src/core/assetLoad.c b/src/core/assetLoad.c index af5bc37..0f7cc51 100644 --- a/src/core/assetLoad.c +++ b/src/core/assetLoad.c @@ -269,7 +269,7 @@ uint16_t jlSpriteBankLoad(const char *path, jlSpriteT **outCels, uint16_t maxCel jlpBigFree(buf); 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 // for shutdown. ownsTileData true: the cel owns its buf copy. spriteInitFields(sp, buf, widthTiles, heightTiles, true); diff --git a/src/core/debug.c b/src/core/debug.c index 9027f36..5b224e1 100644 --- a/src/core/debug.c +++ b/src/core/debug.c @@ -6,7 +6,7 @@ // so far on disk. // // 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 // seconds of emulated floppy time, and a write can wedge indefinitely // inside SmartPort firmware retries -- one such wedge stalled the UBER diff --git a/src/core/init.c b/src/core/init.c index 4cae051..aedd0c8 100644 --- a/src/core/init.c +++ b/src/core/init.c @@ -18,7 +18,7 @@ // Default codegen arena size when jlConfigT.codegenBytes == 0. // 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 // 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 // caches) in every registered sprite BEFORE the arena teardown frees // the slot structs -- see spriteSystemShutdown in spriteInternal.h - // (PERF-AUDIT.md #34). + // (claudeDocs/PERF-AUDIT.md #34). spriteSystemShutdown(); codegenArenaShutdown(); stageFree(); diff --git a/src/core/sprite.c b/src/core/sprite.c index 94b7928..23c88a4 100644 --- a/src/core/sprite.c +++ b/src/core/sprite.c @@ -87,7 +87,7 @@ #endif -// ----- Sprite registry (PERF-AUDIT.md #34) ----- +// ----- Sprite registry (claudeDocs/PERF-AUDIT.md #34) ----- // // jlShutdown frees the codegen arena (codegenArenaShutdown) while // 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 * 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 * owned-tileData allocation in this file (finding #31). */ 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, // and jlSpriteBankLoad (assetLoad.c) all route through here so the // 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) { sp->tileData = tileData; sp->widthTiles = widthTiles; diff --git a/src/core/spriteInternal.h b/src/core/spriteInternal.h index 6936932..c72e218 100644 --- a/src/core/spriteInternal.h +++ b/src/core/spriteInternal.h @@ -206,10 +206,10 @@ struct jlSpriteT { // init path for every sprite -- jlSpriteCreate and // jlSpriteCreateFromSurface in sprite.c plus jlSpriteBankLoad in // 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); -// 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 // on success (idempotent) as a belt-and-braces guard, since compile // is the moment the sprite first holds an arena slot. diff --git a/src/core/surface.c b/src/core/surface.c index a3c3d8a..8ae9f1e 100644 --- a/src/core/surface.c +++ b/src/core/surface.c @@ -141,7 +141,7 @@ jlSurfaceT *jlSurfaceCreate(void) { // 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 // 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. s = (jlSurfaceT *)jlpBigAlloc((uint32_t)sizeof(jlSurfaceT)); if (s == NULL) { diff --git a/src/dos/hal.c b/src/dos/hal.c index 5d7acb7..b1426cc 100644 --- a/src/dos/hal.c +++ b/src/dos/hal.c @@ -389,7 +389,7 @@ void jlpWaitVBL(void) { // old implementation edge-polled $3DA bit 3 and only advanced when a // call happened to land DURING the ~1ms retrace pulse, so any caller // 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; // jlpWaitVBL above still synchronizes to the real retrace. uint16_t jlpFrameCount(void) { diff --git a/src/iigs/hal.c b/src/iigs/hal.c index 455231e..dee4119 100644 --- a/src/iigs/hal.c +++ b/src/iigs/hal.c @@ -480,7 +480,7 @@ void jlpShutdown(void) { // 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 // 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 // IIgs jlShutdown therefore does not return. If QuitGS itself // 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 // MM considers $01:2000-$9CFF free and can place ANY handle inside the // 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 // is in special memory (bank $01) by definition. The claim MUST happen // 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 // 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, -// 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 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 @@ -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 // (incremented by the VBL interrupt handler) to advance. The // 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 // is the only example that calls it, so no verification gate ever // caught this. Tick granularity IS the VBL boundary, so semantics are diff --git a/src/iigs/joeyDraw.s b/src/iigs/joeyDraw.s index 5ba22e9..6774315 100644 --- a/src/iigs/joeyDraw.s +++ b/src/iigs/joeyDraw.s @@ -3576,7 +3576,7 @@ dlnPlotOdd: ; 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 ; 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 and #0xFFF0 ora dlnNibLo @@ -3825,7 +3825,7 @@ dcLoopBody: ; target's high nibble for even x, $FFF0 clears the target's low ; nibble for odd x -- both preserve the whole adjacent byte), then ; 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). ; Plot 1: (cx+x, cy+y) diff --git a/src/x68000/hal.c b/src/x68000/hal.c index c9d81f5..537282e 100644 --- a/src/x68000/hal.c +++ b/src/x68000/hal.c @@ -14,7 +14,7 @@ // 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 // 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 #include diff --git a/src/x68000/halPlanar.c b/src/x68000/halPlanar.c index 43fcc62..8838d0c 100644 --- a/src/x68000/halPlanar.c +++ b/src/x68000/halPlanar.c @@ -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 // 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 // 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 diff --git a/src/x68000/x68kPlanar.h b/src/x68000/x68kPlanar.h index f779467..beca34f 100644 --- a/src/x68000/x68kPlanar.h +++ b/src/x68000/x68kPlanar.h @@ -1,6 +1,6 @@ // 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 // is hash-verified against the Apple IIgs golden reference. This header lands // 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 // 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 -// 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. // // Unlike the Amiga there is no Chip RAM constraint: planes are plain malloc, diff --git a/tests/goldens/uber/amiga.txt b/tests/goldens/uber/amiga.txt index b08b241..2168827 100644 --- a/tests/goldens/uber/amiga.txt +++ b/tests/goldens/uber/amiga.txt @@ -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-.sh and render the comparison +# with tools/uber-perf-table, which reads a fresh build/ 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 1 in CHIP (blitter, addr=$000171C0) amiga: shadow plane 2 in CHIP (blitter, addr=$00019100) diff --git a/tests/goldens/uber/atarist.txt b/tests/goldens/uber/atarist.txt index a5b452b..ededcd8 100644 --- a/tests/goldens/uber/atarist.txt +++ b/tests/goldens/uber/atarist.txt @@ -1,202 +1,217 @@ -UBER: jlSpriteCompile: 1 call in <= 1 frame - -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: ----- begin ----- - -UBER: jlSurfaceClear: 12 iters / 16 frames = 44 ops/sec | hash=428CEE42 - -UBER-CLK: jlSurfaceClear: frame=45 millis=44 ops/sec (270 ms) - -UBER: jlPaletteSet: 1393 iters / 16 frames = 5256 ops/sec | hash=5267FFB1 - -UBER-CLK: jlPaletteSet: frame=5223 millis=5256 ops/sec (265 ms) - -UBER: jlScbSetRange: 641 iters / 16 frames = 2418 ops/sec | hash=5267FFB1 - -UBER-CLK: jlScbSetRange: frame=2403 millis=2418 ops/sec (265 ms) - -UBER: jlDrawPixel: 897 iters / 16 frames = 3384 ops/sec | hash=75D75161 - -UBER-CLK: jlDrawPixel: frame=3363 millis=3384 ops/sec (265 ms) - -UBER: jlDrawLine H: 369 iters / 16 frames = 1341 ops/sec | hash=1E97E361 - -UBER-CLK: jlDrawLine H: frame=1383 millis=1341 ops/sec (275 ms) - -UBER: jlDrawLine V: 49 iters / 22 frames = 132 ops/sec | hash=A0474D91 - -UBER-CLK: jlDrawLine V: frame=133 millis=132 ops/sec (370 ms) - -UBER: jlDrawLine diag: 12 iters / 16 frames = 43 ops/sec | hash=69471D91 - -UBER-CLK: jlDrawLine diag: frame=45 millis=43 ops/sec (275 ms) - -UBER: jlDrawRect 100x100: 33 iters / 18 frames = 108 ops/sec | hash=5CAFF471 - -UBER-CLK: jlDrawRect 100x100: frame=110 millis=108 ops/sec (305 ms) - -UBER: jlDrawCircle r=16: 49 iters / 16 frames = 184 ops/sec | hash=7F1F5531 - -UBER-CLK: jlDrawCircle r=16: frame=183 millis=184 ops/sec (265 ms) - -UBER: jlDrawCircle r=80: 12 iters / 17 frames = 42 ops/sec | hash=B249C9AB - -UBER-CLK: jlDrawCircle r=80: frame=42 millis=42 ops/sec (285 ms) - -UBER: jlFillRect 16x16: 177 iters / 17 frames = 610 ops/sec | hash=F4BD4B0B - -UBER-CLK: jlFillRect 16x16: frame=624 millis=610 ops/sec (290 ms) - -UBER: jlFillRect 80x80: 33 iters / 16 frames = 120 ops/sec | hash=7C952E5F - -UBER-CLK: jlFillRect 80x80: frame=123 millis=120 ops/sec (275 ms) - -UBER: jlFillRect 320x200: 11 iters / 16 frames = 40 ops/sec | hash=6467AFB1 - -UBER-CLK: jlFillRect 320x200: frame=41 millis=40 ops/sec (275 ms) - -UBER: jlFillCircle r=40: 33 iters / 25 frames = 77 ops/sec | hash=6467AFB1 - -UBER-CLK: jlFillCircle r=40: frame=79 millis=77 ops/sec (425 ms) - -UBER: jlSamplePixel: 1313 iters / 16 frames = 4862 ops/sec | hash=6467AFB1 - -UBER-CLK: jlSamplePixel: frame=4923 millis=4862 ops/sec (270 ms) - -UBER: jlTileFill: 433 iters / 16 frames = 1603 ops/sec | hash=6467AFB1 - -UBER-CLK: jlTileFill: frame=1623 millis=1603 ops/sec (270 ms) - -UBER: jlTileCopy: 369 iters / 16 frames = 1341 ops/sec | hash=6467AFB1 - -UBER-CLK: jlTileCopy: frame=1383 millis=1341 ops/sec (275 ms) - -UBER: jlTileCopyMasked: 193 iters / 16 frames = 689 ops/sec | hash=6467AFB1 - -UBER-CLK: jlTileCopyMasked: frame=723 millis=689 ops/sec (280 ms) - -UBER: jlTilePaste: 433 iters / 16 frames = 1574 ops/sec | hash=6467AFB1 - -UBER-CLK: jlTilePaste: frame=1623 millis=1574 ops/sec (275 ms) - -UBER: jlTileSnap: 785 iters / 16 frames = 2907 ops/sec | hash=6467AFB1 - -UBER-CLK: jlTileSnap: frame=2943 millis=2907 ops/sec (270 ms) - -UBER: jlSpriteSaveUnder: 225 iters / 16 frames = 849 ops/sec | hash=CA673FB1 - -UBER-CLK: jlSpriteSaveUnder: frame=843 millis=849 ops/sec (265 ms) - -UBER: jlSpriteDraw: 161 iters / 16 frames = 607 ops/sec | hash=82B9646B - -UBER-CLK: jlSpriteDraw: frame=603 millis=607 ops/sec (265 ms) - -UBER: jlSpriteRestoreUnder: 193 iters / 17 frames = 689 ops/sec | hash=CA673FB1 - -UBER-CLK: jlSpriteRestoreUnder: frame=681 millis=689 ops/sec (280 ms) - -UBER: jlSpriteSaveAndDraw: 97 iters / 17 frames = 328 ops/sec | hash=82B9646B - -UBER-CLK: jlSpriteSaveAndDraw: frame=342 millis=328 ops/sec (295 ms) - -UBER: jlStagePresent full: 15 iters / 16 frames = 55 ops/sec | hash=82B9646B - -UBER-CLK: jlStagePresent full: frame=56 millis=55 ops/sec (270 ms) - -UBER: jlInputPoll: 385 iters / 16 frames = 1400 ops/sec | hash=82B9646B - -UBER-CLK: jlInputPoll: frame=1443 millis=1400 ops/sec (275 ms) - -UBER: jlKeyDown: 6049 iters / 16 frames = 22403 ops/sec | hash=82B9646B - -UBER-CLK: jlKeyDown: frame=22683 millis=22403 ops/sec (270 ms) - -UBER: jlKeyPressed: 6033 iters / 16 frames = 22344 ops/sec | hash=82B9646B - -UBER-CLK: jlKeyPressed: frame=22623 millis=22344 ops/sec (270 ms) - -UBER: jlMouseX: 15745 iters / 16 frames = 58314 ops/sec | hash=82B9646B - -UBER-CLK: jlMouseX: frame=59043 millis=58314 ops/sec (270 ms) - -UBER: joeyJoyConnected: 5905 iters / 16 frames = 21870 ops/sec | hash=82B9646B - -UBER-CLK: joeyJoyConnected: frame=22143 millis=21870 ops/sec (270 ms) - -UBER: jlAudioFrameTick: 3109 iters / 16 frames = 11732 ops/sec | hash=82B9646B - -UBER-CLK: jlAudioFrameTick: frame=11658 millis=11732 ops/sec (265 ms) - -UBER: jlAudioIsPlayingMod: 8065 iters / 16 frames = 29870 ops/sec | hash=82B9646B - -UBER-CLK: jlAudioIsPlayingMod: frame=30243 millis=29870 ops/sec (270 ms) - -UBER: surfaceMarkDirtyRect (via jlFillRect 32x32): 113 iters / 17 frames = 389 ops/sec | hash=D2B9E46B - -UBER-CLK: surfaceMarkDirtyRect (via jlFillRect 32x32): frame=398 millis=389 ops/sec (290 ms) - -UBER: jlSpriteDraw unaligned: 11 iters / 16 frames = 40 ops/sec | hash=69CEBB34 - -UBER-CLK: jlSpriteDraw unaligned: frame=41 millis=40 ops/sec (270 ms) - -UBER: jlSpriteDraw sweep16: 1 iters / 21 frames = 2 ops/sec | hash=24E7E0C9 - -UBER-CLK: jlSpriteDraw sweep16: frame=2 millis=2 ops/sec (350 ms) - -UBER: jlTilePaste map 10x5: 9 iters / 17 frames = 32 ops/sec | hash=92E7B0C9 - -UBER-CLK: jlTilePaste map 10x5: frame=31 millis=32 ops/sec (280 ms) - -UBER: gameFrame composite: 3 iters / 19 frames = 9 ops/sec | hash=55B9EC6B - -UBER-CLK: gameFrame composite: frame=9 millis=9 ops/sec (325 ms) - -UBER: ----- end ----- - -UBER-CHK: ----- begin ----- - -UBER-CHK: edge-pixels: hash=5267FFB1 - -UBER-CHK: edge-drawRect: hash=BC75D707 - -UBER-CHK: edge-circles: hash=20674FB1 - -UBER-CHK: edge-lines: hash=D4F5BCA7 - -UBER-CHK: sprite-clipped: hash=D922E4B4 - -UBER-CHK: sprite-clip-roundtrip: PASS - -UBER-CHK: tilePasteMono: hash=B0829554 - -UBER-CHK: floodFill: hash=780DC48B - -UBER-CHK: drawText: hash=80B5264B - -UBER-CHK: drawText-offgrid: hash=9435DA4B - -UBER-CHK: palette-roundtrip: PASS - -UBER-CHK: random-golden: PASS - -UBER-CHK: arena-churn: PASS - -UBER-CHK: sprite-from-surface: PASS - -UBER-CHK: sprite-from-surface-draw: hash=5F4394C9 - -UBER-CHK: tilePasteMonoAll: hash=30C3A2C9 - -UBER-CHK: floodFillBounded: hash=EF6B6F51 - -UBER-CHK: ----- end ----- - -UBER: total wall time: 289833 ms (17390 frames @ 60 Hz) - -UBER: press any key to exit - -UBER: jlTileMapPaste 10x5: 49 iters / 18 frames = 163 ops/sec | hash=52B9646B -UBER: gameFrameClean composite: 13 iters / 16 frames = 48 ops/sec | hash=C51FE171 +# 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-.sh and render the comparison +# with tools/uber-perf-table, which reads a fresh build/ 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: 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: ----- begin ----- + +UBER: jlSurfaceClear: 12 iters / 16 frames = 44 ops/sec | hash=428CEE42 + +UBER-CLK: jlSurfaceClear: frame=45 millis=44 ops/sec (270 ms) + +UBER: jlPaletteSet: 1393 iters / 16 frames = 5256 ops/sec | hash=5267FFB1 + +UBER-CLK: jlPaletteSet: frame=5223 millis=5256 ops/sec (265 ms) + +UBER: jlScbSetRange: 641 iters / 16 frames = 2418 ops/sec | hash=5267FFB1 + +UBER-CLK: jlScbSetRange: frame=2403 millis=2418 ops/sec (265 ms) + +UBER: jlDrawPixel: 897 iters / 16 frames = 3384 ops/sec | hash=75D75161 + +UBER-CLK: jlDrawPixel: frame=3363 millis=3384 ops/sec (265 ms) + +UBER: jlDrawLine H: 369 iters / 16 frames = 1341 ops/sec | hash=1E97E361 + +UBER-CLK: jlDrawLine H: frame=1383 millis=1341 ops/sec (275 ms) + +UBER: jlDrawLine V: 49 iters / 22 frames = 132 ops/sec | hash=A0474D91 + +UBER-CLK: jlDrawLine V: frame=133 millis=132 ops/sec (370 ms) + +UBER: jlDrawLine diag: 12 iters / 16 frames = 43 ops/sec | hash=69471D91 + +UBER-CLK: jlDrawLine diag: frame=45 millis=43 ops/sec (275 ms) + +UBER: jlDrawRect 100x100: 33 iters / 18 frames = 108 ops/sec | hash=5CAFF471 + +UBER-CLK: jlDrawRect 100x100: frame=110 millis=108 ops/sec (305 ms) + +UBER: jlDrawCircle r=16: 49 iters / 16 frames = 184 ops/sec | hash=7F1F5531 + +UBER-CLK: jlDrawCircle r=16: frame=183 millis=184 ops/sec (265 ms) + +UBER: jlDrawCircle r=80: 12 iters / 17 frames = 42 ops/sec | hash=B249C9AB + +UBER-CLK: jlDrawCircle r=80: frame=42 millis=42 ops/sec (285 ms) + +UBER: jlFillRect 16x16: 177 iters / 17 frames = 610 ops/sec | hash=F4BD4B0B + +UBER-CLK: jlFillRect 16x16: frame=624 millis=610 ops/sec (290 ms) + +UBER: jlFillRect 80x80: 33 iters / 16 frames = 120 ops/sec | hash=7C952E5F + +UBER-CLK: jlFillRect 80x80: frame=123 millis=120 ops/sec (275 ms) + +UBER: jlFillRect 320x200: 11 iters / 16 frames = 40 ops/sec | hash=6467AFB1 + +UBER-CLK: jlFillRect 320x200: frame=41 millis=40 ops/sec (275 ms) + +UBER: jlFillCircle r=40: 33 iters / 25 frames = 77 ops/sec | hash=6467AFB1 + +UBER-CLK: jlFillCircle r=40: frame=79 millis=77 ops/sec (425 ms) + +UBER: jlSamplePixel: 1313 iters / 16 frames = 4862 ops/sec | hash=6467AFB1 + +UBER-CLK: jlSamplePixel: frame=4923 millis=4862 ops/sec (270 ms) + +UBER: jlTileFill: 433 iters / 16 frames = 1603 ops/sec | hash=6467AFB1 + +UBER-CLK: jlTileFill: frame=1623 millis=1603 ops/sec (270 ms) + +UBER: jlTileCopy: 369 iters / 16 frames = 1341 ops/sec | hash=6467AFB1 + +UBER-CLK: jlTileCopy: frame=1383 millis=1341 ops/sec (275 ms) + +UBER: jlTileCopyMasked: 193 iters / 16 frames = 689 ops/sec | hash=6467AFB1 + +UBER-CLK: jlTileCopyMasked: frame=723 millis=689 ops/sec (280 ms) + +UBER: jlTilePaste: 433 iters / 16 frames = 1574 ops/sec | hash=6467AFB1 + +UBER-CLK: jlTilePaste: frame=1623 millis=1574 ops/sec (275 ms) + +UBER: jlTileSnap: 785 iters / 16 frames = 2907 ops/sec | hash=6467AFB1 + +UBER-CLK: jlTileSnap: frame=2943 millis=2907 ops/sec (270 ms) + +UBER: jlSpriteSaveUnder: 225 iters / 16 frames = 849 ops/sec | hash=CA673FB1 + +UBER-CLK: jlSpriteSaveUnder: frame=843 millis=849 ops/sec (265 ms) + +UBER: jlSpriteDraw: 161 iters / 16 frames = 607 ops/sec | hash=82B9646B + +UBER-CLK: jlSpriteDraw: frame=603 millis=607 ops/sec (265 ms) + +UBER: jlSpriteRestoreUnder: 193 iters / 17 frames = 689 ops/sec | hash=CA673FB1 + +UBER-CLK: jlSpriteRestoreUnder: frame=681 millis=689 ops/sec (280 ms) + +UBER: jlSpriteSaveAndDraw: 97 iters / 17 frames = 328 ops/sec | hash=82B9646B + +UBER-CLK: jlSpriteSaveAndDraw: frame=342 millis=328 ops/sec (295 ms) + +UBER: jlStagePresent full: 15 iters / 16 frames = 55 ops/sec | hash=82B9646B + +UBER-CLK: jlStagePresent full: frame=56 millis=55 ops/sec (270 ms) + +UBER: jlInputPoll: 385 iters / 16 frames = 1400 ops/sec | hash=82B9646B + +UBER-CLK: jlInputPoll: frame=1443 millis=1400 ops/sec (275 ms) + +UBER: jlKeyDown: 6049 iters / 16 frames = 22403 ops/sec | hash=82B9646B + +UBER-CLK: jlKeyDown: frame=22683 millis=22403 ops/sec (270 ms) + +UBER: jlKeyPressed: 6033 iters / 16 frames = 22344 ops/sec | hash=82B9646B + +UBER-CLK: jlKeyPressed: frame=22623 millis=22344 ops/sec (270 ms) + +UBER: jlMouseX: 15745 iters / 16 frames = 58314 ops/sec | hash=82B9646B + +UBER-CLK: jlMouseX: frame=59043 millis=58314 ops/sec (270 ms) + +UBER: joeyJoyConnected: 5905 iters / 16 frames = 21870 ops/sec | hash=82B9646B + +UBER-CLK: joeyJoyConnected: frame=22143 millis=21870 ops/sec (270 ms) + +UBER: jlAudioFrameTick: 3109 iters / 16 frames = 11732 ops/sec | hash=82B9646B + +UBER-CLK: jlAudioFrameTick: frame=11658 millis=11732 ops/sec (265 ms) + +UBER: jlAudioIsPlayingMod: 8065 iters / 16 frames = 29870 ops/sec | hash=82B9646B + +UBER-CLK: jlAudioIsPlayingMod: frame=30243 millis=29870 ops/sec (270 ms) + +UBER: surfaceMarkDirtyRect (via jlFillRect 32x32): 113 iters / 17 frames = 389 ops/sec | hash=D2B9E46B + +UBER-CLK: surfaceMarkDirtyRect (via jlFillRect 32x32): frame=398 millis=389 ops/sec (290 ms) + +UBER: jlSpriteDraw unaligned: 11 iters / 16 frames = 40 ops/sec | hash=69CEBB34 + +UBER-CLK: jlSpriteDraw unaligned: frame=41 millis=40 ops/sec (270 ms) + +UBER: jlSpriteDraw sweep16: 1 iters / 21 frames = 2 ops/sec | hash=24E7E0C9 + +UBER-CLK: jlSpriteDraw sweep16: frame=2 millis=2 ops/sec (350 ms) + +UBER: jlTilePaste map 10x5: 9 iters / 17 frames = 32 ops/sec | hash=92E7B0C9 + +UBER-CLK: jlTilePaste map 10x5: frame=31 millis=32 ops/sec (280 ms) + +UBER: gameFrame composite: 3 iters / 19 frames = 9 ops/sec | hash=55B9EC6B + +UBER-CLK: gameFrame composite: frame=9 millis=9 ops/sec (325 ms) + +UBER: ----- end ----- + +UBER-CHK: ----- begin ----- + +UBER-CHK: edge-pixels: hash=5267FFB1 + +UBER-CHK: edge-drawRect: hash=BC75D707 + +UBER-CHK: edge-circles: hash=20674FB1 + +UBER-CHK: edge-lines: hash=D4F5BCA7 + +UBER-CHK: sprite-clipped: hash=D922E4B4 + +UBER-CHK: sprite-clip-roundtrip: PASS + +UBER-CHK: tilePasteMono: hash=B0829554 + +UBER-CHK: floodFill: hash=780DC48B + +UBER-CHK: drawText: hash=80B5264B + +UBER-CHK: drawText-offgrid: hash=9435DA4B + +UBER-CHK: palette-roundtrip: PASS + +UBER-CHK: random-golden: PASS + +UBER-CHK: arena-churn: PASS + +UBER-CHK: sprite-from-surface: PASS + +UBER-CHK: sprite-from-surface-draw: hash=5F4394C9 + +UBER-CHK: tilePasteMonoAll: hash=30C3A2C9 + +UBER-CHK: floodFillBounded: hash=EF6B6F51 + +UBER-CHK: ----- end ----- + +UBER: total wall time: 289833 ms (17390 frames @ 60 Hz) + +UBER: press any key to exit + +UBER: jlTileMapPaste 10x5: 49 iters / 18 frames = 163 ops/sec | hash=52B9646B +UBER: gameFrameClean composite: 13 iters / 16 frames = 48 ops/sec | hash=C51FE171 diff --git a/tests/goldens/uber/dos.txt b/tests/goldens/uber/dos.txt index 95adc4a..119bdec 100644 --- a/tests/goldens/uber/dos.txt +++ b/tests/goldens/uber/dos.txt @@ -1,202 +1,217 @@ -UBER: jlSpriteCompile: 1 call in <= 1 frame - -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: ----- begin ----- - -UBER: jlSurfaceClear: 16 iters / 16 frames = 66 ops/sec | hash=428CEE42 - -UBER-CLK: jlSurfaceClear: frame=70 millis=66 ops/sec (239 ms) - -UBER: jlPaletteSet: 6737 iters / 16 frames = 29809 ops/sec | hash=5267FFB1 - -UBER-CLK: jlPaletteSet: frame=29474 millis=29809 ops/sec (226 ms) - -UBER: jlScbSetRange: 1937 iters / 16 frames = 8495 ops/sec | hash=5267FFB1 - -UBER-CLK: jlScbSetRange: frame=8474 millis=8495 ops/sec (228 ms) - -UBER: jlDrawPixel: 5393 iters / 16 frames = 23757 ops/sec | hash=75D75161 - -UBER-CLK: jlDrawPixel: frame=23594 millis=23757 ops/sec (227 ms) - -UBER: jlDrawLine H: 1313 iters / 16 frames = 5708 ops/sec | hash=1E97E361 - -UBER-CLK: jlDrawLine H: frame=5744 millis=5708 ops/sec (230 ms) - -UBER: jlDrawLine V: 113 iters / 16 frames = 470 ops/sec | hash=A0474D91 - -UBER-CLK: jlDrawLine V: frame=494 millis=470 ops/sec (240 ms) - -UBER: jlDrawLine diag: 33 iters / 21 frames = 109 ops/sec | hash=69471D91 - -UBER-CLK: jlDrawLine diag: frame=110 millis=109 ops/sec (301 ms) - -UBER: jlDrawRect 100x100: 82 iters / 16 frames = 377 ops/sec | hash=5CAFF471 - -UBER-CLK: jlDrawRect 100x100: frame=358 millis=377 ops/sec (217 ms) - -UBER: jlDrawCircle r=16: 97 iters / 16 frames = 449 ops/sec | hash=7F1F5531 - -UBER-CLK: jlDrawCircle r=16: frame=424 millis=449 ops/sec (216 ms) - -UBER: jlDrawCircle r=80: 22 iters / 16 frames = 98 ops/sec | hash=B249C9AB - -UBER-CLK: jlDrawCircle r=80: frame=96 millis=98 ops/sec (223 ms) - -UBER: jlFillRect 16x16: 657 iters / 16 frames = 3013 ops/sec | hash=F4BD4B0B - -UBER-CLK: jlFillRect 16x16: frame=2874 millis=3013 ops/sec (218 ms) - -UBER: jlFillRect 80x80: 97 iters / 17 frames = 414 ops/sec | hash=7C952E5F - -UBER-CLK: jlFillRect 80x80: frame=399 millis=414 ops/sec (234 ms) - -UBER: jlFillRect 320x200: 13 iters / 16 frames = 58 ops/sec | hash=6467AFB1 - -UBER-CLK: jlFillRect 320x200: frame=56 millis=58 ops/sec (221 ms) - -UBER: jlFillCircle r=40: 35 iters / 16 frames = 156 ops/sec | hash=6467AFB1 - -UBER-CLK: jlFillCircle r=40: frame=153 millis=156 ops/sec (223 ms) - -UBER: jlSamplePixel: 10161 iters / 16 frames = 46397 ops/sec | hash=6467AFB1 - -UBER-CLK: jlSamplePixel: frame=44454 millis=46397 ops/sec (219 ms) - -UBER: jlTileFill: 2049 iters / 16 frames = 9313 ops/sec | hash=6467AFB1 - -UBER-CLK: jlTileFill: frame=8964 millis=9313 ops/sec (220 ms) - -UBER: jlTileCopy: 1937 iters / 16 frames = 8764 ops/sec | hash=6467AFB1 - -UBER-CLK: jlTileCopy: frame=8474 millis=8764 ops/sec (221 ms) - -UBER: jlTileCopyMasked: 705 iters / 16 frames = 3147 ops/sec | hash=6467AFB1 - -UBER-CLK: jlTileCopyMasked: frame=3084 millis=3147 ops/sec (224 ms) - -UBER: jlTilePaste: 2177 iters / 16 frames = 9806 ops/sec | hash=6467AFB1 - -UBER-CLK: jlTilePaste: frame=9524 millis=9806 ops/sec (222 ms) - -UBER: jlTileSnap: 4273 iters / 16 frames = 19247 ops/sec | hash=6467AFB1 - -UBER-CLK: jlTileSnap: frame=18694 millis=19247 ops/sec (222 ms) - -UBER: jlSpriteSaveUnder: 2049 iters / 16 frames = 9229 ops/sec | hash=CA673FB1 - -UBER-CLK: jlSpriteSaveUnder: frame=8964 millis=9229 ops/sec (222 ms) - -UBER: jlSpriteDraw: 1393 iters / 16 frames = 6218 ops/sec | hash=82B9646B - -UBER-CLK: jlSpriteDraw: frame=6094 millis=6218 ops/sec (224 ms) - -UBER: jlSpriteRestoreUnder: 1329 iters / 16 frames = 5933 ops/sec | hash=CA673FB1 - -UBER-CLK: jlSpriteRestoreUnder: frame=5814 millis=5933 ops/sec (224 ms) - -UBER: jlSpriteSaveAndDraw: 961 iters / 16 frames = 4271 ops/sec | hash=82B9646B - -UBER-CLK: jlSpriteSaveAndDraw: frame=4204 millis=4271 ops/sec (225 ms) - -UBER: jlStagePresent full: 81 iters / 16 frames = 360 ops/sec | hash=82B9646B - -UBER-CLK: jlStagePresent full: frame=354 millis=360 ops/sec (225 ms) - -UBER: jlInputPoll: 721 iters / 16 frames = 3134 ops/sec | hash=82B9646B - -UBER-CLK: jlInputPoll: frame=3154 millis=3134 ops/sec (230 ms) - -UBER: jlKeyDown: 17505 iters / 16 frames = 77800 ops/sec | hash=82B9646B - -UBER-CLK: jlKeyDown: frame=76584 millis=77800 ops/sec (225 ms) - -UBER: jlKeyPressed: 16945 iters / 16 frames = 74977 ops/sec | hash=82B9646B - -UBER-CLK: jlKeyPressed: frame=74134 millis=74977 ops/sec (226 ms) - -UBER: jlMouseX: 35217 iters / 16 frames = 155140 ops/sec | hash=82B9646B - -UBER-CLK: jlMouseX: frame=154074 millis=155140 ops/sec (227 ms) - -UBER: joeyJoyConnected: 17665 iters / 16 frames = 77819 ops/sec | hash=82B9646B - -UBER-CLK: joeyJoyConnected: frame=77284 millis=77819 ops/sec (227 ms) - -UBER: jlAudioFrameTick: 13489 iters / 16 frames = 59162 ops/sec | hash=82B9646B - -UBER-CLK: jlAudioFrameTick: frame=59014 millis=59162 ops/sec (228 ms) - -UBER: jlAudioIsPlayingMod: 29265 iters / 16 frames = 127794 ops/sec | hash=82B9646B - -UBER-CLK: jlAudioIsPlayingMod: frame=128034 millis=127794 ops/sec (229 ms) - -UBER: surfaceMarkDirtyRect (via jlFillRect 32x32): 337 iters / 16 frames = 1478 ops/sec | hash=D2B9E46B - -UBER-CLK: surfaceMarkDirtyRect (via jlFillRect 32x32): frame=1474 millis=1478 ops/sec (228 ms) - -UBER: jlSpriteDraw unaligned: 1105 iters / 16 frames = 5115 ops/sec | hash=69CEBB34 - -UBER-CLK: jlSpriteDraw unaligned: frame=4834 millis=5115 ops/sec (216 ms) - -UBER: jlSpriteDraw sweep16: 77 iters / 16 frames = 353 ops/sec | hash=24E7E0C9 - -UBER-CLK: jlSpriteDraw sweep16: frame=336 millis=353 ops/sec (218 ms) - -UBER: jlTilePaste map 10x5: 44 iters / 16 frames = 200 ops/sec | hash=92E7B0C9 - -UBER-CLK: jlTilePaste map 10x5: frame=192 millis=200 ops/sec (220 ms) - -UBER: gameFrame composite: 5 iters / 19 frames = 18 ops/sec | hash=55B9EC6B - -UBER-CLK: gameFrame composite: frame=18 millis=18 ops/sec (270 ms) - -UBER: ----- end ----- - -UBER-CHK: ----- begin ----- - -UBER-CHK: edge-pixels: hash=5267FFB1 - -UBER-CHK: edge-drawRect: hash=BC75D707 - -UBER-CHK: edge-circles: hash=20674FB1 - -UBER-CHK: edge-lines: hash=D4F5BCA7 - -UBER-CHK: sprite-clipped: hash=D922E4B4 - -UBER-CHK: sprite-clip-roundtrip: PASS - -UBER-CHK: tilePasteMono: hash=B0829554 - -UBER-CHK: floodFill: hash=780DC48B - -UBER-CHK: drawText: hash=80B5264B - -UBER-CHK: drawText-offgrid: hash=9435DA4B - -UBER-CHK: palette-roundtrip: PASS - -UBER-CHK: random-golden: PASS - -UBER-CHK: arena-churn: PASS - -UBER-CHK: sprite-from-surface: PASS - -UBER-CHK: sprite-from-surface-draw: hash=5F4394C9 - -UBER-CHK: tilePasteMonoAll: hash=30C3A2C9 - -UBER-CHK: floodFillBounded: hash=EF6B6F51 - -UBER-CHK: ----- end ----- - -UBER: total wall time: 23142 ms (1620 frames @ 70 Hz) - -UBER: press any key to exit - -UBER: jlTileMapPaste 10x5: 113 iters / 18 frames = 450 ops/sec | hash=52B9646B -UBER: gameFrameClean composite: 43 iters / 16 frames = 195 ops/sec | hash=C51FE171 +# 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-.sh and render the comparison +# with tools/uber-perf-table, which reads a fresh build/ 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: 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: ----- begin ----- + +UBER: jlSurfaceClear: 16 iters / 16 frames = 66 ops/sec | hash=428CEE42 + +UBER-CLK: jlSurfaceClear: frame=70 millis=66 ops/sec (239 ms) + +UBER: jlPaletteSet: 6737 iters / 16 frames = 29809 ops/sec | hash=5267FFB1 + +UBER-CLK: jlPaletteSet: frame=29474 millis=29809 ops/sec (226 ms) + +UBER: jlScbSetRange: 1937 iters / 16 frames = 8495 ops/sec | hash=5267FFB1 + +UBER-CLK: jlScbSetRange: frame=8474 millis=8495 ops/sec (228 ms) + +UBER: jlDrawPixel: 5393 iters / 16 frames = 23757 ops/sec | hash=75D75161 + +UBER-CLK: jlDrawPixel: frame=23594 millis=23757 ops/sec (227 ms) + +UBER: jlDrawLine H: 1313 iters / 16 frames = 5708 ops/sec | hash=1E97E361 + +UBER-CLK: jlDrawLine H: frame=5744 millis=5708 ops/sec (230 ms) + +UBER: jlDrawLine V: 113 iters / 16 frames = 470 ops/sec | hash=A0474D91 + +UBER-CLK: jlDrawLine V: frame=494 millis=470 ops/sec (240 ms) + +UBER: jlDrawLine diag: 33 iters / 21 frames = 109 ops/sec | hash=69471D91 + +UBER-CLK: jlDrawLine diag: frame=110 millis=109 ops/sec (301 ms) + +UBER: jlDrawRect 100x100: 82 iters / 16 frames = 377 ops/sec | hash=5CAFF471 + +UBER-CLK: jlDrawRect 100x100: frame=358 millis=377 ops/sec (217 ms) + +UBER: jlDrawCircle r=16: 97 iters / 16 frames = 449 ops/sec | hash=7F1F5531 + +UBER-CLK: jlDrawCircle r=16: frame=424 millis=449 ops/sec (216 ms) + +UBER: jlDrawCircle r=80: 22 iters / 16 frames = 98 ops/sec | hash=B249C9AB + +UBER-CLK: jlDrawCircle r=80: frame=96 millis=98 ops/sec (223 ms) + +UBER: jlFillRect 16x16: 657 iters / 16 frames = 3013 ops/sec | hash=F4BD4B0B + +UBER-CLK: jlFillRect 16x16: frame=2874 millis=3013 ops/sec (218 ms) + +UBER: jlFillRect 80x80: 97 iters / 17 frames = 414 ops/sec | hash=7C952E5F + +UBER-CLK: jlFillRect 80x80: frame=399 millis=414 ops/sec (234 ms) + +UBER: jlFillRect 320x200: 13 iters / 16 frames = 58 ops/sec | hash=6467AFB1 + +UBER-CLK: jlFillRect 320x200: frame=56 millis=58 ops/sec (221 ms) + +UBER: jlFillCircle r=40: 35 iters / 16 frames = 156 ops/sec | hash=6467AFB1 + +UBER-CLK: jlFillCircle r=40: frame=153 millis=156 ops/sec (223 ms) + +UBER: jlSamplePixel: 10161 iters / 16 frames = 46397 ops/sec | hash=6467AFB1 + +UBER-CLK: jlSamplePixel: frame=44454 millis=46397 ops/sec (219 ms) + +UBER: jlTileFill: 2049 iters / 16 frames = 9313 ops/sec | hash=6467AFB1 + +UBER-CLK: jlTileFill: frame=8964 millis=9313 ops/sec (220 ms) + +UBER: jlTileCopy: 1937 iters / 16 frames = 8764 ops/sec | hash=6467AFB1 + +UBER-CLK: jlTileCopy: frame=8474 millis=8764 ops/sec (221 ms) + +UBER: jlTileCopyMasked: 705 iters / 16 frames = 3147 ops/sec | hash=6467AFB1 + +UBER-CLK: jlTileCopyMasked: frame=3084 millis=3147 ops/sec (224 ms) + +UBER: jlTilePaste: 2177 iters / 16 frames = 9806 ops/sec | hash=6467AFB1 + +UBER-CLK: jlTilePaste: frame=9524 millis=9806 ops/sec (222 ms) + +UBER: jlTileSnap: 4273 iters / 16 frames = 19247 ops/sec | hash=6467AFB1 + +UBER-CLK: jlTileSnap: frame=18694 millis=19247 ops/sec (222 ms) + +UBER: jlSpriteSaveUnder: 2049 iters / 16 frames = 9229 ops/sec | hash=CA673FB1 + +UBER-CLK: jlSpriteSaveUnder: frame=8964 millis=9229 ops/sec (222 ms) + +UBER: jlSpriteDraw: 1393 iters / 16 frames = 6218 ops/sec | hash=82B9646B + +UBER-CLK: jlSpriteDraw: frame=6094 millis=6218 ops/sec (224 ms) + +UBER: jlSpriteRestoreUnder: 1329 iters / 16 frames = 5933 ops/sec | hash=CA673FB1 + +UBER-CLK: jlSpriteRestoreUnder: frame=5814 millis=5933 ops/sec (224 ms) + +UBER: jlSpriteSaveAndDraw: 961 iters / 16 frames = 4271 ops/sec | hash=82B9646B + +UBER-CLK: jlSpriteSaveAndDraw: frame=4204 millis=4271 ops/sec (225 ms) + +UBER: jlStagePresent full: 81 iters / 16 frames = 360 ops/sec | hash=82B9646B + +UBER-CLK: jlStagePresent full: frame=354 millis=360 ops/sec (225 ms) + +UBER: jlInputPoll: 721 iters / 16 frames = 3134 ops/sec | hash=82B9646B + +UBER-CLK: jlInputPoll: frame=3154 millis=3134 ops/sec (230 ms) + +UBER: jlKeyDown: 17505 iters / 16 frames = 77800 ops/sec | hash=82B9646B + +UBER-CLK: jlKeyDown: frame=76584 millis=77800 ops/sec (225 ms) + +UBER: jlKeyPressed: 16945 iters / 16 frames = 74977 ops/sec | hash=82B9646B + +UBER-CLK: jlKeyPressed: frame=74134 millis=74977 ops/sec (226 ms) + +UBER: jlMouseX: 35217 iters / 16 frames = 155140 ops/sec | hash=82B9646B + +UBER-CLK: jlMouseX: frame=154074 millis=155140 ops/sec (227 ms) + +UBER: joeyJoyConnected: 17665 iters / 16 frames = 77819 ops/sec | hash=82B9646B + +UBER-CLK: joeyJoyConnected: frame=77284 millis=77819 ops/sec (227 ms) + +UBER: jlAudioFrameTick: 13489 iters / 16 frames = 59162 ops/sec | hash=82B9646B + +UBER-CLK: jlAudioFrameTick: frame=59014 millis=59162 ops/sec (228 ms) + +UBER: jlAudioIsPlayingMod: 29265 iters / 16 frames = 127794 ops/sec | hash=82B9646B + +UBER-CLK: jlAudioIsPlayingMod: frame=128034 millis=127794 ops/sec (229 ms) + +UBER: surfaceMarkDirtyRect (via jlFillRect 32x32): 337 iters / 16 frames = 1478 ops/sec | hash=D2B9E46B + +UBER-CLK: surfaceMarkDirtyRect (via jlFillRect 32x32): frame=1474 millis=1478 ops/sec (228 ms) + +UBER: jlSpriteDraw unaligned: 1105 iters / 16 frames = 5115 ops/sec | hash=69CEBB34 + +UBER-CLK: jlSpriteDraw unaligned: frame=4834 millis=5115 ops/sec (216 ms) + +UBER: jlSpriteDraw sweep16: 77 iters / 16 frames = 353 ops/sec | hash=24E7E0C9 + +UBER-CLK: jlSpriteDraw sweep16: frame=336 millis=353 ops/sec (218 ms) + +UBER: jlTilePaste map 10x5: 44 iters / 16 frames = 200 ops/sec | hash=92E7B0C9 + +UBER-CLK: jlTilePaste map 10x5: frame=192 millis=200 ops/sec (220 ms) + +UBER: gameFrame composite: 5 iters / 19 frames = 18 ops/sec | hash=55B9EC6B + +UBER-CLK: gameFrame composite: frame=18 millis=18 ops/sec (270 ms) + +UBER: ----- end ----- + +UBER-CHK: ----- begin ----- + +UBER-CHK: edge-pixels: hash=5267FFB1 + +UBER-CHK: edge-drawRect: hash=BC75D707 + +UBER-CHK: edge-circles: hash=20674FB1 + +UBER-CHK: edge-lines: hash=D4F5BCA7 + +UBER-CHK: sprite-clipped: hash=D922E4B4 + +UBER-CHK: sprite-clip-roundtrip: PASS + +UBER-CHK: tilePasteMono: hash=B0829554 + +UBER-CHK: floodFill: hash=780DC48B + +UBER-CHK: drawText: hash=80B5264B + +UBER-CHK: drawText-offgrid: hash=9435DA4B + +UBER-CHK: palette-roundtrip: PASS + +UBER-CHK: random-golden: PASS + +UBER-CHK: arena-churn: PASS + +UBER-CHK: sprite-from-surface: PASS + +UBER-CHK: sprite-from-surface-draw: hash=5F4394C9 + +UBER-CHK: tilePasteMonoAll: hash=30C3A2C9 + +UBER-CHK: floodFillBounded: hash=EF6B6F51 + +UBER-CHK: ----- end ----- + +UBER: total wall time: 23142 ms (1620 frames @ 70 Hz) + +UBER: press any key to exit + +UBER: jlTileMapPaste 10x5: 113 iters / 18 frames = 450 ops/sec | hash=52B9646B +UBER: gameFrameClean composite: 43 iters / 16 frames = 195 ops/sec | hash=C51FE171 diff --git a/tests/goldens/uber/iigs.txt b/tests/goldens/uber/iigs.txt index e451669..fd9c01c 100644 --- a/tests/goldens/uber/iigs.txt +++ b/tests/goldens/uber/iigs.txt @@ -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-.sh and render the comparison +# with tools/uber-perf-table, which reads a fresh build/ 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: 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 diff --git a/tests/host/millisHost.c b/tests/host/millisHost.c index 343df78..a54aa6d 100644 --- a/tests/host/millisHost.c +++ b/tests/host/millisHost.c @@ -8,7 +8,7 @@ // implementations below instead of the generic stubs. // // 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 // fix makes it accumulate deltas; this harness must then PASS and becomes // the regression gate for it. diff --git a/tools/traceLogo.py b/tools/traceLogo.py new file mode 100755 index 0000000..d113b0a --- /dev/null +++ b/tools/traceLogo.py @@ -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 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('\n') + f.write(f'\n') + f.write(f' {args.input}\n') + for layer in layers: + f.write(f' \n') + f.write('\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))