# 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.
