joeylib2/PERF-AUDIT-PLAN.md

45 KiB

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. delta20 at 50Hz, delta50/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.