joeylib2/PERF-AUDIT-PLAN.md

750 lines
45 KiB
Markdown

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