diff --git a/LLVM816-ASKS.md b/LLVM816-ASKS.md new file mode 100644 index 0000000..bd9c1a5 --- /dev/null +++ b/LLVM816-ASKS.md @@ -0,0 +1,141 @@ +# Asks for the llvm816 toolchain session + +JoeyLib-side findings that need toolchain-side fixes. Context: +PERF-AUDIT.md (findings #79, #77) and PERF-AUDIT-PLAN.md Phase 1. +JoeyLib does not modify toolchains/iigs/llvm-mos per the ownership +split -- these are requests, with evidence. + +## 1. MVN-based memset/memcpy in runtime/src/libc.c (PERF-AUDIT #79) + +`memset`, `memcpy` (and `memmove`'s copy loops) in +`toolchains/iigs/llvm-mos/runtime/src/libc.c` are one-byte-at-a-time +loops through 24-bit pointers: + + void *memset(void *dst, int c, size_t n) { + char *d = (char *)dst; + while (n--) *d++ = (char)c; + return dst; + } + +That is ~30 cycles/byte on the 65816. The ORCA/C baseline these +replaced lowers both to MVN block moves (~7 cycles/byte after setup). + +Measured impact on the reference platform (UBER, honest post-Phase-0 +clock): jlScbSetRange -- a 200-byte memset plus a flag write -- +measures 163 ops/sec on the clang build vs 1005 ops/sec on the +ORCA-era capture (~4x regression at the whole-op level). Every +memset/memcpy-based JoeyLib path pays it: surface copies, sprite +save/restore fallbacks, SCB range fills, stage-alloc clears, the log +buffer, and all example/game code. + +Ask: MVN-based memset/memcpy in libc.c (or intrinsic lowering in the +backend). Requirements from the JoeyLib side: +- Handle bank-crossing source/destination (MVN wraps within a bank; + the routine must split runs at bank boundaries). +- memset needs a 1-byte seed write + overlapping MVN (classic + self-copy fill), or an unrolled STA loop for short fills. +- Keep the size_t=unsigned long (32-bit) signature -- JoeyLib call + sites pass 32-bit counts today and the current behavior is correct, + just slow. +- A short-count fast path (n < ~16) matters too: the sprite + interpreter's per-row copies are 4-17 bytes. + +## 2. FYI: sprite-codegen corruption under investigation (PERF-AUDIT #77) + +JoeyLib's IIgs runtime sprite code generation corrupts memory when +enabled (bisected; disabled by default in spriteCompile.c until +root-caused). The current suspect list is JoeyLib-side (emit-time +buffer handling), but if the hunt lands on codegen/ABI behavior of +clang-emitted calls into arena-resident routines, it becomes a +toolchain question. Nothing to do yet -- flagged so the context is not +a surprise if it arrives. + +## 3. fflush -> FST commit semantics (PERF-AUDIT #80) + +JoeyLib's logger (src/core/debug.c) fflush()es after every line so a +crash/hang leaves the log on disk. On the clang runtime the lines do +not reach the disk image until fclose: a run that executed 33 logged +operations left 3 lines on disk after a forced emulator exit. Question: +does the libc fflush propagate to a GS/OS FST flush for the file, or +only drain the userland buffer? If the latter, an FST-flush call in +fflush (or an exposed fsync-like hook) would restore crash-durable +logging. Not urgent -- JoeyLib works around it with a memory mailbox +under emulation -- but real-hardware crash forensics wants it. + +## 4. malloc/calloc returns 1 (not NULL) at heap exhaustion (PERF-AUDIT #81) -- CRITICAL + +Empirically confirmed under MAME with in-app probes: with the default +link-layout heap (665 bytes at $00:BC67-$00:BF00 in the JoeyLib UBER +build), `calloc(1, ~720)` returns **1**. The bump-path overflow branch +in libc.c malloc -- whose comment already records a "historical +`p + HDR_SZ + n > heapEnd` over-heap miscompile (only manifested for +oversized n)" and assumes the path is unexercised -- is still +miscompiled in exactly that oversized case. Downstream, JoeyLib ran for +weeks on a phantom struct at (void*)1 whose first field happened to +alias the real framebuffer pointer; see PERF-AUDIT.md #81 for the full +forensics. JoeyLib no longer allocates anything large on the C heap, +but ANY program that exhausts this heap gets silent corruption instead +of NULL. Minimal repro: link any GS/OS app with default heap, call +calloc(1, 720), observe the return value. + +## 5. Segment BSS/heap spans are not reserved from the Memory Manager (PERF-AUDIT #81/#77/#85) -- CRITICAL, EMPIRICALLY PROVEN + +Three independent confirmations, in escalating severity: +1. The bank-0 C heap (unreserved) -- #81's phantom-stage chain. +2. The pinned SHR framebuffer $01:2000-$9CFF (unreserved) -- an MM + handle landed at $01:8B04 inside the display (#85). +3. THE GENERAL CASE: a 32KB NewHandle was placed at $08:4200 -- inside + the app's own ENTRY-SEGMENT BANK, overlapping seg1's BSS span. The + loader only claims each segment's image bytes; everything above + (BSS, heap) is MM-free. ANY GS/OS allocation -- the app's own or + the system's -- can land on live program data. + +JoeyLib has stopgap claims for cases 1-2 (jlpInit reserves the +framebuffer and $00:B900-$BEFF). Case 3 was attempted app-side on +2026-07-05 (spacetaxi acceptance test) and the attempt produced two +findings you should have: + +(a) The app CAN learn its BSS spans: the linker-emitted + __bss_seg{0..3}_{lo16,bank,size} symbols (the ones crt0.s zeroes + BSS through) are readable from C via a `.long` DATA32 table in an + asm TU -- direct C IMM16 references fail to link ("cross-segment + reloc ... uses 2-byte form; only IMM24 / DATA32 supports cINTERSEG + patching"). The DATA32 entries resolve as RELOCATED FULL ADDRESSES: + the lo16 entry reads back as the 24-bit runtime span base + ($08:4F00 observed for the spacetaxi build), the size entry + carries the length in its low 16 bits, empty segments read as + bank-base-only. Recipe verified working end-to-end under MAME. + +(b) BUT actually claiming the span kills the app: NewHandle( + attrAddr|attrFixed|attrLocked, base=$08:4F00, size=$6619) from + jlpInit SUCCEEDS -- and the app then dies before reaching main's + first log line, with the PC profile showing GS/OS desktop idle + (silent return-to-Finder). Reproduced twice; reverting only the + claim restores the previous behavior. Mechanism unknown from the + app side -- presumably the Loader/MM bookkeeping objects to a + user handle overlapping a loaded segment's address range, or the + claim starves something during the remaining init. The reverted + implementation was never committed; the full code (asm table + + claim loop) is reproduced verbatim in the copy of this item handed + to the toolchain session at /home/scott/claude/llvm816/JOEYLIB-ASKS.md. + +Ask: crt0/omfEmit/loader cooperation so every segment's FULL +in-memory span (image + BSS + heap) is Memory-Manager-reserved at +load time -- with (b) suggesting the reservation must be made BY the +loader glue (or before/while segments are registered), not by app +code after the fact. This single fix unblocks IIgs sprite codegen +(PERF-AUDIT #77), the Space Taxi IIgs port (a 32KB font-surface +jlpBigAlloc lands on live state layout-dependently -- three distinct +failure modes observed as the binary layout shifted), and removes the +whole corruption class. + +link816 defines [__heap_start, __heap_end) as the leftover entry-bank +space above BSS, but crt0Gsos never claims that range from the GS/OS +Memory Manager, so NewHandle can legally place blocks on top of live +malloc data (a second, independent corruption channel -- and the +crt0Gsos comments already record a Phase-1.1 fopen-hang caused by this +class). Ask: reserve the heap range at startup (attrAddr|attrBank| +attrFixed|attrLocked), or reimplement malloc on top of an MM handle. +Related hardening: pass attrNoSpec on NewHandle calls in the runtime, +and consider reserving the pinned SHR stage range $01:2000-$9FFF that +JoeyLib's jlpStageAllocPixels hands out as a bare constant. diff --git a/PERF-AUDIT-PLAN.md b/PERF-AUDIT-PLAN.md new file mode 100644 index 0000000..c77a2dc --- /dev/null +++ b/PERF-AUDIT-PLAN.md @@ -0,0 +1,750 @@ +# PERF-AUDIT Fix Plan + +Execution plan for all 71 confirmed findings in PERF-AUDIT.md plus the +benchmark-harness fix from its preamble. Finding numbers below (#N) are +PERF-AUDIT.md numbers. Read each finding's "Verifier correction" before +implementing -- several tighten the fix or the claimed impact, and this +plan encodes those corrections as constraints. + +This plan was itself adversarially reviewed (completeness, ordering +conflicts, gate adequacy -- 31 issues found and folded in). The most +important consequence: Phase 0 builds the verification harness that +every later gate depends on, because the existing gates cannot see most +of the changes this plan makes. + +Ground rules for every phase: +- Source toolchains/env.sh before any make; a missing $AMIGA_CC etc. + silently produces stale binaries. +- After editing any IIgs .s/.asm file, delete the affected example + binaries before rebuilding (stale-binary trap). +- Hash gates compare against the golden hashes captured at the end of + Phase 0 (the harness additions change the old hashes once, then they + are frozen). +- "iigs-verify" in a gate always names its example and compares + before/after checksums: `make iigs-verify VERIFY_EXAMPLE=` + (default is draw; that only covers draw-path changes). It reads SHR + memory -- there is NO MAME headless screenshot; do not plan for one. +- Scott commits; the plan never includes git operations. +- Style rules apply to all touched code (camelCase, // comments, braces + always, prototypes at top and alphabetized, two blank lines between + functions, ASCII only). + +Phase order rationale: fix the clock and build the gates first +(otherwise no perf change can be validated and most bug fixes have no +covering check), then correctness bugs, then shared infrastructure, +then per-subsystem work, and the new platform asm last (highest effort +and risk, benefits from everything before it being measurable). + +--- + +## Phase 0 -- Fix the clock and build the verification harness + +The audit's headline: every small-op UBER number carries ~600-800 +cycles of per-iteration harness tax on IIgs (GetTick toolbox chain). +And the plan review's headline: most fixes below are invisible to the +current gates (UBER only hashes on-surface compiled-path ops; Amiga/ST +have no screen-content check; several ops are not exercised at all). +Phase 0 fixes both. + +1. **uber.c batch polling.** In runForFrames, run the op in batches + between jlFrameCount() polls. Batch adaptively: batch=1 for ops + slower than ~1 frame (present, surfaceClear, fillRect-full would + otherwise overrun the window by up to a full batch), 8-16 for fast + ops (pick from the first iteration's duration). Fix the false + "~10-30 cyc" comment. Ops are fixed-coordinate/idempotent (review + verified), so batching does not change what an iteration means. +2. **#15 + #71** -- collapse the IIgs jlFrameCount forwarding: declare + iigsGetTickWord in the IIgs section of port.h and + `#define jlpFrameCount() iigsGetTickWord()`; delete the hal.c + wrapper AND its stale 15-line $C019/gFrameCount comment (#71 -- the + comment sits on the function being deleted, so it lands here, not in + Phase 8); put a one-line GetTick pointer next to the new macro. + Remove the now-stale JL_HAS_FRAME_COUNT entry at platform.h:108. + CAUTION (verifier): do NOT do the same for jlpFrameHz -- gFrameHz is + hal.c-private state; leave it a function. +3. **uber.c non-timed correctness section.** New hashed/logged checks + that run after the timed ops and before the final hash, added NOW so + golden hashes cover every path later phases touch: + - edge-case draws: off-surface pixel spam (both signs, both axes), + jlDrawRect w=65535 and h=2 cases, jlFillCircle huge-r (r=300 with + clipping, r=40000 full-coverage), jlDrawLine far-off-surface + endpoints; + - clipped-sprite draws: all four edges, a corner, fully off-surface + (exercises the interpreted path on every port); + - jlTilePasteMono and jlFloodFill ops drawn into the stage (neither + is currently in the hashed set -- Phase 7 replaces both paths); + - jlDrawText including a string starting out-of-range (Phase 5 + changes its validation); + - jlPaletteSet readback assert via jlPaletteGet: one conforming row, + one deliberately non-conforming row, row[0] forced to 0x0000, + logged PASS/FAIL (palette is not in the surface hash); + - jlRandom golden-sequence check (first N values from a known seed); + - allocator torture: sprite create/compile/destroy churn to fragment + then compact the arena (#5/#34 coverage), a sprite-bank + load/destroy pass (#31 coverage -- allocator mismatch is fatal on + IIgs), and a jlShutdown -> jlInit -> destroy-stale-sprite sequence + (#34) IF the harness structure allows re-init; otherwise this one + becomes a dedicated tiny example. +4. **bench-iigs.sh** -- the IIgs capture path, which does not exist + today (PERF.md's IIgs column was carried over, not re-run): boot + UBER off a writable joey.2mg under headless MAME, cadius-extract + joeylog.txt afterward (or extend the verify-iigs.sh Lua to dump the + log lines from emulated memory). Without this, no "hashes identical + x4" gate in this plan is executable on the reference port. +5. **millis host harness** -- audioSfxMix-style host compile of + genericPort millis logic (check-blank.sh pattern) driving simulated + frame-delta sequences including the 16-bit wrap, for Phase 1 #23. +6. **#25 (first half)** -- run the new harness on all four ports; + record the new honest baseline table in PERF.md marked + post-clock-fix. MANDATORY: tag the SEI-holding rows (present, + surfaceClear, fillRect 320x200) as clock-unreliable in the table -- + the SEI tick-loss artifact survives the batching fix. +7. **#73 (landed during Phase 0 out of necessity)** -- DOS jlpFrameCount + was an $3DA edge-poll counter that lost every frame whose ~1ms + retrace pulse fell between calls; the batched timing loop stalled on + it, and it had been inflating the old DOS column 5-40x on slow ops. + Now derived from the PIT/uclock millis source (poll-rate-independent); + jlpWaitVBL still syncs to real retrace. +8. **#76 (landed during Phase 0 out of necessity)** -- IIgs jlpWaitVBL + polled $C019's VBL bit, which never asserts under MAME's apple2gs: + every VBL-paced program hung forever in emulation, and UBER (the + only in-tree caller) could never complete headless -- the real + reason PERF.md's IIgs column was "carried over". Now waits on a + GetTick change (VBL-interrupt-driven, same boundary semantics). +9. **Freeze golden hashes** -- DONE 2026-07-04 for DOS/ST/Amiga in + tests/goldens/uber/ (43/43/42 hash lines; the Amiga log lost its + final check line to the bench-script timeout -- re-capture with a + longer TIMEOUT when convenient). The IIgs golden is PARTIAL (3 op + lines, codegen-off): the clang-era IIgs port cannot complete UBER + yet -- sprite codegen corrupts memory (#77), the codegen-off run + stalls in the jlDrawPixel timing window after 3 ops (uninvestigated + -- first Phase 1 stabilization item), and jlAudioInit fails + headless. PERF.md documents the 3-port baseline and the IIgs + status; the old IIgs column was ORCA-era and is retired. + +Gate: 4-port build; all new correctness checks PASS; goldens recorded. +Expect small-op numbers to jump 2-10x against the old table. + +Effort: ~half a day to a day (the harness additions are the bulk). + +--- + +## Phase 1 -- Correctness bugs + IIgs stabilization + +All behavior fixes land before perf work so later phases optimize +correct code. Every item here has a covering check from Phase 0. + +THE IIGS STABILIZATION CLUSTER COMPLETED 2026-07-04: UBER runs +end-to-end (green screen, audio live, all checks PASS, 39/43 hashes +matching DOS, honest floor numbers in PERF.md). Fixed along the way: +#81 (phantom stage / tiny-heap malloc-returns-1), #82 (RAM-ring +logger), #83/#86 (line+fillCircle asm DBR-vs-D scratch stash), #85 +(framebuffer + bank-0 MM reservations), audio packaging. #77 is +root-caused to the toolchain (loader does not MM-reserve segment +BSS spans; arena landed at $08:4200 inside the app's own bank) -- +codegen stays off until LLVM816-ASKS item 5 lands. New/remaining +items for the rest of Phase 1: #87 (circle-outline pixel divergence, +the last 4 hash mismatches). Original cluster notes follow: +(a) #77 sprite-codegen corruption (MAME write-watchpoint hunt); +(b) the codegen-off UBER "stall" -- RESOLVED 2026-07-04 in three + layers: #80 (log-durability illusion misdirected the hunt), #81 + (the master bug: 665-byte unprotected heap + malloc-returns-1 -> + phantom stage struct; fixed by moving all core structs to + jlpBigAlloc), and #82 (per-line floppy log writes stalled the app + in SmartPort firmware; fixed with the RAM-ring logger + live Lua + drain). UBER now completes end-to-end on IIgs (33 ops + 17 checks, + green screen); 20 hashes match DOS. Remaining IIgs defects filed + as #83 (diag-line asm pixel divergence) and #84 (interpreted + sprite ops ~1000x slow + wrong) -- both queued in this cluster; +(c) headless jlAudioInit failure -- FIXED 2026-07-04: the audio code + was wired all along; the merlin32-built "ntpplayer" binary was + simply never packed onto joey.2mg. iigs-disk now builds and adds + it; UBER logs "audioInit OK"; +(d) #79 flag to the llvm816 session (libc memset/memcpy byte loops). +Only after (b) is fixed can the IIgs golden hashes be frozen and the +IIgs column return to PERF.md. + +1. **#1 (CRITICAL)** draw.c:549 -- non-IIgs jlDrawPixel: bounds-check + x/y (same `(uint16_t)x >= SURFACE_WIDTH` form as the IIgs branch) + after the NULL check and return early; then call jlpDrawPixel + directly and mark. Deletes the redundant plotPixelNoMark layer on + the public path. +2. **#11** draw.c:591 -- jlDrawRect general path: compute bottom/right + edge coordinates in int32_t; skip an edge whose coordinate is + off-surface instead of letting the int16_t cast wrap. ALSO (from the + #65 verifier correction, same function): skip the interior-edge + fills entirely when h == 2, so jlDrawRect stops passing h == 0 rects + into fillRectOnSurface/surfaceMarkDirtyRect -- this restores the + "already clipped, positive dimensions" contract that Phase 2 #65 + relies on before it drops the guard. +3. **#72** draw.c:417 -- jlDrawLine H/V fast paths: clip x0 (resp. y0) + into range BEFORE anchoring the clamped span (`if (x0 < 0) { span += + x0; x0 = 0; }` after the 32-bit span, return if span <= 0, then clamp + to the surface size). Today a line from far off-surface through the + whole visible row draws nothing. Found during Phase 0 harness work; + covered by the "edge-lines" uber check (its hash changes with this + fix). +4. **#12** draw.c:656 -- jlFillCircle/jlDrawCircle huge-r handling: + 32-bit full-coverage test (farthest corner dist^2 <= r^2 -> full + surface fill / early out), clamp the span loop to intersecting rows, + uint16_t loop variable. Verifier notes: seed yy = y0*y0 when jumping + into the row range (the incremental chain breaks on a jump); the + initial x walk-down from r stays O(r) once per call -- document it. +5. **#23** genericPort.c:40 -- millisElapsed: accumulate milliseconds + incrementally from uint16_t frame deltas (per verifier: do NOT keep + a total frame count and multiply -- that re-overflows at ~20h; carry + the fractional remainder, e.g. delta*20 at 50Hz, delta*50/3 with + mod-3 carry at 60Hz). Update the statics with explicit + load/add/store-style C (no `++`/`+=` on globals -- ORCA-C inc-abs + DBR trap). Document the poll-at-least-once-per-65535-frames bound + (~18 min @60Hz, ~22 min @50Hz -- NOT hours). Delete the dangling + iigs/hal.c:382-386 comment. Verify against the Phase 0 host harness. +6. **#8** codegenArena.c:236 -- IIgs: clamp totalBytes to 64KB with a + specific coreSetError string on failure; fix adventure.c to set + codegenBytes per-platform (#ifdef: small on chunky ports, 256KB only + where a flat heap exists). Document the cap at jlConfigT.codegenBytes. +7. **#46** sprite.c:346 -- gate the IIgs/DOS compiled-draw fast path on + `routineOffsets[shift][SPRITE_OP_DRAW] != SPRITE_NOT_COMPILED` (read + once via the existing byte-pointer idiom; reuse for the call). +8. **#34** codegenArena.c:294 -- shutdown/re-init dangling slots. + PREFERRED (the audit's own fix): a small sprite registry in sprite.c + -- spriteSystemShutdown, called from jlShutdown BEFORE + codegenArenaShutdown, walks live sprites and NULLs sp->slot. Zero + hot-path cost and it fixes BOTH arms: the destroy-after-reinit heap + corruption AND the draw-after-reinit stale-offset execution (an + epoch checked only at free time would miss the second arm). + Alternatives if the registry is unwanted: arena epoch stamped into + jlSpriteT and checked at every dispatch gate (hot-path cost), or a + documented "jlShutdown invalidates all sprites" contract (doc-only). +9. **#5** spriteCompile.c:202 -- on codegenArenaAlloc failure, run + codegenArenaCompact() and retry once; coreSetError on final failure + so prewarm failures are diagnosable. +10. **#31** assetLoad.c:167 -- move sprite allocations to + jlpBigAlloc/jlpBigFree UNIFORMLY. Alloc sites: cel/tileData buffers + (assetLoad.c:167, sprite.c jlSpriteCreateFromSurface buf) AND the + jlSpriteT header mallocs (sprite.c:215, sprite.c:306, + assetLoad.c:175 -- verifier requires these switch in the same + change). Free sites -- ALL FOUR for tileData plus the headers: + jlSpriteDestroy (sprite.c:325 tileData, sprite.c free(sp)), the + jlSpriteCreateFromSurface error path (sprite.c:308), and both + assetLoad error paths (assetLoad.c:172, assetLoad.c:177). A single + missed free() of a jlpBigAlloc pointer corrupts the IIgs heap + (BIG_ALLOC_HDR offset), and three of these sites are failure-path + only -- exercised by the Phase 0 torture check. +11. **#58** atarist/hal.c:1410 -- DELETE the dead dst/pd NULL checks + (callers validate; the finding's category is + unnecessary-conditional, not misordered-check), and apply the same + cleanup to the identical pattern in jlpTileSnap (deref at 1454, + dead checks at 1458/1462) per the verifier correction. +12. **#74** -- root-cause the DOS jlSpriteCompile failure (pre-existing; + UBER logs it; every DOS sprite bench measured the interpreter). + Instrument the sizing/emit/arena-alloc chain under DOSBox; the x86 + emitter itself emits real code for shifts 0-1. Gates Phase 4's + compiled-path verification on DOS. +13. **#75** -- resolve the jlTilePasteMono three-way pixel divergence + (chunky generic vs Amiga vs ST overrides, caught by the Phase 0 + tilePasteMono hash). Decide the contract truth (tile.h: non-zero + mono nibble -> fgColor), fix the divergent implementations, and + re-golden that hash line on the fixed ports. Blocks Phase 7 #3. +14. **#77** -- root-cause the IIgs sprite-codegen memory corruption + (MAME write-watchpoint on the stage struct / gStage static during + jlSpriteCompile + first compiled draw). Until fixed, IIgs builds + run JOEY_IIGS_SPRITE_CODEGEN=0 and the IIgs baseline measures the + interpreter. HIGHEST PRIORITY of the phase with #74: both reference + platforms' compiled sprite paths are currently dead. +15. **#78** -- fix ST jlFillCircle and jlTileFill pixel divergence vs + the chunky reference (DOS==Amiga agree); re-golden ST hash lines. +16. **#79** -- FLAG TO SCOTT (toolchain-owned): llvm-mos runtime + memset/memcpy are ~30 cyc/byte byte-loops vs ORCA's ~7 cyc/byte + MVN; reference-platform regression ~4x on every memset/memcpy path. + Library-side mitigations are #9/#19/#21; the libc fix belongs to + the llvm816 session. + +Gate: 4-port builds; Phase 0 correctness checks all PASS (they cover +every edge case fixed here); hashes match Phase 0 goldens; +`make iigs-verify VERIFY_EXAMPLE=draw` checksum unchanged. + +Effort: ~4-6 hours. + +--- + +## Phase 2 -- Shared hot-path infrastructure + +The cross-TU-call cluster. Everything here touches surfaceInternal.h / +port.h / surface.c / draw.c and ripples through every port; do it as +one unit with a full 4-port verify. + +1. **#10 + #17** -- make gStage a plain extern (`extern jlSurfaceT + *gStage;` in surfaceInternal.h, drop static in surface.c; keep + jlStageGet() as the public accessor). Replace jlStageGet() with a + direct `== gStage` compare in: port.h IIgs macros (jlpFillRect, + jlpSurfaceClear, jlpFillCircle), draw.c:533 jlDrawPixel, palette.c, + scb.c, surface.c internal uses. Precedent: codegenArenaBase did + exactly this. +2. **#20 + #65** -- surfaceMarkDirtyRect: move the stage test and the + h==1 widen into a macro in surfaceInternal.h (ORCA/C 2.2.1 has no + inline -- macro form is a hard constraint; audit macro argument + usage for multiple-evaluation before converting call sites). + Non-stage surfaces then cost one inline compare; single-row marks + widen with no call; multi-row marks keep one call + (iigsMarkDirtyRowsInner on IIgs, C loop elsewhere). Drop the w/h + guard (#65) ONLY after Phase 1 item 2 restored the contract + (jlDrawRect h==2 was the violator); empty ranges then cannot reach + the macro. +3. **#21** -- stageDirtyClearAll: two memsets (0xFF / 0x00), matching + its sibling surfaceMarkDirtyAll. +4. **#47** -- dirty-row test unification, TWO-macro form per the + verifier correction (NOT a blanket four-site replacement): convert + DOS/ST to STAGE_DIRTY_ROW_CLEAN (min > max); keep Amiga's + `min != 0xFF` as a second documented macro (STAGE_DIRTY_ROW_TOUCHED + -- it is deliberate and cheaper under the clipped-marks invariant); + pin the IIgs present asm's compare with a comment naming the + sentinel values it assumes. +5. **#14 + #24** -- pixel read: expose the chunky nibble read as a + macro in surfaceInternal.h using SURFACE_ROW_OFFSET (verifier: + macro, not static inline, so floodFillInternal's chunky-fallback + reads can use it too). jlSamplePixel uses it inline after its bounds + check on chunky builds; jlpGenericSamplePixel body becomes the same + macro. (Do NOT expect a sprite-capture delta: the sprite.c:296-297 + branch only runs on planar ports, which use their own platform + jlpSamplePixel -- review corrected the audit here.) +6. **#13** -- jlFillRect: add the 16-bit on-surface fast path before + the 32-bit clip, in the guarded form (test `w <= SURFACE_WIDTH` and + `h <= SURFACE_HEIGHT` FIRST, then `x >= 0 && y >= 0 && + x <= (int16_t)(SURFACE_WIDTH - (int16_t)w)` etc. -- guarding before + the subtraction is what makes 16-bit safe against w up to 65535). + Verifier: keep the three span emitters calling fillRectOnSurface + directly; do NOT reroute them. +7. **#16** -- jlPaletteSet: memcpy the row + force row[0] = 0x0000, + dropping the undocumented per-entry $0RGB re-mask (pending the + decision-table sign-off; if the mask must stay, at least widen the + loop counter to uint16_t). Mirrors jlPaletteGet. Covered by the + Phase 0 palette readback assert (the surface hash cannot see + palette changes). + +Gate: hashes match goldens x4 (dirty-band changes are hash-blind -- +that is what the next line is for); present-output check: captureDos.sh +against a pre-phase golden PNG for DOS, iigs-verify +VERIFY_EXAMPLE=draw checksum compare for IIgs, and a documented +run-amiga.sh / run-atarist.sh visual pass of the draw example after +multiple presents (68k C dirty loops diverge from the IIgs asm; a +dirty-band under-mark shows as stale rows only at present time). +Capture bench delta against the Phase 0 baseline. + +Effort: ~4-6 hours (mostly the 4-port present verification). + +--- + +## Phase 3 -- draw.c micro-optimizations + +1. **#9 + #37 + #36** -- jlDrawLine/jlDrawCircle fallbacks: compute the + dirty box from the endpoints (line) / center+radius (circle) before + the loop, clamped after, as now; delete the per-pixel and + per-iteration box accumulation (the circle's 8 compares per + iteration go with it). RISK NOTE: a too-small precomputed box + under-marks and is invisible to hashes -- the Phase 2 gate's + present-output checks re-run here. +2. **#38** -- jlFillCircle C span loop: one bounding-box dirty mark + after the loop instead of per-span marks. Over-approximates each + row's band to the circle width; pixel output identical; matches + what the IIgs asm path already does. (Over-marking is the SAFE + direction, unlike item 1.) +3. **#35** -- floodFillInternal C fallback: hoist the matchEqual + predicate out of the walk loops (two loop variants), use the Phase 2 + read macro instead of jlpSamplePixel calls where the port is chunky. +4. **#61** -- plotPixelNoMark: drop the NULL re-check (all callers + validate); KEEP the per-pixel bounds check (load-bearing for + clipping). +5. **#62** -- define the shared on-surface rect predicate ONCE, here, + in its final form (review blocker): the #13-style guarded 16-bit + macro -- `w <= SURFACE_WIDTH && h <= SURFACE_HEIGHT` first, then the + subtraction compares. That form is correct for BOTH users: jlDrawRect + (raw uint16_t w/h up to 65535) and sprite.c (widthPx/heightPx up to + 2040 = 255 tiles * 8 -- NOT "bounded by 320/200"). jlDrawRect's + fast-path test switches to it now; sprite.c merges in Phase 4 as a + pure call-site change with NO rewrite of the macro. + +Gate: hashes match goldens x4 (the Phase 0 edge-case checks cover the +w=65535 / huge-r paths through the new predicate); present-output +checks as in Phase 2; bench diag-line/circle deltas on DOS (the only +port that always runs the fallbacks). + +Effort: ~2-3 hours. + +--- + +## Phase 4 -- Sprite pipeline + codegen + +Landing order within the phase (review): land items 2-12 first as one +reviewable, hash-gated unit; land item 1 (the risky inner-loop rewrite) +LAST as its own change with its own before/after clipped-sprite hash +comparison, so a failed rewrite reverts alone. Within items 2-12, do +the deletions (#55, #27) BEFORE the shared-derive refactor (#29 + #64) +so the refactor never touches code that is about to be deleted. + +1. **#2 + #18 + #45** -- rewrite spriteDrawInterpreted's inner loop + (LAND LAST): + - hoist row-invariant source math out of the column loop (#45), + - carry dst byte pointer + parity incrementally (kill the + writeDstNibble call per pixel, drop its redundant & 0x0F masks), + - aligned-phase byte loop: src byte == 0 skips two pixels, + both-nibbles-opaque stores the whole byte, single-nibble merges + stay RMW; chunk per tile column (4 contiguous bytes, then advance + srcTileRow by TILE_BYTES -- verifier: the naive *src++ sketch + glosses this); keep the per-pixel walker for the misaligned phase. + Rollback: revert just the loop; the writeDstNibble path stays in git + history. +2. **#19** -- jlSpriteSaveUnder/RestoreUnder fallbacks: hoist + SURFACE_ROW_OFFSET-based start pointers, walk with += stride adds; + kill both per-row multiplies. +3. **#44** -- switch isFullyOnSurface's body to the Phase 3 shared + predicate (pure call-site merge; the macro is already 16-bit-safe + for the 2040-px sprite domain -- do not modify it). +4. **#55** -- remove spriteEmit68k.c from the Amiga/ST build lists. + DECISION for Scott: delete the file (git history keeps it) or leave + it unbuilt. +5. **#27** -- Amiga compiled save/restore dispatchers: replace the + bodies with ST-style empty linker stubs -- FULL DELETION BREAKS THE + LINK (sprite.c calls them unconditionally; verifier correction). + Delete the shift-alias loop and the aspirational comments at + spriteCompile.c:172-178/241-245. Note for the future: when real + Amiga save/restore emitters land, the restore gate must accept + planar copyBytes = spriteBytesPerRow+4. +6. **#29 + #64** -- one shared macro/helper derives (shift, routeIdx, + routeOffset, fnAddr) once; the surviving dispatcher gates and + spriteCompiled* callees consume it. +7. **#28** -- spriteCompiledDraw (IIgs): byte-pointer idiom for + routineOffsets like the rest of the file. +8. **#59** -- SPLIT_BACKUP_CACHED: move the split cache per-sprite (the + jlSpriteT already caches banks) or delete it; measure both on IIgs. +9. **#32** -- jlSpriteBankLoad calls spriteInitFields (then overrides + ownsTileData); fix the spriteInternal.h comment. +10. **#39** -- DEFAULT_CODEGEN_BYTES per-platform (#ifdef: keep 8KB + IIgs, size 68k ports from their emitters' measured bytes/sprite; + document at jlConfigT). +11. **#56** -- dedupe emitter helpers (c2pColumn/putWord into a shared + m68k header; shiftedByteAt/spriteSourceByte into a shared codegen + header). +12. **#54 + #63** -- fix the stale spriteEmitIigs.c contract comment + and the sprite.c:343 "IIgs never takes this" comment. + +Gate: hashes match goldens x4 -- the Phase 0 clipped-sprite checks are +the load-bearing coverage here (UBER's timed sprite ops are compiled +and on-surface; they cannot see interpreter regressions). +`make iigs-verify VERIFY_EXAMPLE=sprite` checksum before/after; +captureDos.sh sprite example compare on DOS. Bench: clipped-sprite ops +expect 2-3x; compiled ops modest gains from dispatch dedup. + +Effort: ~1-2 days. + +--- + +## Phase 5 -- Tile path + generic fallbacks + +1. **#22** -- jlDrawText: validate bx/by once at entry (verifier: the + wrap logic does not sanitize the FIRST position -- reject + out-of-range starts), validate srcBx/srcBy from asciiMap per glyph, + call jlpTileCopyMasked directly, accumulate one union dirty rect for + the string. Covered by the Phase 0 drawText hash check (uber.c has + no other drawText coverage). +2. **#48** -- tile.c wrappers: shifts (<< 3) for pixel coords, or fold + coord derivation into the dirty-mark call site. +3. **#51** -- jlpGenericTileCopyMasked: whole-byte store when both + nibbles are opaque; keep the merge for mixed bytes. Bit-identical. +4. **#50 + #70** -- shared 4-byte row-copy macro used by TileCopy, + TilePaste, TileSnap; jlpGenericTileFill uses GENERIC_TILE_ROW0. +5. **#69** -- drop the duplicate fg/bg masks (mask once in the public + wrapper, document the precondition in the generic). +6. **#49** -- jlpGenericFillRect: hoist pxStart/pxEnd/midBytes/edge + nibble work out of the row loop; walk the row pointer with + += stride. +7. **#30** -- assetLoad constants: use TILE_BYTES from joey/tile.h; + derive ASSET_MAX_TILES_W/H from SURFACE_WIDTH/HEIGHT and + TILE_PIXELS_PER_SIDE. +8. **#66, #67, #68, #57, #60** -- delete SURFACE_PIXELS_PER_WORD; + 200 -> SURFACE_HEIGHT in the LUT decl; fix tile.c stale comment; fix + surface.h hash doc; fix or implement the assetLoad segment comment + (DECISION: add the segment directive it promises, or correct the + comment -- check IIgs root-segment pressure first). + +Gate: hashes match goldens x4 (#51 especially must be bit-identical; +the drawText check covers #22); DOS bench deltas on tile ops (DOS runs +the generics); hello example on DOS + IIgs for text rendering. + +Effort: ~3-4 hours. + +--- + +## Phase 6 -- SFX mixer (DOS/ST builds only) + +Verification FIRST (review: no named gate could actually verify this +phase): audioSfxMix.c is portable C -- build a host harness on the +check-blank.sh pattern that compiles the OLD and NEW mixer with host +cc, mixes a fixed seed sample set (fixed-buffer and streaming slots, +rate conversion up and down), and diffs output buffers. This proves #7 +and #33 bit-exact and bounds #6 to 1 LSB concretely before any +emulator run. + +1. **#6** -- interp(): the verifier-validated int16 form (explicit + int16_t locals so gcc emits one muls.w): + `int16_t d = (int16_t)(s1 - s0);` + `int16_t fH = (int16_t)(uint16_t)(frac >> 1);` + `return (int)s0 + (int)(((int32_t)d * fH) >> 15);` + Output may differ by 1 LSB of 8-bit audio (inaudible) -- accepted + deviation, document it. +2. **#7** -- posFp: uint32_t index + uint16_t frac with carry-compare; + split stepFp once per slot; update audioSfxSlotArm/ArmStream/ + streamRefill. Bit-identical. +3. **#33** -- cache slot fields in locals across the inner loop + (volatile dst stores force re-reads today), re-reading only after + streamRefill; AND split the fixed-buffer vs streaming cases into two + inner loops so the loop-invariant isStream branch runs once per slot + (both halves are the audited fix; the plan review caught the split + being dropped). + +Gate: host-harness diff (above); DOS + ST builds; audio example smoke +on DOSBox/Hatari; absence of the ST ~83ms refill warning under load as +a secondary signal. + +Effort: ~3-4 hours including the harness. + +--- + +## Phase 7 -- Platform override work (biggest wins, biggest effort) + +Each item is independently landable. Golden hashes for jlTilePasteMono +and jlFloodFill were captured in Phase 0 against the generic paths -- +the new implementations must reproduce them exactly. + +1. **#3** -- IIgs jlpTilePasteMono: new iigsTilePasteMonoInner in + joeyDraw.s -- 16-entry nibble-pair LUT expands each mono byte + directly into surface rows, no intermediate jlTileT, no second paste + pass. Add JL_HAS_TILE_PASTE_MONO to platform.h IIgs block + port.h + macro. ORCA-M rules: explicit LONGA/LONGI after every REP/SEP, same + load segment as the C callers (DRAWPRIMS), delete stale example + binaries before rebuild. Gate: uber tilePasteMono hash identical to + the Phase 0 generic-path golden on IIgs; `make iigs-verify + VERIFY_EXAMPLE=spacetaxi` before/after (title screen is the + acceptance test -- logo repaint currently costs ~10 frame budgets + per 4-frame period). +2. **#4** -- ST flood plane hooks: implement jlpFloodWalkPlanes + + jlpFloodScanRowPlanes against the word-interleaved StPlanarT layout, + modeled on the Amiga versions (walk 16 px per 4-word group via + combined match masks). Pure 68000, no blitter. C first; hand-asm + only if the C version misses the target. Update the port.h comment + claiming these hooks are Amiga-only. Gate: uber floodFill hash + identical to golden on ST; Hatari flood timing (expect ~5-15x). +3. **#42** -- DOS: #define the planar no-op hooks to ((void)0) like the + IIgs block. +4. **#41** -- fillWord derivation: pick ONE owner. Either the asm + derives it from the nibble (as the port.h comment claims) or C keeps + deriving via a 16-entry uint16_t LUT instead of the * 0x1111 + multiply helper. Update the comment to match reality. +5. **#53** -- iigsDrawPixelInner: remove the dead PHB/PLB framing and + fold the row offset via `adc gRowOffsetLut,x` to eliminate the + dpxlTmp round-trip. Two verifier-identified hazards (the plan review + killed the earlier AUXWRITE caveat -- it does not apply here, and + dpxlTmp is DBR-absolute, not long-absolute): + (a) deleting phb renumbers EVERY stack-relative arg offset + (8/10/12,s -> 7/9/11,s) -- miss one and the routine reads the wrong + arg while still "working"; update the comment block at + joeyDraw.s:1402-1405; + (b) do NOT relocate the scratch D-relative -- D-relative stores land + at the bank-0 alias, not the data-bank .bss. + Gate: iigs-verify VERIFY_EXAMPLE=draw checksum + before/after bench + on the fixed clock (hottest inner op). +6. **#52** -- iigsBlitStageToShr dead upload paths, verifier keep-list + (the review caught two traps here): the entry becomes zero-arg -- + only ONE far pointer + flags word is currently pushed, not two; + delete the marshalling at joeyDraw.s:2765-2772 and both flag-gated + MVN blocks (2787-2859); KEEP the caller-DBR stash at 2780-2784 (the + narrow-row MVN path reloads $E1:9DFE after every MVN -- deleting the + stash corrupts presents); delete the orphaned bsFrame BSS + (4417-4420); update hal.c:98/298 and the stale jlpInit comment at + hal.c:270-273. Gate: iigs-verify against an example that produces + PARTIAL-ROW dirty bands (not just the default draw demo), checksum + before/after. +7. **#26** -- platform.h decorative IIgs flags: make the registry + authoritative AND loud (review: plain #ifdef-consume recreates the + silent-fallback rot this finding describes): keep the flags, and + pair the port.h IIgs macro block with + `#if !defined(JL_HAS_) #error` checks so a missing/removed flag + is a compile failure, not a silent generic fallback. + +Gate (phase-wide): full 4-port UBER re-run against goldens; +per-item gates above. + +Effort: ~2-4 days (dominated by the two new implementations). + +--- + +## Phase 8 -- API-surface decisions + final re-baseline + +1. **#40** -- input predicates: DECISION for Scott. Option A: macro + fast paths in joey/input.h reading the extern state arrays (keeps + functions for ABI; exposes internals in a public header). Option B: + accept the call cost (the honest post-Phase-0 number is far better + than the old 3382 anyway). Recommendation: B unless a real game + shows input in its frame profile. +2. **#43** -- jlRandom 16-bit-half rewrite, IIGS-ONLY behind a platform + #if (verifier: the decomposed form is a wash-to-slight-LOSS on + 68000 -- keep the current 32-bit C for the GCC ports). Output is + bit-identical either way; the Phase 0 golden-sequence check covers + it on all ports. +3. **#25 (final)** -- regenerate PERF.md completely: new IIgs floor, + new ratios, document BOTH artifacts (SEI clock corruption + the + now-fixed per-iteration poll tax) and the honest full-screen + ceiling; drop the stale-baseline caveats. + +Gate: full 4-port bench + hash run; PERF.md tells the truth. + +Effort: ~1-2 hours plus bench runs. + +--- + +## Decisions Scott must make (collected) + +| # | Decision | Recommendation | +| --- | --- | --- | +| #16 | Drop the undocumented $0RGB re-mask in jlPaletteSet (memcpy + force color 0)? Slight behavior change for non-conforming input. | Yes -- contract already requires $0RGB | +| #22 | jlDrawText: out-of-range initial bx/by becomes an explicit reject instead of silently absorbed per-glyph. | Reject at entry | +| #8 | Arena >64KB on IIgs: clamp silently or fail with error? | Clamp + coreSetError note | +| #34 | Sprite registry in sprite.c vs arena epoch vs doc-only contract? | Registry (fixes both corruption arms, zero hot-path cost) | +| #55 | spriteEmit68k.c: delete or keep unbuilt? | Delete (git history keeps it) | +| #38 | Accept dirty-band over-approximation for fillCircle fallback spans? | Yes -- matches IIgs asm path | +| #6 | Accept 1-LSB audio deviation for the muls.w interp rewrite? | Yes -- inaudible | +| #40 | Input predicate macros in public header vs keep functions? | Keep functions (B) -- DECIDED 2026-07-05, documented at the predicate block in src/core/input.c | +| #60 | assetLoad IIgs segment: add the promised directive or fix the comment? | Check root-segment pressure, then decide | + +## What this plan deliberately does NOT do + +- No runtime dispatch, no C++/newer-C features, no ST blitter, no + wholesale planar-architecture changes (per the audit's ground rules + and the standing 68k-asm-rewrite plan -- deep C micro-opts to the + Amiga/ST HALs beyond the listed findings are out of scope because the + hand-asm rewrite will replace them). +- No toolchain changes (llvm-mos/ORCA are owned elsewhere; anything + needing compiler work gets flagged to Scott instead). +- Refuted findings (PERF-AUDIT.md bottom) are not actioned. + +## Progress tracking + +Mark each finding number done here as it lands: + +- Phase 0: [x] uber-batch [x] #15 [x] #71 [x] uber-checks [x] bench-iigs.sh [x] millis-harness [x] #73 [x] #76 [x] #25a (3-port; IIgs blocked on stabilization) [x] goldens (DOS/ST/Amiga; IIgs partial) +- Phase 1 COMPLETE 2026-07-05 (all four ports 43/43 bit-identical hashes): [x] #1 [x] #11 (+h==2) [x] #72 [x] #12 [x] #74 [x] #75 [x] #77 (root-caused; fix=toolchain ask 5; codegen off) [x] #78 [x] #79-flag [x] #81 [x] #82 [x] #83 [x] #85 [x] #86 [x] #87 [x] #23 [x] #8 [x] #46 [x] #34 [x] #5 [x] #58 [x] #31 -- new: #88 filed (jlTileT snap/mono semantics wart, Phase 5) [ ] #23 [ ] #8 [ ] #46 [ ] #34 [ ] #5 [ ] #31 [ ] #58 +- Phase 2 COMPLETE 2026-07-05 (bit-neutral, 43/43 held on all four ports; DOS paletteSet +92%, drawPixel +37%, samplePixel +21%; IIgs paletteSet +8%, samplePixel +6%): [x] #10 [x] #17 [x] #20 [x] #65 [x] #21 [x] #47 [x] #14 [x] #24 [x] #13 [x] #16 (final form = unrolled word copy, NOT memcpy -- the clang IIgs libc memcpy byte-loop regressed it; finding #79 caveat) +- Phase 3 COMPLETE 2026-07-05 (bit-neutral, 43/43 x4; IIgs drawCircle r=80 -> 70 ops/s, fillCircle r=40 -> 42): [x] #9 [x] #37 [x] #36 [x] #38 [x] #35 [x] #61 [x] #62 -- new: #89 filed (diag fallback int16 dx/dy latent, opportunistic) +- Phase 4 COMPLETE 2026-07-05 (43/43 x4; IIgs interpreted spriteDraw 18->48 ops/s (2.7x), save +18%, restore +15%; review harness: 726,915 differential cases, 0 mismatches): all items incl. #19 + #29-runtime stragglers; #55 file deleted; #59 cache deleted; sprite.h docs modernized +- Phase 5 COMPLETE 2026-07-05 (bit-neutral 43/43 x4; DOS tileSnap +42%, tileCopyMasked +29%, tilePaste +18%, fillRect +8-9%): all items; #22 landed as entry-sanitation preserving historical wrap semantics (strict reject would have broken the offgrid golden); jlDrawText needed a noinline union helper for the w65816 reg allocator +- Phase 6 COMPLETE 2026-07-05 (host harness 12/12: #7+#33 bit-identical in exact mode pre-#6; #6 maxAbsDelta=1 on the 3 odd-step scenarios, within the 1-LSB bound; baseline recaptured post-#6; DOS+ST builds clean; ST asm shows 1 muls.w per split loop, 0 __mulsi3): [x] #6 [x] #7 [x] #33 -- emulator audio smoke (DOSBox/Hatari) not yet run +- Phase 7 COMPLETE 2026-07-05: [x] #3 [x] #4 [x] #42 [x] #41 [x] #53 [x] #52 [x] #26 -- #3/#4 landed + + hash-identical x4 (tilePasteMono=B0829554, floodFill=780DC48B). Perf captured + 2026-07-05 via isolated UBER timed ops (same-CPU override-vs-generic before/after, + built by toggling the port.h #3 macro / platform.h #4 flags): #3 IIgs tilePasteMono + asm 1083 vs generic 323 ops/sec = 3.35x (timed hash 8A39C22B identical both paths -- + asm output bit-exact); #4 ST flood plane-hooks 13.5 vs generic 75 frames/flood = 5.6x + (within the 5-15x target). Measured via the UBER micro-benchmark rather than the + plan's spacetaxi/flood-scenario gates -- more isolated, and correctness already + proven by the x4 golden match. + + Golden coverage extended 2026-07-05 (re-analysis follow-up): the original + tilePasteMono/floodFill goldens left two paths hash-blind -- the #3 asm combos + idx 0 (bg:bg) and idx 3 (fg:fg) were never selected by the alternating 0x0F/0xF0 + mono tile, and only jlFloodFill (matchEqual) exercised the #4 ST plane hooks, never + the bounded stop-mask + group-skip branch. Added checkTileMonoFloodExtra() in + uber.c (runs LAST so no earlier cumulative hash shifts): tilePasteMonoAll pastes a + 0x00/0xFF/0x0F/0xF0 tile (all four combos) and floodFillBounded fills an enclosed + color-3 box across four 16px groups. Re-ran all four ports: both new checks + hash-identical x4 (tilePasteMonoAll=30C3A2C9, floodFillBounded=EF6B6F51); goldens + updated to 45/45. The two previously by-inspection-only paths are now empirically + bit-exact vs the DOS generic reference. NOTE: Amiga bench needed TIMEOUT=600 (the + last-running checks overran the default 150s, same truncation as the Phase 0 note). + + #41 verified COMPLETE 2026-07-05: found already landed (opportunistically, + during the earlier tileFill/fillCircle/fillRect asm work) -- iigsTileFillInner, + iigsFillCircleInner, and iigsFillRectInner all take the raw nibble and derive + the fill word/bytes in asm (joeyDraw.s:420-430 etc.); the port.h macros pass + (c & 0x0F); no C-side * 0x1111 survives anywhere (grep-verified, comments + only); the port.h:100-105/136-138 comments match reality. surfaceClear's + fillWord stays C-derived via (d | d<<8) -- shifts/OR, no multiply helper, so + ownership is consistent per-op and documented at each site. No code change; + checkbox was stale. + + #52 verified COMPLETE 2026-07-05: also found already landed (the clang-era + joeyDraw.s rewrite folded it in) -- iigsBlitStageToShr is zero-arg (hal.c:106 + extern, hal.c:309 call), the scbPtr/palettePtr/uploadFlags marshalling and both + flag-gated MVN blocks are gone, the caller-DBR stash to $E1:9DFE is KEPT per the + verifier correction (joeyDraw.s:3214-3218), bsFrame BSS deleted (grep-verified), + and the jlpInit/jlpPresent comments in hal.c already tell the truth (jlpPresent + even credits #52). Gate evidence: today's IIgs UBER run exercised the narrow-row + MVN path (fillRect-32x32 present = 16-word dirty band < the 32-word threshold, + ~300 iterations) and completed to the green done-screen, which requires the DBR + stash restore to work; UBER 45/45 hashes matched. + + #53 verified COMPLETE 2026-07-05: same story -- iigsDrawPixelInner has no + PHB/PLB (frame = php+phd = +3; args correctly renumbered to x@7,s y@9,s + nib@11,s with the ABI comment updated, joeyDraw.s:1856-1858), the row offset + folds directly via `adc gRowOffsetLut,x` (line 1899), dpxlTmp is deleted + (grep-verified), and scratch was NOT relocated D-relative per the verifier + hazard note. No isolated before/after bench: #53 landed inside the wholesale + clang-era rewrite (no "before" tree), and the verifier-predicted ~1% delta is + below MAME run-to-run noise. Correctness gates: UBER jlDrawPixel + edge-pixels + hashes bit-exact vs DOS x4 (45/45), and `make iigs-verify VERIFY_EXAMPLE=draw` + PASS -- checksum=08483E, distinctNibbles=15 (RECORDED AS THE BASELINE; no prior + checksum was ever written down -- future draw-path gates compare against this). + RESIDUAL flagged, out of #53 scope: iigsDrawLineInner / iigsDrawCircleInner / + the tile inners still carry phb in their prologues -- the audit verifier + explicitly deferred that same-pattern audit to the IIGS_PERF_PLAN Phase 4 + line/circle work; their arg-offset comments all assume the +4 frame, so any + future phb deletion must renumber every N,s in each routine. + + #3 SPACETAXI TITLE-SCREEN ACCEPTANCE TEST attempted 2026-07-05 (the plan's + original gate, deferred from the Phase 7 landing): BLOCKED ON FINDING #77 + (toolchain) -- but the attempt built the entire test rig and fixed four real + bugs found along the way. Landed: IIgs asset bake (examples/spacetaxi/ + generated/iigs, .tbk target byte 4), make-iigs-disk.sh DATA-tree packing + (CREATEFOLDER + per-file ADDFILE when STAXI is on the disk -- the spacetaxi + PLAN.md Phase 6 packager task), STAXI now BUILDS on IIgs (the iigs.mk:74 + "blocked on 2-byte cross-seg reloc" comment is stale -- the toolchain landed + it; STAXI still deliberately omitted from iigs-examples pending the #77 fix), + scripts/bench-staxi-title.sh (per-frame SHR logo-cell cadence sampler; the + intended 4-frame period vs measured), scripts/diag-staxi-screen.sh (SHR/RAM + dump + PC histogram + ring extraction). Bug fixes landed (all 45/45 + golden-verified x4): (1) IIgs jlLogReset did a floppy-truncate write -- + first-ever floppy WRITE on the headless path, #82 class; now ring-only, with + jlLogFlush switched to "w" (self-truncating full-ring dump, idempotent); + (2) jlTileBankLoad read tile banks 32 bytes/fread (1000 freads per font + bank!) -- now batched 8KB/fread (ASSET_TILE_READ_BATCH), same truncated-file + contract; (3) NEW API jlTileBankLoadToSurface (tile.h) streams a bank + straight onto a surface's block grid with a 256-byte static scratch -- + no caller-side 32KB buffer, which no IIgs example can allocate (heap is + 665 bytes; a 32KB static blows the bank-0 BSS ceiling); spacetaxi's + loadFontSheet now uses it; (4) jlpGenericSurfaceAllocPixels used calloc -- + on IIgs that's the malloc-returns-1 trap (#81) shredding bank 0 through + pointer 1 on the first OFFSCREEN jlSurfaceCreate (stage uses the pinned + path, so UBER never hit it); now jlpBigAlloc + memset with matching + jlpBigFree in jlpGenericSurfaceFreePixels. THE BLOCKER: with all four + fixed, STAXI boots to "title loaded, tilebank=0" but dies layout-dependently + when jlpBigAlloc handles land inside the app's own unreserved BSS spans + (finding #77 exactly; pixels@$09:0BF4 run survived to title-load, other + layouts died in fopen). An app-side BSS-claim mitigation was built and + REVERTED: the linker's __bss_seg symbols ARE readable via a DATA32 .long + table (C IMM16 refs trip the 2-byte cINTERSEG limit), but a SUCCESSFUL + NewHandle claim over the app's own BSS span kills the app instantly + (silent return-to-Finder, reproduced twice) -- the reservation must be made + loader-side. Full recipe + evidence filed in LLVM816-ASKS.md item 5. + Re-run the gate (scripts/bench-staxi-title.sh both port.h-macro arms) once + the toolchain lands item 5. +- Phase 8 COMPLETE 2026-07-05: [x] #40 [x] #43 [x] #25b -- ALL PHASES DONE. + #40 decided per recommendation B (keep functions; documented at the predicate + block in src/core/input.c and in the decisions table above). #43 landed as an + IIgs-only #if in src/core/random.c: state stored as two uint16_t halves, the + 13/17/5 xorshift triple decomposed to 16-bit shift identities (hi-before-lo + update order per step); bit-identity host-proven (UBER golden sequence 4/4 + + 50M-state sweep vs the 32-bit reference, scratchpad rngHalvesCheck.c) and + emulator-gated (random-golden PASS x4); GCC ports keep the 32-bit form per the + verifier correction. Note: UBER has no timed jlRandom row, so the ~2-3x/call + win is analytic, not benched. #25b: PERF.md fully regenerated from same-day + 4-port captures -- fresh table via tools/uber-perf-table, both clock artifacts + documented (poll tax fixed / SEI tick loss inherent with the three IIgs rows + tagged clock-unreliable against the ~29 ops/sec ceiling), 45/45 x4 correctness + statement, per-port sprite-row caveats (DOS compiled, IIgs interpreter pending + #77), below-floor punch list, stale stabilization/history sections dropped. + Phase-wide gate: full 4-port bench + hash run 2026-07-05, 45/45 goldens on all + four ports, random-golden PASS x4. diff --git a/PERF-AUDIT.md b/PERF-AUDIT.md new file mode 100644 index 0000000..98f0984 --- /dev/null +++ b/PERF-AUDIT.md @@ -0,0 +1,1425 @@ +# JoeyLib Core Audit -- 2026-07-04 + +Deep audit of src/core/, src/generic/, include/joey/ (plus codegen and +hot spots the completeness critic pulled in from src/iigs, src/m68k, +src/atarist). Method: 7 lens-specific finder agents + a 3-gap critic +round, every finding adversarially verified against the actual file on +three lenses (factual accuracy, fix validity on real hardware, +materiality). 77 raw findings -> 71 confirmed, 6 refuted. Where the +verifier tightened a claim, the correction is included -- read it before +acting on a finding. + +Severity: critical = correctness bug or >2x slowdown on a hot op; +high = measurable per-op win (>10%) or real bug on a cold path; +medium = clear cleanup or small win; low = style-level. + +Confirmed: 2 critical, 22 high, 32 medium, 15 low + +## Read this first: the benchmark inflates every small-op number + +The UBER loop (examples/uber/uber.c:66) calls jlFrameCount() between every +op iteration. On the IIgs that is a THREE-layer chain -- jlFrameCount -> +jlpFrameCount -> iigsGetTickWord -> Misc Toolset GetTick ($2503) via the +$E10000 tool dispatcher. The toolbox dispatch alone is several hundred +cycles; with the forwarding layers, the indirect op() call, and the op +wrapper, each benchmark iteration carries roughly 600-800 cycles of pure +harness tax. The uber.c comment claiming the poll costs "~10-30 cyc per op" +is wrong by more than an order of magnitude on the IIgs. + +Cross-check: jlKeyDown is a bounds check plus an array read (~80 cycles +including the call), yet it measures 3382 ops/sec = ~830 cycles/iter. The +missing ~750 cycles are the harness. The same constant sits inside EVERY +small-op number in PERF.md: jlDrawPixel's honest cost is roughly half of +what the table implies, jlSamplePixel/jlPaletteSet/input predicates are +3-10x faster than their entries suggest, and the cross-port ratios for fast +ops partly measure each port's clock-read cost, not library code. + +Fix in uber.c, not in the library: poll the clock every N iterations (e.g. +run the op 8-16 times per while-check and count in batches), or calibrate +an iteration count first and time a fixed batch. Then re-baseline PERF.md. +Until that lands, treat full-screen ops (which amortize the tax) as honest +and every op above ~500 ops/sec as significantly under-reported. Finding +16 (jlFrameCount double pass-through) trims the library-side share; the +toolbox dispatch cost itself is only avoidable by polling less often. + +## Index + + 1. [FIXED 2026-07-05] [bug] src/core/draw.c:549 -- jlDrawPixel (non-IIgs) marks dirty with unclipped x/y, causing out-of-bounds writes to the dirty-band arrays + 2. [CRITICAL] [perf] src/core/sprite.c:193 -- Interpreted sprite inner loop re-derives dst byte address and parity per pixel via a real function call + 3. [HIGH ] [perf] include/joey/platform.h:98 -- IIgs (perf reference) silently runs the generic C nibble loop for jlTilePasteMono -- no JL_HAS_TILE_PASTE_MONO, no port.h macro + 4. [HIGH ] [perf] include/joey/platform.h:205 -- Atari ST defines neither JL_HAS_FLOOD_WALK_PLANES nor JL_HAS_FLOOD_SCAN_ROW_PLANES -- flood fill decodes 4 bitplanes per pixel via a function call + 5. [FIXED 2026-07-05: compact-and-retry + coreSetError] [perf] src/codegen/spriteCompile.c:202 -- jlSpriteCompile ignores the arena's compact-and-retry contract: one fragmented alloc failure silently demotes the sprite to the interpreter forever + 6. [HIGH ] [perf] src/core/audioSfxMix.c:83 -- SFX mixer does a full 32x32 multiply per output sample; on 68000 this is a __mulsi3 library call + 7. [HIGH ] [perf] src/core/audioSfxMix.c:110 -- 64-bit posFp arithmetic executed per sample in the mixer inner loop + 8. [FIXED 2026-07-05] [bug] src/core/codegenArena.c:236 -- IIgs 64KB attrNoCross cap on the codegen arena is unenforced; codegenBytes > 64KB hard-fails jlInit (adventure.c passes 256KB) + 9. [HIGH ] [perf] src/core/draw.c:484 -- jlDrawLine Bresenham fallback accumulates the dirty bounding box with 4 compares per pixel although it equals the endpoint box +10. [HIGH ] [perf] src/core/draw.c:533 -- IIgs stage-identity test is a cross-TU jlStageGet() function call per primitive (per pixel on the hottest op) and is re-checked again inside surfaceMarkDirtyRect +11. [FIXED 2026-07-05, incl. the #65 h==2 companion] [bug] src/core/draw.c:591 -- jlDrawRect general path wraps w/h > 32767 through (int16_t) casts, drawing spurious edges on-surface +12. [FIXED 2026-07-05; bonus: HEAD also INFINITE-LOOPED at exactly r==32767 (int16 tautology), fixed by the same rewrite] [bug] src/core/draw.c:656 -- jlFillCircle (and jlDrawCircle fallback) cast r to int16_t: r > 32767 draws nothing; r in [~380, 32767] iterates up to 32k clipped spans +13. [HIGH ] [perf] src/core/draw.c:703 -- jlFillRect runs 32-bit clip math on every call; the common on-surface case needs only 16-bit compares +14. [HIGH ] [perf] src/core/draw.c:776 -- jlSamplePixel pays a second cross-TU function hop for a ~30-cycle nibble read on chunky ports +15. [HIGH ] [perf] src/core/init.c:148 -- jlFrameCount is a double pass-through on IIgs: jlFrameCount -> jlpFrameCount -> iigsGetTickWord, paid every benchmark iteration and every game-loop pacing check +16. [HIGH ] [perf] src/core/palette.c:106 -- jlPaletteSet re-masks all 15 caller entries against the documented $0RGB contract instead of memcpy + forcing color 0 +17. [HIGH ] [perf] src/core/port.h:116 -- jlStageGet() function call buried in hot-path port.h macros and jlDrawPixel costs a cross-TU hop per primitive +18. [HIGH ] [perf] src/core/sprite.c:189 -- No opaque-pair whole-byte fast path; 5 conditional branches per pixel in the interpreted draw loop +19. [HIGH ] [perf] src/core/sprite.c:524 -- Save/restore interpreted fallbacks bypass SURFACE_ROW_OFFSET and pay two software multiplies per row +20. [HIGH ] [perf] src/core/surface.c:234 -- surfaceMarkDirtyRect is a cross-TU function hop on every drawing op, re-deriving stage-ness the dispatch macro just computed +21. [HIGH ] [perf] src/core/surface.c:317 -- stageDirtyClearAll runs a 200-iteration byte loop every present while its sibling uses memset for the same arrays +22. [HIGH ] [perf] src/core/tile.c:54 -- jlDrawText pays the full public jlTileCopyMasked wrapper plus a multi-row dirty-mark call per glyph +23. [FIXED 2026-07-05; check-millis.sh is the regression gate] [bug] src/generic/genericPort.c:40 -- jlpGenericMillisElapsed wraps backward every ~18 minutes and does a 32-bit multiply+divide per call on IIgs/Amiga +24. [HIGH ] [perf] src/generic/genericSurface.c:55 -- jlpGenericSamplePixel pays a software multiply (y*160) instead of the SURFACE_ROW_OFFSET LUT the codebase built for exactly this +25. [MEDIUM ] [multi-source-of-truth] PERF.md:38 -- PERF.md IIgs baseline predates the Phase-2 jlDrawPixel/dirty-mark rewrite; all cross-port ratios are computed against a stale floor +26. [MEDIUM ] [multi-source-of-truth] include/joey/platform.h:100 -- Eleven IIgs JL_HAS flags are decorative -- the port.h macro overrides bypass the registry, so entries can be added/removed with zero effect +27. [MEDIUM ] [dead-code] src/codegen/spriteCompile.c:246 -- Amiga compiled save/restore dispatchers and the shift-alias loop are dead code contradicted by the emitters +28. [MEDIUM ] [perf] src/codegen/spriteCompile.c:311 -- IIgs spriteCompiledDraw still pays the 2D-array multiply helper the same file dodges everywhere else +29. [MEDIUM ] [duplication] src/codegen/spriteCompile.c:423 -- Dispatcher gate and spriteCompiled* callees each re-derive shift, route offset, and function address per call +30. [MEDIUM ] [multi-source-of-truth] src/core/assetLoad.c:41 -- assetLoad.c re-spells TILE_BYTES and the 40x25 tile-grid limits as private literals +31. [FIXED 2026-07-04 during the stabilization cluster] [bug] src/core/assetLoad.c:167 -- jlSpriteBankLoad allocates up to 32000-byte cels with malloc, violating core.h's own IIgs allocator contract +32. [MEDIUM ] [duplication] src/core/assetLoad.c:180 -- jlSpriteBankLoad re-implements spriteInitFields field by field, and the spriteInternal.h comment contradicts it +33. [MEDIUM ] [perf] src/core/audioSfxMix.c:131 -- Mixer re-reads slot fields through the pointer every sample because dst stores are char-typed and may alias +34. [FIXED 2026-07-05 via the sprite.c registry + spriteSystemShutdown] [bug] src/core/codegenArena.c:294 -- codegenArenaShutdown frees every ArenaSlotT while live sprites still hold pointers; shutdown -> re-init -> jlSpriteDestroy corrupts the heap +35. [MEDIUM ] [perf] src/core/draw.c:168 -- floodFillInternal C fallback evaluates the loop-invariant matchEqual ternary and a jlpSamplePixel function call per pixel +36. [MEDIUM ] [unnecessary-conditional] src/core/draw.c:345 -- jlDrawCircle fallback runs 8 bounding-box compares per iteration that are provably dead after the first iteration +37. [MEDIUM ] [unnecessary-conditional] src/core/draw.c:484 -- Bresenham fallbacks accumulate a bounding box per pixel that is statically known before the loop +38. [MEDIUM ] [perf] src/core/draw.c:668 -- jlFillCircle C span loop pays surfaceMarkDirtyRect (plus IIgs jlStageGet) per span instead of one bounding-box mark +39. [MEDIUM ] [perf] src/core/init.c:20 -- DEFAULT_CODEGEN_BYTES (8KB) is sized to IIgs code density; a single 32x32 sprite's ST/Amiga DRAW routines nearly fill or overflow it, silently forcing the interpreter +40. [MEDIUM ] [perf] src/core/input.c:92 -- jlKeyDown's real cost is ~5x its body: cross-TU call overhead around a two-load predicate +41. [MEDIUM ] [perf] src/core/port.h:130 -- port.h comment says 'the asm derives the fill word' but the jlpTileFill/jlpFillCircle macros compute (c & 0x0F) * 0x1111 in C -- a software multiply helper per call on the 65816 +42. [MEDIUM ] [perf] src/core/port.h:179 -- DOS pays real cross-TU calls to no-op planar hooks on every sprite draw/save/restore and surface copy -- port.h's own comment admits it +43. [MEDIUM ] [perf] src/core/random.c:23 -- jlRandom's 32-bit shift triple (13/17/5) is a worst case for the 16-bit 65816; bit-identical 16-bit-half rewrite available +44. [MEDIUM ] [perf] src/core/sprite.c:103 -- isFullyOnSurface uses 32-bit adds/compares on every hot sprite entry gate, against the file's own stated convention +45. [MEDIUM ] [perf] src/core/sprite.c:179 -- spriteDrawInterpreted row setup redoes a per-row multiply and recomputes loop-invariant column math every row +46. [FIXED 2026-07-05] [bug] src/core/sprite.c:346 -- IIgs/DOS draw fast path never checks SPRITE_NOT_COMPILED before jumping into the arena +47. [MEDIUM ] [multi-source-of-truth] src/core/surfaceInternal.h:54 -- Four divergent implementations of the 'row is dirty' test; the STAGE_DIRTY_ROW_CLEAN macro that should be the single truth is unused +48. [MEDIUM ] [perf] src/core/tile.c:79 -- tile.c wrappers derive pixel coords with * TILE_PIXELS_PER_SIDE multiplies where port.h deliberately uses shifts to dodge the ORCA-C multiply helper +49. [MEDIUM ] [perf] src/generic/genericDraw.c:42 -- jlpGenericFillRect recomputes loop-invariant span bytes/edge masks and the row offset every row +50. [MEDIUM ] [duplication] src/generic/genericTile.c:27 -- Identical 4-byte row-copy inner loop triplicated byte-at-a-time across TileCopy, TilePaste, and TileSnap +51. [MEDIUM ] [perf] src/generic/genericTile.c:59 -- jlpGenericTileCopyMasked does a read-modify-write merge even when both source nibbles are opaque +52. [MEDIUM ] [dead-code] src/iigs/hal.c:298 -- iigsBlitStageToShr's SCB/palette MVN upload paths are dead, yet jlpPresent still marshals both far pointers into the asm every present +53. [MEDIUM ] [perf] src/iigs/joeyDraw.s:1416 -- iigsDrawPixelInner carries dead PHB/PLB framing and a long-absolute scratch round-trip in the hottest inner op +54. [MEDIUM ] [multi-source-of-truth] src/iigs/spriteEmitIigs.c:3 -- spriteEmitIigs.c top-of-file contract describes the deleted self-modifying-stub design; the code below it emits the opposite (C-ABI prologue) +55. [MEDIUM ] [dead-code] src/m68k/spriteEmit68k.c:179 -- spriteEmit68k.c (chunky 68k emitter) is compiled into Amiga and ST builds but nothing calls it +56. [MEDIUM ] [duplication] src/m68k/spriteEmitInterleaved68k.c:83 -- c2pColumn/putWord duplicated byte-for-byte between the two 68k emitters; shiftedByteAt/spriteSourceByte duplicated between IIgs and x86 emitters +57. [LOW ] [cleanup] include/joey/surface.h:66 -- surface.h documents jlSurfaceHash as FNV-1a but the implementation is a 31/251 two-stream product hash +58. [FIXED 2026-07-05, incl. the jlpTileSnap twin] [unnecessary-conditional] src/atarist/hal.c:1410 -- ST jlpTilePasteMono dereferences dst before its own NULL check, and both the dst and duplicate pd NULL checks are dead +59. [LOW ] [perf] src/codegen/spriteCompile.c:362 -- SPLIT_BACKUP_CACHED global single-entry cache thrashes and costs more than it saves with multiple backup buffers +60. [LOW ] [multi-source-of-truth] src/core/assetLoad.c:28 -- assetLoad.c comment claims file-IO lives in a named IIgs segment, but no segment directive exists anywhere +61. [LOW ] [unnecessary-conditional] src/core/draw.c:276 -- plotPixelNoMark re-checks s == NULL per pixel although every caller has already validated it +62. [LOW ] [duplication] src/core/draw.c:574 -- isFullyOnSurface predicate duplicated between sprite.c helper and jlDrawRect's inline test +63. [LOW ] [multi-source-of-truth] src/core/sprite.c:344 -- jlSpriteDraw's fast-path comment claims the IIgs never takes it, contradicting the codegen default +64. [LOW ] [duplication] src/core/sprite.c:409 -- routineOffsets shift*3+op byte-offset idiom hand-expanded at three sites in sprite.c +65. [LOW ] [unnecessary-conditional] src/core/surface.c:245 -- surfaceMarkDirtyRect re-validates w/h that its contract already guarantees positive +66. [LOW ] [dead-code] src/core/surfaceInternal.h:36 -- SURFACE_PIXELS_PER_WORD is defined and never used anywhere in the repo +67. [LOW ] [magic-number] src/core/surfaceInternal.h:125 -- gRowOffsetLut declared with magic literal 200 instead of SURFACE_HEIGHT +68. [LOW ] [dead-code] src/core/tile.c:17 -- Stale comment in tile.c still claims the inner row copies are spelled out in this file +69. [LOW ] [unnecessary-conditional] src/generic/genericTile.c:84 -- fg/bg colors are masked to 4 bits twice on the TilePasteMono path +70. [LOW ] [duplication] src/generic/genericTile.c:142 -- jlpGenericTileFill re-derives the tile row-0 address with its own math instead of the file's GENERIC_TILE_ROW0 macro +71. [LOW ] [cleanup] src/iigs/hal.c:358 -- Stale 15-line comment on IIgs jlpFrameCount documents a deleted $C019-polling/gFrameCount implementation; body is one GetTick call +72. [FIXED 2026-07-05] [bug] src/core/draw.c:417 -- jlDrawLine H/V fast paths clamp the span to the surface size BEFORE clipping, so a line with a far-off-surface start draws nothing (found during Phase 0 harness work) +73. [CRITICAL] [bug] src/dos/hal.c:321 -- DOS jlpFrameCount edge-polled $3DA and lost every frame whose ~1ms retrace pulse fell between calls; the ENTIRE old DOS PERF.md column was inflated 5-40x on slow ops (FIXED during Phase 0: now derived from the millis clock) +74. [FIXED 2026-07-05: pass 2 now emits into the roomy scratch and memcpys into the arena, so sizing and emit agree by construction; DOS jlSpriteDraw jumped 284 -> 6094 ops/sec on the compiled path with bit-identical pixels] [bug] src/codegen/spriteCompile.c:149 -- jlSpriteCompile returns false on DOS (pre-existing at HEAD; UBER logs the failure), so every DOS sprite benchmark and game has been running the interpreter; root cause not yet isolated +75. [FIXED 2026-07-05: both planar overrides decoded the mono tile as their PRIVATE plane-major layout instead of the contract's chunky nibble-pairs; both now fold each 4-byte chunky row into the plane shape mask] [bug] src/generic/genericTile.c:77 -- jlTilePasteMono produces THREE different pixel outputs on chunky (DOS/IIgs generic) vs Amiga vs ST overrides; caught by the Phase 0 harness the first time the op was ever hashed cross-port +76. [CRITICAL] [bug] src/iigs/hal.c:348 -- IIgs jlpWaitVBL polled $C019's VBL bit, which never reads high under MAME's apple2gs; every program pacing with jlWaitVBL hung forever in emulation, and UBER (the only in-tree caller) could never complete headless (FIXED during Phase 0: now waits on a GetTick change) +77. [CRITICAL] [bug] src/iigs/spriteEmitIigs.c + src/codegen/spriteCompile.c -- IIgs sprite codegen corrupts memory under the clang build: with JOEY_IIGS_SPRITE_CODEGEN=1 (the default), UBER's stage/surface state is corrupted between setupSprite and drawShowcase and the app dies in a wild memset over bank 0; with codegen disabled UBER runs. The old "#19 rewrite fixed it" claim was validated on the ORCA build, not clang +78. [FIXED 2026-07-05: the ST fillCircle asm read its color argument at a 6-arg function's stack offset (copy-paste from lineSpan.s) and filled with a garbage color; its span math was also rewritten from midpoint-Bresenham to the exact-disk reference. jlTileFill was EXONERATED -- simulator-proven pure fallout of the fillCircle corruption] -- ST jlFillCircle and jlTileFill produce different pixels than the chunky reference (DOS): first-ever DOS-vs-ST hash comparison diverges at those two ops while Amiga matches DOS on every timed op +79. [HIGH ] [perf] toolchains/iigs/llvm-mos/runtime/src/libc.c:191 -- clang-era IIgs links byte-at-a-time memset/memcpy (~30 cyc/byte) where the ORCA baseline used MVN (~7 cyc/byte); jlScbSetRange measures 163 ops/sec vs the ORCA-era 1005 -- every memset/memcpy-based core path regressed ~4x on the reference platform (toolchain-owned; flag to the llvm816 session) +81. [CRITICAL] [bug] src/core/surface.c:279 + toolchain libc -- the IIgs C heap is 665 bytes of unreserved bank-0 leftover, and at exhaustion its malloc/calloc returns 1 instead of NULL (the libc's own "over-heap miscompile" comment, still live). stageAlloc's calloc(~720) returned 1; the whole port ran on a phantom stage struct at bank-0 zero page whose pixels field ALIASED the real framebuffer address by coincidence -- rendering worked while scb/palette writes scribbled over system bank 0. Root cause of the divergent IIgs hashes, the phase-5 hang, and (pending re-test) #77 (FIXED JoeyLib-side during Phase 1: surface/sprite structs + owned tileData moved to jlpBigAlloc/jlpBigFree -- finding #31 executed; toolchain asks filed) +82. [CRITICAL] [bug] src/core/debug.c (IIgs) -- per-line log writes cost ~8 emulated seconds each through the floppy FST and can wedge indefinitely in SmartPort firmware retries (PC-sampled: slot-5 ROM spin stalled UBER 18+ emulated minutes). FIXED during Phase 1: IIgs logging now writes a RAM ring (gJoeyLogRing, drained live by the MAME Lua probes); jlLogFlush writes the ring to disk in one open/write/close pass +83. [HIGH ] [bug] src/iigs/joeyDraw.s -- iigsDrawLineInner and iigsFillCircleInner stashed the pixels pointer with a DBR store but dereferenced it D-relative (bank 0): every plot went through a garbage pointer -- deterministic wrong pixels vs the chunky reference (FIXED during Phase 1 with the drawPixel-pattern D-relative stash) +89. [LOW ] [bug] src/core/draw.c diagonal Bresenham -- dx/dy computed in int16_t, so endpoints more than 32767 apart (e.g. x0=-32768, x1=100) corrupt the walk and can infinite-loop. Pre-existing and latent (such inputs draw nothing useful and the H/V fast paths and endpoint-box marking are unaffected); noted during the Phase 3 review. Fix opportunistically when the fallback is next touched +88. [LOW ] [bug] cross-port jlTileT semantics -- jlTileSnap on planar ports stores platform-private plane-major bytes in jlTileT.pixels, but jlTilePasteMono (per contract) decodes the same struct as chunky nibble-pairs: a tile snapped on ST/Amiga then mono-pasted decodes differently than on DOS/IIgs. Latent wart flagged during the #75 fix; consider a contract note or a chunky-normalizing snap in Phase 5 +87. [MEDIUM ] [bug] src/iigs/joeyDraw.s -- the odd-column nibble mask in the M=16 plot RMW was BYTE-SWAPPED ($F0FF instead of $FFF0 on the little-endian 65816): it never cleared the target nibble and erased the adjacent byte's low nibble instead. 8 sites in iigsDrawCircleInner's octant plots + 1 latent in iigsDrawLineInner. Simulation-validated root cause (Bresenham geometry was identical to C; first diff at the octant-7/8 pole pairs); fix = the one-constant change at all 9 sites, byte-identical to the C reference across 20 simulated cases (FIXED during Phase 1, pending MAME re-verification with the Wave-1 batch) +86. [CRITICAL] [bug] src/iigs/joeyDraw.s -- the #83 garbage plot pointer is layout-dependent: when a memory shift (e.g. the #85 reservation) changed what it aliased, line plots landed ON the dln* Bresenham state, mutating the endpoint mid-line and hanging the walk in an infinite coordinate wrap that scribbles via out-of-bounds gRowOffsetLut offsets -- a wild-write generator and plausible contributor to the pre-#81 corruption (FIXED with #83) +84. [HIGH ] [bug] src/core/sprite.c + IIgs codegen-off path -- IIgs interpreted sprite ops are pathologically slow (jlSpriteSaveUnder 1 iter/65 frames, jlSpriteDraw 1 iter/368 frames -- ~6 SECONDS for one 16x16 interpreted draw) and pixel-divergent (sprite-clip-roundtrip FAILs; all post-sprite hashes fall out). Needs its own hunt: even finding #2's per-pixel call overhead cannot explain three orders of magnitude +85. [CRITICAL] [bug] src/iigs/hal.c -- the pinned stage framebuffer ($01:2000-$9CFF) was never reserved from the GS/OS Memory Manager, so NewHandle legally placed jlpBigAlloc blocks INSIDE the display: the sprite struct landed at $01:8B04 and every surface clear repainted its fields (widthTiles read as the fill color -- 0x44 after clear(4)); clipRect then clamped draws to near-full-screen, producing finding #84's ~1000x sprite slowness, the 32KB backup-buffer overrun, and the BSS carnage (FIXED during Phase 1: jlpStageAllocPixels now claims the range via NewHandle attrAddr, and all JoeyLib NewHandle sites pass attrNoSpec) +80. [MEDIUM ] [bug] src/core/debug.c:47 -- per-line log durability does not hold on the clang IIgs runtime: jlLogF+fflush lines do not reach the disk image until a clean fclose/exit (GS/OS FST or libc buffering), so a hung run leaves a joeylog.txt that dramatically under-reports progress -- the exact failure debug.c was designed to prevent. The SHR mailbox in uber.c is the reliable progress channel under emulation; fflush-commit semantics are queued as a toolchain question (LLVM816-ASKS.md) + +## Findings + +### 1. jlDrawPixel (non-IIgs) marks dirty with unclipped x/y, causing out-of-bounds writes to the dirty-band arrays + +- **Where:** `src/core/draw.c:549` +- **Severity:** critical | **Category:** bug + +The IIgs branch of jlDrawPixel bounds-checks and returns before marking (draw.c:529), but the #else branch (DOS/Amiga/ST) calls surfaceMarkDirtyRect with the RAW coordinates after plotPixelNoMark silently clipped the plot. surfaceMarkDirtyRect's contract (surface.c:230, 'already clipped to surface bounds') is violated: for s == stage and y outside [0,199], the row loop calls widenRow(y,...) which reads/writes gStageMinWord[y] and gStageMaxWord[y] with no clamp -- e.g. jlDrawPixel(stage, 5, 250, c) writes gStageMinWord[250], corrupting the adjacent gStageMaxWord array or other .bss. Negative x with valid y is also broken: SURFACE_WORD_INDEX(-5) = 0xFE widens gStageMaxWord[y] to 254 (rows are only 80 words), so the next jlStagePresent slams ~3 rows of bytes past the intended row band. Off-surface pixel draws are documented-legal ('Out-of-bounds draws are silent no-ops', draw.h:3) and routine in games, so this fires in normal use on DOS/Amiga/ST. + +**Fix:** Mirror the IIgs branch: in the #else path do `if ((uint16_t)x >= SURFACE_WIDTH || (uint16_t)y >= SURFACE_HEIGHT) { return; }` after the NULL check, then call jlpDrawPixel directly (skipping the plotPixelNoMark layer and its duplicate checks) and mark. Optionally also add a debug-build assert/clamp in surfaceMarkDirtyRect to catch future contract violations. + +**Impact:** Memory corruption (dirty-band arrays + neighboring globals) and present-time over-blit on DOS/Amiga/ST for any off-surface stage pixel; fix also removes one redundant function call + NULL check per on-surface pixel + +### 2. Interpreted sprite inner loop re-derives dst byte address and parity per pixel via a real function call + +- **Where:** `src/core/sprite.c:193` +- **Severity:** critical | **Category:** perf + +For every opaque pixel the loop computes (dx + col), then writeDstNibble (lines 87-96) redoes x >> 1 to find the byte and branches on x & 1 for parity -- all rederivable state the loop could carry incrementally, exactly as it already carries the src inTileX phase (lines 195-199). Worse, ORCA/C 2.2.1 performs no function inlining, so on the IIgs this is a genuine JSL/RTL + 3-arg stack frame per opaque pixel (~60-90 cycles of pure call overhead on top of the ~30-cycle merge). The in-file comment at lines 343-345 states jlSpriteCompile leaves sp->slot NULL on IIgs, so this loop is the ONLY IIgs sprite-draw path (bench: jlSpriteDraw 438 ops/sec = ~6400 cycles/op total). writeDstNibble also masks nibble & 0x0F twice (lines 92, 94) even though the caller's extract at lines 190-191 already yields a 4-bit value. + +**Fix:** Before the col loop set dst = dstRow + ((uint16_t)dx >> 1) and dstOdd = (uint8_t)(dx & 1). Per opaque pixel merge inline: if (dstOdd) { *dst = (uint8_t)((*dst & 0xF0) | nibble); dst++; } else { *dst = (uint8_t)((*dst & 0x0F) | (nibble << 4)); } then dstOdd ^= 1 (advance dst only after the odd half). Drop the redundant & 0x0F masks. Keep writeDstNibble only if a cold path still needs it; otherwise delete it. + +**Impact:** Saves ~70-95 cycles per opaque pixel on 65816 (call overhead + add + shift + redundant masks). An 8x8 mostly-opaque sprite saves ~5000 of its ~6400 cycle budget: roughly 2-3x on IIgs jlSpriteDraw, the perf-floor reference op. DOS gains the per-pixel address arithmetic even where Watcom inlines. + +**Verifier correction:** The per-pixel writeDstNibble call and redundant masks are real, and the fix is valid, but this is the clip-edge/uncompiled FALLBACK path, not every IIgs sprite draw: IIgs sprite codegen is ON by default (spriteCompile.c:144-146) and UBER's 438 ops/sec measured the compiled path. Expected win is 2-3x on the interpreted draw itself (edge-clipped sprites, pre-compile draws), not on the headline bench number. Bonus: the comment at sprite.c:341-345 is stale (contradicts spriteCompile.c #19 note) -- a multiple-sources-of-truth item worth fixing in the same pass. + +### 3. IIgs (perf reference) silently runs the generic C nibble loop for jlTilePasteMono -- no JL_HAS_TILE_PASTE_MONO, no port.h macro + +- **Where:** `include/joey/platform.h:98` +- **Severity:** high | **Category:** perf + +The IIgs table jumps from TILE_PASTE (line 98) to TILE_SNAP (line 99) with no JL_HAS_TILE_PASTE_MONO, and the IIgs macro block in src/core/port.h (lines 37-192) has no jlpTilePasteMono macro, while Amiga (platform.h:146) and ST (platform.h:201) both override it. So jlTilePasteMono (src/core/tile.c:140) dispatches to jlpGenericTilePasteMono (src/generic/genericTile.c:77), a cross-TU call running a 32-iteration per-byte colorize loop ('hi = (srcByte >> 4) ? fgColor : bgColor' etc., lines 86-91) before finally reaching the asm jlpTilePaste. This is a hot path: Space Taxi repaints all 1000 level tiles through jlTilePasteMono (examples/spacetaxi/stRender.c:425) and re-pastes 103 logo cells every 4th frame on the title screen (stRender.c:527). Every sibling tile op on the IIgs is hand asm; this is the one silent miss the flag/override split hides. + +**Fix:** Add JL_HAS_TILE_PASTE_MONO to the IIgs block in platform.h and a jlpTilePasteMono macro in port.h's IIgs section calling a new iigsTilePasteMonoInner in joeyDraw.s that LUT-expands mono bytes (16-entry nibble-pair table) directly into the surface rows, skipping the intermediate jlTileT + separate paste pass. + +**Impact:** Generic path ~= 100 cyc call + ~1500-2200 cyc colorize + ~2530 cyc paste (jlTilePaste = 1106 ops/s) => ~600 ops/s; fused asm ~2x faster per op. Title-screen logo repaint alone is ~450K cycles every 4 frames (~2.5 full 60Hz frame budgets at 2.8MHz). + +**Verifier correction:** All substantive claims hold. Two nits: (1) the '~2.5 full 60Hz frame budgets' figure is the amortized per-frame cost ? the 484K-cycle logo repaint is ~10 frame budgets per 4-frame period at 46.7K cyc/frame, so the impact is understated, not overstated; (2) the '~2530 cyc paste' component uses the full jlTilePaste API cost (1106 ops/s) while the generic path pays only the jlpTilePaste macro/asm inner (the jlTilePasteMono wrapper + dirty mark supply the rest), so the breakdown misattributes but the ~600 ops/s total is still about right. + +### 4. Atari ST defines neither JL_HAS_FLOOD_WALK_PLANES nor JL_HAS_FLOOD_SCAN_ROW_PLANES -- flood fill decodes 4 bitplanes per pixel via a function call + +- **Where:** `include/joey/platform.h:205` +- **Severity:** high | **Category:** perf + +The ST table goes straight from SPRITE_RESTORE (204) to SURFACE_COPY_PLANES (205); the Amiga table has JL_HAS_FLOOD_WALK_PLANES / JL_HAS_FLOOD_SCAN_ROW_PLANES between them (platform.h:150-151, implemented at src/amiga/hal.c:2131 and 2181). Without them, floodFillInternal (src/core/draw.c) takes the portable tier: the walk-out loop calls 'pix = jlpSamplePixel(s, (int16_t)(leftX - 1), y);' (draw.c:166) and the row scan calls 'pix = jlpSamplePixel(s, (int16_t)(leftX + i), scanY);' (draw.c:237) once per pixel. ST surfaces are pure planar (pixels==NULL, atarist/hal.c:2131/2159), so each call lands in jlpSamplePixel (atarist/hal.c:2013) -> stPlanarGetPixel: y*160 address math plus 4 interleaved plane-word tests -- per PIXEL, through a function call. Each filled pixel is visited ~3x (walk + scan above + scan below). Flood fill is about to become per-frame-hot: the planned AGI interpreter draws every pic resource with flood fills. + +**Fix:** Add the two flags to the ST block and implement jlpFloodWalkPlanes / jlpFloodScanRowPlanes in src/atarist/hal.c against the word-interleaved StPlanarT layout (walk runs 16 pixels per 4-word group by combining plane words into a match mask, same structure as the Amiga versions at amiga/hal.c:2131/2181, adjusted for ST_BYTES_PER_GROUP interleave). + +**Impact:** ~180-220 cycles per sampled pixel today; a 100x50 flood ~= 5000 px x 3 visits x ~200 cyc = ~3M cycles = ~0.4s at 8MHz. Plane-word walkers process 16 px per 4 word-reads: estimated 10-20x speedup on ST flood fill. + +**Verifier correction:** Directionally and factually right. Minor additions: port.h:153's comment claiming the planar flood hooks are 'Amiga-only' must be updated with the fix, and the realistic speedup is ~5-15x rather than the quoted 10-20x since the run-edge markBuf walk in draw.c stays per-pixel C either way. + +### 5. jlSpriteCompile ignores the arena's compact-and-retry contract: one fragmented alloc failure silently demotes the sprite to the interpreter forever + +- **Where:** `src/codegen/spriteCompile.c:202` +- **Severity:** high | **Category:** perf + +codegenArenaInternal.h:41-44 states the contract: 'Returns NULL if no free slot is large enough -- the caller should run codegenArenaCompact and retry, or surface the failure.' The only caller does neither: it bails, sp->slot stays NULL, and every subsequent jlSpriteDraw/SaveUnder/RestoreUnder for that sprite runs the interpreter (benched at 0.13-0.22x of the compiled path -- the #1 perf gap). codegenArenaCompact is only reachable via the public jlSpriteCompact (src/core/sprite.c:435), which nothing calls automatically, so a game that creates/destroys sprites across levels accumulates holes until compiles fail even with ample total free space. jlSpritePrewarm additionally swallows the false return ('(void)jlSpriteCompile(sp);', sprite.c:360), so the failure is invisible. + +**Fix:** On the NULL return, call codegenArenaCompact() and retry codegenArenaAlloc(totalSize) once before giving up (failure-path only, zero hot-path cost). Optionally coreSetError on the final failure so prewarm failures are diagnosable. + +**Impact:** Prevents a permanent 5-8x slowdown on the hottest op after arena fragmentation; ~10 lines + +**Verifier correction:** jlSpriteCompile technically satisfies the contract's second arm (it surfaces the failure via the documented false return, sprite.h:86), so 'ignores the contract' is overstated. The real defect: no code anywhere acts on that failure -- jlSpritePrewarm and both example apps swallow it, and nothing ever calls jlSpriteCompact -- so arena fragmentation silently and permanently demotes sprites to the interpreter. Fix as proposed: on codegenArenaAlloc returning NULL, call codegenArenaCompact() and retry the alloc once before returning false; failure-path only, zero hot-path cost. + +### 6. SFX mixer does a full 32x32 multiply per output sample; on 68000 this is a __mulsi3 library call + +- **Where:** `src/core/audioSfxMix.c:83` +- **Severity:** high | **Category:** perf + +interp() runs once per output sample per active slot inside audioSfxOverlayMix. delta is at most +/-255 and frac at most 65535, but both are int32_t, so on the ST (compiled -m68000 per make/atarist.mk line 10, no mulu.l) gcc emits a __mulsi3 library call: 3x mulu.w plus shifts/adds plus call overhead, roughly 250-350 cycles, versus ~74 cycles for a single muls.w. At the ST's 12288 Hz MIX_RATE with one active slot that is ~2.5-4M wasted cycles/sec, ~30-45% of the 8 MHz CPU, and the ST HAL already warns (src/atarist/audio.c:413) that audioSfxOverlayMix can approach the 83 ms refill budget -- this multiply is the single biggest reason. + +**Fix:** Reformulate as a 16x16->32 signed multiply that matches gcc's mulhisi3 pattern: `sample = (int)s0 + (int)(((int32_t)(int16_t)delta * (int32_t)(int16_t)(uint16_t)(frac >> 1)) >> 15);` -- delta fits int16, frac>>1 fits a positive int16, and the result differs from the current value by at most 1 LSB of 8-bit audio (inaudible). Emits one muls.w on 68000; DJGPP is unaffected. + +**Impact:** ~200-280 cycles/sample saved; roughly halves audioSfxOverlayMix cost on ST (~30% of ST CPU while SFX plays) + +**Verifier correction:** Everything as stated, except the proposed one-line expression does not deliver the muls.w on the shipped toolchain (still compiles to __mulsi3). Write it with explicit int16_t locals so gcc matches mulhisi3: 'int16_t delta = (int16_t)(s1 - s0); int16_t fracH = (int16_t)(uint16_t)(frac >> 1); return (int)s0 + (int)(((int32_t)delta * fracH) >> 15);' -- verified to emit a single muls.w with m68k-atari-mint-gcc 15.2.0 at the real ST flags. + +### 7. 64-bit posFp arithmetic executed per sample in the mixer inner loop + +- **Where:** `src/core/audioSfxMix.c:110` +- **Severity:** high | **Category:** perf + +pos is uint64_t (48.16 fixed point), so every sample pays a 64-bit >>16, a 64-bit mask, and a 64-bit add (`pos += (uint64_t)step;` line 142). On the 68000 each of these is a multi-register sequence (~40-70 cycles combined vs ~10 for 32/16-bit ops). The uint64_t exists only to lift the old 65535-input-sample cap (header comment, audioSfxMixInternal.h:37-41), but a split representation gives the same range without any 64-bit ops: a uint32_t sample index covers 4G input samples, far beyond the 48-bit rationale. + +**Fix:** Replace posFp with `uint32_t posIdx` + `uint16_t posFrac`, and split stepFp once per slot into stepInt = stepFp >> 16 and stepFrac = stepFp & 0xFFFF. Inner loop becomes: `frac += stepFrac; idx += stepInt + (frac carry);` (carry via `if (frac < stepFrac) idx++;` after 16-bit wrap, or a uint32_t accumulator for frac). Update audioSfxSlotArm/ArmStream/streamRefill resets accordingly. Output is bit-identical. + +**Impact:** ~30-60 cycles/sample on 68000, ~10-15% of the mixer inner loop + +**Verifier correction:** Impact is ~25-50 cycles/sample (~5-10% of the loop today, ~10-17% after the multiply fix), not a guaranteed 30-60. Use the carry-compare formulation (uint16_t frac; frac += stepFrac; if wrap then idx++) or keep frac in a 16-bit register -- the suggested uint32_t-accumulator '(fracAcc >> 16)' add compiles to a swap chain on m68k-atari-mint-gcc that eats most of the win. Part of the benefit is indirect: freeing the second pos register un-spills step from the stack (add.l 50(%sp) becomes add.l Dn). + +### 8. IIgs 64KB attrNoCross cap on the codegen arena is unenforced; codegenBytes > 64KB hard-fails jlInit (adventure.c passes 256KB) + +- **Where:** `src/core/codegenArena.c:236` +- **Severity:** high | **Category:** bug + +attrNoCross means the block may not cross a 64KB bank boundary, so any totalBytes > 0x10000 is unsatisfiable and NewHandle returns NULL; codegenArenaInit returns false and jlInit (src/core/init.c:99-105) aborts with the generic message 'failed to allocate codegen arena'. Nothing in the arena, init.c, or joey/core.h documents or clamps the cap. examples/adventure/adventure.c:2273 sets `config.codegenBytes = 256UL * 1024`, which would make the app refuse to start on the reference platform even though clamping to one bank would work fine (routineOffsets are uint16_t and per-sprite fnAddr math already fits a bank). + +**Fix:** In codegenArenaInit, under JOEYLIB_PLATFORM_IIGS, clamp: `if (totalBytes > 65536ul) { totalBytes = 65536ul; }` (with a comment naming the attrNoCross bank cap), or fail with a specific error string so the caller knows to shrink codegenBytes. + +**Impact:** Whole-app init failure on IIgs for any config >64KB; one concrete caller (adventure) already trips it + +**Verifier correction:** One refinement: clamping to exactly 65536 may still fail at runtime -- a 64KB attrFixed+attrNoCross+attrPage block requires a completely free bank, which GS/OS may not have. The clamp should be paired with a specific error string (so failure is diagnosable) and/or adventure.c should set codegenBytes per-platform (#ifdef: ~16KB on chunky ports as its own comment notes, 256KB only on Amiga). Also document the IIgs cap at the jlConfigT.codegenBytes declaration in include/joey/core.h. + +### 9. jlDrawLine Bresenham fallback accumulates the dirty bounding box with 4 compares per pixel although it equals the endpoint box + +- **Where:** `src/core/draw.c:484` +- **Severity:** high | **Category:** perf + +Bresenham walks monotonically from (x0,y0) to (x1,y1) and terminates exactly at (x1,y1), so the set of visited points has bounding box [min(x0,x1),max(x0,x1)] x [min(y0,y1),max(y0,y1)] -- exactly what the JL_HAS_DRAW_LINE fast path already computes once up-front at lines 459-462 (bbx/bby/bbw/bbh). The in-loop accumulation therefore recomputes a value that is fully known before the loop, at 4 compare+branch pairs per plotted pixel. This fallback is the ONLY diagonal-line path on DOS (no JL_HAS_DRAW_LINE) and runs for every clipped diagonal line on IIgs/Amiga/ST. + +**Fix:** Before the loop, save the original endpoints and compute minX/maxX/minY/maxY from them (same 4 ternaries as the fast path), then delete the 4 in-loop compares and keep the existing post-loop clamp+mark unchanged. Output is bit-identical. + +**Impact:** ~40-50 cycles/pixel on 68000 (4x cmp.w+bcc) vs a ~150-250 cycle per-pixel plot: ~15-25% off the fallback diagonal-line inner loop; ~20-30 cycles/pixel on 65816 + +### 10. IIgs stage-identity test is a cross-TU jlStageGet() function call per primitive (per pixel on the hottest op) and is re-checked again inside surfaceMarkDirtyRect + +- **Where:** `src/core/draw.c:533` +- **Severity:** high | **Category:** perf + +gStage is `static` in surface.c (line 27), so the stage-identity test everywhere outside surface.c must go through jlStageGet() -- a real cross-segment JSL + ORCA-C prologue/epilogue. The IIgs jlDrawPixel (measured 1755 ops/sec ~= 1600 cyc/call) pays this call per pixel just to compare two pointers. The IIgs macros jlpFillCircle, jlpSurfaceClear, and jlpFillRect in src/core/port.h (lines 97, 106, 116: `((_s) == jlStageGet() ?`) pay it once per invocation -- i.e. once per SPAN in jlFillCircle/jlDrawLine/jlDrawRect span loops via fillRectOnSurface. Worse, fillRectOnSurface then calls surfaceMarkDirtyRect, which re-derives the same fact with its own `s != gStage` compare -- the stage test is evaluated twice per span through two different mechanisms (function call vs direct global), a duplication of the same truth. + +**Fix:** Make gStage non-static, declare `extern jlSurfaceT *gStage;` in surfaceInternal.h (gStageMinWord/gStageMaxWord are already extern there), and replace jlStageGet() in the port.h macros and draw.c hot paths with a direct `(_s) == gStage` compare (two LDA-long + CMP, ~12-16 cycles vs ~40-70 for the call). Keep jlStageGet() as the public accessor. + +**Impact:** ~30-60 cycles per primitive invocation on IIgs: ~3-4% of jlDrawPixel per call, multiplied per-span in fillCircle/drawRect/line loops; also removes the double stage check per span + +### 11. jlDrawRect general path wraps w/h > 32767 through (int16_t) casts, drawing spurious edges on-surface + +- **Where:** `src/core/draw.c:591` +- **Severity:** high | **Category:** bug + +In the clipped (general) path, the bottom-edge row is computed as y + (int16_t)h - 1 and the right-edge column as x + (int16_t)w - 1 (line 595). h and w are uint16_t, so any value > 32767 goes negative in the cast and the edge coordinate wraps by -65536. When the true edge coordinate lands in [65536, 65855+], the wrapped value falls back inside [0, 319]/[0, 199] and a phantom edge is drawn ON-surface: jlDrawRect(s, 100, 5, 10, 65533, c) computes bottom row (int16_t)(5 + (-3) - 1) = 1 and draws a horizontal line at y=1 that should be at y=65537 (off-surface, i.e. nothing). Same for the right edge with large w (e.g. x=300, w=65530 draws a vertical edge at x=293). The on-surface fast path above is safe because it is gated by the 32-bit test at lines 574-576. + +**Fix:** Compute the edge coordinates in int32_t in the general path (int32_t bottom = (int32_t)y + h - 1; skip the edge if bottom > SURFACE_HEIGHT-1 or < 0, else cast), or early-clip the outer rect in 32-bit like jlFillRect does and derive edges from the clipped box. + +**Impact:** Wrong pixels rendered for w or h > 32767 on all four ports; cold path, ~4 extra 32-bit ops only in the already-slow clipped case + +**Verifier correction:** Details are accurate as stated. Worth noting the realistic trigger is unsigned-wrap in caller width math, not literal 32k-wide rects. + +### 12. jlFillCircle (and jlDrawCircle fallback) cast r to int16_t: r > 32767 draws nothing; r in [~380, 32767] iterates up to 32k clipped spans + +- **Where:** `src/core/draw.c:656` +- **Severity:** high | **Category:** bug + +r is uint16_t. For r >= 32768, (int16_t)r is negative, so the span loop never executes and jlFillCircle draws NOTHING -- but a disk of radius 40000 centered anywhere near the surface mathematically covers the entire 320x200 surface, so the correct output is a full-surface fill. jlDrawCircle's fallback has the same wrap at line 338 (`x = (int16_t)r;` makes `while (x >= y)` false immediately), which silently drops perimeter pixels that can legitimately cross the surface (e.g. cx=-39900, cy=100, r=40000 passes through x~100). Separately, for r in [~380, 32767] with the circle not fully on-surface, the loop runs r+1 iterations emitting two clipped jlFillRect calls each even though at most 200 rows can intersect the surface -- 32767 iterations of 32-bit while-loop math plus 65k clipped fill calls is multiple seconds at 2.8 MHz. + +**Fix:** Before the span loop: 32-bit test whether the farthest surface corner from (cx,cy) satisfies dist2 <= r*r; if so, jlFillRect(s, 0, 0, SURFACE_WIDTH, SURFACE_HEIGHT) and return (this also fixes all r >= 32768 full-coverage cases). Then clamp the y loop to rows that can intersect the surface (y limited by cy vs [0,199] bounds, computed in int32 once) and use a uint16_t loop variable. + +**Impact:** Correctness: whole-surface fills silently dropped for huge r; perf: worst case ~32k wasted iterations (seconds per call) reduced to <=200 + +**Verifier correction:** Fix must seed yy=y0*y0 when clamping the y range (the incremental (y+1)^2 chain breaks on a jump), and the initial x walk-down from r is still O(r) once per call unless an integer sqrt seeds x; the wasted-iteration window is any r >= 200 with clipping, not just r >= ~380. + +### 13. jlFillRect runs 32-bit clip math on every call; the common on-surface case needs only 16-bit compares + +- **Where:** `src/core/draw.c:703` +- **Severity:** high | **Category:** perf + +The clip (draw.c:703-717) does 4 sign/zero extensions to int32_t, two 32-bit adds, four 32-bit clamps, and two 32-bit compares on a 16-bit-native CPU where every int32 op is a multi-instruction carry-chained sequence -- roughly 120-200 cycles -- even when the rect is fully on-surface (the overwhelmingly common case: 16x16 HUD fills, tile-sized fills, benchmark ops). The 32-bit width is only needed to survive x<0 with huge w, or w>32767. The library already recognizes this cost elsewhere: jlDrawLine (draw.c:425-427), jlDrawRect (draw.c:574-576), and jlFillCircle (draw.c:664-667) all grew on-surface pre-tests specifically to 'skip jlFillRect's 32-bit clip'. jlFillRect itself never got the fast path, so direct API users always pay it. + +**Fix:** Add a 16-bit fast path before the 32-bit clip: 'if (x >= 0 && y >= 0 && w > 0 && h > 0 && w <= SURFACE_WIDTH && h <= SURFACE_HEIGHT && x <= (int16_t)(SURFACE_WIDTH - w) && y <= (int16_t)(SURFACE_HEIGHT - h)) { fillRectOnSurface(s, x, y, (int16_t)w, (int16_t)h, colorIndex); return; }' -- all uint16/int16 ops -- and keep the 32-bit clip as the slow path. This also lets the three callers above drop their duplicated pre-tests (one source of truth for the fast path). + +**Impact:** ~100-180 cycles per on-surface jlFillRect: ~10-25% of an honest 16x16 fill (450 ops/sec measured), plus dedupes three caller-side on-surface tests + +**Verifier correction:** Add the 16-bit on-surface fast path to jlFillRect, but keep the three span emitters calling fillRectOnSurface directly (do not reroute spans through the public jlFillRect). Realistic impact: ~100-180 cycles per on-surface call on the 65816, ~2-3% of the measured 16x16 fill (more, proportionally, for smaller rects); severity medium, not high. + +### 14. jlSamplePixel pays a second cross-TU function hop for a ~30-cycle nibble read on chunky ports + +- **Where:** `src/core/draw.c:776` +- **Severity:** high | **Category:** perf + +IIgs and DOS define no JL_HAS_SAMPLE_PIXEL, so jlpSamplePixel resolves to the out-of-line jlpGenericSamplePixel function (port.h:394-400). The chain is: public jlSamplePixel call (~60-80 cyc marshalling s/x/y) -> NULL+bounds checks -> a SECOND full call into jlpGenericSamplePixel (~60-80 cyc re-marshalling the exact same three args) -> a 3-line nibble extract. Measured 1916 ops/sec = ~1460 cyc/call for what is fundamentally a ~30-40 cycle indexed read; the inner hop alone is 30-50% of the honest post-fix cost. This is the exact wrapper tax port.h:44-46 documents ('a C wrapper would cost ~60-80 cyc/call of pure overhead') and eliminated for the draw ops via macros -- the read op was left behind. + +**Fix:** On chunky builds (JOEYLIB_NATIVE_CHUNKY without JL_HAS_SAMPLE_PIXEL), inline the read directly in jlSamplePixel after the bounds check: 'uint8_t b = s->pixels[SURFACE_ROW_OFFSET((uint16_t)y) + ((uint16_t)x >> 1)]; return (x & 1) ? (uint8_t)(b & 0x0F) : (uint8_t)(b >> 4);' (draw.c already includes surfaceInternal.h). Equivalently, make jlpGenericSamplePixel a static inline in a shared internal header so all chunky call sites collapse the hop. + +**Impact:** ~80-150 cycles per call, 30-50% of jlSamplePixel; also speeds the flood-fill C walk-out loops on ports without asm flood tiers (DOS/blank) which call it per pixel + +**Verifier correction:** Win on IIgs is larger than stated per-mechanism (inner call ~60-80 cyc PLUS the y*160 software-multiply helper the generic pays because it skips the gRowOffsetLut), but is ~10-17% of the measured op, not 30-50%. To also speed the flood-fill walk loops, the read must be exposed as a macro (not static inline -- ORCA/C 2.2.1) in a shared internal header, since floodFillInternal calls jlpSamplePixel, not the public jlSamplePixel. + +### 15. jlFrameCount is a double pass-through on IIgs: jlFrameCount -> jlpFrameCount -> iigsGetTickWord, paid every benchmark iteration and every game-loop pacing check + +- **Where:** `src/core/init.c:148` +- **Severity:** high | **Category:** perf + +core.h tells callers to poll jlFrameCount faster than 2*jlFrameHz, and UBER polls it once per op iteration. On IIgs the call chain is three deep: the init.c wrapper JSLs to jlpFrameCount in src/iigs/hal.c:372-374, whose entire body is `return iigsGetTickWord();` (asm in peislam.s). Two of the three layers are pure forwarding, ~150-250 cycles per call on the 65816. This is also where a large slice of the 'jlKeyDown = 3382 ops/sec' mystery goes: 2.8 MHz / 3382 is ~830 cycles/iter, of which the frame-count chain in the bench loop is ~250-350 cycles -- it systematically inflates the measured cost of every fast op. + +**Fix:** Use the existing `#if !defined(jlpFrameCount)` hook (src/core/port.h:495): in the IIgs port header declare iigsGetTickWord and `#define jlpFrameCount() iigsGetTickWord()`, deleting the hal.c wrapper. Same treatment for jlpFrameHz (body is `return gFrameHz;`). That leaves one unavoidable public-ABI layer instead of three. + +**Impact:** ~100-170 cycles per jlFrameCount call on IIgs; corrects downward bias in every fast-op UBER number + +**Verifier correction:** Directionally right, three details need fixing. (1) Savings are ~60-80 cycles per call (one removed forwarding layer), not ~100-170: the public jlFrameCount ABI layer remains, and the dominant cost of the chain is the Misc Toolset GetTick dispatch inside iigsGetTickWord (peislam.s:29-35, jsl $E10000), which no wrapper removal touches. The benchmark bias is real but only partially removable this way. (2) The jlpFrameHz 'same treatment' is unsafe as proposed: gFrameHz is static in hal.c (line 59); exporting it for direct reads from CORESYS-segment code risks the documented cross-load-segment DBR/absolute-addressing trap (hal.c:363-371) if the compiler emits DBR-relative addressing, and jlpFrameHz is cold (once per bench op, not per iteration) -- leave it a function or verify long-addressing codegen first. (3) Also remove the now-stale JL_HAS_FRAME_COUNT at include/joey/platform.h:108 and fix the wrong '~10-30 cyc per-iter poll' comment at examples/uber/uber.c:12-14; a cheaper long-term option for the residual toolbox cost is having the asm cache the tick in a long-addressed word updated by an edge-detect, but that changes timing semantics and is a separate decision. + +### 16. jlPaletteSet re-masks all 15 caller entries against the documented $0RGB contract instead of memcpy + forcing color 0 + +- **Where:** `src/core/palette.c:106` +- **Severity:** high | **Category:** perf + +include/joey/palette.h:15-16 states the contract: 'colors16 must point to exactly 16 uint16_t values in $0RGB format', and the only documented silent rewrite is color 0 forced to $000. The & 0x0FFF on entries 1-15 is undocumented defensive validation of already-guaranteed input, paid on every call of an op measured at only 678 ops/sec (~4100 cyc). Under ORCA/C the loop costs two far-pointer indirect accesses, two 32-bit pointer increments, and SEP/REP churn from the uint8_t counter per iteration (~45-60 cyc x 15). The read-side twin jlPaletteGet (line 82) already does a plain 32-byte memcpy of the same row, which lowers to MVN (~7 cyc/byte) on IIgs. + +**Fix:** memcpy(row, colors16, SURFACE_COLORS_PER_PALETTE * sizeof(uint16_t)); +row[0] = 0x0000; +This keeps the documented color-0-black behavior bit-exact for contract-conforming input and mirrors jlPaletteGet. (If the defensive mask must be kept, at minimum change `uint8_t i` to `uint16_t i` to stop the 8-bit-counter SEP/REP churn on the 65816.) + +**Impact:** ~400-600 cyc/call, roughly 10-15% of jlPaletteSet on IIgs + +**Verifier correction:** Directionally and quantitatively right; two mechanism details are off: ORCA/C keeps M=16 and handles the uint8_t counter via AND #$00FF masking (plus 32-bit far-pointer post-increments), not SEP/REP churn per iteration; and ORCA/C memcpy is a library call whose inner loop uses MVN, not an inlined MVN. The proposed fix (memcpy row, then row[0] = 0x0000) is correct as written ? palette.c's own paletteInitDefault comment already treats the re-masking as avoidable overhead. + +### 17. jlStageGet() function call buried in hot-path port.h macros and jlDrawPixel costs a cross-TU hop per primitive + +- **Where:** `src/core/port.h:116` +- **Severity:** high | **Category:** perf + +The IIgs jlpFillRect (port.h:115-120), jlpSurfaceClear (port.h:105-109), and jlpFillCircle (port.h:96-101) macros, plus jlDrawPixel itself (draw.c:533 'if (s == jlStageGet())'), each call the jlStageGet() accessor to answer 'is this the stage?'. gStage is a static in surface.c (surface.c:27), so every jlDrawPixel, every jlFillRect, every fill-circle span (81 spans for r=40), every H/V line, and every flood-fill span pays a JSL+RTL plus a far-pointer load (~25-60 cyc) to fetch a pointer that changes only at init/shutdown. The repo already fixed this exact pattern for the codegen arena: codegenArenaInternal.h:62-66 says 'Direct extern access (instead of a getter function) so per-frame hot paths ... skip the JSL/PHB/RTL/PLB the wrapper would impose.' On the post-Phase-2 jlDrawPixel chain (~400-500 cyc) this call is ~8-12% of the whole op. + +**Fix:** Declare 'extern jlSurfaceT *gStage;' in surfaceInternal.h (renaming or keeping jlStageGet() as the public accessor shim, same as codegenArenaBase()), and change the port.h macros and draw.c:533 to compare '(_s) == gStage' directly. Read-only cross-TU global reads are safe on 65816 (only '++' on globals is the DBR trap). + +**Impact:** ~25-60 cycles removed per jlDrawPixel/jlFillRect/jlSurfaceClear/jlFillCircle-span call; ~8-12% on jlDrawPixel, multiplied 81x inside one jlFillCircle r=40 C-span fallback + +**Verifier correction:** Direct-extern gStage fix is correct and safe, but: (1) drop the flood-fill claim ? IIgs flood spans go through iigsFloodWalkAndScansInner and never reach jlpFillRect, so flood is unaffected on every port; (2) impact today is ~25-50 cyc per call = ~1.5-3% of the measured jlDrawPixel (1755 ops/sec ? 1600 cyc) and ~0.5-1% of jlFillRect16x16; the 8-12% figure only holds for the hypothetical post-optimization ~400-500 cyc chain. Severity: medium, not high. The 81x-per-fill-circle multiplier applies only to the off-stage/off-surface C span fallback. + +### 18. No opaque-pair whole-byte fast path; 5 conditional branches per pixel in the interpreted draw loop + +- **Where:** `src/core/sprite.c:189` +- **Severity:** high | **Category:** perf + +Each pixel executes 5 conditional branches: src-parity ternary (line 190), transparency test (line 192), dst-parity branch inside writeDstNibble (line 91), tile-boundary test (line 196), and the loop bound (line 185). Src parity (sx + col) & 1 and dst parity (dx + col) & 1 differ by the constant (dx ^ sx) & 1 for the whole draw, so the common aligned case (even x, no odd clip) can be specialized to a byte-wise loop: read one src byte = two pixels; byte == 0 skips both; both nibbles opaque = one whole-byte store instead of two read-modify-write merges. The first review round flagged this exact missing fast path in genericTile.c (the byte-merge structure at src/generic/genericTile.c lines 52-67 is the template, itself still missing the both-opaque single store at line 59) but not here, where it matters more: this loop is every IIgs sprite draw and the IIgs/DOS partial-clip fallback. + +**Fix:** Hoist alignedPhase = (((dx ^ sx) & 1) == 0) out of the row loop. Aligned case: loop over src bytes -- b = *src++; if (b == 0) { dst++; continue; } if ((b & 0xF0) && (b & 0x0F)) { *dst++ = b; } else merge the single opaque nibble; handle one odd leading/trailing pixel outside the loop. Keep the current per-pixel walker only for the misaligned phase. + +**Impact:** Aligned opaque pair drops from ~2 RMW merges (~50-60 cycles even after inlining) to one load+test+store (~15 cycles) on 65816; branch count falls from 10 to ~3 per pair. Combined with the dst-pointer fix this is ~3-5x on the aligned-opaque inner loop, the dominant case for typical sprites. + +**Verifier correction:** Valid missing fast path, but on the interpreted fallback (edge-clipped and uncompiled draws) rather than 'every IIgs sprite draw' -- IIgs codegen is on by default. Implementation must chunk the byte loop per tile column (4 contiguous bytes, then advance srcTileRow by TILE_BYTES), which the sketched *src++ loop glosses over. + +### 19. Save/restore interpreted fallbacks bypass SURFACE_ROW_OFFSET and pay two software multiplies per row + +- **Where:** `src/core/sprite.c:524` +- **Severity:** high | **Category:** perf + +The restore fallback (line 524) and the save fallback twin (line 632: srcRow = &s->pixels[(dy + row) * SURFACE_BYTES_PER_ROW];) compute row addresses with a raw * 160 instead of SURFACE_ROW_OFFSET -- the macro surfaceInternal.h:124-130 exists precisely because ORCA/C emits a ~150-cycle multiply helper for this (surfaceInternal.h:72 comment), and spriteDrawInterpreted at line 173 already uses it. Both loops additionally recompute the backup index per row with a variable multiply ((uint16_t)row * (uint16_t)copyBytes, lines 526 and 633), another ~30-50 cycle helper call on 65816. Since sp->slot is NULL on IIgs (comment lines 343-345), these fallbacks ARE the per-sprite-per-frame IIgs save/restore path. This is also a multiple-sources-of-truth violation: row addressing has a blessed single mechanism and these two loops re-derive it inline. + +**Fix:** Hoist pointers and strength-reduce both loops: dst = &s->pixels[SURFACE_ROW_OFFSET((uint16_t)by) + byteStart]; src = backup->bytes; then per row memcpy(dst, src, (size_t)copyBytes); dst += SURFACE_BYTES_PER_ROW; src += copyBytes;. Apply identically in jlSpriteSaveUnder lines 631-636. + +**Impact:** ~180-200 cycles per row saved on IIgs (both multiply helpers replaced by two pointer adds). An 8-row sprite saves ~1500 cycles per save or restore call -- plausibly 30-50% of each interpreted save/restore op. 68000 ports unaffected (chunky loop skipped); DOS gains the variable multiply. + +**Verifier correction:** Fix and mechanism are correct, but on IIgs these loops are the clipped/uncompiled fallback, not the primary per-frame path (compiled MVN save/restore is on by default per spriteCompile.c). The cycle savings apply to the fallback op; the SURFACE_ROW_OFFSET bypass is independently a concrete multiple-sources-of-truth cleanup. + +### 20. surfaceMarkDirtyRect is a cross-TU function hop on every drawing op, re-deriving stage-ness the dispatch macro just computed + +- **Where:** `src/core/surface.c:234` +- **Severity:** high | **Category:** perf + +Every primitive tail pays a full cross-TU call (~60-80 cyc of push/JSL/prologue) into surfaceMarkDirtyRect just to run 's != gStage' (surface.c:242) -- a test jlpFillRect's macro already evaluated via jlStageGet() one line earlier (two computations of the same fact per op) -- plus two shifts and a branch, before either the inline h==1 widen or a second JSL into iigsMarkDirtyRowsInner for h>1. Call sites on the hot five: fillRectOnSurface (draw.c:54), jlTilePaste (tile.c:161), jlTileCopy/Masked/Fill (tile.c:83/102/122), jlSpriteDraw via sprite.c:351 and spriteDrawInterpreted (sprite.c:203). For jlTilePaste the mark chain (~150-200 cyc: call + checks + JSL + 8-row widen) rivals the 32-byte paste itself; Phase 2.1 of IIGS_PERF_PLAN.md fixed this for jlDrawPixel only. + +**Fix:** Move surfaceMarkDirtyRect into surfaceInternal.h as a static inline (it needs only the already-extern gStageMinWord/gStageMaxWord, a widenRow inline, and the extern gStage from the previous finding). h==1 widens fully inline; h>1 keeps the single JSL to iigsMarkDirtyRowsInner. Non-stage surfaces then cost one inline compare instead of a wasted function call. + +**Impact:** ~60-100 cycles per jlFillRect/jlTile*/jlSpriteDraw call (10-25% of an honest 8x8 tile op); also removes the dead call entirely for non-stage surfaces + +**Verifier correction:** Impact is ~60-120 cycles per op, which is ~2-4% of the MEASURED per-call times (jlTilePaste 1106 ops/s = ~2530 cyc, jlFillRect16x16/jlSpriteDraw ~6200-6400 cyc); the 10-25% figure only holds against the op's intrinsic cost with harness/API overhead excluded. Requires making gStage extern (it is static in surface.c). Note the redundant stage-ness applies to the jlpFillRect/jlpSurfaceClear/jlpFillCircle macros (which call jlStageGet(), itself a ~60-cyc cross-TU call worth converting to an extern-gStage compare in the same pass); the jlpTile* macros never compute stage-ness. On IIgs the sprite path hits this via sprite.c:203 (interpreted), since sp->slot is always NULL there; sprite.c:351/420 apply to the 68k compiled path. One caveat: if any IIgs build still uses ORCA/C 2.2.1, its 'inline' is parsed but not honored, making the win 68k-only under that compiler. + +### 21. stageDirtyClearAll runs a 200-iteration byte loop every present while its sibling uses memset for the same arrays + +- **Where:** `src/core/surface.c:317` +- **Severity:** high | **Category:** perf + +jlStagePresent calls stageDirtyClearAll after every frame (src/core/present.c:25), so this loop is on the flagship hot op. Yet surfaceMarkDirtyAll, 90 lines up in the same file (lines 225-226), already switched to `memset(gStageMinWord, 0, SURFACE_HEIGHT); memset(gStageMaxWord, STAGE_DIRTY_FULL_MAX, SURFACE_HEIGHT);` with a comment explaining memset 'lowers to a tight fill (MVN-seeded on IIgs) and drops the 200-iter indexed-store loop'. The clear-all variant was left on the slow form: ~200 iterations of two 8-bit indexed stores plus loop overhead under ORCA/C is roughly 5000-8000 cycles per present (the per-present budget at the honest ~29-42 ops/sec is ~66-96k cycles), and the byte-at-a-time loop also runs per-frame on the 68k ports. + +**Fix:** void stageDirtyClearAll(void) { + memset(gStageMinWord, STAGE_DIRTY_CLEAN_MIN, SURFACE_HEIGHT); + memset(gStageMaxWord, STAGE_DIRTY_CLEAN_MAX, SURFACE_HEIGHT); +} +(constant fills 0xFF / 0x00, same values the loop stores; deletes the row variable). + +**Impact:** ~5000-8000 cyc per jlStagePresent on IIgs (~7-10% of a present); every frame on all four ports + +**Verifier correction:** Net saving is the loop cost (~4000-6000 cyc) minus the two memsets (~2800 cyc for 400 bytes), i.e. roughly 2000-4000 cyc per present on IIgs (~3-6% of the honest ~66-96k cycle present budget), not the full 5000-8000. Still a clear every-frame win on all four ports and removes a duplicated slow variant of logic the author already converted to memset in surfaceMarkDirtyAll. + +### 22. jlDrawText pays the full public jlTileCopyMasked wrapper plus a multi-row dirty-mark call per glyph + +- **Where:** `src/core/tile.c:54` +- **Severity:** high | **Category:** perf + +Per character, jlDrawText re-enters the public wrapper which re-checks dst/src for NULL (already checked once at tile.c:43), re-validates cx/cy against block bounds (guaranteed by the cursor-wrap logic at lines 57-62; only srcBx/srcBy from asciiMap actually need validation), and then calls surfaceMarkDirtyRect for an 8-row rect. On IIgs an h=8 mark takes the JSL-to-iigsMarkDirtyRowsInner path (surface.c:262), so every glyph costs a wrapper prologue (~60-80 cyc) plus a cross-segment dirty-mark call that re-widens the same 8 rows the previous glyph just widened. + +**Fix:** In jlDrawText, validate srcBx/srcBy inline, call jlpTileCopyMasked(...) directly, and accumulate a bounding block rect (minCx/maxCx/minCy/maxCy) across the string; issue one surfaceMarkDirtyRect for the union after the loop (over-approximation on wrap is safe). + +**Impact:** ~300-400 cycles saved per glyph on IIgs (~10-15% of text throughput); a 20-char HUD string saves ~7000 cycles per redraw + +**Verifier correction:** The fix must also bounds-check the caller's bx/by once at jlDrawText entry (e.g. return, or clamp, when bx >= TILE_BLOCKS_PER_ROW || by >= TILE_BLOCKS_PER_COL): the wrap logic at tile.c:56-62 only guarantees in-range cx/cy after the first wrap, so with a direct jlpTileCopyMasked call an out-of-range initial by would produce up to 40 out-of-bounds tile writes (past the 200-entry gRowOffsetLut on IIgs) that the current wrapper's validation silently absorbs. Note this is a tiny behavior change for garbage inputs (chars before the first wrap were previously skipped, not drawn); the header documents bx in [0,39], by in [0,24], so rejecting them is fine. With that added, the rest of the fix (inline srcBx/srcBy validation from asciiMap, direct jlpTileCopyMasked, one unioned surfaceMarkDirtyRect after the loop) is sound on all four ports. + +### 23. jlpGenericMillisElapsed wraps backward every ~18 minutes and does a 32-bit multiply+divide per call on IIgs/Amiga + +- **Where:** `src/generic/genericPort.c:40` +- **Severity:** high | **Category:** bug + +jlpFrameCount() returns uint16_t, so the product wraps to 0 after 65536 frames: 18.2 minutes at 60Hz, 21.8 at 50Hz. The uint32_t return type advertises a monotonic millisecond clock, so any game computing `now - last` deltas sees a huge underflow at the wrap and stalls or skips. Only ST and DOS override JL_HAS_MILLIS_ELAPSED (platform.h lines 218/248); the comment at line 37 confirms "This is the IIgs/Amiga path" -- the reference platform is affected. Each call also pays a 32-bit software multiply and a 32-bit divide (hundreds of cycles on the 65816). + +**Fix:** Keep a static uint32_t accumulated frame count: read jlpFrameCount(), extend by tracking the uint16_t delta since the previous call ((uint16_t)(now - last) handles wrap), add to the 32-bit total, then convert. Cache jlpFrameHz() once; for the common 50/60Hz values replace the generic divide with *20 (50Hz) or *50/3 (60Hz) fast cases, keeping the general path as fallback. + +**Impact:** correctness: eliminates the 18-min timer wrap on IIgs/Amiga; perf: drops a 32-bit div per call except in cold generic case + +**Verifier correction:** All core claims stand. Refinements to the fix: (1) do not convert an accumulated total frame count (totalFrames*1000/hz re-overflows uint32 at ~19.9h @60Hz) -- accumulate milliseconds incrementally per uint16_t frame delta, carrying the fractional-frame remainder (e.g. ms += delta*20 at 50Hz; at 60Hz ms += delta*50/3 with mod-3 remainder carry), which pushes the wrap to the natural 2^32 ms (~49.7 days) where uint32_t delta arithmetic is exact for callers; (2) the fix only observes every wrap if called at least once per 65535 frames (~18 min) -- true for any per-frame caller, but document it; (3) on ORCA-C update the statics with explicit load/add/store (the gFrameCount pattern, src/iigs/hal.c:363-371), never ++ on file-scope data (inc abs DBR trap); (4) nuance on failure mode: pure 'delta >= threshold then reset' callers self-heal with one spurious fire -- the severe damage is accumulator-style callers like agi.c:787-789 (freeze + v11 script-clock jump); (5) related cleanup: src/iigs/hal.c:382-386 is a dangling comment for the removed IIgs jlpMillisElapsed acknowledging the old wrap limitation -- delete it, and core.h:61's 'decoupled from frame rate' claim is also currently false on IIgs/Amiga (frame-derived), which the accumulator fix does not change and the doc should note. + +### 24. jlpGenericSamplePixel pays a software multiply (y*160) instead of the SURFACE_ROW_OFFSET LUT the codebase built for exactly this + +- **Where:** `src/generic/genericSurface.c:55` +- **Severity:** high | **Category:** perf + +port.h has no IIgs macro override for jlpSamplePixel, so every IIgs jlSamplePixel call (measured 1916 ops/sec, ~1460 cyc/call) lands here and pays the ORCA/C software multiply helper (~100-150 cyc) for the constant *160. surfaceInternal.h (included by this TU at line 12) defines SURFACE_ROW_OFFSET precisely to dodge this: on IIgs it is a single ASL + indexed read of gRowOffsetLut, and on gcc/Watcom ports it compiles to the same shift+add chain as the raw multiply. The same multiply is also paid per-pixel by the flood-fill C fallback walks in draw.c (lines 151, 166, 177, 237) on DOS. + +**Fix:** uint8_t byte = s->pixels[SURFACE_ROW_OFFSET((uint16_t)y) + ((uint16_t)x >> 1)]; -- identical output on all ports, drops the multiply helper on IIgs. + +**Impact:** ~100-150 cyc/call saved on IIgs, roughly 7-10% of jlSamplePixel; zero cost/change on other ports + +**Verifier correction:** Directionally and numerically correct as written. Small precision fix: the per-pixel DOS flood-walk mention is not a real cost (x86 compilers strength-reduce the constant multiply, and the fix expands to the identical expression there); the concrete IIgs beneficiaries are jlSamplePixel itself (~8-10%/call), the once-per-call flood seed reads, and the per-pixel sprite-capture path in src/core/sprite.c:296-297. + +### 25. PERF.md IIgs baseline predates the Phase-2 jlDrawPixel/dirty-mark rewrite; all cross-port ratios are computed against a stale floor + +- **Where:** `PERF.md:38` +- **Severity:** medium | **Category:** multi-source-of-truth + +IIGS_PERF_PLAN.md declares Phase 2 DONE ('jlDrawPixel fully inlined on IIgs ... This is the 5-10x path', plan line ~28-31), yet PERF.md (regenerated 2026-06-30) still shows 1755 ops/sec -- the exact figure the plan's pre-fix analysis derived its '~1595 cyc/call' from (plan line 196: 'jlDrawPixel costs ~1595 cyc/call (2.8e6 / 1755)'). PERF.md's own preamble admits the IIgs column is 'unchanged from the last IIgs capture'. So the reference floor that the whole perf directive ('every target must meet or beat the IIgs') is measured against does not describe the shipped code: the current draw.c:521-542 chain models at ~400-500 cyc (~5600-7000 ops/sec). Amiga's 2.04x and ST's 1.18x on jlDrawPixel are therefore probably violations, not passes -- the same class of measurement lie the plan's Phase 0 was created to kill for SEI ops. The ~1500 'missing' cycles in the task brief are roughly half this stale baseline and half the jlStageGet/asm-framing findings. + +**Fix:** Re-capture the IIgs UBER column on MAME (make iigs-verify path) before any further cross-port tuning, and mark stale-capture columns explicitly in PERF.md (date per column) so a ratio can never silently mix captures from different code revisions. + +**Impact:** Scope: every IIgs row and every Amiga/ST/DOS ratio in PERF.md for the pixel-family ops; misdirects which ports are 'passing' the perf floor + +**Verifier correction:** The IIgs PERF.md column is genuinely one stale capture predating the whole IIGS_PERF_PLAN implementation (identical since commit 087bc77, 2026-05-27), so a recapture with per-column capture dates is warranted. But the jlDrawPixel impact is ~7%, not 3-4x: draw.c:524-527 records the shipped inlined form measured at 1875 ops/sec on hardware (vs 1672 for an all-C inline), so Amiga (~1.91x) and ST (~1.10x) remain passes against the honest floor. The rows where staleness plausibly changes conclusions are the fillRect/clear family: Phase 0.1 removed the fillRect SEI (table still shows the inflated 60), Phase 3's computed-jump slam should raise fillRect 16x16/80x80, and Phase 5.1's PHA-slam should raise surfaceClear -- meaning some 68k 'passes' near 1.0-1.3x on those rows (e.g. ST fillRect 1.11x/1.33x, Amiga 0.95x) could flip to violations after recapture, not the pixel rows. + +### 26. Eleven IIgs JL_HAS flags are decorative -- the port.h macro overrides bypass the registry, so entries can be added/removed with zero effect + +- **Where:** `include/joey/platform.h:100` +- **Severity:** medium | **Category:** multi-source-of-truth + +For every IIgs op that port.h implements as a macro (SURFACE_CLEAR, DRAW_PIXEL, FILL_RECT, TILE_FILL, TILE_COPY, TILE_COPY_MASKED, TILE_PASTE, TILE_SNAP, SPRITE_DRAW, SPRITE_SAVE, SPRITE_RESTORE), the dispatch stanza's '#if !defined(jlp)' guard (e.g. port.h:205, 275) short-circuits before the JL_HAS flag is ever tested, and no core gate consults them (core only gates on JL_HAS_DRAW_LINE/DRAW_CIRCLE/FILL_CIRCLE/FLOOD_* -- draw.c:111,313,456,630). So the registry that platform.h line 81-86 declares authoritative is silently non-authoritative for the reference machine: deleting JL_HAS_TILE_PASTE from the IIgs table changes nothing, and forgetting to add a flag (the TILE_PASTE_MONO gap above) produces no error. This is the systemic mechanism behind silent-generic-fallback rot. Full sweep result: all other flags in all four tables do have matching jlp symbols, and no port defines a jlp function without its flag -- the drift today is confined to the IIgs macro set and the missing entries reported separately. + +**Fix:** In port.h's IIgs macro block, pair each macro with a consistency check ('#if !defined(JL_HAS_TILE_PASTE) #error "IIgs macro override without registry flag" #endif' or one grouped check), so the table and the macro set can never diverge silently in either direction. + +**Impact:** Compile-time guard; zero runtime cost. Closes the exact hole that let jlTilePasteMono run generic C on the perf-reference machine unnoticed. + +**Verifier correction:** Accurate version: the 11 IIgs flags are decorative and the registry comment (platform.h:84-86) is a second, false source of truth; pairing each IIgs port.h macro with '#if !defined(JL_HAS_) #error #endif' (covering all 15 macro ops, including DRAW_LINE/DRAW_CIRCLE/FILL_CIRCLE/FLOOD_WALK_AND_SCANS whose flags stay load-bearing via core gates) prevents future macro/flag drift and keeps the table trustworthy as documentation. It does NOT close the finding-0 class of hole: an op missing from both the macro set and the registry (TILE_PASTE_MONO) triggers no check and silently takes the generic ? catching that class requires a deliberate per-port audit of which generic-fallback ops are intentional (DOS relies on generics by design), not a mechanical guard. + +### 27. Amiga compiled save/restore dispatchers and the shift-alias loop are dead code contradicted by the emitters + +- **Where:** `src/codegen/spriteCompile.c:246` +- **Severity:** medium | **Category:** dead-code + +The comments here (lines 174-178, 241-245) claim Amiga 'SAVE/RESTORE emit only shift 0 and shift 1' and that this loop aliases slots 2..7 -- but spriteEmitSavePlanar68k and spriteEmitRestorePlanar68k both just `return 0u;` ('Save/restore are not compiled on the Amiga yet', spriteEmitPlanar68k.c lines 223-235). So every routineOffsets[*][SAVE/RESTORE] entry is SPRITE_NOT_COMPILED, the alias loop copies sentinel onto sentinel, and the entire Amiga spriteCompiledSaveUnder/spriteCompiledRestoreUnder bodies (lines 557-623) are unreachable: the sprite.c gates require a non-sentinel offset. Every Amiga sprite save/restore therefore runs the interpreted C walkers in amiga/hal.c -- consistent with the measured 0.13-0.22x sprite gap -- while this file reads as if compiled save/restore ships. Note also that when the emitters do land, jlSpriteRestoreUnder's chunky-only gate (sprite.c lines 496-497: copyBytes must equal spriteBytesPerRow or spriteBytesPerRow+1) will reject the planar shifted width (backup->width = widthPx+8 gives copyBytes = spriteBytesPerRow+4), silently keeping 7 of 8 x-alignments on the interpreted path. + +**Fix:** Either delete the alias loop plus the dead Amiga dispatcher bodies until the emitters exist (leaving a one-line comment), or land the emitters; in either case make the restore gate platform-aware (accept the planar shifted copyBytes, e.g. via a SPRITE_RESTORE_SHIFT_FROM_BACKUP macro next to SPRITE_SHIFT_INDEX in spriteInternal.h) so the compiled path is reachable for shifted x. + +**Impact:** ~70 lines of unreachable dispatch code; comments actively misdirect the Amiga sprite-perf work that memory notes flag as the number-one gap + +**Verifier correction:** Core claim correct. Fix adjustment: replace the Amiga dispatcher bodies with ST-style empty linker stubs (sprite.c calls them unconditionally, so full deletion breaks the link), delete or clearly mark the alias loop and the lines 172-178/241-245 comments as aspirational, and when the emitters land make the restore gate accept the planar copyBytes = spriteBytesPerRow+4 case. + +### 28. IIgs spriteCompiledDraw still pays the 2D-array multiply helper the same file dodges everywhere else + +- **Where:** `src/codegen/spriteCompile.c:311` +- **Severity:** medium | **Category:** perf + +The save and restore dispatchers in this same file rewrite routineOffsets[shift][op] as byte-pointer arithmetic specifically because 'uint16_t arr[N][M] indexing' and 'shift * SPRITE_OP_COUNT ... can lower to a multiply helper call' on ORCA/C (comments at lines 419-422 and 426, and sprite.c lines 559-561). But spriteCompiledDraw -- the single hottest dispatch, behind jlSpriteDraw at 438 ops/sec -- still uses the plain 2D index. DRAW is the one op where this was not applied. + +**Fix:** Match the save/restore pattern: `routineOffset = *(uint16_t *)((uint8_t *)sp->routineOffsets + (((((uint16_t)shift << 1) + shift) + SPRITE_OP_DRAW) << 1));` then add routineOffset into fnAddr (or take it from the caller per the dedup finding). + +**Impact:** ~30-50 cycles per jlSpriteDraw on IIgs (0.5-0.8% of the op), zero risk + +### 29. Dispatcher gate and spriteCompiled* callees each re-derive shift, route offset, and function address per call + +- **Where:** `src/codegen/spriteCompile.c:423` +- **Severity:** medium | **Category:** duplication + +The hot chain does the same derivations twice per op. jlSpriteSaveUnder computes shift, routeIdx = (shift<<1)+shift+SPRITE_OP_SAVE, and loads routeOffset to gate the fast path (sprite.c lines 565-567); spriteCompiledSaveUnder then recomputes shift (line 392), cacheIdx (line 423), and re-loads routineOffset (line 427). Restore is worse: sprite.c lines 487-501 compute spriteBytesPerRow, copyBytes, shift, routeIdx, and routeOffset, then spriteCompiledRestoreUnder calls spriteChunkyRestoreShift which recomputes copyBytes and spriteBytesPerRow (widthTiles*4 again) and re-derives shift (lines 471-482). jlSpriteSaveAndDraw triples it: it computes saveIdx/drawIdx and loads both offsets (sprite.c 408-413), then both callees redo everything, and both callees also independently compute the screen row pointer from SURFACE_ROW_OFFSET(y) (lines 398 and 308) even though sprite.h line 134 promises 'the destination ptr is computed once'. widthPx/heightPx are likewise recomputed from tiles at every entry (sprite.c 338-339, 395-396, 551-552; spriteCompile.c 394-395) when they are create-time constants. + +**Fix:** Pass the already-derived values through: change spriteCompiledDraw/SaveUnder/RestoreUnder signatures (internal, spriteInternal.h) to take uint8_t shift and uint16_t routineOffset from the gate; in SaveAndDraw compute the row pointer once and hand it to both. Additionally precompute widthPx, heightPx, and per-shift copyBytes into jlSpriteT at spriteInitFields time. + +**Impact:** ~40-100 cycles per compiled sprite op on IIgs (0.6-1.5% of jlSpriteDraw, more for SaveAndDraw); removes three copies of the same index-math truth + +**Verifier correction:** Directionally right; one detail off: plain jlSpriteDraw's gate (sprite.c:346) does NOT derive shift/routeOffset today (only save/restore gates do), so the draw-path saving is just the widthPx/heightPx precompute (~10-20 cyc); the real wins are RestoreUnder (~100+ cyc: kills the spriteChunkyRestoreShift call) and SaveAndDraw (row pointer once). Refactor must touch all four platform dispatcher variants and keep Amiga/ST's SPRITE_NOT_COMPILED delegate path working. + +### 30. assetLoad.c re-spells TILE_BYTES and the 40x25 tile-grid limits as private literals + +- **Where:** `src/core/assetLoad.c:41` +- **Severity:** medium | **Category:** multi-source-of-truth + +The file already includes joey/tile.h, which is the declared single source of truth for tile geometry ('one source of truth for the 4bpp packed layout' per sprite.c:18-19): TILE_BYTES == 32, TILE_BLOCKS_PER_ROW == 40, TILE_BLOCKS_PER_COL == 25. ASSET_TILE_DATA_BYTES is the fread size written straight into outTiles[i].pixels (assetLoad.c:238), whose real capacity is TILE_BYTES -- if tile geometry ever changed, the load would silently read the wrong byte count into the struct. The 40/25 caps would likewise silently diverge from the real surface grid. + +**Fix:** #define ASSET_TILE_DATA_BYTES TILE_BYTES, ASSET_MAX_TILES_W TILE_BLOCKS_PER_ROW, ASSET_MAX_TILES_H TILE_BLOCKS_PER_COL (or use the tile.h names directly and delete the local defines). + +**Impact:** Zero runtime cost; removes three shadow constants that must currently be kept in sync with tile.h by hand + +### 31. jlSpriteBankLoad allocates up to 32000-byte cels with malloc, violating core.h's own IIgs allocator contract + +- **Where:** `src/core/assetLoad.c:167` +- **Severity:** medium | **Category:** bug + +tileBytes can reach ASSET_MAX_TILES_W * ASSET_MAX_TILES_H * 32 = 32000 bytes per cel, and a bank loads up to maxCels of them. include/joey/core.h:30-36 documents that on the IIgs the C heap lives in bank 0, is tiny, caps a single malloc at ~32 KB, and that 'multi-KB caches and frame buffers must come from the IIgs Memory Manager' via jlAlloc. Loading even a modest sprite bank (e.g. 16 cels of 32x32 = 4 KB each) exhausts the bank-0 heap and the loader silently returns a short count mid-file. src/core/sprite.c (lines 215/255/306) has the same pattern, so a fix should cover both and free with the matching jlFree. + +**Fix:** Route cel pixel buffers (and jlSpriteT headers if desired) through jlAlloc/jlFree instead of malloc/free, after confirming the IIgs sprite emitter has no bank-0 assumption on tileData; update jlSpriteFree/ownsTileData teardown to use jlFree. + +**Impact:** Prevents silent short-loads of sprite banks beyond ~4-8 cels on IIgs + +**Verifier correction:** Directionally correct; three details to fix in the writeup: (1) the teardown function is jlSpriteDestroy (sprite.c:316-328, frees tileData with free()), not 'jlSpriteFree'; (2) jlSpriteCreateFromSurface's buf (sprite.c:255) and both jlSpriteT header mallocs (sprite.c:215/306, assetLoad.c:175) must switch in the same commit because jlSpriteDestroy cannot distinguish malloc'd from jlAlloc'd tileData; (3) on IIgs the heap is ~32KB TOTAL (0x4000-0xBF00), not just per-allocation capped, and the 8KB codegen arena mallocs from the same pool, so exhaustion arrives even sooner than the finding's 16-cel estimate. + +### 32. jlSpriteBankLoad re-implements spriteInitFields field by field, and the spriteInternal.h comment contradicts it + +- **Where:** `src/core/assetLoad.c:180` +- **Severity:** medium | **Category:** duplication + +assetLoad.c lines 180-190 duplicate the exact body of spriteInitFields (sprite.c lines 117-127), including all four memsets with their subtle 0xFF-vs-0 sentinel rules. Two sources of truth for jlSpriteT initialization means the next field added to the struct (this already happened once with cachedSizeBytes) must be remembered in both places; missing the loader copy leaves bank-loaded sprites with garbage in a dispatch-critical field -- e.g. an uninitialized routineOffsets entry of 0 is a VALID arena offset and would dispatch into stale code, exactly the failure mode the 0xFF fill exists to prevent (sprite.c lines 113-116). Separately, spriteInternal.h line 39 documents tileData as 'NULL for loaded sprites', which this code disproves (loaded sprites own a malloc'd copy) -- and if it were true, jlSpriteCompile would reject every loaded sprite (spriteCompile.c line 165). + +**Fix:** Make spriteInitFields non-static, declare it in spriteInternal.h, and call `spriteInitFields(sp, buf, widthTiles, heightTiles, true);` from jlSpriteBankLoad. Fix the spriteInternal.h line 39 comment. + +**Impact:** Removes an 11-line duplicate of sentinel-sensitive init code; closes a future stale-dispatch/crash class for bank-loaded sprites + +### 33. Mixer re-reads slot fields through the pointer every sample because dst stores are char-typed and may alias + +- **Where:** `src/core/audioSfxMix.c:131` +- **Severity:** medium | **Category:** perf + +The loop caches pos and step in locals but reads slot->length (line 127), slot->sample, and slot->streamLen (lines 114, 123-124) through the slot pointer on every sample. Because `dst[i] = ...` (line 141) writes through a (volatile) uint8_t pointer, C aliasing rules force the compiler to assume the store may modify *slot and reload those fields each iteration -- 3-4 extra memory operands per sample on the 68000 (~40-60 cycles). The per-sample `if (isStream)` branch also runs 12288 times/sec even though isStream is loop-invariant. + +**Fix:** Hoist `const uint8_t *smp = slot->sample; uint32_t length = slot->length; uint16_t streamLen = slot->streamLen;` into locals before the inner loop (re-snapshot streamLen after streamRefill), and split the fixed-buffer and streaming cases into two separate inner loops so the isStream test happens once per slot instead of once per sample. + +**Impact:** ~10-15% of the mixer inner loop on ST + +**Verifier correction:** Real but the per-sample overhead is 2 slot-field reloads (length, sample ptr; streamLen on the stream path) plus the isStream test -- about 30-40 cycles/sample, not 3-4 operands/40-60: gcc already hoists slot->fill into a register and reads length only once per iteration, reusing it for both bounds checks. Hoisting into locals plus splitting the fixed/stream loops is the right fix and was verified to keep the fields in registers across the volatile store; expect ~7-9% of the loop today, ~10-13% after the __mulsi3 fix. + +### 34. codegenArenaShutdown frees every ArenaSlotT while live sprites still hold pointers; shutdown -> re-init -> jlSpriteDestroy corrupts the heap + +- **Where:** `src/core/codegenArena.c:294` +- **Severity:** medium | **Category:** bug + +jlShutdown (init.c:129) calls this with no way to clear sp->slot in user-owned sprites. While the arena stays down, codegenArenaFree's `gCodegenArenaBase == NULL` guard (line 207) saves a later jlSpriteDestroy -- but after a second jlInit the guard passes and lines 210-214 read/write the freed struct (`slot->used = false; gUsedBytes -= slot->size;`) and coalesce through freed next/prev pointers: heap corruption. Drawing the old sprite instead executes stale offsets inside the NEW arena (silent garbage execution). Relatedly, the double-free 'ignore' at lines 210-212 is false safety: per codegenArenaInternal.h:46-49 the struct 'may be freed (if it coalesced into a neighbor)', so the `!slot->used` check itself can be the use-after-free. (Compaction, by contrast, was audited safe: all four emitters are genuinely position-independent -- 68k d16(An)-only, IIgs abs,Y with DBR from the argument and MVN bank operands that are data banks moving intact with the memmove, x86 [esi+disp] -- so the cachedDstBank/cachedSrcBank cache stays valid across moves.) + +**Fix:** Keep a small registry (or intrusive list) of compiled jlSpriteT in sprite.c; have a spriteSystemShutdown hook walk it from jlShutdown, calling codegenArenaFree and NULLing sp->slot + routineOffsets before codegenArenaShutdown runs. + +**Impact:** Eliminates heap corruption / wild execution across any shutdown/re-init cycle with surviving sprites + +**Verifier correction:** Accurate as written; note the exploit window is specifically shutdown -> re-init -> destroy/draw (destroys while the arena stays down are already safe via the line-207 guard, and every current example shuts down only at exit). A documentation-only fix (sprites are invalidated by jlShutdown; destroy them first) would also close it at zero code cost if the author prefers. + +### 35. floodFillInternal C fallback evaluates the loop-invariant matchEqual ternary and a jlpSamplePixel function call per pixel + +- **Where:** `src/core/draw.c:168` +- **Severity:** medium | **Category:** perf + +matchEqual is fixed for the whole fill, but the walk-left loop (line 168), walk-right loop (line 179), and the markBuf fill (lines 239-241) re-branch on it per pixel. Each walked pixel also pays a full jlpSamplePixel function call that re-derives the row base (SURFACE_ROW_OFFSET(y), a *160 multiply on non-IIgs generic) even though y is constant across the entire walk and scan. On the ST this is the worst case: it has no JL_HAS_FLOOD_* hooks at all (platform.h lines 189-241), so every walked/scanned pixel is a function call into the planar samplePixel that decodes 4 plane bits -- flood fill on ST will be far below the IIgs reference, violating the perf floor. Flood fill is a declared hot op for the planned AGI/Sierra picture playback. + +**Fix:** Split the walk and markBuf loops into matchEqual/boundary variants selected once per call (compile-time-style if/else duplication, no dispatch tables), and give the chunky generic a row-walk helper that takes a precomputed row pointer and steps bytes (2 px/byte) instead of calling jlpSamplePixel per pixel; add the analogous ST plane-row walker (JL_HAS_FLOOD_WALK_PLANES / JL_HAS_FLOOD_SCAN_ROW_PLANES) like Amiga has. + +**Impact:** Removes 1-2 branches plus a ~100-300 cycle call per walked pixel on DOS/ST; ST flood fill is currently per-pixel planar-decode calls and stands to gain several x + +**Verifier correction:** The generic samplePixel's y*160 is strength-reduced to shifts/adds by gcc on 68000/DOS, so the per-pixel cost there is the function call + (on ST) the 4-bit plane decode, not a multiply; the ST plane-walker hook is where the multiple-x gain lives. + +### 36. jlDrawCircle fallback runs 8 bounding-box compares per iteration that are provably dead after the first iteration + +- **Where:** `src/core/draw.c:345` +- **Severity:** medium | **Category:** unnecessary-conditional + +On the first iteration x == r and y == 0, so the accumulation immediately sets maxX = cx+r, minX = cx-r, maxY = cy+r, minY = cy-r. Every subsequent candidate uses offsets x,y <= r, so none of the 8 conditionals can ever fire again -- the box is a compile-time-derivable constant of (cx, cy, r), and the accumulation (unlike the plots) does not depend on which pixels were on-surface since it folds off-surface reflections too. All 8 compare+branch pairs per iteration are pure dead work. + +**Fix:** Delete the 8 in-loop conditionals; before the loop compute the box once in int32 (minX = cx-r, maxX = cx+r, minY = cy-r, maxY = cy+r, then the existing clamp). The clamped result is bit-identical, including the fully-off-surface collapse to no-op. + +**Impact:** 8 compare/branch pairs (~50-80 cycles) per iteration, ~0.7*r iterations per circle: ~5% of the fallback circle path (every circle on DOS, every clipped circle on IIgs/Amiga/ST) + +### 37. Bresenham fallbacks accumulate a bounding box per pixel that is statically known before the loop + +- **Where:** `src/core/draw.c:484` +- **Severity:** medium | **Category:** unnecessary-conditional + +A line's dirty box is exactly min/max of its two endpoints -- the JL_HAS_DRAW_LINE fast path already computes precisely that in four ternaries BEFORE drawing (draw.c:459-462: 'int16_t bbx = (x0 < x1) ? x0 : x1;' etc.), yet the fallback re-derives it with 4 compare+branches on EVERY plotted pixel. Same pattern in jlDrawCircle (draw.c:345-352): 8 folds per iteration whose result is fully determined after the first iteration, because iteration one runs with x==r, y==0 and immediately sets the box to cx-r..cx+r / cy-r..cy+r; every later fold is dead work. DOS defines neither JL_HAS_DRAW_LINE nor JL_HAS_DRAW_CIRCLE, so every diagonal line and every circle outline there pays this; IIgs/Amiga/ST pay it on every clipped line/circle. + +**Fix:** jlDrawLine: hoist the endpoint min/max computation above the loop (reuse the exact 4-ternary block from the fast path) and delete the in-loop folds. jlDrawCircle: set minX=cx-x, maxX=cx+x, minY=cy-x, maxY=cy+x once before the loop (x==r there; keep the existing post-loop clamp, which already handles fully-off-surface circles). + +**Impact:** ~20-40 cycles/pixel removed from the clipped line path (4 compare+branch on 65816/68000) and ~8 folds/iteration from circle outlines; DOS pays this on every diagonal line and circle + +**Verifier correction:** Valid, but entirely redundant with findings 3 and 5; treat as one fix. For the circle, hoist in int32 (per finding 5) rather than int16 so centers near int16 limits do not under-mark. + +### 38. jlFillCircle C span loop pays surfaceMarkDirtyRect (plus IIgs jlStageGet) per span instead of one bounding-box mark + +- **Where:** `src/core/draw.c:668` +- **Severity:** medium | **Category:** perf + +In the onSurface branch every one of the 2r+1 scanline spans routes through fillRectOnSurface, which is jlpFillRect + surfaceMarkDirtyRect. That is 2 extra function calls per span (3 on IIgs, where the jlpFillRect macro also calls jlStageGet() per span), yet the exact final dirty rect is already known: the (cx-ir, cy-ir, 2ir+1, 2ir+1) square the JL_HAS_FILL_CIRCLE path marks once at line 634. For non-stage surfaces surfaceMarkDirtyRect is a pure wasted call per span (immediate `s != gStage` return). This path is hit for every on-surface fill circle on DOS, and on IIgs/Amiga/ST whenever jlpFillCircle returns false (IIgs: any non-stage surface). + +**Fix:** In the onSurface branch call jlpFillRect(...) directly per span (no fillRectOnSurface), and after the loop mark once: surfaceMarkDirtyRect(s, (int16_t)(cx - ir), (int16_t)(cy - ir), (int16_t)(2 * ir + 1), (int16_t)(2 * ir + 1)). Identical dirty coverage, since the y=0 span already spans cx-r..cx+r. + +**Impact:** Eliminates 2r+1 surfaceMarkDirtyRect calls (+2r+1 jlStageGet calls on IIgs): ~100-160 cycles/span on 65816, r=30 saves ~6-10k cycles (~15-25% of the C-fallback fill circle) + +**Verifier correction:** Savings are the surfaceMarkDirtyRect + fillRectOnSurface wrapper calls per span only; the per-span jlStageGet inside the jlpFillRect macro remains unless gStage is also made directly comparable (finding 4). Dirty coverage is not identical: the single square mark over-marks rows away from the center exactly like the JL_HAS_FILL_CIRCLE asm path already does (safe; slightly more present-time slam on DOS stage fills, pure win for non-stage surfaces where marks are no-op calls). + +### 39. DEFAULT_CODEGEN_BYTES (8KB) is sized to IIgs code density; a single 32x32 sprite's ST/Amiga DRAW routines nearly fill or overflow it, silently forcing the interpreter + +- **Where:** `src/core/init.c:20` +- **Severity:** medium | **Category:** perf + +The '~3-4 KB per 32x32 sprite' claim is IIgs math (5-9 bytes/destbyte, opaque pairs 3 bytes). The 68k emitters are 4-8x less dense: ST emits 2 DRAW shifts at 4 planes * 6 bytes per opaque column-row (spriteEmitInterleaved68k.c:191-196) = ~3.2KB/shift for an all-opaque 32x32 (~6.4KB for both shifts), and mixed columns cost 12-16 bytes/plane (line 198), up to ~8KB per shift. Amiga is ~3.1KB (shift 0 only) opaque, ~8KB mixed. So with the default (used by examples draw, hello, keys, joy, pattern, audio), the second sprite -- or even the first partially-transparent one -- fails codegenArenaAlloc and runs interpreted with no error. This matches the benched 'compile fails -> interpreted' symptom on the 68k ports. + +**Fix:** Make the default per-platform, e.g. `#if 68k/DOS: 32KB, IIgs: 8KB` (68k heaps are large; only the IIgs justification in the comment holds there), and correct the comment to state per-target densities. + +**Impact:** Removes silent interpreter fallback (5-8x sprite slowdown) for every app using the default on Amiga/ST + +**Verifier correction:** Directionally right, two details wrong. (1) The named examples (draw/hello/keys/joy/pattern/audio) set codegenBytes=8*1024 explicitly and create no sprites; every sprite-using example sets 32KB+ ? so nothing in the repo currently hits this. It is a latent trap only for user apps that pass codegenBytes=0. (2) The 68k bench fallback is NOT this bug: uber uses 32KB; its interpreter fallback comes from shift coverage (ST compiles only byte-aligned x, Amiga only shift 0; save/restore emit 0 on both). Accurate math: ST all-opaque 32x32 needs ~6.4KB (both shifts, one slot) so the FIRST fits the 8KB default and the second fails; a substantially-mixed 32x32 (~10-16.6KB) fails alone. Amiga: opaque ~3.1KB, fully-mixed ~8.2KB fails alone. Fix: make the default per-platform (e.g. 32KB on 68k/DOS, 8KB IIgs), rewrite the comment with per-target densities, and consider setting an error string or documenting jlSpriteCompiledSize()==0 as the failure probe so the fallback is diagnosable. + +### 40. jlKeyDown's real cost is ~5x its body: cross-TU call overhead around a two-load predicate + +- **Where:** `src/core/input.c:92` +- **Severity:** medium | **Category:** perf + +The function body is one compare plus one long-addressed byte load (~30 cycles on 65816), but every call pays JSL/RTL plus prologue/epilogue plus parameter marshalling (~120-180 cycles). Games are told to call the predicates per key per frame (include/joey/input.h header comment), so a typical 6-10 key poll burns ~1-1.5K cycles/frame in pure call overhead. Combined with the jlFrameCount forwarding finding, this fully accounts for the 3382 ops/sec bench: jlKeyDown itself is a minority of the measured 830 cycles/iter. + +**Fix:** The state arrays are already extern (src/core/inputInternal.h). Move the declarations into joey/input.h (documented as read-only) and make jlKeyDown/jlKeyPressed/jlKeyReleased and the mouse predicates `static inline` (or macros) in the header so the compare+load inlines at the call site; keep the out-of-line definitions for ABI/linkage compatibility. + +**Impact:** ~5x per predicate call (~150 -> ~30 cycles); ~1K cycles/frame in a typical input handler on IIgs + +**Verifier correction:** Directionally correct, but the fix must not rely on 'static inline' for the IIgs: ORCA/C 2.2.1 accepts at best but never performs inlining, so under the current baseline compiler header inlines are a no-op (or a parse error). Use macro-form predicates in joey/input.h (like the existing jlp macros in port.h), or guard true 'static inline' behind #if defined(__clang__)/__GNUC__ for the clang-IIgs/m68k-gcc builds while ORCA keeps the extern functions. Keep the out-of-line definitions for linkage. Note the macro caveat: args are evaluated 2-3 times, so a public macro jlKeyPressed(keys[i++]) would misbehave ? either document plain-lvalue args or restrict macros to the internal build. Realistic win: ~120-150 -> ~30-35 cyc per predicate call on 65816 (~4x, not quite 5x), roughly 1K cyc/frame in a typical handler. + +### 41. port.h comment says 'the asm derives the fill word' but the jlpTileFill/jlpFillCircle macros compute (c & 0x0F) * 0x1111 in C -- a software multiply helper per call on the 65816 + +- **Where:** `src/core/port.h:130` +- **Severity:** medium | **Category:** perf + +Line 126 documents 'tileFill: takes colorIndex; the asm derives the fill word ((c&0x0F) * 0x1111)' -- but the macro body performs the multiply in C and passes the finished fillWord to iigsTileFillInner (prototype at port.h:62 takes 'uint16_t fillWord'). jlpFillCircle does the same at line 99. The comment and code disagree (second source of truth), and the code picked the losing option: when the caller's colorIndex is a variable (the normal case -- core jlTileFill passes the API argument straight through), ORCA-C lowers *0x1111 to its 16x16 software multiply helper. Meanwhile the surrounding comments (port.h:125, surfaceInternal.h:117-120) document dodging that exact helper for *160. + +**Fix:** Make the comment true: change iigsTileFillInner / iigsFillCircleInner to accept the raw nibble and derive the fill word in asm (ASL x4 + ORA, twice: ~12 cycles), and pass '(uint16_t)((_c) & 0x0F)' from the macros. Constant-color call sites lose nothing (the asm expansion is still cheaper than a helper call). + +**Impact:** ~100-300 cycles saved per variable-color jlTileFill / jlFillCircle call on the reference machine (roughly 5-10% of a tileFill-class op), plus removes a comment/code contradiction. + +**Verifier correction:** Directionally right with three detail fixes: (1) the asm derivation costs ~20-30 cycles (pha / 4x asl / ora 1,s gives n*0x11, then sta/xba/ora doubles it into a word), not ~12; still 5-10x cheaper than the multiply helper. (2) 'Constant-color call sites lose nothing' is moot ? the only call sites of these macros (tile.c:121, draw.c:633) pass runtime variables, so nothing constant-folds today anyway. (3) Under the in-progress clang/llvm-mos migration the *0x1111 may be strength-reduced inline (~30-80 cyc with 65816 register spills), shrinking but not eliminating the win; on the ORCA/C baseline the helper-call estimate stands. Also update the fillCircle comment at port.h:90-93, which asserts the C multiply is 'still cheaper than three sequential ORs/shifts' ? false by the repo's own ~150 cyc multiply figure. + +### 42. DOS pays real cross-TU calls to no-op planar hooks on every sprite draw/save/restore and surface copy -- port.h's own comment admits it + +- **Where:** `src/core/port.h:179` +- **Severity:** medium | **Category:** perf + +The ((void)0) elision block for jlpSurfaceCopyPlanes / jlpSprite{Draw,Save,Restore}Planes (port.h:185-190) is scoped to JOEYLIB_PLATFORM_IIGS only. DOS defines no JL_HAS_SPRITE_* flags, so those hooks resolve to jlpGenericSpriteDrawPlanes/SavePlanes/RestorePlanes -- empty bodies in src/generic/genericSprite.c:10-22 -- called for real: jlSpriteSaveAndDraw's compiled fast path makes two no-op calls per sprite (sprite.c:417-418, since COMPILED_SPRITE_WRITES_PLANES is 0 on DOS per sprite.c:36-39), jlSpriteRestoreUnder one (sprite.c:505/530), jlSpriteDraw one (sprite.c:355), and jlSurfaceCopy one (surface.c:70). All args are pushed, the call executes, nothing happens. + +**Fix:** Move the four planar-hook elision macros out of the IIgs-only block into a '#if defined(JOEYLIB_NATIVE_CHUNKY)' block (covers IIgs, DOS, and the blank template, which is chunky by default); genericSprite.c then compiles to dead code on every shipping chunky port and can be dropped from those builds. + +**Impact:** 3 wasted far-ish calls (~30-60 cycles each with 4-6 pushed args in real-mode x86) per sprite save+draw+restore cycle -- a few percent of DOS per-sprite cost, and deletes an entire misleading TU from chunky builds. + +**Verifier correction:** Mechanism and fix are correct, but the cost model is wrong: DOS builds with DJGPP (i586-pc-msdosdjgpp-gcc, 32-bit protected mode per toolchains/env.sh:61-62), not real mode ? there are no far calls. An empty cdecl call with 4-6 pushed 32-bit args is ~10-25 cycles on 486/Pentium class, not 30-60, so the per-sprite win is ~1-3%, on the port that is already the fastest. The real value is the cleanup: gate the four elision macros on JOEYLIB_NATIVE_CHUNKY (covers IIgs/DOS/blank), making genericSprite.c + jlpGenericSurfaceCopyPlanes dead on all shipping chunky builds. Note the behavior is documented as intentional at port.h:179-181, so also update that comment when applying the fix. + +### 43. jlRandom's 32-bit shift triple (13/17/5) is a worst case for the 16-bit 65816; bit-identical 16-bit-half rewrite available + +- **Where:** `src/core/random.c:23` +- **Severity:** medium | **Category:** perf + +On the IIgs each 32-bit shift lowers to either a helper call or a per-bit asl/rol loop; 13+17+5 = 35 single-bit 32-bit shifts is ~400-700 cycles per jlRandom call, for a generator explicitly chosen (file header) to avoid multiply cost on the 65816. The same update can be computed on uint16_t halves with far cheaper shifts: x<<13 is hi'=(hi<<13)|(lo>>3), lo'=lo<<13; x>>17 touches only the low half (lo ^= hi>>1, hi unchanged); x<<5 is hi'=(hi<<5)|(lo>>11), lo'=lo<<5. Output stays bit-identical, preserving the cross-port determinism contract in core.h. + +**Fix:** Store gRngState as two uint16_t halves (or unpack/repack locally) and apply the three xors with the 16-bit shift identities above; keep jlRandom returning the recombined uint32_t. Optionally hand-roll the 65816 version per the perf directive -- the >>17 step in particular collapses to a single 16-bit shift and xor. + +**Impact:** ~2-3x per jlRandom call on IIgs (~500 -> ~200 cycles); matters for per-entity per-frame RNG use + +**Verifier correction:** Directionally correct, but make the 16-bit-halves path IIgs-only (or hand-rolled 65816 asm per the perf directive) rather than a portable rewrite: on the 68000, GCC emits native 32-bit shifts (lsl.l/lsr.l at 8+2n cycles plus swap tricks for >>17, ~100 cycles total for all three shifts), so the halves form is a wash-to-slight-loss there. Keep the existing 32-bit C for GCC targets (Amiga/ST/DOS) and gate the decomposed version behind the IIgs platform define; output is bit-identical either way so cross-port determinism is preserved. + +### 44. isFullyOnSurface uses 32-bit adds/compares on every hot sprite entry gate, against the file's own stated convention + +- **Where:** `src/core/sprite.c:103` +- **Severity:** medium | **Category:** perf + +isFullyOnSurface runs on every jlSpriteDraw (line 346), jlSpriteSaveAndDraw (line 403), and jlSpriteSaveUnder (line 562) call -- the hottest per-frame gates -- and does two int32 add+compare sequences (lines 103 and 106). On ORCA/C 65816 each 32-bit add/compare is a multi-instruction software sequence or helper call. The same file explicitly avoids this pattern 370 lines later: jlSpriteRestoreUnder's comment (lines 469-472) says the edge tests 'don't need 32-bit arithmetic (which would invoke a costly long-compare helper)'. The 32-bit cast exists only because x + widthPx can overflow 16-bit int; rearranging the comparison keeps everything in native 16-bit: widthPx is at most 255*8 = 2040, so SURFACE_WIDTH - (int16_t)widthPx fits int16_t. + +**Fix:** Rewrite as pure 16-bit arithmetic: `if (x < 0 || y < 0) { return false; } if (x > (int16_t)(SURFACE_WIDTH - (int16_t)widthPx)) { return false; } if (y > (int16_t)(SURFACE_HEIGHT - (int16_t)heightPx)) { return false; } return true;`. No overflow: widthPx/heightPx <= 2040 so the subtraction stays in [-1720, 319]. + +**Impact:** ~60-200 cycles per sprite op on IIgs (roughly 1-3% of a 6,400-cycle jlSpriteDraw), on all three hot entry points; also drops two 32-bit compares per call on 68k + +**Verifier correction:** Cost is likely at the low end of the claimed range (~50-120 cycles) since llvm-mos inlines 32-bit adds as lda/adc pairs rather than helper calls, but the win is real on all three gates. Equivalent simpler form also works: with x >= 0 and widthPx <= 2040, (uint16_t)x + widthPx cannot wrap 16 bits, so an unsigned 16-bit add+compare is also correct. + +### 45. spriteDrawInterpreted row setup redoes a per-row multiply and recomputes loop-invariant column math every row + +- **Where:** `src/core/sprite.c:179` +- **Severity:** medium | **Category:** perf + +(srcY >> 3) * wTiles is a variable 16-bit multiply executed every row (~30-50 cycle helper on 65816) even though the tile band only changes once every 8 rows, and the column terms at lines 181-183 -- inTileX = sx & 7 and ((sx >> 3) << 5) -- are fully loop-invariant (sx never changes across rows) yet are recomputed per row. srcY itself just counts up from sy, so all of tileRowBase is derivable incrementally. + +**Fix:** Hoist srcColBase = ((uint16_t)sx >> 3) << 5 and inTileXStart = sx & 7 above the row loop. Track inTileY = srcY & 7 and a bandBase pointer: seed bandBase once with the (sy >> 3) * wTiles multiply, then per row srcTileRow = sp->tileData + bandBase + (inTileY << 2) + srcColBase; when ++inTileY wraps to 8, reset it and bandBase += (uint16_t)wTiles << 5. + +**Impact:** ~60-90 cycles per row on 65816 (multiply helper + invariant shifts/adds), ~500-700 cycles per 8-row draw = roughly 8-10% of the measured jlSpriteDraw op on IIgs; also removes a MULU-class op per row on DOS. + +**Verifier correction:** Real per-row redundancy with a valid incremental fix, but impact is ~30-100 cycles/row on the interpreted FALLBACK path only (a few percent of that op, not 8-10% of the benchmarked jlSpriteDraw, which ran the compiled path). Worth doing as part of the same interpreted-loop rework as findings 0/1. + +### 46. IIgs/DOS draw fast path never checks SPRITE_NOT_COMPILED before jumping into the arena + +- **Where:** `src/core/sprite.c:346` +- **Severity:** medium | **Category:** bug + +jlSpriteDraw gates only on sp->slot != NULL, and the IIgs and DOS spriteCompiledDraw variants (spriteCompile.c lines 309-313 and 705-706) add sp->routineOffsets[shift][SPRITE_OP_DRAW] to the slot address with no sentinel check. jlSpriteCompile explicitly stores SPRITE_NOT_COMPILED (0xFFFF) per (shift,op) whenever an emitter returns 0 (spriteCompile.c line 233-234), so if any DRAW shift ever emits 0 bytes while the slot still allocates, fnAddr = arenaBase + slot->offset + 0xFFFF and the CPU JSLs/calls into arbitrary arena bytes -> crash. The sibling dispatchers already guard: jlSpriteSaveUnder checks routeOffset != SPRITE_NOT_COMPILED (sprite.c line 568), and the Amiga/ST spriteCompiledDraw check and delegate to jlpSpriteDrawPlanes (spriteCompile.c lines 543, 658). Today the IIgs/x86 draw emitters happen to emit both shifts, so this is latent, but it is the only dispatch path with no guard. + +**Fix:** In jlSpriteDraw, fold the DRAW offset check into the fast-path gate (read routineOffsets[shift][SPRITE_OP_DRAW] once, compare against SPRITE_NOT_COMPILED, and pass the offset down per the duplication finding below), falling back to spriteDrawInterpreted when absent -- mirroring the SaveUnder/RestoreUnder gates. + +**Impact:** Removes a latent jump-to-garbage crash; combined with the dispatch-dedup fix it costs zero extra cycles + +**Verifier correction:** Not triggerable today: the IIgs/x86 DRAW emitters structurally always emit for shifts 0/1 (the only shifts those dispatchers select), so this is a latent contract violation, not an active crash. The stronger framing: spriteInternal.h:74-76 documents that dispatchers must check routineOffsets != SPRITE_NOT_COMPILED before calling spriteCompiledDraw, and jlSpriteDraw is the only dispatcher that skips it. Hoisting the guard (via SPRITE_SHIFT_INDEX + one table read passed down to spriteCompiledDraw) is cycle-neutral and lets the redundant Amiga/ST in-callee checks be deleted. + +### 47. Four divergent implementations of the 'row is dirty' test; the STAGE_DIRTY_ROW_CLEAN macro that should be the single truth is unused + +- **Where:** `src/core/surfaceInternal.h:54` +- **Severity:** medium | **Category:** multi-source-of-truth + +grep shows this macro has zero uses. Each present hand-rolls its own predicate: DOS `if (minWord > maxWord) { continue; }` (src/dos/hal.c:289), ST `if (gStageMinWord[y] <= gStageMaxWord[y])` (src/atarist/hal.c:665), IIgs asm `cmp gStageMaxWord,x / bcc+beq` (joeyDraw.s:2903-2911), but Amiga tests `if (gStageMinWord[y] != STAGE_DIRTY_CLEAN_MIN)` (src/amiga/hal.c:752 and 845). The Amiga form is semantically different for a band where only maxWord was widened (min still 0xFF, max > 0 -- reachable today via the unclipped jlDrawPixel bug): IIgs/DOS/ST treat that row dirty, Amiga treats it clean. Four expressions of one invariant is exactly the multiple-sources-of-truth drift the cleanup checklist targets. + +**Fix:** Make the C ports use STAGE_DIRTY_ROW_CLEAN(y) (or a positive STAGE_DIRTY_ROW_DIRTY twin) and change Amiga's min!=0xFF test to the min>max form; keep a comment in joeyDraw.s pinning the asm compare to the macro's definition. If unification is declined, delete the unused macro. + +**Impact:** Consistency across 3 C ports + 1 asm port; removes a latent Amiga skip-row divergence + +**Verifier correction:** The multi-source-of-truth cleanup is real, but the 'latent Amiga skip-row divergence' cannot drop real content. (1) Amiga's min!=0xFF tests at hal.c:752/845 are only the idle-frame early-out and the anyDirty snapshot flag; the actual per-row copy decision (hal.c:786) uses rowMin > rowMax, identical semantics to the other ports. (2) The divergent state (min=0xFF, max=0xFF) is reachable only through the non-IIgs jlDrawPixel path (draw.c:549 marks dirty with raw unclipped x,y after plotPixelNoMark discarded the off-surface pixel; x in [-4,-1] gives word index 0xFF) ? a state where no pixel was written, so Amiga treating the row clean is correct and the other ports merely do wasted copy work. widenRow (surface.c:47-54) guarantees any in-bounds write sets min<=79<0xFF, so Amiga never misses real content. Amiga's comment at hal.c:742-743 documents this invariant deliberately. Right fix: have DOS/ST use STAGE_DIRTY_ROW_CLEAN (or a dirty-positive twin), keep Amiga's cheaper single-array test but name it as a second macro (e.g. STAGE_DIRTY_ROW_TOUCHED) documented as equivalent under the clipped-marks invariant, and pin the joeyDraw.s compare to the macro with a comment. Separately worth flagging: the real bug this exposes is draw.c:549 passing raw x,y into surfaceMarkDirtyRect ? an out-of-range y indexes gStageMinWord[y] out of bounds on DOS/Amiga/ST (potential OOB write via widenRow), which is more severe than the predicate drift itself. + +### 48. tile.c wrappers derive pixel coords with * TILE_PIXELS_PER_SIDE multiplies where port.h deliberately uses shifts to dodge the ORCA-C multiply helper + +- **Where:** `src/core/tile.c:79` +- **Severity:** medium | **Category:** perf + +All five public tile wrappers (jlTileCopy 79-80, jlTileCopyMasked 99-100, jlTileFill 117-118, jlTilePasteMono 141-142, jlTilePaste 157-158) compute the dirty-rect pixel coords with a runtime multiply. The IIgs macro layer in port.h uses `(_by) << 3` / `(_bx) << 2` for the same derivation, with the comment "Use SURFACE_ROW_OFFSET (LUT lookup) to dodge a software multiply helper", and surfaceInternal.h documents that ORCA-C emits a multiply helper even for the implicit *2 of uint16_t array indexing. If ORCA-C does not strength-reduce *8 (the surrounding code assumes it does not), each tile-op call pays up to two ~150-cycle helper calls purely for dirty-rect bookkeeping -- against jlTilePaste's measured ~2530 cyc/call that is up to ~12%. Two derivations of the same block-to-pixel mapping (wrapper multiplies vs port.h shifts) is also a mild dual-source-of-truth. + +**Fix:** Replace with shifts: dstPixelX = (uint16_t)((uint16_t)dstBx << 3); (same for the other nine sites), or add a TILE_BLOCK_TO_PIXEL(_b) macro next to TILE_PIXELS_PER_SIDE in tile.h and use it in both places. + +**Impact:** up to ~300 cyc/call (~12% of jlTilePaste) on IIgs under ORCA-C; zero-risk on all other ports + +**Verifier correction:** Impact is 'up to ~300 cyc' contingent on ORCA-C 2.2.1 not strength-reducing *8 (strongly indicated by surfaceInternal.h's documented *2-indexing helper, but worth a one-off disassembly check); on gcc/Watcom ports it is a pure wash. Also note jlpGenericTilePasteMono/jlpGenericTileFill in src/generic/genericTile.c:134-135 repeat the same * TILE_PIXELS_PER_SIDE pattern -- if a TILE_BLOCK_TO_PIXEL(_b) macro is added to tile.h, use it there too for one source of truth (harmless on the gcc ports that compile it, but consistent). + +### 49. jlpGenericFillRect recomputes loop-invariant span bytes/edge masks and the row offset every row + +- **Where:** `src/generic/genericDraw.c:42` +- **Severity:** medium | **Category:** perf + +x and w are constant across the row loop, so pxStart/pxEnd, the odd-left-edge test (line 44), midBytes (line 48), and the odd-right-edge test (line 53) resolve identically for every row -- but all are re-derived per row, including two conditional branches. The row base is also re-derived per row via SURFACE_ROW_OFFSET(y + row) (a LUT read on IIgs, a *160 on DOS if OpenWatcom fails to strength-reduce) instead of advancing one pointer by SURFACE_BYTES_PER_ROW. This is the fill used by every IIgs NON-stage fillRect (the jlpFillRect macro defers to it, port.h:120) and every DOS fillRect. + +**Fix:** Hoist before the loop: compute firstByteIndex, hasLeftNibble, midBytes, hasRightNibble once from x/w; inside the loop keep a single `uint8_t *line` advanced by `line += SURFACE_BYTES_PER_ROW`, doing (optional left RMW) + memset + (optional right RMW) with the precomputed constants. + +**Impact:** ~20-40 cycles/row on 65816 (2 branches + adds + LUT read): ~10% of a 16x16 non-stage fill on IIgs; smaller but free win on DOS + +**Verifier correction:** Directionally right and if anything understated: per-row waste under ORCA-C is ~40-90+ cycles once the SURFACE_ROW_OFFSET LUT read plus the 32-bit pointer rebuild from s->pixels are counted, so the win on an IIgs non-stage 16x16 fill is likely 15-30%, not ~10%. Two scope caveats: (1) this does NOT affect the UBER 450 ops/sec jlFillRect16x16 number, which measures the IIgs stage asm path (iigsFillRectInner); (2) h==1 span fills (circle scanlines / axis-aligned lines routed through fillRectOnSurface on DOS) gain nothing from the hoist since the 'loop' runs once. Beneficiaries are IIgs offscreen-surface fills, all DOS fills, and the rare Amiga/ST portData==NULL fallback. + +### 50. Identical 4-byte row-copy inner loop triplicated byte-at-a-time across TileCopy, TilePaste, and TileSnap + +- **Where:** `src/generic/genericTile.c:27` +- **Severity:** medium | **Category:** duplication + +The exact same statement appears in jlpGenericTileCopy (line 27), jlpGenericTilePaste (line 107), and jlpGenericTileSnap (line 125); the three loops differ only in the two stride constants. Beyond the duplication, the copy is 4 single-byte moves where the surface side is always 4-byte aligned (heap base plus by*8*160 + bx*4). On the 16-bit DOS target (the only port that runs these -- IIgs uses asm macros, Amiga/ST override all five tile ops per platform.h lines 95-99/141-146/196-201) two word moves or an inlined memcpy(d, s, 4) halves the memory operations. jlTileT.pixels is only byte-aligned, so use memcpy(d, s, TILE_BYTES_PER_ROW), which Watcom/gcc inline optimally and which stays safe for a future 68k-family blank port where a misaligned word move is a crash. + +**Fix:** Add one static inline helper, e.g. static inline void genericTileRowsCopy(uint8_t *d, uint16_t dStride, const uint8_t *s, uint16_t sStride) { for (row...) { memcpy(d, s, TILE_BYTES_PER_ROW); d += dStride; s += sStride; } } and call it from all three functions with the appropriate strides. + +**Impact:** ~2x fewer inner-loop memory ops on DOS tile copy/paste/snap; collapses 3 duplicate loops into 1 shared routine + +**Verifier correction:** Two detail fixes: (1) DOS is DJGPP gcc, 32-bit protected mode (env.sh:62), not '16-bit'; memcpy compiles to one 32-bit move per row, so the win is 8->2 memory ops (~4x), better than claimed. (2) The reason the current form is slow is that uint8_t* d/s may alias, which blocks gcc store merging ? memcpy asserts non-overlap and unlocks it. Nit: jlTileCopy documents src==dst surfaces as legal; same-block copy gives d==s, technically UB for memcpy though harmless with the generated dword move (blocks at distinct coords never partially overlap). Keep the helper static inline; it only needs to serve DOS/BLANK. + +### 51. jlpGenericTileCopyMasked does a read-modify-write merge even when both source nibbles are opaque + +- **Where:** `src/generic/genericTile.c:59` +- **Severity:** medium | **Category:** perf + +For a byte whose two nibbles are both non-transparent (the majority of bytes in solid glyph strokes and most tileset art), the result is simply srcByte, yet the code loads d[col], performs two masked merges, and stores. Only the mixed case (exactly one transparent nibble) needs the destination read. This is the DOS masked-copy inner loop (IIgs uses iigsTileCopyMaskedInner, Amiga/ST override with planar functions), running 32 times per tile. + +**Fix:** Restructure the per-byte body: if (srcHi != transHi && srcLo != transLo) { d[col] = srcByte; continue; } then fall through to the existing skip/merge logic for the transparent and mixed cases. + +**Impact:** saves one memory load and ~4 ALU ops per fully-opaque byte (typically 50-90% of non-skipped bytes) in the DOS masked tile copy + +**Verifier correction:** Correct as stated, but scope is DOS/BLANK only (plus unreachable Amiga/ST portData==NULL fallbacks); severity is closer to low-medium since DOS is rarely the bottleneck. IIgs, Amiga, and ST hot paths are untouched by this fix. + +### 52. iigsBlitStageToShr's SCB/palette MVN upload paths are dead, yet jlpPresent still marshals both far pointers into the asm every present + +- **Where:** `src/iigs/hal.c:298` +- **Severity:** medium | **Category:** dead-code + +uploadFlags is hard-coded 0 at the only call site, so the flag-gated SCB (200-byte) and palette (512-byte) MVN blocks in joeyDraw.s (lines 2787-2851, `lda bsFrame+8 ; uploadFlags` gates both) can never execute -- hal.c's own comment (lines 290-292) says 'The asm's now-unreached SCB/palette MVN code is dead -- a removal candidate.' The cost is not just dead bytes in the bank-limited DRAWPRIMS load segment: every present still pushes two 4-byte far pointers plus the flags word, and the asm entry copies all ten bytes into its rebuilt D-frame (joeyDraw.s:2763-2772) before ever looking at a dirty band. + +**Fix:** Change the entry to iigsBlitStageToShr(void): delete the scbPtr/palettePtr/uploadFlags marshalling and D-frame rebuild in joeyDraw.s, delete the two flag-gated MVN blocks, and call it with no args from jlpPresent (uploadScbAndPaletteIfNeeded already owns SCB/palette upload). + +**Impact:** ~50-100 cyc per present of argument marshalling + frame rebuild; frees dead code space in the crowded DRAWPRIMS bank + +**Verifier correction:** Two detail fixes: (1) ABI ? per the clang w65816 cdecl documented at joeyDraw.s:2716-2719, scbPtr (arg0) arrives in A:X and is never pushed; only palettePtr (4 bytes) + uploadFlags (2 bytes) go on the stack, so it is one pushed far pointer plus a word, not two. (2) Fix scope ? when deleting the marshalling (joeyDraw.s:2765-2772) and both flag-gated MVN blocks (2787-2859), KEEP the caller-DBR stash at joeyDraw.s:2780-2784 (sep/lda 3,s/sta 0xE19DFE/rep): the narrow-row MVN path in phase 3 reloads $E1:9DFE at line 3122 after every MVN to restore DBR. Also delete the now-orphaned bsFrame BSS block (joeyDraw.s:4417-4420), change the extern at hal.c:98 and the call at hal.c:298 to zero-arg, and update the stale jlpInit comment at hal.c:270-273 that still says iigsBlitStageToShr uploads SCB/palette. Impact restated: ~90-110 cyc/present (~0.15% of a full-screen present, ~1-2% of an all-clean one) plus ~150 bytes freed in the crowded DRAWPRIMS bank ? value is primarily dead-code cleanup, not measurable perf. + +### 53. iigsDrawPixelInner carries dead PHB/PLB framing and a long-absolute scratch round-trip in the hottest inner op + +- **Where:** `src/iigs/joeyDraw.s:1416` +- **Severity:** medium | **Category:** perf + +The routine (joeyDraw.s:1412-1487) never writes DBR -- every instruction between phb and plb is rep/sep, stack-relative loads, DP stores after tcd, absolute loads, and [pix],y accesses -- so the phb/plb pair (3+4 cyc) is pure dead weight. The offset math also stores the LUT row into memory and immediately re-adds it ('sta dpxlTmp ... lda 8,s / lsr a / clc / adc dpxlTmp', lines 1441-1446) where reordering to 'lda 8,s ; lsr a ; clc ; adc gRowOffsetLut,x' saves the ~11-cycle round-trip; and dpxlTmp/dpxlNibPart live in absolute bss (4-cyc accesses) even though D is already repointed at a bank-0 scratch block, so widening dpxlScratch and addressing them D-relative saves ~2 cyc per access. This is the Phase-2.2 residual from IIGS_PERF_PLAN.md, still unfixed. Total ~25-35 cycles out of a ~140-cycle inner routine that runs once per jlDrawPixel and per Bresenham plot. + +**Fix:** Delete phb/plb (DBR is never modified); fold the row-offset add into 'adc gRowOffsetLut,x' directly instead of via dpxlTmp; extend dpxlScratch to 8 bytes and address tmp/nibPart as D-relative offsets 4/6. + +**Impact:** ~25-35 cycles of ~140 (about 20% of the inner plot, ~6-8% of the full post-Phase-2 jlDrawPixel call); same pattern audit applies to the line/circle inners + +**Verifier correction:** Valid parts: (1) delete phb/plb ? DBR is never written in iigsDrawPixelInner ? saving 7 cycles, and renumber the stack-relative arg offsets from 8/10/12,s to 7/9/11,s (and the comment at lines 1402-1405); (2) fold the row offset directly with 'lda 8,s / lsr a / clc / adc gRowOffsetLut,x' (X still holds y*2), eliminating dpxlTmp and its store/reload for ~10 cycles. Drop the third sub-fix: relocating scratch to D-relative offsets in dpxlScratch saves ~0 cycles (DL != 0 makes dp access 5 cyc, same as absolute) and is hazardous ? D-relative stores land in bank 0 at (dpxlScratch & 0xFFFF), not at the data-bank .bss symbol (the routine's own comment documents this disagreement), so widening the .bss reservation does not reserve the bank-0 bytes actually written. Net win ~17 cycles of a ~150-cycle inner (~11% of the inner, ~1% of the measured 1595-cycle jlDrawPixel call, roughly 1755 -> 1774 ops/sec), not 25-35 cycles / 6-8% of the call. Also: iigsDrawPixelInner runs once per jlDrawPixel and per plot only in the clipped Bresenham C fallback; the on-surface line/circle fast paths plot inline in their own asm inners (which deserve the same phb/plb and round-trip audit, per IIGS_PERF_PLAN.md Phase 4). + +### 54. spriteEmitIigs.c top-of-file contract describes the deleted self-modifying-stub design; the code below it emits the opposite (C-ABI prologue) + +- **Where:** `src/iigs/spriteEmitIigs.c:3` +- **Severity:** medium | **Category:** multi-source-of-truth + +The #19 rewrite (documented in spriteCompile.c:133-146) replaced the self-modifying stub with plain C function pointers, and this same file emits the C-ABI prologue at lines 336-342 (PHB/TAY/SEP/TXA/PHA/PLB) -- directly contradicting its own header ('No stack arg, no prologue', 'DBR = program bank', 'Y = destRow loaded by stub'). spriteEmitter.h:75 repeats the stale claim ('IIgs uses a self-modifying MVN-stub on top of these bytes'), and spriteEmitter.h:6-9 claims 'The planar 68k emitters now return 0 bytes after Phase 11', contradicted by spriteEmitPlanar68k.c emitting real shift-0 DRAW bytes (and by spriteEmitter.h's own lines 94-96). Anyone editing the IIgs emitter against its stated contract would break the real one (e.g., dropping the prologue back out). + +**Fix:** Rewrite the spriteEmitIigs.c header to describe the C-ABI convention (arg in A:X, PHB prologue, PLB/RTL epilogue), and fix spriteEmitter.h lines 5-9 and 75 to match the current emitters. + +**Impact:** Removes actively-misleading contracts on the two files most likely to be hand-edited for perf work + +**Verifier correction:** Finding is accurate as written. One addition for the fix scope: src/codegen/spriteCompile.c:172-178 is a third stale source of truth ? it claims the Amiga planar DRAW 'emits a unique pre-shifted variant per shift in 0..7', but spriteEmitDrawPlanar68k returns 0 for any shift != 0 (only shift 0 is emitted, matching spriteEmitter.h:94-96). The doc rewrite should fix that comment too. Precise conventions for the new header text: DRAW is void draw(uint8_t *dstRow0) with the 24-bit pointer arriving as A = low 16 bits, X = bank; SAVE/RESTORE are void copy(uint32_t packed) with packed = srcOff | (dstOff << 16), arriving as A = srcOff, X = dstOff; all preserve caller DBR via PHB/PLB and return via RTL with M=16. + +### 55. spriteEmit68k.c (chunky 68k emitter) is compiled into Amiga and ST builds but nothing calls it + +- **Where:** `src/m68k/spriteEmit68k.c:179` +- **Severity:** medium | **Category:** dead-code + +spriteCompile.c dispatches exclusively to spriteEmitDrawPlanar68k (Amiga), spriteEmitDrawInterleaved68k (ST), spriteEmitDrawX86, and spriteEmitDrawIigs (lines 52-99). No source file references spriteEmitDraw68k/spriteEmitSave68k/spriteEmitRestore68k (grep across src/, include/, make/ finds only the definitions, the prototypes in spriteEmitter.h lines 64/83-84, and the .o rules), yet make/amiga.mk line 57 and make/atarist.mk line 42 both build and link spriteEmit68k.o. This is the pre-planar chunky emitter that the planar/interleaved rewrites replaced; it also redefines TILE_PIXELS/TILE_BYTES/TILE_BYTES_PER_ROW locally instead of using joey/tile.h. + +**Fix:** Remove spriteEmit68k.c, its prototypes in spriteEmitter.h, and the $(BUILD)/obj/68k/spriteEmit68k.o entries from make/amiga.mk and make/atarist.mk (or move the file to stuff/ if it is wanted as reference for a future chunky-68k target). + +**Impact:** ~330 dead lines removed from two ports' builds and link images; eliminates a stale second copy of the tile-geometry constants + +**Verifier correction:** Correct as a dead-code finding, with two refinements: (1) the 'link images' impact is overstated ? nothing references the symbols, so the static-archive member is never pulled into final binaries; the real cost is ~330 dead source lines, two compile units, and a duplicate copy of the tile-geometry constants. (2) The fix should also sweep stale references the finding missed: the 'spriteEmit68k.c shared with ST/Amiga' comments at make/amiga.mk:45-47 and make/atarist.mk:31-33, the '68k chunky (unused)' sizing comment block in spriteEmitter.h (keep SPRITE_EMIT_MAX_BYTES_PER_DESTBYTE_68K itself ? it still sizes the Amiga/ST scratch), and docs/amiga_planar.md:5 / docs/atarist_planar.md:6 which cite the file under its old src/codegen/ path. + +### 56. c2pColumn/putWord duplicated byte-for-byte between the two 68k emitters; shiftedByteAt/spriteSourceByte duplicated between IIgs and x86 emitters + +- **Where:** `src/m68k/spriteEmitInterleaved68k.c:83` +- **Severity:** medium | **Category:** duplication + +spriteEmitInterleaved68k.c:83-116 (c2pColumn + putWord) is character-identical to spriteEmitPlanar68k.c:89-122; spriteEmitIigs.c:52-113 (shiftedByteAt + spriteSourceByte) is identical to spriteEmitX86.c:168-232 (modulo comments); the dead spriteEmit68k.c carries a third copy. These helpers ARE the transparency/c2p contract that must match the interpreters bit-for-bit (the planar file even says 'Identical pixel walk to jlpSpriteDrawPlanes'), so a fix applied to one copy and not the others produces per-platform sprite corruption that only shows on the un-fixed target. Constants TILE_BYTES/TILE_BYTES_PER_ROW/TILE_PIXELS/TRANSPARENT_NIBBLE are also re-defined per file instead of coming from joey/tile.h as sprite.c does. + +**Fix:** Add src/codegen/spriteEmitCommon.c/.h with c2pColumn, putWord (68k), shiftedByteAt, spriteSourceByte, and take the tile geometry macros from joey/tile.h; each emitter includes it. Emit-time only, so the extra call is free at runtime. + +**Impact:** Removes ~160 duplicated lines and 3-way drift risk in the pixel-walk contract + +**Verifier correction:** Directionally right; refine the fix: (1) split the common code by family instead of one spriteEmitCommon.c -- put c2pColumn/putWord in src/m68k/ (only Amiga/ST build that dir) and shiftedByteAt/spriteSourceByte in src/codegen/ shared by DOS+IIgs -- otherwise the linker's whole-archive-member pull drags 68k c2p helpers into DOS/IIgs binaries; (2) joey/tile.h names the constant TILE_PIXELS_PER_SIDE, not TILE_PIXELS, so emitters need the rename, and TRANSPARENT_NIBBLE is NOT in tile.h (src/core/sprite.c:22 re-defines it locally too) -- hoist it into spriteInternal.h, which every emitter and sprite.c already includes; (3) delete the dead spriteEmit68k.c outright along with its LIB_OBJS lines in make/amiga.mk:57 and make/atarist.mk:42 and its prototypes in spriteEmitter.h:64,83-84, rather than de-duplicating into it; (4) on IIgs, moving statics out of spriteEmitIigs.c changes ORCA-Linker segment packing (known order-sensitive), so rebuild and re-verify link order after the refactor. + +### 57. surface.h documents jlSurfaceHash as FNV-1a but the implementation is a 31/251 two-stream product hash + +- **Where:** `include/joey/surface.h:66` +- **Severity:** low | **Category:** cleanup + +jlpGenericSurfaceHash (src/generic/genericSurface.c:61-90) mixes via SURFACE_HASH_MIX_BYTE, the strength-reduced `lo *= 31 + b / hi *= 251 + b` pair defined in surfaceInternal.h:77-80 -- not FNV-1a (no 0x811c9dc5 offset basis, no xor-then-multiply, different prime). This is not just a stale comment: the hash's whole purpose is bit-exact cross-port comparison against the IIgs golden reference, and Amiga/ST supply their own plane-reading overrides. Anyone writing a new port override from the public header's description would implement real FNV-1a and silently fail every UBER pixel-compare. + +**Fix:** Rewrite the header comment to describe the actual algorithm ('two-stream 16-bit multiplicative hash, streams combined hi<<16|lo; must match SURFACE_HASH_MIX_BYTE in surfaceInternal.h byte-for-byte across ports'), pointing overriders at the macro as the single definition. + +**Impact:** Prevents a future port override from breaking cross-port hash validation; doc-only change + +**Verifier correction:** The header is wrong about more than the algorithm: it also misstates the hashed domain. The real hash covers the packed chunky byte stream (32000 bytes, 2 px/byte -- not 64000 per-pixel indices), then the 200 SCB bytes, then the 256 palette uint16s folded high-byte-then-low, seeded lo=0xACE1/hi=0x1357, returned as (hi<<16)|lo. The comment rewrite should state all of that (algorithm, seeds, domain, combine order) and name SURFACE_HASH_MIX_BYTE in surfaceInternal.h as the single source of truth that any port override must reuse byte-for-byte. + +### 58. ST jlpTilePasteMono dereferences dst before its own NULL check, and both the dst and duplicate pd NULL checks are dead + +- **Where:** `src/atarist/hal.c:1410` +- **Severity:** low | **Category:** unnecessary-conditional + +Line 1410 reads dst->portData, then line 1414 checks 'dst == NULL' -- if dst could be NULL the check is too late; in fact it cannot be (the only caller, jlTilePasteMono at src/core/tile.c:129, validates 'dst == NULL || in == NULL' before dispatch), so lines 1414-1416 are dead either way. Line 1418's 'pd == NULL' recheck is also dead: line 1410 already returned on portData == NULL. The Amiga counterpart (amiga/hal.c:1816-1826) has none of these extra checks. This is on the ST mono-tile hot path (Space Taxi logo repaint, 103 calls every 4th frame). + +**Fix:** Delete lines 1414-1416 and the redundant pd NULL check at 1418-1420; keep only the portData==NULL generic-fallback branch (or drop even that, since ST surfaces always carry portData post-Phase-9 and the generic fallback writes to pixels which is always NULL on ST, i.e. it silently does nothing anyway). + +**Impact:** Removes 2 dead conditionals (~20-30 cycles) per mono tile paste on 68000, and removes a misleading 'fallback works' impression -- the generic fallback branch is actually a silent no-op on ST. + +**Verifier correction:** All details check out. Two refinements: (1) the caller reference should be tile.c:140 (dispatch) with the NULL validation at tile.c:129; (2) the identical dead-check pattern (deref at 1454, dst/chunkyOut NULL check at 1458, pd recheck at 1462) exists in the adjacent jlpTileSnap and should be cleaned in the same pass. + +### 59. SPLIT_BACKUP_CACHED global single-entry cache thrashes and costs more than it saves with multiple backup buffers + +- **Where:** `src/codegen/spriteCompile.c:362` +- **Severity:** low | **Category:** perf + +The cache assumes one backup buffer per program ('effectively never changes after the first call', line 354), but the normal pattern is one jlSpriteBackupT + buffer per moving sprite. With two or more sprites saved/restored per frame the pointer ping-pongs, so every call misses: it pays a 24-bit pointer compare (a multi-instruction 32-bit compare on the 65816) and then does the full SPLIT_POINTER anyway plus three global stores. Even on a hit, the 32-bit compare costs a comparable amount to SPLIT_POINTER itself, which is just four byte loads and two stores. The right home for the cached split is the backup record: save already computes it, and restore reuses the same backup. + +**Fix:** Add `uint16_t bytesLo; uint8_t bytesBank;` to jlSpriteBackupT (or a per-sprite cache beside cachedDstBank), have spriteCompiledSaveUnder store the split it computes, and have spriteCompiledRestoreUnder read it; delete gLastBackupBytes/-Lo/-Bank and SPLIT_BACKUP_CACHED. + +**Impact:** ~20-40 cycles per save/restore call on IIgs whenever a game uses more than one backup buffer (the common case); removes global mutable state + +**Verifier correction:** Impact estimate is right but the fix touches the public jlSpriteBackupT struct; either accept that (source-compatible addition, document the fields as internal) or take the simpler fix: delete gLastBackupBytes/SPLIT_BACKUP_CACHED and use SPLIT_POINTER unconditionally ? it already beats the cache whenever more than one backup buffer is live, which every real example in the repo is. + +### 60. assetLoad.c comment claims file-IO lives in a named IIgs segment, but no segment directive exists anywhere + +- **Where:** `src/core/assetLoad.c:28` +- **Severity:** low | **Category:** multi-source-of-truth + +The comment describes a load-bearing mitigation for the documented ORCA linker 64KB-bank failure mode, but the file contains no #pragma/segment directive and make/iigs.mk assigns none -- the mitigation it documents does not exist. Whoever next hits 'Code exceeds code bank size' will be misled into believing the stdio cluster is already segregated. Either the directive was lost in the ORCA->clang migration or the comment was aspirational. + +**Fix:** Either implement it (a section attribute / link-script placement under the clang toolchain) or delete the comment and note the real current placement; one source of truth between comment and build. + +**Impact:** Debugging-time hazard only; no runtime cost + +**Verifier correction:** Accurate as filed; one refinement: the stdio-bank-bursting failure mode itself was an ORCA-Linker-era problem, and the clang pipeline's multi-segment emitter now handles >64KB overflow automatically, so the comment is doubly stale -- the right fix is almost certainly to delete/rewrite it rather than implement a named segment under link816. + +### 61. plotPixelNoMark re-checks s == NULL per pixel although every caller has already validated it + +- **Where:** `src/core/draw.c:276` +- **Severity:** low | **Category:** unnecessary-conditional + +All three callers -- jlDrawCircle (line 299), jlDrawLine (line 399), and non-IIgs jlDrawPixel (line 518) -- test s == NULL before reaching plotPixelNoMark, so the NULL branch inside it can never be taken; in the Bresenham circle fallback it executes 8 times per iteration. This is the 'validation repeated at multiple levels' pattern: on 68000 a tst.l+beq on a 32-bit pointer is ~10-12 cycles per plotted pixel, on top of the per-pixel function call itself (plotPixelNoMark is not inlined by ORCA/C 2.2.1). + +**Fix:** Delete the NULL check from plotPixelNoMark and document the non-NULL precondition in its comment (its callers are all in this TU and already comply); keep the bounds check, which is the function's actual job. + +**Impact:** ~10 cycles per plotted pixel in the line/circle fallback loops (x8 per circle iteration); pure dead branch removal + +### 62. isFullyOnSurface predicate duplicated between sprite.c helper and jlDrawRect's inline test + +- **Where:** `src/core/draw.c:574` +- **Severity:** low | **Category:** duplication + +This is the exact predicate implemented by the static isFullyOnSurface() in src/core/sprite.c:99-110 (including the same int32_t-widening trick for the right/bottom edges). The same rect-on-surface question is also answered ad hoc in jlDrawLine's H/V span guards (draw.c:425-426, 443-444). Two hand-maintained spellings of one clip invariant is precisely the 'same clip computation in draw.c and sprite.c' class on the cleanup checklist. + +**Fix:** Move isFullyOnSurface into surfaceInternal.h as a static inline (or macro, for ORCA/C which does not inline) named e.g. SURFACE_RECT_ON_SURFACE(x, y, w, h), and use it from both sprite.c (3 call sites) and jlDrawRect. + +**Impact:** One source of truth for the on-surface fast-path gate used by sprites, rects, and spans; no runtime change + +### 63. jlSpriteDraw's fast-path comment claims the IIgs never takes it, contradicting the codegen default + +- **Where:** `src/core/sprite.c:344` +- **Severity:** low | **Category:** multi-source-of-truth + +spriteCompile.c lines 144-146 set JOEY_IIGS_SPRITE_CODEGEN to 1 by default ('Codegen is ON by default'), and the file's #19 block describes the rewritten C-ABI IIgs codegen as live -- so on IIgs sp->slot is NOT left NULL and this fast path IS the production path (jlSpriteDraw 438 ops/sec is the compiled path). A reader trusting this comment would conclude the IIgs compiled branch is dead and could 'clean it up' or skip optimizing it. Related stale docs in the public header compound this: sprite.h line 88 says codegen is 'currently x86/DOS' and lines 17-21 describe jlSpritePrewarm as a no-op under an interpreter-only v1, while all four ports now have emitters wired. + +**Fix:** Rewrite the sprite.c comment to describe current behavior (IIgs compiled path live; interpreter is the clip-edge/uncompiled fallback), and update sprite.h's jlSpriteCompile/jlSpritePrewarm prose to name the actual per-port status. + +**Impact:** Documentation-only, but it misdirects optimization effort on the reference platform's hottest sprite path + +**Verifier correction:** Add a third stale instance the finding missed: spriteInternal.h:74 still describes the IIgs compiled entry points as 'inline asm + self-modifying stub on IIgs', contradicting the rewritten C-ABI dispatch documented in spriteCompile.c:133-143 and :285-293. All three doc sites should be fixed together. + +### 64. routineOffsets shift*3+op byte-offset idiom hand-expanded at three sites in sprite.c + +- **Where:** `src/core/sprite.c:409` +- **Severity:** low | **Category:** duplication + +The multiply-free routineOffsets[shift][op] access ((shift<<1)+shift+op, then <<1 for bytes) is spelled out independently in jlSpriteSaveAndDraw (lines 409-413), jlSpriteRestoreUnder (lines 500-501), and jlSpriteSaveUnder (lines 566-567), each with its own copy of the 'dodges ~MUL4' comment. The encoding must stay in lockstep with SPRITE_OP_COUNT == 3 and with the emitters in src/codegen/spriteCompile.c that fill the same table; a fourth op added to SPRITE_OP_COUNT would require finding and fixing three hand-rolled *3 expansions. + +**Fix:** Add to spriteInternal.h, next to SPRITE_NOT_COMPILED: #define SPRITE_ROUTINE_OFFSET(_sp, _shift, _op) (*(uint16_t *)((uint8_t *)(_sp)->routineOffsets + ((uint16_t)((((uint16_t)(_shift) << 1) + (_shift) + (_op))) << 1))) and use it at all three sites (identical codegen, since it is the same expression). + +**Impact:** Collapses 3 duplicated pointer-math blocks into one macro co-located with the table's layout constants; no runtime change + +**Verifier correction:** Scope is slightly understated: the same shift*3+op idiom also appears twice in src/codegen/spriteCompile.c (:423-427 spriteCompiledSaveUnder, :478-482 spriteCompiledRestoreUnder), where the index additionally feeds the cachedDstBank/cachedSrcBank pointers ? so the shared definition should expose the index computation (e.g. a SPRITE_ROUTINE_INDEX(shift, op) macro) that both the offset-read macro and the IIgs cache-pointer math build on, covering all five sites. + +### 65. surfaceMarkDirtyRect re-validates w/h that its contract already guarantees positive + +- **Where:** `src/core/surface.c:245` +- **Severity:** low | **Category:** unnecessary-conditional + +The function's own contract comment (lines 230-233: 'Drawing primitives pass the rect they actually wrote (already clipped to surface bounds, w and h positive)') makes this check dead for conforming callers, and I verified every call site: fillRectOnSurface is caller-proven positive, the line/circle accumulated boxes only mark after a `minX <= maxX && minY <= maxY` clamp (draw.c:379, 511), flood spans are rightX-leftX+1 >= 1, sprite paths mark only after clipRect/isFullyOnSurface succeed, and tile.c passes constant 8s after tile-coord validation. Two signed compares + branches per draw op (~10-15 cyc on the 65816) on the most-called internal function in the library. Note this check does NOT protect against the real contract violation (finding 1) -- jlDrawPixel passes w=h=1 with a bad y -- so it must not be treated as the safety net; it can be dropped once the jlDrawPixel clip fix lands. + +**Fix:** After fixing jlDrawPixel's unclipped mark, delete the `if (w <= 0 || h <= 0)` early-out (optionally demote to a debug-build assert), leaving only the s != gStage gate. + +**Impact:** ~10-15 cyc on every dirty-marking draw op on IIgs; two branches on 68k ports + +**Verifier correction:** The check is not literally dead today: jlDrawRect(h==2) reaches surfaceMarkDirtyRect with h==0 via fillRectOnSurface (draw.c:582/584). Deleting the check is still behaviorally safe because both the portable mark loop and the top-tested IIgs asm treat empty ranges as no-ops (and iigsFillRectInner is also h==0-safe), but the cleanest fix is to also make jlDrawRect skip the interior-edge fills when h==2, restoring fillRectOnSurface's documented contract before dropping the guard. + +### 66. SURFACE_PIXELS_PER_WORD is defined and never used anywhere in the repo + +- **Where:** `src/core/surfaceInternal.h:36` +- **Severity:** low | **Category:** dead-code + +A repo-wide grep (all .c/.h including src//, src/codegen/, and examples/) finds exactly one occurrence: this definition. The related quantity that code actually uses is baked into SURFACE_WORD_INDEX's '>> 2' shift two macros below, so this name is a stale second spelling of the same fact that nothing reads. + +**Fix:** Delete the define, or -- if the name is worth keeping as documentation -- express SURFACE_WORD_INDEX's shift in terms of it so it has exactly one consumer. + +**Impact:** Dead macro removal; no runtime effect + +**Verifier correction:** Prefer the deletion option. The alternate fix ('express SURFACE_WORD_INDEX's shift in terms of it') is not directly expressible ? a shift needs log2(4)=2, not 4, so you would either divide (risking a helper call on ORCA/C 2.2.1, exactly what the header's lines 39-43 comment engineered around) or introduce a companion SURFACE_WORD_SHIFT 2, which just trades one loose constant for another. Delete the define and let the existing comment carry the documentation. + +### 67. gRowOffsetLut declared with magic literal 200 instead of SURFACE_HEIGHT + +- **Where:** `src/core/surfaceInternal.h:125` +- **Severity:** low | **Category:** magic-number + +Everywhere else in this same header the row count is spelled SURFACE_HEIGHT (e.g. gStageMinWord[SURFACE_HEIGHT] at lines 66-67, and the compile-time band-fits-byte guard). The LUT is indexed by SURFACE_ROW_OFFSET with y in [0, SURFACE_HEIGHT); a raw 200 here means a future height change would compile cleanly against a mis-sized extern while the IIgs hal.c builder fills a differently-sized array. + +**Fix:** extern const uint16_t gRowOffsetLut[SURFACE_HEIGHT]; (and match the definition in src/iigs/hal.c). + +**Impact:** Declaration-only; closes one silent-divergence point for the 320x200 contract + +**Verifier correction:** One factual error in the proposed fix: gRowOffsetLut is not defined in src/iigs/hal.c ? it is asm BSS in src/iigs/joeyDraw.s:269-272 ('.zero 400'), filled at init by the asm row-LUT builder (referenced from hal.c:108's comment). There is no C definition for the extern size to cross-check against, so the change is hygiene/documentation only; a real height change would still require manually resizing the .zero 400 in the asm. Fix is simply: extern const uint16_t gRowOffsetLut[SURFACE_HEIGHT]; plus optionally a comment in joeyDraw.s noting the 2*SURFACE_HEIGHT byte reservation. + +### 68. Stale comment in tile.c still claims the inner row copies are spelled out in this file + +- **Where:** `src/core/tile.c:17` +- **Severity:** low | **Category:** dead-code + +The copies the comment refers to were moved to src/generic/genericTile.c (as lines 24-25 and 29-30 themselves note), so there are no inner copies "below" and the no- rationale no longer applies to this TU -- tile.c no longer touches pixel bytes at all. The vestigial placeholder sections at lines 22-30 ("----- Prototypes -----" and "----- Internal helpers -----" containing only moved-elsewhere notes) are also dead scaffolding. Misleading segment-placement comments are dangerous in this codebase given the ORCA linker bank-packing sensitivities. + +**Fix:** Delete lines 17-19 and collapse the empty Prototypes/Internal-helpers placeholder sections (lines 22-30) into a single one-line pointer to genericTile.c; if the DRAWPRIMS/no-memcpy constraint still matters, move that comment to genericTile.c where the loops now live. + +**Impact:** comment-only; prevents a future editor from applying a stale segment constraint to the wrong file + +### 69. fg/bg colors are masked to 4 bits twice on the TilePasteMono path + +- **Where:** `src/generic/genericTile.c:84` +- **Severity:** low | **Category:** unnecessary-conditional + +jlTilePasteMono in src/core/tile.c already masks both colors before dispatch (lines 135-136: `fgColor &= 0x0Fu; bgColor &= 0x0Fu;`), then jlpGenericTilePasteMono masks them again. Beyond the redundant ANDs, having both layers claim responsibility for masking is a small multiple-sources-of-truth problem: Amiga/ST function overrides receive already-masked values, so the contract should be that jlp receives masked colors and the generic should not re-mask. + +**Fix:** Delete lines 84-85 from jlpGenericTilePasteMono and document the pre-masked contract in the prototype comment in port.h (matching how the IIgs macros own `(_c) & 0x0F` for the ops where the wrapper does not mask). + +**Impact:** 2 redundant ops per call; clarifies which layer owns color masking + +**Verifier correction:** Real but as a low-severity contract-cleanup, not a perf item: the 2 redundant ANDs are noise next to the 32-iteration colorize loop. The valuable part is the port.h contract comment, since masking ownership currently differs op-by-op (TilePasteMono wrapper masks; TileFill wrapper does not and the jlp layer masks). + +### 70. jlpGenericTileFill re-derives the tile row-0 address with its own math instead of the file's GENERIC_TILE_ROW0 macro + +- **Where:** `src/generic/genericTile.c:142` +- **Severity:** low | **Category:** duplication + +Every other function in genericTile.c derives the block's row-0 pointer via GENERIC_TILE_ROW0 (defined at line 12), but jlpGenericTileFill first expands bx/by to pixel coords with two multiplies (lines 134-135: `pixelX = bx * TILE_PIXELS_PER_SIDE`) and then converts back down with a shift -- a second spelling of the same block-to-address mapping that must be kept in sync by hand. The intermediate pixelX/pixelY variables serve no other purpose since this function does not mark dirty (the core wrapper does). + +**Fix:** Delete pixelX/pixelY and use row = GENERIC_TILE_ROW0(s->pixels, bx, by); like the other four functions. Optionally write the four bytes as two uint16_t stores of (doubled<<8)|doubled via the shared row helper. + +**Impact:** removes 2 multiplies + 2 locals and one divergent copy of the address math in the DOS tile fill + +**Verifier correction:** The fix (use GENERIC_TILE_ROW0) is right, but the perf framing is nil: bx*8/by*8 are strength-reduced to shifts by every compiler that runs this generic, so this is purely a single-source-of-truth cleanup. The optional 'two uint16_t stores' suggestion is unnecessary type-punning for zero measured gain on DOS ? skip it and keep the four byte stores or a memset-style fill. + +### 71. Stale 15-line comment on IIgs jlpFrameCount documents a deleted $C019-polling/gFrameCount implementation; body is one GetTick call + +- **Where:** `src/iigs/hal.c:358` +- **Severity:** low | **Category:** cleanup + +The comment block at lines 358-371 explains a $C019 edge-detected counter and why gFrameCount needs an explicit lda+adc+sta instead of '++' (the INC-abs DBR trap) -- but the function body (line 373) is just 'return iigsGetTickWord();' and no gFrameCount variable exists anywhere in src/iigs/ (grep finds it only in this comment). The comment now asserts a timing mechanism ('No IRQ involvement') that is the opposite of reality: GetTick is the VBL-interrupt-driven tick, which is exactly the clock whose SEI artifact already inflated UBER numbers once. The registry-side comment (platform.h:108 'GetTick word, function') is the accurate one; this in-file duplicate is a wrong second source of truth. + +**Fix:** Replace lines 358-371 with a one-liner: '// Frame counter: low word of the VBL-interrupt GetTick (see peislam.s). SEI-holding ops distort it -- see perf notes.' + +**Impact:** Documentation-only, but prevents re-tripping the known GetTick/SEI benchmarking trap and the INC-abs cargo-culting for a variable that no longer exists. + +**Verifier correction:** Minor nit only: the stale block is 14 lines (358-371), not 15. Everything else in the finding is accurate as stated. + +### 72. jlDrawLine H/V fast paths clamp the span to the surface size BEFORE clipping, so a line with a far-off-surface start draws nothing + +- **Where:** `src/core/draw.c:417` +- **Severity:** high | **Category:** bug + +Found while building the Phase 0 verification harness (not part of the +original 71-agent audit; confirmed by direct code trace). The horizontal +fast path computes `span = (int32_t)x1 - (int32_t)x0 + 1` and then clamps +`if (span > SURFACE_WIDTH) { span = SURFACE_WIDTH; }` BEFORE any clipping +against the surface. For a line whose left endpoint is far off-surface -- +jlDrawLine(s, -20000, 50, 20000, 50, c) -- the clamped span (320) is +anchored at x0 = -20000, so the subsequent jlFillRect(s, -20000, 50, 320, +1, c) clips to nothing and the call is a silent no-op. The correct output +is the full visible row [0..319] at y=50 (the line passes through the +entire surface). The vertical path (draw.c:439-441) has the identical bug +for far-off-surface y0. The diagonal Bresenham fallback is unaffected +(per-pixel clipping). Off-surface endpoints are documented-legal input +(draw.h: out-of-bounds draws clip). + +**Fix:** Clip x0 into range before anchoring the clamped span: after +computing the 32-bit span, do `if (x0 < 0) { span += x0; x0 = 0; }` (span +shrinks by the off-surface distance; if span <= 0 the line is entirely +off-surface and returns), THEN clamp span to SURFACE_WIDTH. Mirror for the +vertical path with y0/SURFACE_HEIGHT. The on-surface fast-path test below +is unchanged. + +**Impact:** Correctness: legal H/V lines with a far-off-surface start +silently vanish on every port. The uber.c "edge-lines" check captures the +current (wrong) behavior; its hash changes when this fix lands. + +### 73. DOS jlpFrameCount edge-polled $3DA and lost frames whenever callers polled slower than the retrace pulse + +- **Where:** `src/dos/hal.c:321` (old implementation) +- **Severity:** critical | **Category:** bug -- FIXED during Phase 0 + +The old counter incremented only when a CALL observed the $3DA retrace +bit high after a previous call observed it low. The VGA retrace pulse is +~1ms of the 14.3ms frame, so any caller doing more than ~1ms of work +between jlFrameCount() polls silently lost frames -- including any real +game pacing its main loop this way. Consequence for measurement: UBER's +"16-frame" windows stretched 5-40x in real time for ops slower than +~1ms, inflating every slow-op number in PERF.md's DOS column +(jlSurfaceClear read 449x the IIgs; the honest number is ~2.5x). The +Phase 0 batched timing loop stalled on it outright, which is how it was +found. Fix (landed): jlpFrameCount = millis * 70 / 1000 off the existing +PIT-ISR/uclock millisecond source -- poll-rate-independent, monotonic; +jlpWaitVBL still syncs to real retrace. + +### 74. jlSpriteCompile returns false on DOS -- every DOS sprite bench and game runs the interpreter + +- **Where:** `src/codegen/spriteCompile.c:149` (failure surfaces here; root cause not yet isolated) +- **Severity:** high | **Category:** bug + +Pre-existing at HEAD (verified against a pristine uber.c build): UBER +logs "jlSpriteCompile failed" on DOS, so sp->slot stays NULL and every +jlSpriteDraw/SaveUnder/RestoreUnder runs interpreted. The old PERF.md +DOS sprite rows therefore measured the interpreter (and were ALSO +inflated by finding #73). spriteEmitX86.c emits real code for shifts +0-1, the arena initializes (jlInit succeeds), and the 16KB scratch is a +plain malloc on DJGPP -- the failure is in the sizing/emit/arena-alloc +chain and needs instrumented bisection. Root-cause lands in Phase 1 +(it gates Phase 4's compiled-path work on DOS). + +### 75. jlTilePasteMono renders differently on chunky, Amiga, and ST -- three-way pixel divergence + +- **Where:** `src/generic/genericTile.c:77` + the Amiga/ST jlpTilePasteMono overrides +- **Severity:** high | **Category:** bug + +First-ever cross-port hash of the op (Phase 0 harness, mono pattern +alternating 0xF0/0x0F bytes, fg=5/bg=9 then fg=14/bg=0): DOS(generic) += 5EE7ED99, ST = 5D8774F9, Amiga = 1C87B4F9 -- after identical hashes +for every preceding check on all three ports. The three implementations +disagree about the mono-tile decode (likely nibble order / bit sense). +The public contract (tile.h: non-zero source nibble -> fgColor) must +pick one behavior; fix the divergent ports in Phase 1 and re-golden the +tilePasteMono hash line. Blocks Phase 7 #3 (the IIgs asm must match the +agreed behavior, not whichever variant it was written against). + +### 76. IIgs jlpWaitVBL polled $C019, which never asserts under MAME -- infinite hang for any VBL-paced program in emulation + +- **Where:** `src/iigs/hal.c:348` (old implementation) +- **Severity:** critical | **Category:** bug -- FIXED during Phase 0 + +The old wait spun until $C019 bit 7 read high ("active scan"), then +until it read low again. Probing MAME's apple2gs at two beam positions +(frame-start and frame-done callbacks, 120 frames each) shows bit 7 +NEVER reads high under the emulator, so the first loop never exits. +Symptom chain that hid it: DRAW and PATTERN (the examples every +verification gate uses) never call jlWaitVBL or jlFrameCount, so +iigs-verify passed for months while UBER -- the only caller -- stalled +right after its red "running" bar (PC probe: a 46-byte call-free spin; +the screen frozen at exactly the 1280 bytes of 0x11 the bar paints). +This also explains why PERF.md's IIgs column was "carried over" rather +than re-captured: UBER on IIgs could not complete under headless MAME +at HEAD. Whether real hardware asserts the bit as the code expected is +unverified; the fix removes the dependence either way. + +**Fix (landed):** jlpWaitVBL waits for the firmware tick to advance -- +`tick = iigsGetTickWord(); while (iigsGetTickWord() == tick) {}`. The +tick increments from the VBL interrupt, so tick-edge granularity IS the +VBL boundary; semantics unchanged on real hardware, and it works under +MAME. Callers must not hold SEI (a masked VBL interrupt freezes the +tick, exactly as it froze the old status bit). + +**Impact:** Unblocks the IIgs UBER baseline capture (bench-iigs.sh) and +fixes jlWaitVBL for every future game on the reference platform under +emulation. + +### 77. IIgs sprite codegen corrupts memory under the clang build + +**ROOT-CAUSED 2026-07-04 (final):** with #81/#85/#86 fixed, forensic +dumps show the residual mechanism exactly: the codegen arena's +NewHandle landed at $08:4200 -- INSIDE the app's own entry-segment +bank, overlapping seg1's BSS span -- because the GS/OS loader only +MM-claims each segment's IMAGE bytes, leaving BSS+heap spans as +MM-free space. The emitted routines were verified BYTE-VALID (correct +C-ABI prologue, correct pixel stores); writing them simply clobbers +live app state. Fix is toolchain-owned (reserve full segment spans in +crt0/loader -- LLVM816-ASKS.md item 5, now with this empirical proof); +codegen stays OFF on IIgs until then. No JoeyLib-side code fault +remains in the emit or dispatch paths. + +**RETEST 2026-07-04 (post-#81 allocator fix):** the original +corrupt-at-first-compile failure is RESOLVED -- a codegen-on UBER run +now completes all 33 timed ops and most checks. Two residues: (a) the +run hangs inside checkAllocator's SECOND/THIRD jlSpriteCompile (arena +churn; sub-marker 112) -- the remaining #77 hunt target; (b) finding +#84 applies to the COMPILED path too (jlSpriteDraw measured 1 op/368 +frames with codegen on, same hash as interpreted), so the compiled +path either never truly executes or shares the interpreter's +pathology. Codegen default stays 0 pending (a). + +- **Where:** `src/iigs/spriteEmitIigs.c` + `src/codegen/spriteCompile.c` (path known; exact write not yet isolated) +- **Severity:** critical | **Category:** bug + +Bisected during Phase 0. With codegen ON (default), UBER on IIgs stalls +at drawShowcase's jlSurfaceClear: MAME stack/DP forensics show +jlpGenericSurfaceClear (the stage check failed -- the stage pointer or +struct was already corrupted) calling memset with a garbage destination +that walked into bank 0 and shredded the direct page. The corruption +happens between setupSprite (jlSpriteCompile) and drawShowcase. With +JOEY_IIGS_SPRITE_CODEGEN=0 the run proceeds normally. The +spriteCompile.c #19 note says the self-modifying-stub corruption was +"rewritten away" -- that was validated on the ORCA build; the clang +build (different ABI, different segment layout, different emitted-code +assumptions in spriteEmitIigs.c's C-ABI prologue) has its own +corruption. Root-cause with MAME write-watchpoints on the stage struct +in Phase 1; until then the IIgs baseline is captured codegen-off and +the IIgs sprite rows measure the interpreter (same situation DOS is in +via finding #74). + +### 78. ST jlFillCircle and jlTileFill draw different pixels than the chunky reference + +- **Where:** `src/atarist/` fillCircle span fill + tile fill overrides +- **Severity:** high | **Category:** bug + +First-ever DOS-vs-ST timed-op hash comparison (Phase 0 goldens): +identical hashes through jlFillRect 320x200, then divergence at +jlFillCircle r=40 and again at jlTileFill. Amiga (also planar) matches +DOS on every timed op, so the chunky semantics are the agreed truth and +the ST implementations are the outliers. Historically only IIgs-vs-68k +comparisons were run, so a DOS/ST disagreement could hide. Fix in +Phase 1 alongside #75 (tilePasteMono three-way divergence), then +re-golden the affected ST hash lines. + +### 79. clang-era IIgs libc memset/memcpy are byte loops -- ~4x regression vs the ORCA baseline + +- **Where:** `toolchains/iigs/llvm-mos/runtime/src/libc.c:191` (toolchain-owned) +- **Severity:** high | **Category:** perf + +jlScbSetRange (a 200-byte memset + flag) measures 163 ops/sec on the +codegen-off clang build vs 1005 ops/sec in the old PERF.md column -- +impossible unless the old column was captured on the ORCA build, whose +memset lowers to MVN (~7 cyc/byte). The clang runtime's memset/memcpy +(libc.c) are `while (n--) *d++ = c;` byte loops through 24-bit +pointers (~30 cyc/byte). Every memset/memcpy-based path in the core +(scb range fill, surface copy, sprite save/restore fallback memcpy, +stage clear fallback, log buffering) regressed ~4x on the reference +platform when the toolchain switched. The library-side plan already +reduces memset/memcpy dependence (findings #9, #19, #21), but the +libc routines themselves are toolchain-owned: FLAG TO SCOTT for the +llvm816 session (MVN-based memset/memcpy in runtime/src/libc.c). +Also note: PERF.md's IIgs column provenance is the ORCA build -- the +clang build's honest numbers are materially different across the +board, which changes every cross-port ratio. + +### 80. IIgs per-line log flushing does not commit to disk under the clang runtime + +- **Where:** `src/core/debug.c:47` (design assumption broken; root cause in the runtime/FST layer) +- **Severity:** medium | **Category:** bug + +debug.c's contract is flush-after-every-line so a crash or hang leaves +every line on disk. Empirically (Phase 1 mailbox vs log comparison): +UBER ran all 33 timed ops -- 33 jlLogF calls, each followed by fflush +-- yet only 3 op lines were on the disk image at MAME exit. Lines only +persist after a clean fclose (the completed Jun-30 capture) or when +enough buffered data forces a block write. Either the llvm-mos libc +fflush does not issue an FST commit, or GS/OS caches the blocks until +file close. Consequences: every "the log stops at line N" inference +about IIgs hangs is unreliable (this misdirected the Phase 1 hunt +once); crash forensics on real hardware would hit the same wall. +Workarounds in place: the uber.c SHR mailbox ($E1:9DC8) is the +progress channel under emulation. Fix directions: toolchain-side +fflush->FST flush call (asked in LLVM816-ASKS.md), or debug.c +IIgs-side fclose+reopen-append per line (correct but seconds-per-line +under emulated FST -- only acceptable behind a debug flag). + +### 81. IIgs C heap: 665 bytes, unprotected, and malloc returns 1 (not NULL) at exhaustion -- the port ran on a phantom stage + +- **Where:** `src/core/surface.c:279` (victim); root cause in the toolchain libc/link layout +- **Severity:** critical | **Category:** bug -- JoeyLib side FIXED during Phase 1 + +The full forensic chain (MAME memory probes + in-app mailbox pokes + +link-map correlation + an independent static audit): + +1. The linker defines the C heap as the leftover space in the entry + bank above BSS: `__heap_start=$00:BC67`, `__heap_end=$00:BF00` -- + 665 bytes, in bank 0, and never reserved from the GS/OS Memory + Manager (nothing in crt0Gsos claims it). +2. `stageAlloc`'s `calloc(1, sizeof(jlSurfaceT))` (~720 bytes) cannot + fit. The libc malloc's overflow branch -- whose own comment records + a "historical over-heap miscompile (only manifested for oversized + n)" and assumes the path "is never exercised" -- returns **1** + instead of NULL. +3. `gStage = (jlSurfaceT *)1` passes every NULL check. Its `pixels` + field dereferences $00:0001, which happens to contain $00012000 -- + the exact pinned stage framebuffer address -- so all drawing and + present work flawlessly. Its scb[200] and palette[512] fields alias + bank-0 low memory: every jlScbSetRange/jlPaletteSet scribbled over + system space, and jlSurfaceHash hashed that garbage (the divergent + IIgs hashes in the Phase 0 goldens). +4. Cumulative bank-0 damage + further heap exhaustion during the + sprite churn produced the phase-5 checkAllocator hang, the moving + freeze points, and (pending confirmation) finding #77's + codegen-path corruption. An independent static audit of the + emitters verified the emit-time code itself is clean and separately + flagged that Memory Manager handles can legally be placed over the + unreserved heap (second corruption channel, toolchain-owned). + +**Fix (landed, JoeyLib side):** finding #31 executed ahead of +schedule and extended to surfaces -- ALL core structs now come from +jlpBigAlloc/jlpBigFree (IIgs Memory Manager; plain malloc elsewhere): +the stage struct, jlSurfaceCreate structs, jlSpriteT headers, owned +tileData, and the assetLoad cel buffers, with every error-path free +converted symmetrically. DOS re-benched bit-identical to the frozen +goldens (43/43 hashes). The residual C-heap load is the log FILE plus +arena bookkeeping nodes (well under 665 bytes). + +**Toolchain asks (filed in LLVM816-ASKS.md):** (a) malloc/calloc must +return NULL at exhaustion -- minimal repro is calloc(1, 720) against +the default heap; (b) reserve [__heap_start, __heap_end) from the +Memory Manager in crt0Gsos (or back malloc with an MM handle); +(c) consider attrNoSpec on NewHandle calls and reserving the pinned +stage range $01:2000-$9FFF. + +### 82. IIgs per-line log writes stall the app in floppy firmware -- replaced with a RAM ring + +- **Where:** `src/core/debug.c` (IIgs branch) -- FIXED during Phase 1 +- **Severity:** critical | **Category:** bug + +PC sampling of a "hung" UBER run landed in slot-5 SmartPort firmware +and ROM: the app was inside GS/OS floppy writes. Each jlLogF line cost +~8 emulated seconds of FST/floppy time (the steady ~4-ops-per-2000- +frames crawl), and the end-of-run logging wedged in a firmware retry +loop for 18+ emulated minutes. Combined with #80 (per-line fflush never +commits anyway), the flush-per-line-to-floppy design was slow, lossy, +AND hang-prone under emulation. Fix (landed): on IIgs, jlLog/jlLogF +append to a RAM ring exported as gJoeyLogRing/gJoeyLogRingHead; +scripts/bench-iigs.sh's Lua drains the ring live (magic-word-gated +mailbox handshake) and reconstructs joeylog.txt on the host; +jlLogFlush writes the ring to disk once (open/write/close -- fclose is +the only committing operation) for real-hardware persistence. Result: +the first complete IIgs UBER capture (33 ops + 17 checks, green +screen at frame ~29000). + +### 83. IIgs diagonal-line asm diverges from the portable Bresenham + +**ROOT-CAUSED AND FIXED 2026-07-04 (with #86):** iigsDrawLineInner (and +iigsFillCircleInner) stashed the pixels pointer with a DATA-BANK store +(`sta dlnScratch` under DBR) but dereferenced it D-relative (`[pix],y` +reads bank 0 at D+0) -- the exact DBR-vs-D disagreement that +iigsDrawPixelInner's and iigsDrawCircleInner's comments record fixing, +never applied to line/fillCircle. Every plot went through whatever +garbage pointer lived at the bank-0 alias of the scratch symbol: +deterministic wrong pixels (this finding), and -- when a memory-layout +shift changed what the garbage pointed at -- the plots could land on +the dln* walk state itself, mutating the endpoint mid-line and hanging +the Bresenham in an infinite wrap (#86; PC-sampled at dlnLoop/dlnStep +during the Phase 1 hunt). The wrap-walk also makes the routine a wild- +write generator (gRowOffsetLut indexed far out of bounds, stores +through garbage row offsets), a plausible contributor to the original +pre-#81 corruption soup. Both functions now use drawPixel's canonical +pattern (phx/pha, tcd, D-relative stores; arg offsets unchanged). +iigsBlitStageToShr has the same stale pattern in its bsFrame stash but +those dereferences are the #52 dead paths -- deleted by that cleanup +rather than fixed. + +- **Where:** `src/iigs/joeyDraw.s` (iigsDrawLineInner) + the JL_HAS_DRAW_LINE gate in draw.c +- **Severity:** high | **Category:** bug + +First complete IIgs capture: hashes match DOS exactly through the H/V +lines, then diverge at `jlDrawLine diag` (DOS and Amiga agree with each +other). The on-surface diagonal goes through the asm fast path on IIgs, +so either iigsDrawLineInner's Bresenham differs from the C reference +(step order, error seeding, endpoint handling) or the gate feeds it +different values. The drawRect/circle/fillRect hashes after it also +diverge until the full-screen fill wipes the state and hashes +RECONVERGE (samplePixel through tileSnap match DOS again) -- so the +damage is contained to the line/circle output pixels, and the circle +asm needs the same scrutiny. Hunt: compare asm vs C pixel-by-pixel for +the UBER diag line (0,0)-(319,199) in a MAME framebuffer dump. + +### 84. IIgs interpreted sprite ops: ~6 seconds per 16x16 draw and wrong pixels + +- **Where:** `src/core/sprite.c` interpreted paths as compiled by the clang w65816 backend +- **Severity:** high | **Category:** bug + +With codegen off (finding #77 mitigation), the sprite ops measure: +jlSpriteSaveUnder 1 iter / 65 frames, jlSpriteDraw 1 iter / 368 frames +(~6.1 SECONDS for one fully-on-surface 16x16 interpreted draw at +2.8MHz -- three orders of magnitude beyond any plausible cost; finding +#2's writeDstNibble overhead accounts for ~2x, not 1000x). And the +output is wrong: sprite-clip-roundtrip FAILs (save/draw/restore does +not restore the pre-save pixels) and every post-sprite hash diverges. +Suspects: a clang-w65816 codegen pathology in the inner loop (e.g. a +software-multiply or long-pointer sequence per pixel plus something +quadratic), or memory-layout aliasing that makes the loop rewalk. +jlSpriteRestoreUnder (memcpy rows) runs at a sane 4023 ops/sec, so the +slowness is specific to the nibble-walking loops. Needs disassembly of +the compiled spriteDrawInterpreted + a MAME cycle profile. + +### 85. The pinned framebuffer was never reserved from the Memory Manager -- MM handles landed inside the display + +- **Where:** `src/iigs/hal.c` (jlpStageAllocPixels / all NewHandle sites) -- FIXED during Phase 1 +- **Severity:** critical | **Category:** bug + +The empirical chain that resolved finding #84's "1000x slow sprites": +a diagnostic poke showed the interpreted draw looping over w=280-320, +h=100-170 with sp->widthTiles == sp->heightTiles == 0x44 (= 68 tiles, +i.e. a 544x544 sprite clamped to the surface by clipRect). 0x44 is not +data -- it is COLOR 4 DOUBLED, the fill byte of the jlSurfaceClear(4) +that precedes UBER's sprite section. A follow-up watch of the sprite +struct's address showed it living at $01:8B04 -- INSIDE the pinned +stage framebuffer ($01:2000-$9CFF) -- with its bytes tracking each +successive clear's fill pattern ($AA during the showcase). Mechanism: +jlpStageAllocPixels handed out the bare constant $01:2000 without ever +claiming the range from the Memory Manager (the #77 static audit's +candidate 2), so NewHandle considered the framebuffer free and placed +the #81-fix's jlpBigAlloc blocks inside it. Every surface clear then +rewrote the structs: garbage sprite dimensions (the #84 slowness and +wrong pixels), a full-surface save through the 256-byte gBackupBytes +(the BSS carnage: smashed gBackup, the impossible op-counter values, +sprite-clip-roundtrip FAIL), and plausibly the residual #77 +second-compile hang (arena bookkeeping in painted-over memory). + +**Fix (landed):** jlpStageAllocPixels claims $01:2000 (SURFACE_PIXELS_SIZE +bytes) via NewHandle attrAddr|attrFixed|attrLocked and holds the handle +(released in jlpStageFreePixels); failure to claim leaves a +coreSetError breadcrumb. Additionally every JoeyLib NewHandle site +(jlpBigAlloc, the codegen arena, all three audio handles) now passes +attrNoSpec so no JoeyLib handle can land in banks $00/$01 at all. +Toolchain note appended to LLVM816-ASKS.md item 5: the runtime should +consider the same reservation for any of its own MM usage. + +## Refuted findings (for the record) + +- `src/codegen/spriteCompile.c:202` -- jlSpriteCompile never compacts-and-retries on arena exhaustion, silently demoting sprites to the interpreter forever + - Why refuted: Accuracy: FAILS. Quoted code exists (spriteCompile.c:202-206), but the claim 'jlSpriteCompile does neither' misreads the contract. codegenArenaInternal.h:42-44 offers two sanctioned options: compact-and-retry OR surface the failure ? and jlSpriteCompile surfaces it by returning false. The public API (include/joey/sprite.h:85-87) explicitly documents 'Returns false if the arena is full (caller may +- `src/core/input.c:79` -- jlWaitForAnyKey makes 59 cross-TU predicate calls per poll instead of scanning the arrays it already owns + - Why refuted: Accuracy: PASS ? line 79 does call jlKeyPressed for all 59 keys per spin iteration, both arrays are module globals in this same file, the bounds check is loop-dead, and ~9K cyc/iteration on 65816 is a fair estimate. Fix: technically valid (identical semantics, early return preserved). Materiality: FAIL ? jlWaitForAnyKey is a blocking busy-wait whose sole purpose is to burn cycles until a human pre +- `src/core/sprite.c:155` -- Planar ports run clipRect in spriteDrawInterpreted solely for the dirty mark, then jlpSpriteDrawPlanes re-clips the same sprite + - Why refuted: Accuracy: mostly right -- on Amiga/ST s->pixels is NULL so spriteDrawInterpreted reduces to clipRect + surfaceMarkDirtyRect, jlSpriteDraw:354-355 then calls jlpSpriteDrawPlanes which performs its own full clip (verified amiga/hal.c:1673-1685, atarist/hal.c:1749-1764), and 68k sprite compile currently fails so this is the live path. Fix-validity: workable but weaker than claimed -- moving surfaceMa +- `src/core/sprite.c:65` -- clipRect negation of dstX/dstY overflows int16_t at INT16_MIN + - Why refuted: Accuracy: technically correct on paper -- at sprite.c:65/70, negating INT16_MIN is formal UB where int is 16-bit (ORCA/C, Watcom) and truncating impl-defined conversion on 32-bit-int m68k gcc, and the analysis that all other extents are wrap-safe checks out (dstX+w max 2359, w<=2040). Fix-validity: the early rejection guard is correct and overflow-free, though 'zero cost' is slightly off (two extr +- `src/core/codegenArena.c:113` -- codegenArenaAlloc does no size rounding: 68000 code alignment is guaranteed only by the accident that every current 68k routine happens to be word-sized + - Why refuted: Accuracy: the mechanics check out (no rounding at codegenArena.c:113, back-to-back compaction packing at 165-168, both 68k emitters emit exclusively via 2-byte putWord, odd-address 68000 code fetch is an address error), but the core risk claim is wrong: even-ness is NOT 'the accident that every current 68k routine happens to be word-sized' -- every 68000 instruction is architecturally a multiple o +- `src/codegen/spriteCompile.c:185` -- emitTotalSize performs a full throwaway emit of every routine, doubling sprite-compile cost on 2.8MHz/8MHz targets + - Why refuted: ACCURACY: factually correct -- emitTotalSize (spriteCompile.c:108-130) runs all 24 (shift,op) emitters to completion into scratch and the emit loop (210-240) runs them all again; the IIgs draw emitter does real per-destbyte work (shiftedByteAt at spriteEmitIigs.c:346/367, c2pColumn on 68k), so compile cost genuinely ~doubles and the ~2000-extra-calls estimate for a 32x32 sprite is the right order. diff --git a/PERF.md b/PERF.md index 7df8d34..4ba76c8 100644 --- a/PERF.md +++ b/PERF.md @@ -1,70 +1,120 @@ # JoeyLib cross-port performance (UBER benchmark) -Regenerated 2026-06-30. ST/Amiga/DOS columns are fresh measurements from -the headless harness (`scripts/bench-atarist.sh` Hatari, `bench-amiga.sh` -FS-UAE, `bench-dos.sh` DOSBox). The IIgs column is the perf reference: the -generic+override redesign was bit-identical on IIgs (MAME `iigs-verify` -checksum 0817FE), so its codegen — and thus its ops/sec — is unchanged from -the last IIgs capture. Every non-IIgs cell is a ratio (port / IIgs); the -directive is that every target meet or beat the IIgs floor (>= 1.0x). +Regenerated 2026-07-05 (PERF-AUDIT-PLAN.md Phase 8, #25b) from +same-day captures of all four ports. This supersedes the 2026-07-04 +Phase 0 baseline; every phase of the audit fix plan (0-8) has landed, +so these are the numbers the library actually ships with. -Sprite ops now meet or beat the IIgs on BOTH 68k targets after this session's -work (compiled DRAW emitters + raw-plane-byte save/restore); they were the -dominant violations (down to 0.13x). The ST column is honest for the first -time — the ST port was crashing on first present (user-mode I/O poke) until -the `Super(0L)` fix in jlpInit. +**2026-07-06 update:** the IIgs sprite column is now COMPILED. Finding +#77 (the loader/segment-BSS + malloc-heap reservation that forced the +codegen arena into the app's own BSS) was fixed toolchain-side and +JOEY_IIGS_SPRITE_CODEGEN was re-enabled; the four IIgs sprite rows were +re-captured (bench-iigs.sh) and jumped 5-25x (SaveUnder 104->543, +Draw 48->907, RestoreUnder 110->568, SaveAndDraw 15->376 ops/sec), +bit-identical to the interpreter goldens (diff-uber-hashes 45/45). The +ST and DOS columns are unchanged from the 2026-07-05 capture. -Genuine remaining sub-1.0x cells and why: - * **ST jlDrawCircle (0.61-0.65x)** — inherent planar-outline cost: a 4-plane - word RMW per sparse outline pixel (8 memory accesses) vs the IIgs single - nibble, amplified by ST video-DMA contention. Cycle-bound; ~0.80x ceiling - even fully optimized. Not winnable in this format. - * **Amiga jlFillCircle (0.82x)** — already asm span-fill (long-store middles); - closing the gap needs a blitter area-fill rewrite (risk: the blitter has - wedged the system before). - * **ST jlSpriteSaveAndDraw (0.97x)** — ST runs save+draw as two dispatched - ops; the IIgs fuses them. Marginal. - * **ST jlTileFill (0.98x), Amiga jlFillRect 80x80 (0.95x)** — within noise. - * **ST jlFillRect 320x200 (0.53x)** — ARTIFACT: the IIgs reference (60) is the - SEI-inflated full-screen reading (see the ceiling note below; honest ~28). - Against the honest reference ST is ~1.1x. Same caveat for surfaceClear / - stagePresent full. +Also 2026-07-06: the Amiga `jlpFillRect` partial-width path was moved +onto the blitter (BLTAFWM/BLTALWM hardware edge masks + masked minterm), +which lifted `jlFillRect 16x16` 567->1153, `80x80` 71->269 (both from +below-floor to well above it), and -- because they build on fillRect -- +`jlDrawLine V` 144->269 and `jlDrawRect 100x100` 122->203, all +hash-identical (Amiga 45/45). The one remaining Amiga below-floor cell +is `jlFillCircle r=40` (74%); a whole-circle asm routine was tried and +did NOT help (per-span dispatch is not the cost -- the 8-way-symmetry +overlap fill dominates), so it stays on the C span walker for now. -| Op | IIgs (ops/sec) | Amiga (vs IIGS) | Atari ST (vs IIGS) | DOS (vs IIGS) | +Correctness statement backing this table: all four ports produce +BYTE-IDENTICAL pixels on every measured op and every correctness +check -- 45/45 surface hashes match the frozen goldens in +tests/goldens/uber/ (34 timed ops + 11 hashed checks; the PASS/FAIL +checks -- palette round-trip, PRNG golden sequence, sprite clip +round-trip, arena churn, sprite-from-surface -- pass everywhere). +The Phase 0-era divergences (#75 tilePasteMono three-way, #78 ST +fillCircle/tileFill, #87 IIgs circle outline) are all fixed. + +## Measurement honesty (read before trusting any number) + +Three clock artifacts have shaped this table's history. Two are fixed, +one is inherent -- know them before comparing rows: + +1. **Per-iteration poll tax -- FIXED (Phase 0).** The pre-audit bench + loop called jlFrameCount() between every op; on the IIgs that was a + GetTick toolbox chain (~600-800 cycles), so every small-op number + carried the harness cost, not the op cost. Ops now run in adaptive + batches per poll. Small-op numbers jumped 2-10x on every port when + this landed; never compare against pre-Phase-0 captures. +2. **SEI tick loss -- INHERENT on IIgs, rows tagged below.** The + benchmark clock is jlFrameCount = the VBL-interrupt-driven GetTick. + An op that holds SEI while it runs suppresses those interrupts, the + clock loses ticks, and the op over-reports. The three SEI-holding + rows -- `jlSurfaceClear`, `jlFillRect 320x200`, and + `jlStagePresent full` -- are CLOCK-UNRELIABLE in the IIgs column. + The physics check: a full-screen op moves 32000 bytes at ~6 + cyc/word, so ~29 ops/sec is the hard ceiling at 2.8 MHz. The + present row's 68 and the clear row's 35 sit above it -- inflated; + the honest values are at or below the ceiling. +3. **ST frame-rate miscalibration -- FOUND and FIXED 2026-07-06; the + ST column below is the corrected (millis-clock) capture.** Before the + fix, ops/sec was `iters * jlFrameHz() / actualFrames` and the ST's + `jlpFrameHz()` hardcoded 50 -- but Hatari runs the emulated ST at + ~60 Hz VBL, so every ST number was scaled by 50/60 ~= 0.83 (~17% + pessimistic). Caught by cross-checking against UBER's independent + Timer-C 200 Hz millis clock (refresh-independent), which ran ~16-22% + faster; the Amiga's two clocks agreed exactly (FS-UAE = PAL 50 Hz), + confirming it was ST-only. Two fixes landed: (a) UBER now computes + ops/sec from `jlMillisElapsed` (refresh-independent on all four + ports; identical to the old formula on IIgs where millis is + GetTick-derived), and (b) ST `jlpFrameHz()` now reads the shifter + resolution + sync-mode registers to return the true 50/60/71 Hz. The + per-op `UBER-CLK:` log line reports frame-vs-millis as a standing + cross-check (they now agree). ST cells rose ~17% (e.g. jlSpriteDraw + 503->585, jlFillRect 80x80 97->115); DOS/IIgs/Amiga unchanged. + +## Baseline (2026-07-05): absolute ops/sec per port + +The IIgs is the reference and the perf floor (every other port must +meet or beat it -- project directive). Non-IIgs cells show absolute +ops/sec plus percentage of the IIgs number; sub-100% cells are bolded +as below-floor. `tools/uber-perf-table` emits this table directly; +regenerate after any re-capture (Amiga needs TIMEOUT=600 -- the +default cuts off the final checks). + +| Op | IIgs (ops/sec) | Amiga (ops/sec, % of IIGS) | Atari ST (ops/sec, % of IIGS) | DOS (ops/sec, % of IIGS) | | --- | --- | --- | --- | --- | -| jlSurfaceClear | 28 | 2.68x | 1.32x | 449.21x | -| jlPaletteSet | 678 | 9.09x | 4.27x | 22.01x | -| jlScbSetRange | 1005 | 3.62x | 1.86x | 14.20x | -| jlDrawPixel | 1755 | 2.04x | 1.18x | 9.40x | -| jlDrawLine H | 682 | 2.26x | 1.61x | 26.93x | -| jlDrawLine V | 90 | 1.70x | 1.14x | 145.83x | -| jlDrawLine diag | 35 | 1.14x | 1.06x | 475.37x | -| jlDrawRect 100x100 | 75 | 1.67x | 1.16x | 206.49x | -| jlDrawCircle r=16 | 232 | 1.20x | **0.65x** | 64.79x | -| jlDrawCircle r=80 | 56 | 1.16x | **0.61x** | 301.25x | -| jlFillRect 16x16 | 450 | 1.24x | 1.11x | 36.04x | -| jlFillRect 80x80 | 75 | **0.95x** | 1.33x | 205.09x | -| jlFillRect 320x200 | 60 | 1.13x | **0.53x** | 188.92x | -| jlFillCircle r=40 | 38 | **0.82x** | 1.39x | 398.24x | -| jlSamplePixel | 1916 | 3.42x | 1.92x | 18.04x | -| jlTileFill | 1252 | 1.74x | **0.98x** | 10.60x | -| jlTileCopy | 997 | 1.80x | 1.04x | 7.88x | -| jlTileCopyMasked | 498 | 1.81x | 1.10x | 32.48x | -| jlTilePaste | 1106 | 1.99x | 1.12x | 14.64x | -| jlTileSnap | 1473 | 2.29x | 1.53x | 9.96x | -| jlSpriteSaveUnder | 528 | 1.05x | 1.29x | 23.69x | -| jlSpriteDraw | 438 | 2.09x | 1.09x | 23.85x | -| jlSpriteRestoreUnder | 487 | 1.08x | 1.11x | 34.83x | -| jlSpriteSaveAndDraw | 277 | 1.23x | **0.97x** | 53.79x | -| jlStagePresent full | 42 | 7.81x | 1.10x | 412.17x | -| jlInputPoll | 273 | 13.94x | 4.19x | 37.87x | -| jlKeyDown | 3382 | 7.75x | 3.76x | 18.94x | -| jlKeyPressed | 3345 | 7.66x | 3.81x | 18.73x | -| jlMouseX | 4170 | 10.40x | 5.22x | 26.30x | -| joeyJoyConnected | 3378 | 7.71x | 3.71x | 18.96x | -| jlAudioFrameTick | 4106 | 8.00x | 2.50x | 12.42x | -| jlAudioIsPlayingMod | 3536 | 9.30x | 4.33x | 26.78x | -| surfaceMarkDirtyRect (via jlFillRect 32x32) | 240 | 1.42x | 1.31x | 47.12x | +| jlSurfaceClear | 35 | 75 (214%) | 44 (126%) | 70 (200%) | +| jlPaletteSet | 1623 | 11503 (709%) | 5256 (324%) | 29474 (1816%) | +| jlScbSetRange | 163 | 3953 (2425%) | 2418 (1483%) | 8474 (5199%) | +| jlDrawPixel | 3423 | 5853 (171%) | **3322 (97%)** | 23524 (687%) | +| jlDrawLine H | 1143 | 1703 (149%) | 1341 (117%) | 5674 (496%) | +| jlDrawLine V | 99 | 269 (272%) | 120 (121%) | 494 (499%) | +| jlDrawLine diag | 42 | **40 (95%)** | 42 (100%) | 110 (262%) | +| jlDrawRect 100x100 | 90 | 203 (226%) | 100 (111%) | 399 (443%) | +| jlDrawCircle r=16 | 306 | **285 (93%)** | **178 (58%)** | 424 (139%) | +| jlDrawCircle r=80 | 70 | **63 (90%)** | **40 (57%)** | 96 (137%) | +| jlFillRect 16x16 | 603 | 1153 (191%) | **585 (97%)** | 2874 (477%) | +| jlFillRect 80x80 | 110 | 269 (245%) | 115 (105%) | 380 (345%) | +| jlFillRect 320x200 | 21 | 66 (314%) | 38 (181%) | 56 (267%) | +| jlFillCircle r=40 | 42 | **31 (74%)** | 75 (179%) | 153 (364%) | +| jlSamplePixel | 5223 | 7253 (139%) | **4862 (93%)** | 44244 (847%) | +| jlTileFill | 2043 | 2303 (113%) | **1516 (74%)** | 8894 (435%) | +| jlTileCopy | 1443 | 1853 (128%) | **1307 (91%)** | 8404 (582%) | +| jlTileCopyMasked | 624 | 953 (153%) | 677 (108%) | 3084 (494%) | +| jlTilePaste | 1803 | 2303 (128%) | **1544 (86%)** | 9454 (524%) | +| jlTileSnap | 2643 | 3503 (133%) | 2907 (110%) | 18624 (705%) | +| jlSpriteSaveUnder | 543 | 567 (104%) | 833 (153%) | 8964 (1651%) | +| jlSpriteDraw | 907 | 953 (105%) | **585 (64%)** | 6094 (672%) | +| jlSpriteRestoreUnder | 568 | **553 (97%)** | 655 (115%) | 5744 (1011%) | +| jlSpriteSaveAndDraw | 376 | **332 (88%)** | **318 (85%)** | 4204 (1118%) | +| jlStagePresent full | 68 | 359 (528%) | **56 (82%)** | 354 (521%) | +| jlInputPoll | 243 | 4003 (1647%) | 1425 (586%) | 3084 (1269%) | +| jlKeyDown | 12303 | 39753 (323%) | 22826 (186%) | 76514 (622%) | +| jlKeyPressed | 14043 | 38003 (271%) | 22826 (163%) | 74064 (527%) | +| jlMouseX | 30123 | 96703 (321%) | 59354 (197%) | 153864 (511%) | +| joeyJoyConnected | 12303 | 39303 (319%) | 22283 (181%) | 77144 (627%) | +| jlAudioFrameTick | 50223 | 56503 (113%) | **11724 (23%)** | 58664 (117%) | +| jlAudioIsPlayingMod | 13503 | 56403 (418%) | 29870 (221%) | 127334 (943%) | +| surfaceMarkDirtyRect (via jlFillRect 32x32) | 323 | 853 (264%) | 376 (116%) | 1474 (456%) | > **IIgs full-screen ceiling (~29 ops/sec).** A full-screen op moves > 32000 bytes = 16000 word-stores x 6 cyc (STA long,X / PEI, both @@ -76,3 +126,75 @@ Genuine remaining sub-1.0x cells and why: > report its ops/sec. Treat full-screen IIgs numbers above ~29 as > inflated and cross-check against the cycle model. +## Reading notes + +* **Sprite rows measure different code per port.** DOS runs COMPILED + sprites (finding #74 fixed 2026-07-05). Amiga/ST run their + interpreted planar paths (compiled save/restore emitters are stubs + by design, Phase 4 #27). The IIgs now runs COMPILED sprites too + (finding #77 fixed 2026-07-06; JOEY_IIGS_SPRITE_CODEGEN back on) -- + the IIgs sprite rows jumped 5-25x and the huge non-IIgs ratios on + these rows collapsed accordingly, which re-exposes a real 68k sprite + gap (see below). +* **The below-floor cells are the standing punch list** (ST column + corrected +17% by the 2026-07-06 clock fix, artifact #3 above -- many + ST cells crossed above the floor; what remains is genuinely below). + ST circles (57-58%) are the worst honest graphics gap -- pure-68000 + constraint, no blitter allowed (STF baseline). The IIgs circle-outline + case is a known structural loss (3-register 65816 vs 16-register 68000 + Bresenham; see feedback memory). With IIgs sprites now compiled, a + 68k sprite gap re-opened: ST jlSpriteDraw 64%, ST jlSpriteSaveAndDraw + 85%, ST jlTileFill 74%, and Amiga jlSpriteRestoreUnder 97% + + jlSpriteSaveAndDraw 88% remain below the IIgs floor (they were masked + while the IIgs interpreted; ST jlSpriteRestoreUnder is now 115%, above + floor, after the clock fix). The ST jlSpriteDraw case was investigated + 2026-07-06 and is ARCHITECTURAL, the same root as the ST circle gap: + DRAW is already compiled (JIT, shifts 0/1), but the word-interleaved + planar layout forces `move.b #imm,(d16,An)` (~18 cyc) per plane byte, + which loses in wall-time to the IIgs chunky `sta` (~5 cyc @ 2.8 MHz) -- + ST actually BEATS the IIgs on the block-copy SAVE (833 vs 543) and only + trails on the c2p+mask DRAW. The one real DRAW win (merge the two tile + columns into one `move.w` per plane word) exists only at shift 0; the + UBER benchmark draws at x=40 = shift 1, where the columns fall in + different interleaved groups, so it is byte-bound and the metric cannot + move. jlSpriteSaveAndDraw (85%) is save+draw and is dominated by that + same DRAW (SAVE is already fast). ST jlAudioFrameTick at 23% is the YM mixer + cost, tracked separately. Amiga jlFillCircle r=40 (74%) was probed + 2026-07-06 with a full whole-circle asm routine (Bresenham + inline + 4-plane long-fill span): it held 45/45 hashes but did NOT move the + number (34 vs 31, within the coarse 11-iter sample), so the per-span + C dispatch is NOT the bottleneck -- the asm was reverted. The real cost + is the midpoint fill's 8-way symmetry filling each row 2-4x (idempotent + overlap): the true lever is a scanline rewrite (one span per row, no + overlap), which would roughly halve the byte writes and stay + hash-identical. NB: this op is timed at batch=1 (~29 ms each) by the + 50 Hz frame counter, a coarse measurement -- the same ~4-5x + model-vs-measured gap shows on ST sprite-draw, so part of these two + cells may be measurement, not silicon. +* **jlRandom has no timed row.** Phase 8 (#43) rewrote its xorshift32 + update in 16-bit halves on the IIgs only (~2-3x fewer cycles per + call on the 65816; 32-bit shifts there are per-bit helper loops). + Output is bit-identical -- host-proven over the golden sequence and + a 50M-state sweep, and gated by UBER's random-golden check on all + four ports. +* **Input predicates stay functions** (Phase 8 #40 decision): the + post-clock-fix numbers (12K-77K ops/sec) put call overhead nowhere + near a real frame budget; macro forms in the public header were + rejected as not worth the API exposure. +* **IIgs libc memset/memcpy remain ~4x slow** (finding #79, + toolchain-owned): llvm-mos runtime byte loops vs ORCA's MVN. The + jlScbSetRange and jlPaletteSet IIgs rows carry that tax; library-side + mitigations landed (#16 unrolled word copy, #21), the libc fix is + flagged to the llvm816 session. + +## History + +Pre-2026-07-04 tables (ORCA-era IIgs column, artifact-inflated DOS +numbers, un-batched loops) live in git history only -- every +conclusion drawn from them was re-derived from Phase 0 onward. The +Phase 0 baseline (2026-07-04) and per-phase deltas are recorded in +PERF-AUDIT-PLAN.md's progress tracker; headline gains since Phase 0: +IIgs interpreted spriteDraw 18 -> 48 ops/sec (Phase 4), IIgs +tilePasteMono asm 3.35x (Phase 7 #3), ST flood plane hooks 5.6x +(Phase 7 #4), DOS paletteSet +92% / tileSnap +42% (Phases 2/5), +DOS compiled sprites 284 -> ~6100 ops/sec (#74). diff --git a/examples/adventure/adventure.c b/examples/adventure/adventure.c index 86a2ca6..4e4c618 100644 --- a/examples/adventure/adventure.c +++ b/examples/adventure/adventure.c @@ -2270,7 +2270,14 @@ int main(void) { * 2 items = 22 sprites. Amiga planar emits 8 shifts/sprite at * ~1.5 KB each, so we ask for 256 KB to be safe. Chunky ports * need ~16 KB. */ +#ifdef JOEYLIB_PLATFORM_IIGS + // IIgs: the codegen arena is a single attrNoCross Memory Manager + // block and cannot exceed one 64 KB bank; 16 KB covers the chunky + // emitter's needs for this sprite set. + config.codegenBytes = 16UL * 1024; +#else config.codegenBytes = 256UL * 1024; +#endif config.audioBytes = 64UL * 1024; if (!jlInit(&config)) { diff --git a/examples/agi/agi.c b/examples/agi/agi.c index b0a9fce..53bc38d 100644 --- a/examples/agi/agi.c +++ b/examples/agi/agi.c @@ -149,7 +149,7 @@ static void updateEgoFromInput(void); static AgiGameT gGame; static AgiPicT gPic; static jlSurfaceT *gBackdrop; -static jlSurfaceT *gStage; +static jlSurfaceT *gAgiStage; static AgiVmT gVm; static LogicCacheSlotT gLogicCache[AGI_MAX_RESOURCES]; // View cache: 256 slots of ~5 KB each (~1.3 MB). Far too large for the @@ -546,7 +546,7 @@ static void cbOverlayPic(void *ctx, uint8_t picId) { static void cbShowPic(void *ctx) { (void)ctx; jlLogF("cbShowPic v11=%u", (unsigned)gVm.vars[11]); - if (gStage == NULL) { + if (gAgiStage == NULL) { return; } // Clear the WHOLE stage first so the rows above/below the AGI @@ -556,10 +556,10 @@ static void cbShowPic(void *ctx) { // copy below and gets restored over every subsequent frame -- // so clear.lines never visually erases anything that sat in // the border bands. - jlSurfaceClear(gStage, COLOR_MISSING); - agiPicBlit(&gPic, gStage, PIC_DEST_Y); + jlSurfaceClear(gAgiStage, COLOR_MISSING); + agiPicBlit(&gPic, gAgiStage, PIC_DEST_Y); if (gBackdrop != NULL) { - jlSurfaceCopy(gBackdrop, gStage); + jlSurfaceCopy(gBackdrop, gAgiStage); } gPicReady = true; // The stage just got repainted; any prior save-under refers to @@ -1716,8 +1716,8 @@ int main(int argc, char **argv) { jlLogReset(); jlLogF("AGI TRACE build=%s %s", __DATE__, __TIME__); jlLogFlush(); - gStage = jlStageGet(); - jlPaletteSet(gStage, 0u, kAgiPalette); + gAgiStage = jlStageGet(); + jlPaletteSet(gAgiStage, 0u, kAgiPalette); jlRandomSeed(0xA61DA61Du); // Bring up the SB DMA + mixer. AGI music feeds the SFX stream @@ -1786,7 +1786,7 @@ int main(int argc, char **argv) { } } - jlSurfaceClear(gStage, COLOR_MISSING); + jlSurfaceClear(gAgiStage, COLOR_MISSING); if (gBackdrop != NULL) { jlSurfaceClear(gBackdrop, COLOR_MISSING); } @@ -1852,7 +1852,7 @@ int main(int argc, char **argv) { { uint32_t nowMs2 = jlMillisElapsed(); if (nowMs2 - lastRenderMs >= AGI_RENDER_PERIOD_MS) { - renderFrame(gStage); + renderFrame(gAgiStage); lastRenderMs = nowMs2; } else { jlStagePresent(); diff --git a/examples/spacetaxi/generated/amiga/sprites/sprites.spr b/examples/spacetaxi/generated/amiga/sprites/sprites.spr index 8dd31fe..90ef8db 100644 Binary files a/examples/spacetaxi/generated/amiga/sprites/sprites.spr and b/examples/spacetaxi/generated/amiga/sprites/sprites.spr differ diff --git a/examples/spacetaxi/generated/amiga/tiles/tilebank0.tbk b/examples/spacetaxi/generated/amiga/tiles/tilebank0.tbk new file mode 100644 index 0000000..27bdef1 Binary files /dev/null and b/examples/spacetaxi/generated/amiga/tiles/tilebank0.tbk differ diff --git a/examples/spacetaxi/generated/atarist/sprites/sprites.spr b/examples/spacetaxi/generated/atarist/sprites/sprites.spr index 8dd31fe..90ef8db 100644 Binary files a/examples/spacetaxi/generated/atarist/sprites/sprites.spr and b/examples/spacetaxi/generated/atarist/sprites/sprites.spr differ diff --git a/examples/spacetaxi/generated/atarist/tiles/tilebank0.tbk b/examples/spacetaxi/generated/atarist/tiles/tilebank0.tbk new file mode 100644 index 0000000..a943d40 Binary files /dev/null and b/examples/spacetaxi/generated/atarist/tiles/tilebank0.tbk differ diff --git a/examples/spacetaxi/generated/iigs/font.tbk b/examples/spacetaxi/generated/iigs/font.tbk new file mode 100644 index 0000000..f9883f4 Binary files /dev/null and b/examples/spacetaxi/generated/iigs/font.tbk differ diff --git a/examples/spacetaxi/generated/iigs/levels/level01.dat b/examples/spacetaxi/generated/iigs/levels/level01.dat new file mode 100644 index 0000000..f8a6f9a Binary files /dev/null and b/examples/spacetaxi/generated/iigs/levels/level01.dat differ diff --git a/examples/spacetaxi/generated/iigs/levels/level02.dat b/examples/spacetaxi/generated/iigs/levels/level02.dat new file mode 100644 index 0000000..dea5903 Binary files /dev/null and b/examples/spacetaxi/generated/iigs/levels/level02.dat differ diff --git a/examples/spacetaxi/generated/iigs/levels/level03.dat b/examples/spacetaxi/generated/iigs/levels/level03.dat new file mode 100644 index 0000000..9b5a703 Binary files /dev/null and b/examples/spacetaxi/generated/iigs/levels/level03.dat differ diff --git a/examples/spacetaxi/generated/iigs/levels/level04.dat b/examples/spacetaxi/generated/iigs/levels/level04.dat new file mode 100644 index 0000000..74993ec Binary files /dev/null and b/examples/spacetaxi/generated/iigs/levels/level04.dat differ diff --git a/examples/spacetaxi/generated/iigs/levels/level05.dat b/examples/spacetaxi/generated/iigs/levels/level05.dat new file mode 100644 index 0000000..8467bf0 Binary files /dev/null and b/examples/spacetaxi/generated/iigs/levels/level05.dat differ diff --git a/examples/spacetaxi/generated/iigs/levels/level06.dat b/examples/spacetaxi/generated/iigs/levels/level06.dat new file mode 100644 index 0000000..d232194 Binary files /dev/null and b/examples/spacetaxi/generated/iigs/levels/level06.dat differ diff --git a/examples/spacetaxi/generated/iigs/levels/level07.dat b/examples/spacetaxi/generated/iigs/levels/level07.dat new file mode 100644 index 0000000..805246c Binary files /dev/null and b/examples/spacetaxi/generated/iigs/levels/level07.dat differ diff --git a/examples/spacetaxi/generated/iigs/levels/level08.dat b/examples/spacetaxi/generated/iigs/levels/level08.dat new file mode 100644 index 0000000..3d6d273 Binary files /dev/null and b/examples/spacetaxi/generated/iigs/levels/level08.dat differ diff --git a/examples/spacetaxi/generated/iigs/levels/level09.dat b/examples/spacetaxi/generated/iigs/levels/level09.dat new file mode 100644 index 0000000..fc9246e Binary files /dev/null and b/examples/spacetaxi/generated/iigs/levels/level09.dat differ diff --git a/examples/spacetaxi/generated/iigs/levels/level10.dat b/examples/spacetaxi/generated/iigs/levels/level10.dat new file mode 100644 index 0000000..c4717cc Binary files /dev/null and b/examples/spacetaxi/generated/iigs/levels/level10.dat differ diff --git a/examples/spacetaxi/generated/iigs/levels/level11.dat b/examples/spacetaxi/generated/iigs/levels/level11.dat new file mode 100644 index 0000000..4c3ce87 Binary files /dev/null and b/examples/spacetaxi/generated/iigs/levels/level11.dat differ diff --git a/examples/spacetaxi/generated/iigs/levels/level12.dat b/examples/spacetaxi/generated/iigs/levels/level12.dat new file mode 100644 index 0000000..6a5ba48 Binary files /dev/null and b/examples/spacetaxi/generated/iigs/levels/level12.dat differ diff --git a/examples/spacetaxi/generated/iigs/levels/level13.dat b/examples/spacetaxi/generated/iigs/levels/level13.dat new file mode 100644 index 0000000..459177b Binary files /dev/null and b/examples/spacetaxi/generated/iigs/levels/level13.dat differ diff --git a/examples/spacetaxi/generated/iigs/levels/level14.dat b/examples/spacetaxi/generated/iigs/levels/level14.dat new file mode 100644 index 0000000..4a4374a Binary files /dev/null and b/examples/spacetaxi/generated/iigs/levels/level14.dat differ diff --git a/examples/spacetaxi/generated/iigs/levels/level15.dat b/examples/spacetaxi/generated/iigs/levels/level15.dat new file mode 100644 index 0000000..f091239 Binary files /dev/null and b/examples/spacetaxi/generated/iigs/levels/level15.dat differ diff --git a/examples/spacetaxi/generated/iigs/levels/level16.dat b/examples/spacetaxi/generated/iigs/levels/level16.dat new file mode 100644 index 0000000..e6471a5 Binary files /dev/null and b/examples/spacetaxi/generated/iigs/levels/level16.dat differ diff --git a/examples/spacetaxi/generated/iigs/levels/level17.dat b/examples/spacetaxi/generated/iigs/levels/level17.dat new file mode 100644 index 0000000..4aa266c Binary files /dev/null and b/examples/spacetaxi/generated/iigs/levels/level17.dat differ diff --git a/examples/spacetaxi/generated/iigs/levels/level18.dat b/examples/spacetaxi/generated/iigs/levels/level18.dat new file mode 100644 index 0000000..2303e0d Binary files /dev/null and b/examples/spacetaxi/generated/iigs/levels/level18.dat differ diff --git a/examples/spacetaxi/generated/iigs/levels/level19.dat b/examples/spacetaxi/generated/iigs/levels/level19.dat new file mode 100644 index 0000000..76c0dd3 Binary files /dev/null and b/examples/spacetaxi/generated/iigs/levels/level19.dat differ diff --git a/examples/spacetaxi/generated/iigs/levels/level20.dat b/examples/spacetaxi/generated/iigs/levels/level20.dat new file mode 100644 index 0000000..0c70215 Binary files /dev/null and b/examples/spacetaxi/generated/iigs/levels/level20.dat differ diff --git a/examples/spacetaxi/generated/iigs/levels/level21.dat b/examples/spacetaxi/generated/iigs/levels/level21.dat new file mode 100644 index 0000000..cd178e5 Binary files /dev/null and b/examples/spacetaxi/generated/iigs/levels/level21.dat differ diff --git a/examples/spacetaxi/generated/iigs/levels/level22.dat b/examples/spacetaxi/generated/iigs/levels/level22.dat new file mode 100644 index 0000000..89df634 Binary files /dev/null and b/examples/spacetaxi/generated/iigs/levels/level22.dat differ diff --git a/examples/spacetaxi/generated/iigs/levels/level23.dat b/examples/spacetaxi/generated/iigs/levels/level23.dat new file mode 100644 index 0000000..ccf8623 Binary files /dev/null and b/examples/spacetaxi/generated/iigs/levels/level23.dat differ diff --git a/examples/spacetaxi/generated/iigs/levels/level24.dat b/examples/spacetaxi/generated/iigs/levels/level24.dat new file mode 100644 index 0000000..a499a94 Binary files /dev/null and b/examples/spacetaxi/generated/iigs/levels/level24.dat differ diff --git a/examples/spacetaxi/generated/iigs/levels/title.dat b/examples/spacetaxi/generated/iigs/levels/title.dat new file mode 100644 index 0000000..9887b56 Binary files /dev/null and b/examples/spacetaxi/generated/iigs/levels/title.dat differ diff --git a/examples/spacetaxi/generated/iigs/sprites/sprites.spr b/examples/spacetaxi/generated/iigs/sprites/sprites.spr new file mode 100644 index 0000000..90ef8db Binary files /dev/null and b/examples/spacetaxi/generated/iigs/sprites/sprites.spr differ diff --git a/examples/spacetaxi/generated/iigs/tiles/tbank0.tbk b/examples/spacetaxi/generated/iigs/tiles/tbank0.tbk new file mode 100644 index 0000000..8fd5cd9 Binary files /dev/null and b/examples/spacetaxi/generated/iigs/tiles/tbank0.tbk differ diff --git a/examples/spacetaxi/generated/iigs/tiles/tbank1.tbk b/examples/spacetaxi/generated/iigs/tiles/tbank1.tbk new file mode 100644 index 0000000..8cf9956 Binary files /dev/null and b/examples/spacetaxi/generated/iigs/tiles/tbank1.tbk differ diff --git a/examples/spacetaxi/generated/iigs/tiles/tbank2.tbk b/examples/spacetaxi/generated/iigs/tiles/tbank2.tbk new file mode 100644 index 0000000..2ead5e1 Binary files /dev/null and b/examples/spacetaxi/generated/iigs/tiles/tbank2.tbk differ diff --git a/examples/spacetaxi/stRender.c b/examples/spacetaxi/stRender.c index c0fd1aa..bdab0cf 100644 --- a/examples/spacetaxi/stRender.c +++ b/examples/spacetaxi/stRender.c @@ -1139,42 +1139,34 @@ static void destroySprites(void) { } +// The 1000-glyph font streams straight onto the font surface via +// jlTileBankLoadToSurface -- no 32KB staging buffer. The old +// malloc(32000) was lethal on IIgs (the clang libc heap is ~665 +// bytes and malloc returns 1, not NULL, at exhaustion -- LLVM816-ASKS +// item 4 -- so jlTileBankLoad fread the payload through pointer 1 and +// shredded bank 0 before the first present), and a 32KB static blows +// the bank-0 BSS ceiling. The streaming loader exists for exactly +// this case; its block grid matches ST_FONT_COLS row-major layout. static bool loadFontSheet(jlSurfaceT *stage) { - jlTileT *fontTiles; - uint16_t count; - uint16_t i; - uint16_t palette[16]; + uint16_t count; + uint16_t palette[16]; (void)stage; - // Load font.tbk into a temp tile array, then paste each glyph - // into a regular surface that jlDrawText can sample. ~32 KB of - // temp memory for the 1000-tile font; freed before returning. - fontTiles = (jlTileT *)malloc(sizeof(jlTileT) * ST_FONT_TILES_MAX); - if (fontTiles == NULL) { - return false; - } - count = jlTileBankLoad(ST_FONT_PATH, fontTiles, ST_FONT_TILES_MAX, - NULL, palette); - if (count == 0u) { - free(fontTiles); - return false; - } gRender.fontSurface = jlSurfaceCreate(); if (gRender.fontSurface == NULL) { - free(fontTiles); + return false; + } + count = jlTileBankLoadToSurface(ST_FONT_PATH, gRender.fontSurface, palette); + if (count == 0u) { + jlSurfaceDestroy(gRender.fontSurface); + gRender.fontSurface = NULL; return false; } // Font palette is authoritative for the font surface so jlDrawText // reads back the authored colors. Stage palette is untouched. jlPaletteSet(gRender.fontSurface, 0u, palette); jlScbSetRange(gRender.fontSurface, 0, SURFACE_HEIGHT - 1, 0); - for (i = 0u; i < count; i++) { - uint8_t bx = (uint8_t)(i % ST_FONT_COLS); - uint8_t by = (uint8_t)(i / ST_FONT_COLS); - jlTilePaste(gRender.fontSurface, bx, by, &fontTiles[i]); - } gRender.fontReady = true; - free(fontTiles); return true; } diff --git a/examples/uber/uber.c b/examples/uber/uber.c index 1611223..43eb004 100644 --- a/examples/uber/uber.c +++ b/examples/uber/uber.c @@ -9,9 +9,14 @@ // UBER_FRAMES so results are directly comparable across ports // regardless of CPU speed or VBL rate. // -// jlFrameCount is wall-clock-based per port; the per-iter poll -// adds ~10-30 cyc per op which shows up as noise on the very -// fastest ops but is below ~5% even for ~500 cyc/op work. +// jlFrameCount is wall-clock-based per port, but READING it is not +// free: on the IIgs it is a Misc Toolset GetTick call (several +// hundred cycles per poll -- an earlier comment here claimed +// ~10-30 cyc, which was wrong by more than an order of magnitude +// and inflated every fast op's measured cost by a near-constant +// ~600-800 cycles/iter). runForFrames therefore polls the clock +// once per batch of UBER_BATCH op calls for sub-frame ops +// (calibrated by the first call), amortizing the poll to noise. // // One-shot ops (jlSpriteCompile) get one call each, timed by frame // delta -- coarser but representative. @@ -40,6 +45,68 @@ #define UBER_FRAMES 16u +// Op calls per clock poll for sub-frame ops. The unrolled batch in +// runForFrames must contain exactly this many op() calls. Ops slower +// than one frame poll every call instead, so a batch cannot overrun +// the measurement window by more than one op. +#define UBER_BATCH 16u + +// ----- IIgs progress mailbox (headless-MAME diagnosis) ----- +// +// The SHR SCB region covers rows 0..199 at $E1:9D00-$E1:9DC7; the bytes +// at $E1:9DC8-$E1:9DFF are unused by hardware and by jlpPresent. UBER +// mirrors its progress there so a MAME Lua probe can see WHERE a +// headless run is (and whether the timing loop and the tick are alive) +// even when the joeylog FST flush never lands on disk. Layout: +// +0 (9DC8) uint8 current timed-op index (from timeOp); during +// phase 5 it is 100 + the correctness-check number +// +1 (9DC9) uint8 phase (UBER_PHASE_*) +// +2 (9DCA) uint16 heartbeat (one increment per timing-loop pass) +// +4 (9DCC) uint16 last jlFrameCount() value the timing loop saw +// No-ops on every other port. +#define UBER_PHASE_SETUP_SPRITE 1u +#define UBER_PHASE_AUDIO_INIT 2u +#define UBER_PHASE_SHOWCASE 3u +#define UBER_PHASE_TIMED_RUN 4u +#define UBER_PHASE_CHECKS 5u +#define UBER_PHASE_DONE 6u + +#ifdef JOEYLIB_PLATFORM_IIGS +#define UBER_MB_OP ((volatile uint8_t *)0xE19DC8L) +#define UBER_MB_PHASE ((volatile uint8_t *)0xE19DC9L) +#define UBER_MB_HEARTBEAT ((volatile uint16_t *)0xE19DCAL) +#define UBER_MB_TICK ((volatile uint16_t *)0xE19DCCL) +// One-time layout facts for probe correlation: +8 stage pointer value, +// +12 address of uber.c's gStage static, +16 log-ring base address, +// +20 log-ring head-counter address (see src/core/debug.c's IIgs RAM +// ring -- the Lua probe drains the log live through these). +#define UBER_MB_MAGIC ((volatile uint16_t *)0xE19DCEL) +#define UBER_MB_STAGEPTR ((volatile uint32_t *)0xE19DD0L) +#define UBER_MB_GSTAGEAT ((volatile uint32_t *)0xE19DD4L) +#define UBER_MB_LOGRING ((volatile uint32_t *)0xE19DD8L) +#define UBER_MB_LOGHEAD ((volatile uint32_t *)0xE19DDCL) +// TEMP #84: the gSprite pointer value, poked after setupSprite so the +// Lua probe can watch the struct bytes get overwritten in place. +#define UBER_MB_SPRITEPTR ((volatile uint32_t *)0xE19DEAL) +// "JL" -- the Lua probes refuse to trust the layout words until this +// appears (the SCB region holds 0x80 fill before the app writes it). +#define UBER_MB_MAGIC_VAL 0x4A4Cu +extern uint8_t gJoeyLogRing[]; +extern uint16_t gJoeyLogRingHead; +#define uberMbOp(_i) (*UBER_MB_OP = (uint8_t)(_i)) +#define uberMbPhase(_p) (*UBER_MB_PHASE = (uint8_t)(_p)) +#define uberMbBeat() (*UBER_MB_HEARTBEAT = (uint16_t)(*UBER_MB_HEARTBEAT + 1u)) +#define uberMbTick(_t) (*UBER_MB_TICK = (uint16_t)(_t)) +#define uberMbLayout() (*UBER_MB_STAGEPTR = (uint32_t)gStage, *UBER_MB_GSTAGEAT = (uint32_t)&gStage, *UBER_MB_LOGRING = (uint32_t)&gJoeyLogRing[0], *UBER_MB_LOGHEAD = (uint32_t)&gJoeyLogRingHead, *UBER_MB_MAGIC = UBER_MB_MAGIC_VAL) +#else +#define uberMbOp(_i) ((void)0) +#define uberMbPhase(_p) ((void)0) +#define uberMbBeat() ((void)0) +#define uberMbTick(_t) ((void)0) +#define uberMbLayout() ((void)0) +#endif + + typedef void (*OpFn)(void); static const char *gCurName = "(none)"; @@ -51,21 +118,70 @@ static unsigned char gBackupBytes[256]; static jlTileT gTileScratch; +// Current timed-op number (1-based); mirrored into the mailbox. Lives +// up here because runForFrames re-writes it every loop pass: the SHR +// SCB upload in jlStagePresent can overwrite the adjacent mailbox +// bytes, so a single write at timeOp start can be stomped mid-window. +static uint8_t gOpIndex = 0; + + // Run `op` in a tight loop until `targetFrames` jlFrameCount ticks // have elapsed. Returns iterations completed. -static unsigned long runForFrames(OpFn op, unsigned int targetFrames, uint16_t *actualFramesOut) { +static unsigned long runForFrames(OpFn op, unsigned int targetFrames, uint16_t *actualFramesOut, uint32_t *millisDeltaOut) { unsigned long count; uint16_t startFrame; uint16_t endFrame; + uint16_t now; + uint32_t startMillis; + uint32_t endMillis; + bool slowOp; count = 0UL; jlWaitVBL(); - startFrame = jlFrameCount(); + startFrame = jlFrameCount(); + startMillis = jlMillisElapsed(); - while ((uint16_t)(jlFrameCount() - startFrame) < targetFrames) { - op(); - count++; + // Calibrate: jlWaitVBL aligned us to a tick edge, so a sub-frame op + // cannot see the counter advance during a single call. If the first + // call DOES advance it, the op is slower than one frame -- poll the + // clock every call so a batch cannot overrun the window. + op(); + count++; + slowOp = ((uint16_t)(jlFrameCount() - startFrame) != 0u); + + for (;;) { + now = jlFrameCount(); + uberMbTick(now); + uberMbBeat(); + uberMbOp(gOpIndex); + if ((uint16_t)(now - startFrame) >= targetFrames) { + break; + } + if (slowOp) { + op(); + count++; + } else { + // Exactly UBER_BATCH calls between clock polls (the poll is + // a toolbox call on IIgs; per-op polling dominated fast ops). + op(); + op(); + op(); + op(); + op(); + op(); + op(); + op(); + op(); + op(); + op(); + op(); + op(); + op(); + op(); + op(); + count += UBER_BATCH; + } } /* Capture the actual elapsed frames -- the last iter typically * overruns the target. Using actual instead of target as the @@ -73,10 +189,15 @@ static unsigned long runForFrames(OpFn op, unsigned int targetFrames, uint16_t * * (where count is forced low while real time stretches well * past targetFrames). */ endFrame = jlFrameCount(); + endMillis = jlMillisElapsed(); *actualFramesOut = (uint16_t)(endFrame - startFrame); if (*actualFramesOut == 0u) { *actualFramesOut = 1u; /* defensive: avoid div-by-zero */ } + *millisDeltaOut = endMillis - startMillis; + if (*millisDeltaOut == 0u) { + *millisDeltaOut = 1u; /* defensive: avoid div-by-zero */ + } return count; } @@ -91,26 +212,37 @@ static unsigned long runForFrames(OpFn op, unsigned int targetFrames, uint16_t * static void timeOp(const char *name, OpFn op) { unsigned long iters; unsigned long opsPerSec; + unsigned long opsPerSecFr; uint16_t actualFrames; + uint32_t millisDelta; uint32_t hash; gCurName = name; + gOpIndex++; + uberMbOp(gOpIndex); - iters = runForFrames(op, UBER_FRAMES, &actualFrames); + iters = runForFrames(op, UBER_FRAMES, &actualFrames, &millisDelta); if (iters == 0UL) { jlLogF("UBER: %s: 0 iters (op too slow?)\n", name); return; } - /* Divide by ACTUAL elapsed frames, not the target. For sub-frame - * ops actualFrames ~= UBER_FRAMES so the answer is unchanged; - * for ops that overrun (slow jlStagePresent etc.), this stops - * inflating ops/sec. */ - opsPerSec = (iters * (unsigned long)jlFrameHz()) / (unsigned long)actualFrames; - hash = jlSurfaceHash(gStage); + /* Report ops/sec from the MILLISECOND clock, not the frame counter. + * jlMillisElapsed is refresh-independent on every port (ST Timer-C + * 200 Hz, Amiga audio-ISR tick, DOS PIT, IIgs GetTick-derived so + * identical to the old formula there), so it does not depend on + * jlFrameHz() matching the emulator's real VBL rate -- the ST used + * to hardcode 50 Hz while Hatari runs ~60, under-reporting ~17%. + * The frame-derived rate is kept as the UBER-CLK cross-check: after + * the jlpFrameHz fix the two agree; a divergence flags a clock bug. */ + opsPerSec = (iters * 1000UL) / (unsigned long)millisDelta; + opsPerSecFr = (iters * (unsigned long)jlFrameHz()) / (unsigned long)actualFrames; + hash = jlSurfaceHash(gStage); jlLogF("UBER: %s: %lu iters / %u frames = %lu ops/sec | hash=%08lX\n", name, iters, actualFrames, opsPerSec, (unsigned long)hash); + jlLogF("UBER-CLK: %s: frame=%lu millis=%lu ops/sec (%lu ms)\n", + name, opsPerSecFr, opsPerSec, (unsigned long)millisDelta); } @@ -300,6 +432,7 @@ static void DRAWSHOWCASE_ATTR drawShowcase(void) { uint8_t byEnd; uint16_t held; + uberMbPhase(UBER_PHASE_SHOWCASE); jlPaletteSet(gStage, 0, gShowcasePal); jlScbSetRange(gStage, 0, 199, 0); jlSurfaceClear(gStage, SC_BG_COLOR); @@ -416,9 +549,292 @@ static void DRAWSHOWCASE_ATTR drawShowcase(void) { } +// ----- Non-timed correctness checks (Phase 0 verification harness) ----- +// +// Each check draws a deterministic scene and logs the surface hash so +// tools/diff-uber-hashes can compare ports against each other and against +// the frozen golden logs. Several checks intentionally capture the CURRENT +// behavior of known bugs (PERF-AUDIT.md #1, #11, #12, #72): their hash +// lines are EXPECTED to change when the Phase 1 fixes land -- re-golden +// exactly those lines then, nothing else. +// +// PASS/FAIL lines assert invariants that must hold on every port both +// before and after the fixes (round-trips, forced palette color 0, the +// PRNG golden sequence, arena bookkeeping). + +static void __attribute__((noinline)) chkHash(const char *name) { + jlLogF("UBER-CHK: %s: hash=%08lX\n", name, (unsigned long)jlSurfaceHash(gStage)); +} + + +static void __attribute__((noinline)) chkPassFail(const char *name, bool pass) { + jlLogF("UBER-CHK: %s: %s\n", name, pass ? "PASS" : "FAIL"); +} + + +// Edge-coordinate draws. The off-surface pixel coords stay modest (within +// ~200 rows of the surface) so the pre-fix dirty-band overwrite (finding #1, +// non-IIgs) lands inside the paired band arrays instead of unrelated +// globals; Phase 1 turns these into true no-ops. +static void __attribute__((noinline)) checkEdgeDraws(void) { + uberMbOp(101); + jlDrawPixel(gStage, -5, 100, 5); + jlDrawPixel(gStage, 330, 100, 5); + jlDrawPixel(gStage, 100, -3, 5); + jlDrawPixel(gStage, 100, 210, 5); + chkHash("edge-pixels"); + // w=65535 draws phantom edges today (finding #11); h==2 exercises the + // zero-height interior-edge fills; the third rect clips normally. + jlDrawRect(gStage, 10, 10, 65535u, 100, 5); + jlDrawRect(gStage, 50, 20, 30, 2, 6); + jlDrawRect(gStage, -10, 150, 340, 40, 7); + chkHash("edge-drawRect"); + // r=300 clips every span; r=40000 should cover the surface but draws + // nothing today (finding #12). + jlFillCircle(gStage, 160, 100, 300, 4); + jlFillCircle(gStage, 160, 100, 40000u, 9); + jlDrawCircle(gStage, 10, 10, 40000u, 3); + chkHash("edge-circles"); + // Far-off-surface endpoints. The H line should fill the whole visible + // row but draws nothing today (finding #72: the H/V fast path clamps + // span to 320 BEFORE clipping, so a huge span anchored off-surface + // clips away entirely). The diagonal clips per-pixel and is correct. + jlDrawLine(gStage, -20000, 50, 20000, 50, 2); + jlDrawLine(gStage, 60, -20000, 60, 20000, 2); + jlDrawLine(gStage, -300, -200, 620, 400, 8); + chkHash("edge-lines"); +} + + +// Clipped sprite draws take the interpreted path on every port (the +// compiled routines require fully-on-surface): the ONLY cross-port +// coverage of that path, which UBER's timed ops (on-surface, compiled) +// never touch. Also asserts the save/draw/restore round-trip restores +// the exact pre-save pixels at a clipped position. +static void __attribute__((noinline)) checkClippedSprites(void) { + uint32_t before; + + uberMbOp(102); + jlSpriteDraw(gStage, gSprite, -8, 50); + jlSpriteDraw(gStage, gSprite, 312, 50); + jlSpriteDraw(gStage, gSprite, 150, -8); + jlSpriteDraw(gStage, gSprite, 150, 192); + jlSpriteDraw(gStage, gSprite, -8, -8); + jlSpriteDraw(gStage, gSprite, 400, 100); + chkHash("sprite-clipped"); + before = jlSurfaceHash(gStage); + jlSpriteSaveUnder(gStage, gSprite, -8, 100, &gBackup); + jlSpriteDraw(gStage, gSprite, -8, 100); + jlSpriteRestoreUnder(gStage, &gBackup); + chkPassFail("sprite-clip-roundtrip", jlSurfaceHash(gStage) == before); +} + + +static void __attribute__((noinline)) checkTileMonoFlood(void) { + static jlTileT mono; + uint8_t i; + + uberMbOp(103); + for (i = 0; i < TILE_BYTES; i++) { + mono.pixels[i] = (uint8_t)((i & 1) ? 0x0F : 0xF0); + } + jlTilePasteMono(gStage, 10, 10, &mono, 5, 9); + jlTilePasteMono(gStage, 11, 10, &mono, 14, 0); + chkHash("tilePasteMono"); + jlDrawRect(gStage, 240, 150, 40, 30, 1); + jlFloodFill(gStage, 250, 160, 12); + chkHash("floodFill"); +} + + +static void __attribute__((noinline)) checkDrawText(void) { + static uint16_t asciiMap[256]; + uint16_t i; + + uberMbOp(104); + for (i = 0; i < 256u; i++) { + asciiMap[i] = TILE_NO_GLYPH; + } + // 'A' -> tile (5,5), 'B' -> tile (6,6). Seed both glyph tiles here -- + // the section-opening surface clear wiped whatever the timed tile ops + // left there. + jlTileFill(gStage, 5, 5, 9); + jlTileFill(gStage, 6, 6, 3); + asciiMap['A'] = (uint16_t)(5u | (5u << 8)); + asciiMap['B'] = (uint16_t)(6u | (6u << 8)); + jlDrawText(gStage, 2, 20, gStage, asciiMap, "ABBA"); + chkHash("drawText"); + // Out-of-range start: the entry sanitation loop (Phase 5, #22) + // reproduces the historical absorb-and-wrap semantics bit-for-bit + // (the first glyph is consumed without drawing, the second wraps + // to (0,21) and draws) while guaranteeing jlpTileCopyMasked never + // sees an out-of-range destination. Separate hash line so any + // future semantic change stays isolated. + jlDrawText(gStage, 200, 20, gStage, asciiMap, "AB"); + chkHash("drawText-offgrid"); +} + + +static void __attribute__((noinline)) checkPalette(void) { + static const uint16_t conforming[16] = { + 0x0ABC, 0x0111, 0x0222, 0x0333, 0x0444, 0x0555, 0x0666, 0x0777, + 0x0888, 0x0999, 0x0AAA, 0x0BBB, 0x0CCC, 0x0DDD, 0x0EEE, 0x0123 + }; + uint16_t readBack[16]; + uint16_t i; + bool ok; + + // Contract invariants (stable across the Phase 2 #16 change): color 0 + // is forced to $000 even when the caller passes nonzero, and + // conforming $0RGB entries 1..15 round-trip exactly. + uberMbOp(105); + jlPaletteSet(gStage, 2, conforming); + jlPaletteGet(gStage, 2, readBack); + ok = (readBack[0] == 0x0000u); + for (i = 1; i < 16u; i++) { + if (readBack[i] != conforming[i]) { + ok = false; + } + } + chkPassFail("palette-roundtrip", ok); +} + + +static void __attribute__((noinline)) checkRandom(void) { + static const uint32_t expected[4] = { + 0x87985AA5UL, 0x155B24A3UL, 0x4820F4C4UL, 0x81B3AC98UL + }; + uint16_t i; + bool ok; + + // xorshift32 golden sequence from a fixed seed; bit-identical on + // every port and across the Phase 8 (#43) IIgs rewrite. + uberMbOp(106); + jlRandomSeed(0x12345678UL); + ok = true; + for (i = 0; i < 4u; i++) { + if (jlRandom() != expected[i]) { + ok = false; + } + } + if (jlRandomRange(100u) != 43u) { + ok = false; + } + chkPassFail("random-golden", ok); + jlRandomSeed(1u); +} + + +// Arena churn: create/compile/destroy so a hole opens and is reused, then +// compact and assert the used-byte counter returns to its pre-churn value. +// Covers the codegen allocator paths UBER's single long-lived sprite never +// exercises (findings #5, #34 land here in Phase 1). Also runs the +// owned-tileData path (jlSpriteCreateFromSurface) that finding #31 +// re-allocates in Phase 1, and a sprite-bank load if an asset is present. +static void __attribute__((noinline)) checkAllocator(void) { + jlSpriteT *a; + jlSpriteT *b; + jlSpriteT *c; + uint32_t usedBefore; + + // Sub-markers 111+ pinpoint the statement that hangs (Phase 1 + // stabilization; the coarse marker froze on this check). + uberMbOp(111); + usedBefore = jlSpriteCodegenBytesUsed(); + a = jlSpriteCreate(gBallTiles, BALL_TILES_X, BALL_TILES_Y); + b = jlSpriteCreate(gBallTiles, BALL_TILES_X, BALL_TILES_Y); + if (a == NULL || b == NULL) { + chkPassFail("arena-churn", false); + return; + } + uberMbOp(112); + (void)jlSpriteCompile(a); + (void)jlSpriteCompile(b); + uberMbOp(113); + jlSpriteDestroy(a); + uberMbOp(114); + c = jlSpriteCreate(gBallTiles, BALL_TILES_X, BALL_TILES_Y); + if (c == NULL) { + jlSpriteDestroy(b); + chkPassFail("arena-churn", false); + return; + } + (void)jlSpriteCompile(c); + uberMbOp(115); + jlSpriteDestroy(b); + jlSpriteDestroy(c); + uberMbOp(116); + jlSpriteCompact(); + uberMbOp(117); + chkPassFail("arena-churn", jlSpriteCodegenBytesUsed() == usedBefore); + uberMbOp(118); + a = jlSpriteCreateFromSurface(gStage, 0, 0, 2, 2); + if (a != NULL) { + (void)jlSpriteCompile(a); + uberMbOp(119); + jlSpriteDraw(gStage, a, 280, 20); + jlSpriteDestroy(a); + } + chkPassFail("sprite-from-surface", a != NULL); + uberMbOp(120); + chkHash("sprite-from-surface-draw"); +} + + +// Coverage the primary checkTileMonoFlood golden misses (found in the +// #3/#4 re-analysis): the alternating 0x0F/0xF0 mono pattern only +// selects IIgs #3 combo indices 1 and 2, and jlFloodFill only drives +// the matchEqual branch of the ST #4 plane hooks. Runs LAST so it +// perturbs no earlier cumulative full-stage hash. +static void __attribute__((noinline)) checkTileMonoFloodExtra(void) { + // 0x00 -> combo idx 0 (bg:bg), 0xFF -> idx 3 (fg:fg), 0x0F -> idx 1 + // (bg:fg), 0xF0 -> idx 2 (fg:bg): all four opacity pairs in one tile. + static const uint8_t comboBytes[4] = { 0x00u, 0xFFu, 0x0Fu, 0xF0u }; + static jlTileT monoAll; + uint8_t i; + + uberMbOp(121); + for (i = 0; i < TILE_BYTES; i++) { + monoAll.pixels[i] = comboBytes[i & 3u]; + } + jlTilePasteMono(gStage, 10, 12, &monoAll, 5, 9); + jlTilePasteMono(gStage, 11, 12, &monoAll, 14, 0); + chkHash("tilePasteMonoAll"); + + // Bounded flood: an enclosed color-3 box over a pre-cleared interior + // so the fill stays contained and drives the bounded stop mask + // (pix == boundary || pix == new) plus the planar group-skip across + // several 16px groups. matchColor = boundaryColor = 3, matchEqual = 0. + jlFillRect(gStage, 40, 140, 64, 40, 0); + jlDrawRect(gStage, 44, 144, 52, 32, 3); + jlFloodFillBounded(gStage, 70, 160, 7, 3); + chkHash("floodFillBounded"); +} + + +static void __attribute__((noinline)) runCorrectnessChecks(void) { + uberMbPhase(UBER_PHASE_CHECKS); + jlLogF("UBER-CHK: ----- begin -----\n"); + jlSurfaceClear(gStage, 0); + checkEdgeDraws(); + checkClippedSprites(); + checkTileMonoFlood(); + checkDrawText(); + checkPalette(); + checkRandom(); + checkAllocator(); + checkTileMonoFloodExtra(); + // jlShutdown -> jlInit -> stale-sprite-destroy (#34) needs a teardown + // of the whole library mid-run; it gets a dedicated micro-example in + // Phase 1 rather than risking the benchmark's display state here. + jlLogF("UBER-CHK: ----- end -----\n"); +} + + // ----- Main ----- static void __attribute__((noinline)) runAllTests(void) { + uberMbPhase(UBER_PHASE_TIMED_RUN); jlLogF("UBER: ----- begin -----\n"); // Surface / palette / SCB. @@ -525,6 +941,7 @@ static void __attribute__((noinline)) reportElapsed(uint16_t startFrame) { static bool __attribute__((noinline)) setupSprite(void) { uint16_t before; + uberMbPhase(UBER_PHASE_SETUP_SPRITE); buildBallSprite(); gSprite = jlSpriteCreate(gBallTiles, BALL_TILES_X, BALL_TILES_Y); if (gSprite == NULL) { @@ -578,6 +995,7 @@ int main(void) { jlShutdown(); return 1; } + uberMbLayout(); // A simple visible palette so users see SOMETHING during the run. setupPalette(); @@ -591,6 +1009,9 @@ int main(void) { jlShutdown(); return 1; } +#ifdef JOEYLIB_PLATFORM_IIGS + *UBER_MB_SPRITEPTR = (uint32_t)gSprite; +#endif // Audio: only init/shutdown is exercised. Triggering jlAudioPlaySfx // without first calling jlAudioPlayMod leaves NTP's engine in a @@ -601,6 +1022,7 @@ int main(void) { // ship a MOD asset, so we skip the SFX exercise. The frame-tick and // isPlayingMod calls below still get timed (both are no-op fast // paths on IIgs). + uberMbPhase(UBER_PHASE_AUDIO_INIT); if (jlAudioInit()) { jlLogF("UBER: audioInit OK\n"); } else { @@ -615,9 +1037,12 @@ int main(void) { runAllTests(); + runCorrectnessChecks(); + reportElapsed(startFrame); // Done. Green screen + waitForKey. + uberMbPhase(UBER_PHASE_DONE); jlSurfaceClear(gStage, 2); jlStagePresent(); diff --git a/include/joey/core.h b/include/joey/core.h index 4ecd2ed..c1d33d9 100644 --- a/include/joey/core.h +++ b/include/joey/core.h @@ -7,7 +7,15 @@ #include "types.h" typedef struct { - uint32_t codegenBytes; // runtime compiled-sprite cache size + // Runtime compiled-sprite cache size. 0 selects a per-platform + // default: 8 KB on IIgs, 32 KB on Amiga/ST/DOS (the 68k/x86 + // emitters are ~4-8x less code-dense than the 65816's, so a + // 32x32 sprite can need 8-16 KB there). Size against + // jlSpriteCompiledSize() when tuning. On the IIgs the cache is one + // Memory Manager block allocated with attrNoCross (it may not span + // a 64 KB bank boundary), so values above 65536 are clamped to + // 65536; even a clamped request can fail if no free bank exists. + uint32_t codegenBytes; uint32_t audioBytes; // reserved; not yet consulted by any audio engine } jlConfigT; @@ -58,8 +66,11 @@ uint16_t jlFrameCount(void); // by to convert iters-per-N-frames to ops/sec. uint16_t jlFrameHz(void); -// Monotonic wall-clock millisecond counter since jlInit. Decoupled -// from frame rate -- two consecutive calls separated by a long +// Monotonic wall-clock millisecond counter since jlInit. On DOS/ST it +// reads a real hardware timer; on IIgs/Amiga the generic implementation +// derives it from the frame counter (frame-granularity steps, and the +// caller must poll at least once per 65535 frames -- ~18 min at 60Hz -- +// or wrapped frames are lost). Two consecutive calls separated by a long // render report the true elapsed time, not the number of VBLs that // fired. (Per-port millisecond timer sources are documented at // jlpMillisElapsed in src/core/port.h.) diff --git a/include/joey/platform.h b/include/joey/platform.h index 327c996..69a68ab 100644 --- a/include/joey/platform.h +++ b/include/joey/platform.h @@ -96,6 +96,7 @@ #define JL_HAS_TILE_COPY // iigsTileCopyInner, macro #define JL_HAS_TILE_COPY_MASKED // iigsTileCopyMaskedInner, macro #define JL_HAS_TILE_PASTE // iigsTilePasteInner, macro + #define JL_HAS_TILE_PASTE_MONO // iigsTilePasteMonoInner (stage), macro -> generic else #define JL_HAS_TILE_SNAP // iigsTileSnapInner, macro #define JL_HAS_SPRITE_DRAW // chunky -> codegen; planes mirror is noop macro #define JL_HAS_SPRITE_SAVE // noop macro (codegen path saves) @@ -105,7 +106,8 @@ #define JL_HAS_STAGE_ALLOC_PIXELS // pinned $01:2000 display memory, function #define JL_HAS_STAGE_FREE_PIXELS // no-op (pinned), function #define JL_HAS_FRAME_HZ // runtime 50/60 from battery RAM, function - #define JL_HAS_FRAME_COUNT // GetTick word, function + // (frameCount: port.h #defines jlpFrameCount straight to iigsGetTickWord, + // the GetTick asm wrapper -- no function override to register here.) #define JL_HAS_WAIT_VBL // $C019 VBL poll, function // audio: every real port implements the full engine #define JL_HAS_AUDIO_INIT @@ -202,6 +204,8 @@ #define JL_HAS_SPRITE_DRAW // st planar sprite draw, function #define JL_HAS_SPRITE_SAVE // st planar sprite save, function #define JL_HAS_SPRITE_RESTORE // st planar sprite restore, function + #define JL_HAS_FLOOD_WALK_PLANES // st planar flood walk-out, function + #define JL_HAS_FLOOD_SCAN_ROW_PLANES // st planar flood scan-row, function #define JL_HAS_SURFACE_COPY_PLANES // st planar buffer memcpy, function #define JL_HAS_SAMPLE_PIXEL // st planar nibble decode, function #define JL_HAS_SURFACE_HASH // st planar hash, function @@ -242,6 +246,8 @@ #elif defined(JOEYLIB_PLATFORM_DOS) // chunky generics: surface clear, draw pixel/line/circle, fill circle/rect, // tiles, surface readers, allocation -- DOS overrides only the timing services. + // (The planar dual-write hooks are not linked to generics: every chunky port + // elides them to ((void)0) in port.h's shared chunky block, PERF-AUDIT #42.) #define JL_HAS_FRAME_HZ // VGA ~70, function #define JL_HAS_FRAME_COUNT // $3DA retrace edge counter, function #define JL_HAS_WAIT_VBL // $3DA retrace poll, function diff --git a/include/joey/sprite.h b/include/joey/sprite.h index 71fc14d..417a3d8 100644 --- a/include/joey/sprite.h +++ b/include/joey/sprite.h @@ -13,12 +13,14 @@ // // Performance contract: jlSpriteDraw should be at least as fast as the // IIgs reference (the DESIGN.md spec calls for runtime-compiled draw -// code per CPU). v1 ships with an interpreted fallback that gives -// correct output everywhere; codegen lands per platform after the -// API stabilizes. jlSpritePrewarm is a hint that the application is -// about to draw the sprite repeatedly -- a future codegen-enabled -// build will use it to compile shift variants ahead of the first -// draw. With the interpreter it is a no-op. +// code per CPU). Every port has a runtime emitter (DOS x86, Amiga/ST +// 68k planar, IIgs 65816); jlSpriteCompile compiles the shift/op +// variants into the codegen arena and the draw/save/restore calls +// dispatch to the compiled routines, falling back to the interpreter +// for clipped draws or uncompiled variants. On the IIgs the compiled +// path is temporarily toolchain-gated off (PERF-AUDIT.md #77). +// jlSpritePrewarm compiles ahead of the first draw so the emit cost +// does not land mid-frame. #ifndef JOEYLIB_SPRITE_H #define JOEYLIB_SPRITE_H @@ -82,7 +84,7 @@ void jlSpriteDestroy(jlSpriteT *sp); // Compile the sprite's draw routines into the codegen arena. After // this returns true, jlSpriteDraw uses the compiled fast path on -// platforms where the emitter is wired (currently x86/DOS). Returns +// platforms where the emitter is wired (all four ports). Returns // false if the arena is full (caller may run jlSpriteCompact and // retry), the platform doesn't have a real emitter yet, or the // sprite has no source tile data. diff --git a/include/joey/surface.h b/include/joey/surface.h index ba5c838..a872a37 100644 --- a/include/joey/surface.h +++ b/include/joey/surface.h @@ -63,13 +63,22 @@ bool jlSurfaceSaveFile(const jlSurfaceT *src, const char *path); // identity (no reallocation). bool jlSurfaceLoadFile(jlSurfaceT *dst, const char *path); -// FNV-1a 32-bit hash of the surface's logical pixel content (color -// indices in row-major order, 0..15 per pixel). Same logical pixels -// produce the same hash on every port regardless of internal storage -// format -- so a hash captured on IIgs (chunky) compares directly -// against the same op's output on Amiga (planar) once the planar -// rewrite is done. Used by the UBER validation harness to -// pixel-compare ports against an IIgs golden reference. +// 32-bit content hash of the surface. NOT FNV-1a: a two-stream 16-bit +// multiplicative hash -- per input byte b, lo = lo * 31 + b and +// hi = hi * 251 + b (mod 2^16), seeded lo = 0xACE1 / hi = 0x1357, and +// combined as (hi << 16) | lo. The hashed domain, in order: +// 1. the packed chunky pixel byte stream (32000 bytes, two 4-bit +// pixels per byte, high nibble = left pixel, row-major), +// 2. the 200 SCB bytes, +// 3. the 256 palette uint16 entries, each folded high byte then +// low byte. +// SURFACE_HASH_MIX_BYTE in src/core/surfaceInternal.h is the single +// source of truth for the per-byte mix; a port override (the planar +// Amiga/ST readers) must reuse it byte-for-byte and feed the identical +// byte sequence, so the same logical pixels produce the same hash on +// every port regardless of internal storage format. Used by the UBER +// validation harness to pixel-compare ports against an IIgs golden +// reference. uint32_t jlSurfaceHash(const jlSurfaceT *s); #endif diff --git a/include/joey/tile.h b/include/joey/tile.h index 02ffb29..3d95c82 100644 --- a/include/joey/tile.h +++ b/include/joey/tile.h @@ -84,6 +84,18 @@ void jlTileFill(jlSurfaceT *s, uint8_t bx, uint8_t by, uint8_t colorIndex); uint16_t jlTileBankLoad(const char *path, jlTileT *outTiles, uint16_t maxTiles, bool *outValid, uint16_t *outPalette); +// Stream a baked .tbk straight onto a surface's 8x8 block grid: tile i +// lands at block (i % TILE_BLOCKS_PER_ROW, i / TILE_BLOCKS_PER_ROW), +// the same row-major layout the bake tools author against. Use this +// instead of jlTileBankLoad + a jlTileT[] when the bank is too big for +// a caller-side buffer -- a full 1000-tile font sheet needs 32000 +// bytes, which no IIgs example can heap-allocate (the clang libc heap +// is a few hundred bytes) or hold in bank-0 BSS. One file pass with a +// small internal scratch; per-target/magic validation and outPalette +// behave exactly like jlTileBankLoad. Blocks beyond the file's tile +// count keep their prior surface content. Returns tiles pasted. +uint16_t jlTileBankLoadToSurface(const char *path, jlSurfaceT *dst, uint16_t *outPalette); + // Capture the 8x8 block at (bx, by) into the caller's jlTileT. Used // for save-under style work where allocating a scratch surface would // be overkill. diff --git a/make/amiga.mk b/make/amiga.mk index 39fc66d..8ed653d 100644 --- a/make/amiga.mk +++ b/make/amiga.mk @@ -42,9 +42,9 @@ SHARED_S := $(wildcard $(SRC_68K)/*.s) # code out of every Amiga binary. CORE_C_SRCS_AMIGA := $(filter-out %/audioSfxMix.c, $(CORE_C_SRCS)) -# Sprite codegen: the 68k emitters (spriteEmit68k.c shared with ST, plus the -# Amiga-planar spriteEmitPlanar68k.c) live in src/m68k/ and build into obj/68k/; -# only the CPU-independent dispatch stays in src/codegen. +# Sprite codegen: the Amiga-planar emitter (spriteEmitPlanar68k.c) lives in +# src/m68k/ and builds into obj/68k/; only the CPU-independent dispatch stays +# in src/codegen. CODEGEN_DIR := $(REPO_DIR)/src/codegen LIB_OBJS := \ @@ -54,7 +54,6 @@ LIB_OBJS := \ $(patsubst $(SRC_DIR)/amiga/%.s,$(BUILD)/obj/port/%.o,$(PORT_S_SRCS)) \ $(patsubst $(SRC_68K)/%.s,$(BUILD)/obj/68k/%.o,$(SHARED_S)) \ $(BUILD)/obj/port/ptplayer.o \ - $(BUILD)/obj/68k/spriteEmit68k.o \ $(BUILD)/obj/68k/spriteEmitPlanar68k.o \ $(BUILD)/obj/codegen/spriteCompile.o @@ -160,8 +159,8 @@ $(BUILD)/obj/68k/%.o: $(SRC_68K)/%.s @mkdir -p $(dir $@) $(AMIGA_CC) $(CFLAGS) -c $< -o $@ -# 68k sprite emitters (spriteEmit68k.c, spriteEmitPlanar68k.c) now live in -# src/m68k/; -I$(CODEGEN_DIR) lets them resolve spriteEmitter.h. +# 68k sprite emitters (spriteEmitPlanar68k.c) live in src/m68k/; +# -I$(CODEGEN_DIR) lets them resolve spriteEmitter.h. $(BUILD)/obj/68k/%.o: $(SRC_68K)/%.c @mkdir -p $(dir $@) $(AMIGA_CC) $(CFLAGS) -I$(CODEGEN_DIR) -c $< -o $@ diff --git a/make/atarist.mk b/make/atarist.mk index 6d55bac..021f899 100644 --- a/make/atarist.mk +++ b/make/atarist.mk @@ -28,9 +28,9 @@ PORT_C_SRCS := $(wildcard $(SRC_DIR)/atarist/*.c) PORT_S_SRCS := $(wildcard $(SRC_DIR)/atarist/*.s) SHARED_S := $(wildcard $(SRC_68K)/*.s) -# Sprite codegen: the 68k emitters (spriteEmit68k.c shared with Amiga, plus the -# ST-interleaved spriteEmitInterleaved68k.c) live in src/m68k/ and build into -# obj/68k/; only the CPU-independent dispatch stays in src/codegen. +# Sprite codegen: the ST-interleaved emitter (spriteEmitInterleaved68k.c) +# lives in src/m68k/ and builds into obj/68k/; only the CPU-independent +# dispatch stays in src/codegen. CODEGEN_DIR := $(REPO_DIR)/src/codegen LIB_OBJS := \ @@ -39,7 +39,6 @@ LIB_OBJS := \ $(patsubst $(SRC_DIR)/atarist/%.c,$(BUILD)/obj/port/%.o,$(PORT_C_SRCS)) \ $(patsubst $(SRC_DIR)/atarist/%.s,$(BUILD)/obj/port/%.o,$(PORT_S_SRCS)) \ $(patsubst $(SRC_68K)/%.s,$(BUILD)/obj/68k/%.o,$(SHARED_S)) \ - $(BUILD)/obj/68k/spriteEmit68k.o \ $(BUILD)/obj/68k/spriteEmitInterleaved68k.o \ $(BUILD)/obj/codegen/spriteCompile.o @@ -127,8 +126,8 @@ $(BUILD)/obj/68k/%.o: $(SRC_68K)/%.s @mkdir -p $(dir $@) $(ST_CC) $(CFLAGS) -c $< -o $@ -# 68k sprite emitters (spriteEmit68k.c, spriteEmitInterleaved68k.c) now live in -# src/m68k/; -I$(CODEGEN_DIR) lets them resolve spriteEmitter.h. +# 68k sprite emitters (spriteEmitInterleaved68k.c) live in src/m68k/; +# -I$(CODEGEN_DIR) lets them resolve spriteEmitter.h. $(BUILD)/obj/68k/%.o: $(SRC_68K)/%.c @mkdir -p $(dir $@) $(ST_CC) $(CFLAGS) -I$(CODEGEN_DIR) -c $< -o $@ diff --git a/make/iigs.mk b/make/iigs.mk index 441b8b7..982876e 100644 --- a/make/iigs.mk +++ b/make/iigs.mk @@ -80,8 +80,11 @@ iigs-examples: $(BINDIR)/PATTERN $(BINDIR)/DRAW $(BINDIR)/KEYS $(BINDIR)/JOY \ # Build the JOEYLIB data disk (build/iigs/bin/joey.2mg) that run-iigs-mame.sh / # run-iigs.sh mount on flop4. Packs the launchable examples as ProDOS S16 apps # (an 800KB floppy can't hold every example; see make-iigs-disk.sh). -iigs-disk: iigs-examples - BINDIR="$(BINDIR)" $(REPO_DIR)/scripts/make-iigs-disk.sh $(BINDIR)/joey.2mg +# NTP_BIN rides along so jlpAudioInit's runtime load of "ntpplayer" finds it +# on the volume -- without it every example logs "audioInit failed" (Phase 1 +# IIgs stabilization item c: the audio code is wired; the file was missing). +iigs-disk: iigs-examples $(NTP_BIN) + BINDIR="$(BINDIR)" NTP_BIN="$(NTP_BIN)" $(REPO_DIR)/scripts/make-iigs-disk.sh $(BINDIR)/joey.2mg # Headless visual-verification gate: boot GS/OS under MAME, launch the DRAW # example off joey.2mg, and assert the SHR framebuffer was actually rendered diff --git a/scripts/bench-iigs.sh b/scripts/bench-iigs.sh new file mode 100755 index 0000000..6bda612 --- /dev/null +++ b/scripts/bench-iigs.sh @@ -0,0 +1,252 @@ +#!/usr/bin/env bash +# bench-iigs.sh - Headless UBER perf capture for the IIgs (the reference +# port). Until this script existed, PERF.md's IIgs column was carried over +# from old captures because nothing could get joeylog.txt off the emulated +# disk; every "hashes identical x4" gate needs this. +# +# Boots GS/OS under headless MAME with a WORK COPY of joey.2mg, drives the +# Finder to launch UBER (same keystroke timeline as verify-iigs.sh), then +# polls the Super Hi-Res framebuffer for UBER's solid-green "done" screen +# (jlSurfaceClear color 2 -> SHR bytes 0x22). When seen, MAME exits and +# cadius extracts JOEYLOG.TXT from the work image into build/iigs/bin/ +# joeylog.txt, where tools/uber-perf-table and diff-uber-hashes expect it. +# +# Usage: scripts/bench-iigs.sh +# MAME_ROMPATH override ROM dir (default ~/.mame/roms) +# BENCH_MAX_FRAMES hard frame cap before giving up (default 36000) +# +# Requires: toolchains/env.sh sourced; build/iigs/bin/joey.2mg with UBER on +# it (run 'make iigs-disk', ensure UBER was not skipped for space). +set -euo pipefail + +repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) + +: "${LLVM816_ROOT:?bench-iigs.sh: source toolchains/env.sh first}" +CADIUS="${CADIUS:-$LLVM816_ROOT/tools/cadius/cadius}" + +sys_disk=$repo/toolchains/emulators/support/gsos-system.po +data_disk=$repo/build/iigs/bin/joey.2mg +out_log=$repo/build/iigs/bin/joeylog.txt +rompath="${MAME_ROMPATH:-$HOME/.mame/roms}" +maxFrames="${BENCH_MAX_FRAMES:-36000}" + +[ -x "$CADIUS" ] || { echo "bench-iigs: cadius not found at $CADIUS" >&2; exit 2; } +for f in "$sys_disk" "$data_disk"; do + [ -f "$f" ] || { echo "bench-iigs: missing $f (run 'make iigs-disk')" >&2; exit 2; } +done +if ! "$CADIUS" CATALOG "$data_disk" 2>/dev/null | grep -qE '^ UBER +S16'; then + echo "bench-iigs: UBER not on joey.2mg (800KB floppy full? JOEY_DISK_EXAMPLES=\"UBER\" scripts/make-iigs-disk.sh)" >&2 + exit 2 +fi + +work=$(mktemp -d -t joeylib-bench.XXXXXX) +trap 'rm -rf "$work"' EXIT +cp "$sys_disk" "$work/boot.po" +cp "$data_disk" "$work/joey.2mg" + +# Finder keystroke timeline is verify-iigs.sh's, launching UBER. From frame +# 4500 on, sample the SHR framebuffer every 60 frames: UBER ends on a solid +# color-2 clear + present, so nearly every byte reads 0x22. The red "running" +# bar and the showcase can never look like that. On green, exit; the log was +# already flushed (uber.c calls jlLogFlush before the key wait). +cat > "$work/bench.lua" <= 60 +end + +-- UBER's progress mailbox in the unused SCB bytes (see uber.c): +-- 9DC8 opIndex, 9DC9 phase, 9DCA-B heartbeat, 9DCC-D last-seen tick, +-- 9DD8-B log-ring base address, 9DDC-F log-ring head-counter address. +local function mailbox() + local op = mem:read_u8(0xE19DC8) + local phase = mem:read_u8(0xE19DC9) + local hb = mem:read_u8(0xE19DCA) | (mem:read_u8(0xE19DCB) << 8) + local tick = mem:read_u8(0xE19DCC) | (mem:read_u8(0xE19DCD) << 8) + return string.format("op=%d phase=%d heartbeat=%d tick=%d", op, phase, hb, tick) +end + +local function rd32(addr) + return mem:read_u8(addr) | (mem:read_u8(addr+1) << 8) | (mem:read_u8(addr+2) << 16) | (mem:read_u8(addr+3) << 24) +end + +-- Live drain of the IIgs RAM log ring (src/core/debug.c): the app pokes +-- the ring base and head-counter addresses into the mailbox at startup; +-- we read new bytes each frame and emit whole lines as UBERLOG events. +-- The head is a free-running 16-bit total; ring size 8192 must match +-- JOEY_LOG_RING_BYTES. +local RING_SIZE = 8192 +local logRingBase = 0 +local logHeadAddr = 0 +local logRead = 0 +local logPartial = "" +local function drainLog() + if logRingBase == 0 then + -- Latch only after the app's magic word appears: the SCB region + -- holds 0x80 fill before uberMbLayout() runs, and a garbage + -- latch reads noise forever. + local magic = mem:read_u8(0xE19DCE) | (mem:read_u8(0xE19DCF) << 8) + if magic ~= 0x4A4C then + return + end + logRingBase = rd32(0xE19DD8) & 0xFFFFFF + logHeadAddr = rd32(0xE19DDC) & 0xFFFFFF + if logRingBase ~= 0 then + io.write(string.format("BENCH-IIGS logring base=%06X head=%06X\n", logRingBase, logHeadAddr)) + io.flush() + end + return + end + local head = mem:read_u8(logHeadAddr) | (mem:read_u8(logHeadAddr + 1) << 8) + while logRead ~= head do + local b = mem:read_u8(logRingBase + (logRead % RING_SIZE)) + logRead = (logRead + 1) & 0xFFFF + if b == 10 then + io.write("UBERLOG " .. logPartial .. "\n") + logPartial = "" + elseif b >= 32 and b < 127 then + logPartial = logPartial .. string.char(b) + end + end + io.flush() +end + +local steps = { + {3000, function() nat:post("J") end}, + {3120, function() press(key_cmd) end}, + {3126, function() nat:post("o") end}, + {3180, function() release(key_cmd) end}, + {3540, function() nat:post("UBER") end}, + {3660, function() press(key_cmd) end}, + {3666, function() nat:post("o") end}, + {3720, function() release(key_cmd) end}, +} + +local pcs = {} + +emu.register_frame_done(function() + frame = frame + 1 + while idx <= #steps and frame >= steps[idx][1] do + steps[idx][2]() + idx = idx + 1 + end + if frame >= 4200 and frame % 20 == 0 then + drainLog() + end + if frame >= 4500 and frame % 2000 == 0 then + io.write(string.format("BENCH-IIGS progress frame=%d %s\n", frame, mailbox())) + -- TEMP #84: sprite-geometry diagnostics from sprite.c pokes. + io.write(string.format("BENCH-IIGS spritediag drawW=%d drawH=%d tiles=%04X saveW=%d saveH=%d\n", + mem:read_u8(0xE19DE0) | (mem:read_u8(0xE19DE1) << 8), + mem:read_u8(0xE19DE2) | (mem:read_u8(0xE19DE3) << 8), + mem:read_u8(0xE19DE4) | (mem:read_u8(0xE19DE5) << 8), + mem:read_u8(0xE19DE6) | (mem:read_u8(0xE19DE7) << 8), + mem:read_u8(0xE19DE8) | (mem:read_u8(0xE19DE9) << 8))) + -- TEMP #84: watch the sprite struct bytes in place (16 bytes at + -- the pointer setupSprite poked; printable ASCII shown). + local sptr = rd32(0xE19DEA) & 0xFFFFFF + if sptr ~= 0 then + local bytes = {} + local text = {} + for i = 0, 15 do + local b = mem:read_u8(sptr + i) + bytes[#bytes + 1] = string.format("%02X", b) + if b >= 32 and b < 127 then + text[#text + 1] = string.char(b) + else + text[#text + 1] = "." + end + end + io.write(string.format("BENCH-IIGS spritemem ptr=%06X %s |%s| stagePtr=%08X\n", + sptr, table.concat(bytes, " "), table.concat(text), rd32(0xE19DD0))) + end + io.flush() + end + if frame >= 4500 and frame % 60 == 0 and greenDone() then + io.write(string.format("BENCH-IIGS done frame=%d %s\n", frame, mailbox())) + io.flush() + manager.machine:exit() + end + -- On timeout, sample the PC across 40 frames first so a hang site + -- can be resolved against the link map (ALIGN=0x10000 means the + -- bank-local offset equals the link offset within its segment). + if frame >= $maxFrames and frame < $maxFrames + 40 then + local pc = cpu.state["PC"].value + pcs[string.format("%06X", pc)] = (pcs[string.format("%06X", pc)] or 0) + 1 + end + if frame >= $maxFrames + 40 then + io.write(string.format("BENCH-IIGS timeout frame=%d %s\n", frame, mailbox())) + for k, v in pairs(pcs) do + io.write(string.format("BENCH-IIGS pc %s count=%d\n", k, v)) + end + io.flush() + manager.machine:exit() + end +end) +LUA + +echo "bench-iigs: running UBER headless under MAME (cap $maxFrames frames)..." >&2 +cd "$work" +out=$(QT_QPA_PLATFORM=offscreen SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy \ + timeout 1200 mame apple2gs \ + -rompath "$rompath" \ + -flop3 "$work/boot.po" -flop4 "$work/joey.2mg" \ + -video none -sound none -nothrottle \ + -autoboot_script "$work/bench.lua" &1) || true + +echo "$out" | grep -E '^BENCH-IIGS (progress|pc|spritediag|spritemem|logring)' | sed 's/^/bench-iigs: /' >&2 +marker=$(echo "$out" | grep -E '^BENCH-IIGS (done|timeout)' | tail -1) +echo "bench-iigs: $marker" >&2 +if echo "$marker" | grep -q timeout; then + echo "bench-iigs: WARNING -- frame cap hit before the green done screen (partial capture likely)" >&2 +fi +if [ -z "$marker" ]; then + echo "bench-iigs: FAIL -- MAME produced no marker (boot/launch failed)" >&2 + echo "$out" | tail -15 >&2 + exit 1 +fi + +# Primary log source: the RAM-ring drain (UBERLOG lines emitted live by +# the Lua probe -- immune to the FST non-persistence and floppy-write +# stalls of findings #80/#82). Falls back to cadius extraction of the +# on-disk joeylog.txt only if the ring never produced anything. +echo "$out" | grep -E '^UBERLOG ' | sed 's/^UBERLOG //' > "$out_log" +if [ ! -s "$out_log" ]; then + echo "bench-iigs: no ring log captured; falling back to on-disk extraction" >&2 + "$CADIUS" EXTRACTFILE "$work/joey.2mg" "/JOEYLIB/JOEYLOG.TXT" "$work/" >/dev/null 2>&1 || true + extracted=$(find "$work" -maxdepth 1 -iname 'joeylog.txt*' | head -1) + if [ -z "$extracted" ]; then + echo "bench-iigs: FAIL -- no ring log AND no JOEYLOG.TXT on the work image" >&2 + "$CADIUS" CATALOG "$work/joey.2mg" >&2 || true + exit 1 + fi + # cadius may suffix the ProDOS type (#04xxxx); normalize and strip CRs. + tr -d '\r' < "$extracted" > "$out_log" +fi + +ops=$(grep -c "ops/sec" "$out_log" || true) +chks=$(grep -c "UBER-CHK" "$out_log" || true) +done_marker=$(grep -c "press any key to exit" "$out_log" || true) +echo "bench-iigs: captured $ops op lines, $chks check lines (complete=$done_marker)" >&2 +echo " -> $out_log" >&2 +grep "UBER" "$out_log" | sed 's/ | hash=.*//' || true diff --git a/scripts/bench-staxi-title.sh b/scripts/bench-staxi-title.sh new file mode 100755 index 0000000..0bdc138 --- /dev/null +++ b/scripts/bench-staxi-title.sh @@ -0,0 +1,160 @@ +#!/usr/bin/env bash +# bench-staxi-title.sh - Space Taxi title-screen logo cadence measurement +# (the PERF-AUDIT #3 acceptance test: the logo repaints via 103 +# jlTilePasteMono calls, intended every 4th frame -- stRender.c +# titleAnimateLogo's `& 3u` divider mirroring the C64 original). +# +# Boots GS/OS under headless MAME with a WORK COPY of joey.2mg (must +# contain STAXI + its DATA tree; build with +# JOEY_DISK_EXAMPLES="STAXI" scripts/make-iigs-disk.sh), +# launches STAXI via the standard Finder keystroke timeline, then +# samples 8 known logo tile cells from the SHR framebuffer EVERY frame. +# Each frame the sampled 256 bytes fold into a rolling hash; a hash +# change means the logo visibly changed on screen (the flip-book tile +# differs on every 4-frame step, so a healthy title changes every 4 +# frames; a slow repaint stretches the observed period). +# +# Emits "STAXI-CHG frame=N sum=XXXXXX" per change, heartbeats, and a +# final verify-style summary. Post-process the change frames into +# periods with the analysis snippet in the acceptance report. +# +# Usage: scripts/bench-staxi-title.sh [label] +# MAME_ROMPATH override ROM dir (default ~/.mame/roms) +# STAXI_MAX_FRAMES frame cap (default 15000) +# STAXI_MAX_CHANGES stop after this many logo changes (default 80) +set -euo pipefail + +repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +: "${LLVM816_ROOT:?bench-staxi-title.sh: source toolchains/env.sh first}" +CADIUS="${CADIUS:-$LLVM816_ROOT/tools/cadius/cadius}" + +sys_disk=$repo/toolchains/emulators/support/gsos-system.po +data_disk=$repo/build/iigs/bin/joey.2mg +rompath="${MAME_ROMPATH:-$HOME/.mame/roms}" +maxFrames="${STAXI_MAX_FRAMES:-15000}" +maxChanges="${STAXI_MAX_CHANGES:-80}" +label="${1:-run}" + +for f in "$sys_disk" "$data_disk"; do + [ -f "$f" ] || { echo "bench-staxi-title: missing $f" >&2; exit 2; } +done +if ! "$CADIUS" CATALOG "$data_disk" 2>/dev/null | grep -qE '^ STAXI +S16'; then + echo "bench-staxi-title: STAXI not on joey.2mg (JOEY_DISK_EXAMPLES=\"STAXI\" scripts/make-iigs-disk.sh)" >&2 + exit 2 +fi + +work=$(mktemp -d -t joeylib-staxi.XXXXXX) +trap 'rm -rf "$work"' EXIT +cp "$sys_disk" "$work/boot.po" +cp "$data_disk" "$work/joey.2mg" + +cat > "$work/staxi.lua" <> 4) & 0x0F] = true + checksum = (checksum + b) & 0xFFFFFF + end + local n = 0 + for _ in pairs(distinct) do n = n + 1 end + io.write(string.format("STAXI-%s label=$label frame=%d changes=%d distinctNibbles=%d nonZeroBytes=%d checksum=%06X\n", + tag, frame, nChanges, n, nonzero, checksum)) + io.flush() +end + +emu.register_frame_done(function() + frame = frame + 1 + while idx <= #steps and frame >= steps[idx][1] do + steps[idx][2]() + idx = idx + 1 + end + if frame >= 3800 then + local s = logoSum() + if s ~= prevSum then + io.write(string.format("STAXI-CHG frame=%d sum=%06X\n", frame, s)) + io.flush() + prevSum = s + nChanges = nChanges + 1 + if nChanges >= $maxChanges then + summary("DONE") + manager.machine:exit() + end + end + end + if frame % 1500 == 0 then + io.write(string.format("STAXI-HEART frame=%d changes=%d\n", frame, nChanges)) + io.flush() + end + if frame >= $maxFrames then + summary("TIMEOUT") + manager.machine:exit() + end +end) +LUA + +echo "bench-staxi-title[$label]: running STAXI headless under MAME (cap $maxFrames frames, $maxChanges changes)..." >&2 +cd "$work" +out=$(QT_QPA_PLATFORM=offscreen SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy \ + timeout "${STAXI_WALL_TIMEOUT:-900}" mame apple2gs \ + -rompath "$rompath" \ + -flop3 "$work/boot.po" -flop4 "$work/joey.2mg" \ + -video none -sound none -nothrottle \ + -autoboot_script "$work/staxi.lua" &1) || true + +echo "$out" | grep -E '^STAXI-(CHG|HEART|DONE|TIMEOUT)' > "$repo/build/iigs/bin/staxi-title-$label.log" || true +tail -3 "$repo/build/iigs/bin/staxi-title-$label.log" >&2 +lines=$(grep -c '^STAXI-CHG' "$repo/build/iigs/bin/staxi-title-$label.log" || true) +echo "bench-staxi-title[$label]: $lines change events -> build/iigs/bin/staxi-title-$label.log" >&2 diff --git a/scripts/check-millis.sh b/scripts/check-millis.sh new file mode 100755 index 0000000..89b8eb5 --- /dev/null +++ b/scripts/check-millis.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# check-millis.sh - Host-cc harness for the generic millisecond clock +# (jlpGenericMillisElapsed, the IIgs/Amiga path). Compiles genericPort.c +# with mocked jlpFrameCount/jlpFrameHz (tests/host/millisHost.c) and runs +# frame sequences including the 16-bit wrap. +# +# EXPECTED TO FAIL until the Phase 1 #23 fix lands (the clock runs +# backward at the 65536-frame wrap); after that it must PASS and becomes +# the regression gate. See PERF-AUDIT-PLAN.md Phase 0/Phase 1. +set -euo pipefail + +repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +cc=${CC:-cc} +work=$(mktemp -d -t joeylib-millis.XXXXXX) +trap 'rm -rf "$work"' EXIT + +cd "$repo" +# The BLANK platform block already registers JL_HAS_FRAME_COUNT and +# JL_HAS_FRAME_HZ (blank.c normally supplies them); millisHost.c provides +# the mocks here instead, so no extra -D flags are needed. +"$cc" -DJOEYLIB_PLATFORM_BLANK \ + -Iinclude -Iinclude/joey -Isrc/core -Wall \ + src/generic/genericPort.c tests/host/millisHost.c \ + -o "$work/millisHost" + +"$work/millisHost" diff --git a/scripts/check-sfxmix.sh b/scripts/check-sfxmix.sh new file mode 100755 index 0000000..7605e41 --- /dev/null +++ b/scripts/check-sfxmix.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# check-sfxmix.sh - Host-cc differential harness for the shared SFX +# overlay mixer (src/core/audioSfxMix.c), built on the check-blank.sh / +# check-millis.sh pattern. Compiles the CURRENT mixer TU with the host +# cc plus tests/host/sfxMixHost.c and runs a fixed-seed scenario set +# (fixed-buffer + streaming slots, rate up/down, multi-slot, mid-mix +# refill and end-of-stream, amplitude 0/max, >64k read positions). +# +# First run captures tests/goldens/sfxmix-baseline.txt (capture it +# against the CURRENT mixer (the go-forward reference)); later runs diff per scenario: +# default exact mode: FNV-1a hash + raw bytes must match +# the baseline (gate for findings #7 and #33, +# which must be bit-identical). +# JL_SFX_TOLERANCE=1 per-sample mode for finding #6: prints each +# scenario's max-abs-delta and allows a deviation +# of tolerance * slots per output sample (the +# 1-LSB interp deviation stacks across +# simultaneous slots before the clamp). +# +# The harness binary is also run twice and the outputs compared, so a +# nondeterministic harness (or mixer reading uninitialized memory) +# fails loudly instead of producing a flaky baseline. +set -euo pipefail + +repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +cc=${CC:-cc} +work=$(mktemp -d -t joeylib-sfxmix.XXXXXX) +trap 'rm -rf "$work"' EXIT +baseline="$repo/tests/goldens/sfxmix-baseline.txt" + +cd "$repo" +# BLANK platform: audioSfxMix.c is portable C with no jlp* hooks; the +# stream fill callback is mocked inside sfxMixHost.c. +"$cc" -DJOEYLIB_PLATFORM_BLANK \ + -Iinclude -Iinclude/joey -Isrc/core -Wall \ + src/core/audioSfxMix.c tests/host/sfxMixHost.c \ + -o "$work/sfxMixHost" + +"$work/sfxMixHost" > "$work/out1.txt" +"$work/sfxMixHost" > "$work/out2.txt" +if ! cmp -s "$work/out1.txt" "$work/out2.txt"; then + echo "check-sfxmix: FAIL - harness output differs across two runs (nondeterministic)" >&2 + exit 1 +fi + +if [ ! -f "$baseline" ]; then + mkdir -p "$(dirname "$baseline")" + cp "$work/out1.txt" "$baseline" + count=$(grep -c '^name=' "$baseline") + echo "check-sfxmix: baseline captured ($count scenarios) -> tests/goldens/sfxmix-baseline.txt" + exit 0 +fi + +tolerance="${JL_SFX_TOLERANCE:-}" +if [ -n "$tolerance" ]; then + echo "check-sfxmix: comparing against baseline (per-sample, tolerance ${tolerance} LSB * slots)" +else + echo "check-sfxmix: comparing against baseline (exact hash mode)" +fi + +if python3 - "$baseline" "$work/out1.txt" "$tolerance" <<'PY' +import sys + + +def parse(path): + order = [] + scen = {} + name = None + with open(path) as f: + for line in f: + line = line.strip() + if line.startswith("name="): + fields = dict(p.split("=", 1) for p in line.split()) + name = fields["name"] + order.append(name) + scen[name] = {"hash": fields["hash"], "slots": int(fields["slots"]), "raw": b""} + elif line.startswith("raw=") and name is not None: + scen[name]["raw"] = bytes.fromhex(line[4:]) + return order, scen + + +baseOrder, base = parse(sys.argv[1]) +curOrder, cur = parse(sys.argv[2]) +tolerance = int(sys.argv[3]) if sys.argv[3] else None + +fail = False +if curOrder != baseOrder: + print("check-sfxmix: scenario list changed vs baseline") + print(" baseline: " + " ".join(baseOrder)) + print(" current: " + " ".join(curOrder)) + fail = True + +for name in baseOrder: + if name not in cur: + continue + b = base[name] + c = cur[name] + label = " %-24s" % (name + ":") + if len(b["raw"]) != len(c["raw"]): + print("%s FAIL (length %d -> %d)" % (label, len(b["raw"]), len(c["raw"]))) + fail = True + continue + if tolerance is None: + ok = (b["hash"] == c["hash"]) and (b["raw"] == c["raw"]) + if ok: + print("%s PASS (hash %s)" % (label, c["hash"])) + else: + print("%s FAIL (hash %s -> %s)" % (label, b["hash"], c["hash"])) + fail = True + else: + maxDelta = 0 + for x, y in zip(b["raw"], c["raw"]): + d = abs(x - y) + if d > maxDelta: + maxDelta = d + allowed = tolerance * b["slots"] + ok = maxDelta <= allowed + print("%s maxAbsDelta=%d (allowed %d) %s" % (label, maxDelta, allowed, "PASS" if ok else "FAIL")) + fail = fail or not ok + +sys.exit(1 if fail else 0) +PY +then + echo "check-sfxmix: PASS" +else + echo "check-sfxmix: FAIL (mixer output diverged from tests/goldens/sfxmix-baseline.txt)" >&2 + exit 1 +fi diff --git a/scripts/diag-staxi-screen.sh b/scripts/diag-staxi-screen.sh new file mode 100755 index 0000000..0e63232 --- /dev/null +++ b/scripts/diag-staxi-screen.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +# diag-staxi-screen.sh - one-off diagnostic for the STAXI title test: +# boot + launch STAXI, dump the full SHR framebuffer + palettes as hex +# at a target frame, then extract JOEYLOG.TXT from the work disk so the +# game's own init/asset-load log is visible. Outputs land in +# build/iigs/bin/ (staxi-shr.hex, staxi-joeylog.txt). +set -euo pipefail + +repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +: "${LLVM816_ROOT:?diag-staxi-screen.sh: source toolchains/env.sh first}" +CADIUS="${CADIUS:-$LLVM816_ROOT/tools/cadius/cadius}" + +sys_disk=$repo/toolchains/emulators/support/gsos-system.po +data_disk=$repo/build/iigs/bin/joey.2mg +rompath="${MAME_ROMPATH:-$HOME/.mame/roms}" +dumpFrame="${STAXI_DUMP_FRAME:-9000}" + +work=$(mktemp -d -t joeylib-staxidiag.XXXXXX) +trap 'rm -rf "$work"' EXIT +cp "$sys_disk" "$work/boot.po" +cp "$data_disk" "$work/joey.2mg" + +cat > "$work/diag.lua" <= steps[idx][1] do + steps[idx][2]() + idx = idx + 1 + end + -- Hang localization: sample the PC over 60 frames before the dump, + -- and probe NEWVIDEO (0xE0C029: bit7 = SHR on -> jlpInit ran). + if frame == $dumpFrame - 120 then + io.write(string.format("SHRPROBE newvideo=%02X shadow=%02X\n", + mem:read_u8(0xE0C029), mem:read_u8(0xE0C035))) + io.flush() + end + if frame >= $dumpFrame - 100 and frame < $dumpFrame - 40 then + local pc = cpu.state["PC"].value + local k = string.format("%06X", pc) + pcs[k] = (pcs[k] or 0) + 1 + end + if frame == $dumpFrame - 40 then + for k, v in pairs(pcs) do + io.write(string.format("SHRPC %s count=%d\n", k, v)) + end + io.flush() + end + if frame == $dumpFrame - 20 then + -- Locate the jlLog RAM ring without UBER's mailbox: bulk-read every + -- RAM bank and emit it as hex; the host greps for the build-stamp + -- text. read_range failures are reported, not swallowed. + local banks = {0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,0xE0,0xE1} + for _, bank in ipairs(banks) do + local base = bank * 65536 + local ok, buf = pcall(function() return mem:read_range(base, base + 65535, 8) end) + if ok and buf ~= nil then + for off = 0, 65535, 256 do + local chunk = string.sub(buf, off + 1, off + 256) + local hex = {} + for i = 1, #chunk do + hex[#hex + 1] = string.format("%02X", string.byte(chunk, i)) + end + io.write(string.format("SHRMEM %02X%04X %s\n", bank, off, table.concat(hex))) + end + else + io.write(string.format("SHRMEM-FAIL bank=%02X err=%s\n", bank, tostring(buf))) + end + end + io.flush() + end + if frame == $dumpFrame then + -- SHR pixel dump: 200 rows x 160 bytes as hex lines. + for row = 0, 199 do + local parts = {} + for b = 0, 159 do + parts[#parts + 1] = string.format("%02X", mem:read_u8(0xE12000 + row * 160 + b)) + end + io.write("SHRROW " .. table.concat(parts) .. "\n") + end + -- SCBs + palettes. + local scb = {} + for i = 0, 15 do scb[#scb + 1] = string.format("%02X", mem:read_u8(0xE19D00 + i)) end + io.write("SHRSCB " .. table.concat(scb, " ") .. "\n") + local pal = {} + for i = 0, 31 do pal[#pal + 1] = string.format("%02X", mem:read_u8(0xE19E00 + i)) end + io.write("SHRPAL " .. table.concat(pal, " ") .. "\n") + io.flush() + manager.machine:exit() + end +end) +LUA + +echo "diag-staxi: dumping SHR at frame $dumpFrame..." >&2 +cd "$work" +out=$(QT_QPA_PLATFORM=offscreen SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy \ + timeout 900 mame apple2gs \ + -rompath "$rompath" \ + -flop3 "$work/boot.po" -flop4 "$work/joey.2mg" \ + -video none -sound none -nothrottle \ + -autoboot_script "$work/diag.lua" &1) || true + +echo "$out" | grep -E '^SHR(PROBE|PC)|^SHRMEM-FAIL' | sed 's/^/diag-staxi: /' >&2 +echo "$out" | grep -E '^SHRMEM ' > "$repo/build/iigs/bin/staxi-mem.hex" || true +echo "$out" | grep -E '^SHR(ROW|SCB|PAL)' > "$repo/build/iigs/bin/staxi-shr.hex" +wc -l "$repo/build/iigs/bin/staxi-shr.hex" >&2 + +# The game flushed its log to the volume root; pull it off the WORK copy. +rm -f "$repo/build/iigs/bin/staxi-joeylog.txt" "$work/JOEYLOG.TXT"* 2>/dev/null || true +"$CADIUS" EXTRACTFILE "$work/joey.2mg" "/JOEYLIB/JOEYLOG.TXT" "$work/" >/dev/null 2>&1 || true +found=$(ls "$work"/JOEYLOG.TXT* 2>/dev/null | head -1) +if [ -n "$found" ]; then + tr -d '\r' < "$found" > "$repo/build/iigs/bin/staxi-joeylog.txt" + echo "diag-staxi: extracted JOEYLOG.TXT:" >&2 +else + echo "diag-staxi: no JOEYLOG.TXT on work disk (flush never ran?)" >&2 +fi diff --git a/scripts/make-iigs-disk.sh b/scripts/make-iigs-disk.sh index 03bb5af..fbb4c5d 100755 --- a/scripts/make-iigs-disk.sh +++ b/scripts/make-iigs-disk.sh @@ -60,6 +60,56 @@ for name in "${order[@]}"; do fi done +# The NTP replayer binary (audio): jlpAudioInit fopen()s "ntpplayer" from +# the volume at runtime (see src/iigs/audio_full.c NTP_PATH). Plain ProDOS +# BIN ($06) file. Skipped with a warning if it doesn't fit or wasn't built. +if [ -n "${NTP_BIN:-}" ] && [ -f "$NTP_BIN" ]; then + ntpSz=$(stat -c%s "$NTP_BIN") + ntpNeed=$(( (ntpSz + 511) / 512 + 2 )) + if [ "$ntpNeed" -le "$(freeBlocks)" ]; then + cp "$NTP_BIN" "$work/NTPPLAYER#060000" + "$CADIUS" ADDFILE "$OUT" "/$VOL" "$work/NTPPLAYER#060000" >/dev/null + rm -f "$work/NTPPLAYER#060000" + added+=(NTPPLAYER) + else + skipped+=(NTPPLAYER) + fi +fi + +# Space Taxi runtime data: the game fopen()s a DATA/ tree (levels/*.dat, +# tiles/tbank*.tbk, sprites/sprites.spr, font.tbk -- see stRender.c / +# stLevel.c). Packed only when STAXI made it onto the disk, from the IIgs +# asset bake at examples/spacetaxi/generated/iigs (levels and .spr are +# cross-target; .tbk banks are IIgs-baked -- assetLoad.c validates the +# header target byte). GS/OS ProDOS lookups are case-insensitive, so the +# uppercase ProDOS names match the game's lowercase fopen paths. +staxiData="$repo/examples/spacetaxi/generated/iigs" +if [[ " ${added[*]:-} " == *" STAXI "* ]]; then + if [ -d "$staxiData" ]; then + "$CADIUS" CREATEFOLDER "$OUT" "/$VOL/DATA" >/dev/null + "$CADIUS" CREATEFOLDER "$OUT" "/$VOL/DATA/LEVELS" >/dev/null + "$CADIUS" CREATEFOLDER "$OUT" "/$VOL/DATA/TILES" >/dev/null + "$CADIUS" CREATEFOLDER "$OUT" "/$VOL/DATA/SPRITES" >/dev/null + dataAdded=0 + while IFS= read -r f; do + rel=${f#"$staxiData/"} + dir=$(dirname "$rel") + base=$(basename "$rel") + dst="/$VOL/DATA" + if [ "$dir" != "." ]; then + dst="/$VOL/DATA/${dir^^}" + fi + cp "$f" "$work/${base^^}#060000" + "$CADIUS" ADDFILE "$OUT" "$dst" "$work/${base^^}#060000" >/dev/null + rm -f "$work/${base^^}#060000" + dataAdded=$((dataAdded + 1)) + done < <(find "$staxiData" -type f | sort) + added+=("DATA[$dataAdded files]") + else + echo " WARNING: STAXI packed without its DATA tree ($staxiData missing)" >&2 + fi +fi + echo "iigs-disk: $OUT (volume /$VOL)" echo " added: ${added[*]:-none}" if [ ${#skipped[@]} -gt 0 ]; then diff --git a/src/amiga/hal.c b/src/amiga/hal.c index da68d60..f848b54 100644 --- a/src/amiga/hal.c +++ b/src/amiga/hal.c @@ -24,6 +24,7 @@ #include #include "joey/debug.h" +#include "joey/tile.h" #include #include @@ -88,6 +89,7 @@ typedef struct { static void amigaBlitterCopyPlanes(uint8_t *dst[AMIGA_BITPLANES], uint8_t *src[AMIGA_BITPLANES], uint16_t widthWords, uint16_t height); static void amigaBlitterFillOne(uint8_t *dst, uint16_t widthWords, uint16_t height, uint16_t dstModBytes, bool setBits); +static void amigaBlitterMaskedFillOne(uint8_t *dst, uint16_t widthWords, uint16_t height, uint16_t dstModBytes, uint16_t fwm, uint16_t lwm, bool setBits); static void amigaPlanarSetPixel(AmigaPlanarT *pd, int16_t x, int16_t y, uint8_t color); static void buildCopperList(const jlSurfaceT *src); static void installCopperList(void); @@ -173,6 +175,14 @@ static volatile struct Custom *const gCust = &custom; #define AMIGA_BLT_SIZESHIFT 6 #define AMIGA_BLT_MASK_ALL 0xFFFFu +// Minterms for the masked solid-fill blit (amigaBlitterMaskedFillOne): +// A is the constant $FFFF cut to the rect by the first/last-word masks, +// C and D both address the destination. Set-bits writes D = A OR C +// (force the masked bits on, preserve the rest); clear-bits writes +// D = (NOT A) AND C (force the masked bits off, preserve the rest). +#define AMIGA_BLT_MINTERM_SET 0xFAu // D = A | C +#define AMIGA_BLT_MINTERM_CLEAR 0x0Au // D = ~A & C + // Blitter A->D straight copy of `height` rows of `widthWords` words // across all four planes. src[i]/dst[i] are the per-plane start // addresses (already advanced to the first row/byte to copy by the @@ -235,6 +245,41 @@ static void amigaBlitterFillOne(uint8_t *dst, uint16_t widthWords, uint16_t heig } +// Blitter masked solid fill of one plane: `height` rows of `widthWords` +// words starting at `dst`, `dstModBytes` added to the pointer after each +// row ((40 - widthWords*2) for a sub-row rect). Handles partial-pixel +// left/right edges via the hardware first/last-word masks (fwm/lwm), so +// any rect width -- not just whole words -- fills in a single blit per +// plane with no CPU read-modify-write. +// +// A is left as a constant $FFFF in BLTADAT (SRCA off, no A DMA); the +// blitter still runs that constant through the fwm/lwm masking in the A +// data path, which is what cuts the partial edge words. C reads the +// destination so bits outside the mask survive, D writes it back. When +// widthWords == 1 the blitter ANDs fwm and lwm onto the single word, so +// a one-word-wide rect needs no special case. Caller owns the blitter; +// this WaitBlit's before its own register setup so back-to-back +// per-plane calls are safe. +static void amigaBlitterMaskedFillOne(uint8_t *dst, uint16_t widthWords, uint16_t height, + uint16_t dstModBytes, uint16_t fwm, uint16_t lwm, + bool setBits) { + uint16_t minterm; + + minterm = setBits ? AMIGA_BLT_MINTERM_SET : AMIGA_BLT_MINTERM_CLEAR; + WaitBlit(); + gCust->bltcon0 = (uint16_t)(SRCC | DEST | minterm); + gCust->bltcon1 = 0u; + gCust->bltadat = AMIGA_BLT_MASK_ALL; + gCust->bltafwm = fwm; + gCust->bltalwm = lwm; + gCust->bltcmod = dstModBytes; + gCust->bltdmod = dstModBytes; + gCust->bltcpt = (APTR)dst; + gCust->bltdpt = (APTR)dst; + gCust->bltsize = (uint16_t)((height << AMIGA_BLT_SIZESHIFT) | widthWords); +} + + // Build a user copper list for per-scanline palette (SCB emulation). // One WAIT + 16 MOVEs per displayed scanline + one CEND. The list is // stored in gNewUCL until installCopperList swaps it onto the screen. @@ -749,7 +794,7 @@ void jlpPresent(const jlSurfaceT *src) { if (!gPrevAnyDirty) { bool anyDirty = false; for (y = 0; y < SURFACE_HEIGHT; y++) { - if (gStageMinWord[y] != STAGE_DIRTY_CLEAN_MIN) { + if (STAGE_DIRTY_ROW_TOUCHED(y)) { anyDirty = true; break; } @@ -842,7 +887,7 @@ void jlpPresent(const jlSurfaceT *src) { for (i = 0; i < SURFACE_HEIGHT; i++) { gPrevStageMinWord[i] = gStageMinWord[i]; gPrevStageMaxWord[i] = gStageMaxWord[i]; - if (gStageMinWord[i] != STAGE_DIRTY_CLEAN_MIN) { + if (STAGE_DIRTY_ROW_TOUCHED(i)) { anyDirty = true; } } @@ -1022,39 +1067,30 @@ void jlpSurfaceClear(jlSurfaceT *s, uint8_t doubled) { } -// Phase 3 planar dual-write for jlFillRect: writes the four off-screen -// shadow plane buffers alongside the chunky shadow. Caller (cross- -// platform jlFillRect) has already done the chunky write via -// the chunky generic. The shadow planes are off- -// screen so this is invisible until jlStagePresent. +// Planar jlFillRect: fills the four off-screen shadow plane buffers via +// the blitter. Caller (cross-platform jlFillRect) has already clipped +// the rect to the surface, so x/y/w/h are on-surface. The shadow planes +// are off-screen, so this is invisible until jlStagePresent. // -// Layout reminder (see docs/amiga_planar.md): each plane byte covers -// 8 horizontal pixels; bit 7 = leftmost pixel of that byte. So a -// rect clipped to [x, x+w) needs: -// * bytes [x/8 .. (x+w-1)/8] in each plane row -// * leading partial byte if (x % 8) != 0 (only bits [7-x%8 .. 0] -// get touched -- the upper bits stay) -// * trailing partial byte if ((x+w-1) % 8) != 7 (only bits [7 .. -// 7-(x+w-1)%8] get touched) -// * single-byte case (byteFirst == byteLast) collapses to one -// read-modify-write with a combined mask. -// For each plane, the bit value at every pixel in the rect is -// constant: (colorIndex >> plane) & 1. Set bit -> OR with mask; -// clear bit -> AND with ~mask. +// Layout reminder (see docs/amiga_planar.md): each plane word covers 16 +// horizontal pixels; bit 15 = leftmost pixel of that word. The blitter +// fills whole words and cuts the partial-pixel left/right edges with its +// hardware first/last-word masks (fwm/lwm), so a single DEST(+C) blit per +// plane handles any width -- no per-pixel-edge CPU read-modify-write. +// For each plane the bit value at every rect pixel is constant: +// (colorIndex >> plane) & 1; set bits OR the mask into the plane, clear +// bits AND its complement (see the two masked-fill minterms). void jlpFillRect(jlSurfaceT *s, int16_t x, int16_t y, int16_t w, int16_t h, uint8_t colorIndex) { AmigaPlanarT *pd; - uint16_t byteFirst; - uint16_t byteLast; - uint16_t numMid; - uint8_t leftMask; - uint8_t rightMask; + uint16_t wordFirst; + uint16_t wordLast; + uint16_t widthWords; + uint16_t fwm; + uint16_t lwm; + uint16_t dstMod; uint16_t plane; - int16_t row; - int16_t yEnd; uint8_t bitVal; - uint8_t fullByte; - uint8_t *p; - uint8_t *planeBase; + uint8_t *dst; if (s == NULL || w == 0u || h == 0u) { return; @@ -1065,243 +1101,62 @@ void jlpFillRect(jlSurfaceT *s, int16_t x, int16_t y, int16_t w, int16_t h, uint return; } - /* Variable shifts on 68000 cost 8 cyc per bit shifted -- for shifts - * by up to 7 that's ~30 cyc per mask, ~60 cyc both. LUTs compile - * to a single byte load. jlFillCircle r=40 fills 160 spans, so - * this saves ~160*60=9600 cyc per jlFillCircle. */ - static const uint8_t kLeftMaskLut[8] = { - 0xFFu, 0x7Fu, 0x3Fu, 0x1Fu, 0x0Fu, 0x07u, 0x03u, 0x01u - }; - static const uint8_t kRightMaskLut[8] = { - 0x80u, 0xC0u, 0xE0u, 0xF0u, 0xF8u, 0xFCu, 0xFEu, 0xFFu - }; + // Word-granular rect geometry. wordFirst/wordLast index the 16-pixel + // plane words the rect touches; fwm/lwm are the blitter first/last- + // word masks that cut the partial-pixel edges (pixel 0 is bit 15 of + // its word, so the left mask drops the high bits of the first word + // and the right mask drops the low bits of the last). One blit per + // plane then fills any width, so there is no byte-aligned or + // per-pixel-edge special case to follow. + wordFirst = (uint16_t)((uint16_t)x >> 4); + wordLast = (uint16_t)(((uint16_t)x + w - 1u) >> 4); + widthWords = (uint16_t)(wordLast - wordFirst + 1u); + fwm = (uint16_t)(AMIGA_BLT_MASK_ALL >> ((uint16_t)x & 15u)); + lwm = (uint16_t)(AMIGA_BLT_MASK_ALL << (15u - (((uint16_t)x + w - 1u) & 15u))); + dstMod = (uint16_t)(AMIGA_BYTES_PER_ROW - (widthWords << 1)); - byteFirst = (uint16_t)((uint16_t)x >> 3); - byteLast = (uint16_t)(((uint16_t)x + w - 1u) >> 3); - - leftMask = kLeftMaskLut [(uint16_t)x & 7u]; - rightMask = kRightMaskLut[((uint16_t)x + w - 1u) & 7u]; - - yEnd = y + (int16_t)h; - - /* Full-row fast path: no partial-byte RMW on either edge, so each - * plane row is a whole 40-byte (20-word) store. jlFillRect 320x200 - * (clear-to-color, full-screen wipes) is the dominant case and - * lands here. With the shadow planes now in CHIP RAM the blitter - * can do this DEST-only block fill -- (h << 6) | 20 words, BLTDMOD - * = 0 because consecutive full-width rows are contiguous. Set bits - * write $FFFF (minterm $FF), clear bits write $0000 (minterm $00). - * One OwnBlitter for all four planes; WaitBlit before DisownBlitter - * so the planes are complete before the present copy / any later - * CPU draw reads them. */ - if (byteFirst == 0u && byteLast == (uint16_t)(AMIGA_BYTES_PER_ROW - 1u)) { + /* Full-width fast path: the rect spans every plane word with both + * edge masks all-ones, so each plane row is a whole 20-word store + * with no destination read. jlFillRect 320x200 (full-screen wipes) + * is the dominant case and lands here. DEST-only block fill -- + * (h << 6) | 20 words, BLTDMOD = 0 because consecutive full-width + * rows are contiguous; set bits write $FFFF (minterm $FF), clear + * bits $0000 (minterm $00). One OwnBlitter for all four planes; + * WaitBlit before DisownBlitter so the planes are complete before + * the present copy or any later CPU draw reads them. */ + if (wordFirst == 0u && wordLast == (uint16_t)(AMIGA_BLT_WORDS_PER_ROW - 1u) + && fwm == AMIGA_BLT_MASK_ALL && lwm == AMIGA_BLT_MASK_ALL) { uint8_t *rowBase; OwnBlitter(); for (plane = 0; plane < AMIGA_BITPLANES; plane++) { bitVal = (uint8_t)((colorIndex >> plane) & 1u); rowBase = pd->planes[plane] + (uint16_t)y * AMIGA_BYTES_PER_ROW; - amigaBlitterFillOne(rowBase, AMIGA_BLT_WORDS_PER_ROW, h, 0u, bitVal != 0u); + amigaBlitterFillOne(rowBase, AMIGA_BLT_WORDS_PER_ROW, (uint16_t)h, 0u, bitVal != 0u); } WaitBlit(); DisownBlitter(); return; } - /* Byte-aligned partial-row fast path: when both edges are full - * bytes (leftMask == rightMask == 0xFF) every byte in the row is - * a full overwrite -- no RMW needed. UBER jlFillRect 80x80 at x=120 - * lands here (byteFirst=15, byteLast=24). Plane bases are - * AllocMem(MEMF_CHIP)-returned and thus long-aligned, and y*40 is - * also a multiple of 4, so rowP alignment is determined by - * byteFirst alone -- computed once, not per-row. */ - if (leftMask == 0xFFu && rightMask == 0xFFu) { - uint16_t nBytes = (uint16_t)(byteLast - byteFirst + 1u); - uint8_t alignBytes = (uint8_t)((4u - (byteFirst & 3u)) & 3u); - uint16_t midBytes; - uint16_t midLongs; - uint16_t tailBytes; - - if (alignBytes > nBytes) { - alignBytes = (uint8_t)nBytes; - } - midBytes = (uint16_t)(nBytes - alignBytes); - midLongs = (uint16_t)(midBytes >> 2); - tailBytes = (uint16_t)(midBytes & 3u); - - for (plane = 0; plane < AMIGA_BITPLANES; plane++) { - bitVal = (uint8_t)((colorIndex >> plane) & 1u); - fullByte = bitVal ? 0xFFu : 0x00u; - uint32_t fillLong = (uint32_t)fullByte * 0x01010101UL; - planeBase = pd->planes[plane]; - uint8_t *rowP = planeBase - + (uint16_t)y * AMIGA_BYTES_PER_ROW - + byteFirst; - for (row = y; row < yEnd; row++) { - uint8_t *pp = rowP; - uint8_t ab = alignBytes; - uint16_t ml = midLongs; - uint16_t tb = tailBytes; - while (ab > 0u) { - *pp++ = fullByte; - ab--; - } - if (ml > 0u) { - uint32_t *p32 = (uint32_t *)pp; - do { - *p32++ = fillLong; - } while (--ml); - pp = (uint8_t *)p32; - } - while (tb > 0u) { - *pp++ = fullByte; - tb--; - } - rowP += AMIGA_BYTES_PER_ROW; - } - } - return; - } - - /* Hoist bitVal-dependent setup outside the row loop. Two - * specialized per-plane paths (OR for set, AND-NOT for clear) - * give gcc -O2 simple branchless inner loops. Row pointer is - * advanced by += AMIGA_BYTES_PER_ROW instead of recomputed per - * row -- saves the per-iter multiply. - * - * Single-byte case (byteFirst == byteLast) uses the combined - * mask; multi-byte case uses leading + middle long-fill + - * trailing. The middle long-fill path is identical to the - * earlier code (align, long stores, drain) but lifted into the - * per-plane scope so the constants are loop-invariant. */ - { - uint8_t notLeftMask = (uint8_t)~leftMask; - uint8_t notRightMask = (uint8_t)~rightMask; - - if (byteFirst == byteLast) { - uint8_t mask = (uint8_t)(leftMask & rightMask); - uint8_t notMask = (uint8_t)~mask; - for (plane = 0; plane < AMIGA_BITPLANES; plane++) { - planeBase = pd->planes[plane]; - p = planeBase + (uint16_t)y * AMIGA_BYTES_PER_ROW + byteFirst; - if ((colorIndex >> plane) & 1u) { - /* OR path */ - for (row = y; row < yEnd; row++) { - *p = (uint8_t)(*p | mask); - p += AMIGA_BYTES_PER_ROW; - } - } else { - /* AND-NOT path */ - for (row = y; row < yEnd; row++) { - *p = (uint8_t)(*p & notMask); - p += AMIGA_BYTES_PER_ROW; - } - } - } - return; - } - - numMid = (uint16_t)(byteLast - byteFirst - 1u); - - /* Hoist middle-region alignment outside both per-plane and - * per-row loops. midStart = planeBase + y*40 + byteFirst + 1. - * Plane bases are AllocMem(MEMF_CHIP) long-aligned and y*40 is - * a multiple of 4, so midStart's alignment is determined by - * (byteFirst + 1) & 3 alone -- constant across planes/rows. */ - uint8_t midAlignBytes = (uint8_t)((4u - ((byteFirst + 1u) & 3u)) & 3u); - uint16_t midRem; - uint16_t midLongs; - uint16_t midTail; - - if (midAlignBytes > numMid) { - midAlignBytes = (uint8_t)numMid; - } - midRem = (uint16_t)(numMid - midAlignBytes); - midLongs = (uint16_t)(midRem >> 2); - midTail = (uint16_t)(midRem & 3u); - - /* Small-numMid byte-only path: when there are no full longs to - * fill, the unified long-fill machinery's runtime ml/tb checks - * cost more than they save. UBER jlFillRect 16x16 (numMid=1) - * lands here. */ - if (midLongs == 0u) { - for (plane = 0; plane < AMIGA_BITPLANES; plane++) { - bitVal = (uint8_t)((colorIndex >> plane) & 1u); - fullByte = bitVal ? 0xFFu : 0x00u; - uint8_t leadBits = (uint8_t)(bitVal ? leftMask : notLeftMask); - uint8_t trailBits = (uint8_t)(bitVal ? rightMask : notRightMask); - planeBase = pd->planes[plane]; - p = planeBase + (uint16_t)y * AMIGA_BYTES_PER_ROW + byteFirst; - - if (bitVal) { - for (row = y; row < yEnd; row++) { - uint8_t *pp = p; - uint16_t m = numMid; - *pp = (uint8_t)(*pp | leadBits); pp++; - while (m > 0u) { *pp++ = fullByte; m--; } - *pp = (uint8_t)(*pp | trailBits); - p += AMIGA_BYTES_PER_ROW; - } - } else { - for (row = y; row < yEnd; row++) { - uint8_t *pp = p; - uint16_t m = numMid; - *pp = (uint8_t)(*pp & leadBits); pp++; - while (m > 0u) { *pp++ = fullByte; m--; } - *pp = (uint8_t)(*pp & trailBits); - p += AMIGA_BYTES_PER_ROW; - } - } - } - return; - } - - for (plane = 0; plane < AMIGA_BITPLANES; plane++) { - bitVal = (uint8_t)((colorIndex >> plane) & 1u); - fullByte = bitVal ? 0xFFu : 0x00u; - uint32_t fillLong = (uint32_t)fullByte * 0x01010101UL; - uint8_t leadBits = (uint8_t)(bitVal ? leftMask : notLeftMask); - uint8_t trailBits= (uint8_t)(bitVal ? rightMask : notRightMask); - planeBase = pd->planes[plane]; - p = planeBase + (uint16_t)y * AMIGA_BYTES_PER_ROW + byteFirst; - - if (bitVal) { - for (row = y; row < yEnd; row++) { - uint8_t *pp = p; - uint8_t ab = midAlignBytes; - uint16_t ml = midLongs; - uint16_t tb = midTail; - *pp = (uint8_t)(*pp | leadBits); pp++; - while (ab > 0u) { *pp++ = fullByte; ab--; } - { - uint32_t *p32 = (uint32_t *)pp; - do { *p32++ = fillLong; } while (--ml); - pp = (uint8_t *)p32; - } - while (tb > 0u) { *pp++ = fullByte; tb--; } - *pp = (uint8_t)(*pp | trailBits); - p += AMIGA_BYTES_PER_ROW; - } - } else { - for (row = y; row < yEnd; row++) { - uint8_t *pp = p; - uint8_t ab = midAlignBytes; - uint16_t ml = midLongs; - uint16_t tb = midTail; - *pp = (uint8_t)(*pp & leadBits); pp++; - while (ab > 0u) { *pp++ = fullByte; ab--; } - { - uint32_t *p32 = (uint32_t *)pp; - do { *p32++ = fillLong; } while (--ml); - pp = (uint8_t *)p32; - } - while (tb > 0u) { *pp++ = fullByte; tb--; } - *pp = (uint8_t)(*pp & trailBits); - p += AMIGA_BYTES_PER_ROW; - } - } - } + /* General masked fill: A is the constant $FFFF cut to the rect by + * the fwm/lwm word masks, C reads and D writes the destination so + * bits outside the mask survive. One blit per plane covers any + * width, partial pixel edges included -- the blitter does the edge + * masking in hardware, replacing the old per-pixel-edge CPU walkers. + * UBER jlFillRect 16x16 / 80x80 and every jlFillCircle span land + * here. widthWords == 1 needs no special case: the blitter ANDs both + * edge masks onto the single word. */ + OwnBlitter(); + for (plane = 0; plane < AMIGA_BITPLANES; plane++) { + bitVal = (uint8_t)((colorIndex >> plane) & 1u); + dst = pd->planes[plane] + + (uint16_t)y * AMIGA_BYTES_PER_ROW + + (uint16_t)(wordFirst << 1); + amigaBlitterMaskedFillOne(dst, widthWords, (uint16_t)h, dstMod, fwm, lwm, bitVal != 0u); } + WaitBlit(); + DisownBlitter(); } @@ -1805,18 +1660,22 @@ void jlpTilePaste(jlSurfaceT *dst, uint8_t bx, uint8_t by, const uint8_t *chunky } -/* Planar monochrome paste. monoTile is plane-major (matches the - * Amiga jlpTileSnap output): bytes [0..7] = plane 0 = shape - * bits, bytes [8..15] = plane 1 (zero for monochrome tiles), etc. - * Each pixel's output color: fgColor where shape bit is 1, bgColor - * where 0. Output is written directly to the dst planar buffers via - * the per-plane formula - * out_plane_k = (shape & mask_fg_k) | (~shape & mask_bg_k) - * where mask_X_k = $FF if (X & (1<portData; @@ -1824,17 +1683,29 @@ void jlpTilePasteMono(jlSurfaceT *dst, uint8_t bx, uint8_t by, const uint8_t *mo jlpGenericTilePasteMono(dst, bx, by, monoTile, fgColor, bgColor); return; } + // Fold each chunky nibble-pair row into one 1bpp shape byte. + for (row = 0; row < TILE_PIXELS_PER_SIDE; row++) { + uint8_t bits = 0u; + for (col = 0; col < TILE_BYTES_PER_ROW; col++) { + uint8_t srcByte = *monoTile++; + bits = (uint8_t)(bits << 2); + if (srcByte & 0xF0u) { + bits = (uint8_t)(bits | 0x02u); + } + if (srcByte & 0x0Fu) { + bits = (uint8_t)(bits | 0x01u); + } + } + shape[row] = bits; + } rowBase = (uint16_t)((uint16_t)by * 8u) * AMIGA_BYTES_PER_ROW + bx; - /* monoTile[0..7] is the shape (plane 0 of the source tile). */ - const uint8_t *shape = monoTile; for (plane = 0; plane < AMIGA_BITPLANES; plane++) { - uint8_t mask_fg = (uint8_t)((fgColor & (1u << plane)) ? 0xFFu : 0x00u); - uint8_t mask_bg = (uint8_t)((bgColor & (1u << plane)) ? 0xFFu : 0x00u); - uint8_t *p = pd->planes[plane] + rowBase; + uint8_t maskFg = (uint8_t)((fgColor & (1u << plane)) ? 0xFFu : 0x00u); + uint8_t maskBg = (uint8_t)((bgColor & (1u << plane)) ? 0xFFu : 0x00u); + uint8_t *p = pd->planes[plane] + rowBase; for (row = 0; row < 8u; row++) { uint8_t s = shape[row]; - p[row * AMIGA_BYTES_PER_ROW] = (uint8_t)((uint8_t)(s & mask_fg) | - (uint8_t)((uint8_t)(~s) & mask_bg)); + p[row * AMIGA_BYTES_PER_ROW] = (uint8_t)((uint8_t)(s & maskFg) | (uint8_t)((uint8_t)(~s) & maskBg)); } } } diff --git a/src/atarist/fillCircle.s b/src/atarist/fillCircle.s index 6cf3fbe..d80ae6d 100644 --- a/src/atarist/fillCircle.s +++ b/src/atarist/fillCircle.s @@ -1,16 +1,17 @@ | Atari ST word-interleaved planar jlFillCircle -- 68000 hand-rolled. | -| Bresenham midpoint circle, 4 horizontal spans per Bresenham iter, -| paired by shared x-range so leftMask/rightMask are computed once -| per pair: -| Pair A: x in [cx-bx, cx+bx], rows y = cy+by, cy-by -| Pair B: x in [cx-by, cx+by], rows y = cy+bx, cy-bx +| Exact-disk span walk, bit-identical to the chunky reference span +| loop in src/core/draw.c jlFillCircle: for each y offset in [0, r] +| the largest x with x*x + y*y <= r*r is found incrementally (no +| in-loop multiply), then the span (cx-x, cx+x) is drawn at rows +| cy+y and cy-y. leftMask/rightMask are computed once per row pair. | | Caller MUST guarantee the bounding box (cx-r, cy-r) (cx+r, cy+r) -| is fully on-surface. Off-surface circles fall back to the C walker. +| is fully on-surface (so r <= 99 and r*r <= 9801 fits a word). +| Off-surface circles fall back to the C walker. | | Phase 10 final: 16-way color dispatch at the OUTER loop. Each color -| variant has its own Bresenham body where SPAN_BODY inlines a hard- +| variant has its own span-walk body where SPAN_BODY inlines a hard- | coded 4-plane mask RMW (no btst, no bsr/rts). Saves ~120 cyc per | applyMask call (was ~180 via bsr applyMask with runtime btst on d7). | @@ -30,7 +31,7 @@ .equ SP_FC_CX, SP_FC_OFF + 4 + 2 .equ SP_FC_CY, SP_FC_OFF + 8 + 2 .equ SP_FC_R, SP_FC_OFF + 12 + 2 - .equ SP_FC_COLOR, SP_FC_OFF + 20 + 3 + .equ SP_FC_COLOR, SP_FC_OFF + 16 + 3 | ---- COMPUTE_PAIR_MASKS macro ----------------------------------- @@ -143,63 +144,59 @@ .endm -| ---- CO_BODY macro: per-color full Bresenham loop body ---------- +| ---- CO_BODY macro: per-color exact-disk span loop body --------- +| +| Mirrors src/core/draw.c jlFillCircle's on-surface span loop: +| for y = 0 .. r: +| while xx > r2 - yy: xx -= 2x - 1; x-- +| span (cx-x, cx+x) at rows cy+y and cy-y +| yy += 2y + 1 +| The y == 0 pass writes row cy twice with identical bytes, which is +| cheaper than a per-color skip branch. +| +| Regs: d2 = x, d3 = y, d4 = xx, d7 = yy, a0 = r2 (loop-invariant). +| d0/d1/a4 are macro scratch; d5/d6 hold the full-group longs. .macro CO_BODY color .Lfc_loop_\color: - cmp.w %d3,%d2 - bcs.w .Lfc_done + | walk x down: while xx > r2 - yy { xx -= 2x - 1; x-- } + move.w %a0,%d0 + sub.w %d7,%d0 | d0 = xxLimit = r2 - yy +.Lfc_walk_\color: + cmp.w %d0,%d4 + bls.s .Lfc_walkDone_\color | xx <= xxLimit + sub.w %d2,%d4 + sub.w %d2,%d4 + addq.w #1,%d4 | xx -= 2x - 1 + subq.w #1,%d2 | x-- + bra.s .Lfc_walk_\color +.Lfc_walkDone_\color: - | --- Pair A: x range = (cx - bx, cx + bx) + | masks for x range (cx - x, cx + x), shared by both rows move.w SP_FC_CX(%sp),%d0 move.w %d0,%d1 sub.w %d2,%d0 add.w %d2,%d1 COMPUTE_PAIR_MASKS - | Span A1: y = cy + by + | span at row cy + y move.w SP_FC_CY(%sp),%d0 add.w %d3,%d0 SPAN_BODY \color - | Span A2: y = cy - by + | span at row cy - y move.w SP_FC_CY(%sp),%d0 sub.w %d3,%d0 SPAN_BODY \color - | --- Pair B: x range = (cx - by, cx + by) - move.w SP_FC_CX(%sp),%d0 - move.w %d0,%d1 - sub.w %d3,%d0 - add.w %d3,%d1 - COMPUTE_PAIR_MASKS - - | Span B1: y = cy + bx - move.w SP_FC_CY(%sp),%d0 - add.w %d2,%d0 - SPAN_BODY \color - - | Span B2: y = cy - bx - move.w SP_FC_CY(%sp),%d0 - sub.w %d2,%d0 - SPAN_BODY \color - - | --- Bresenham step + | yy += 2y + 1; y++; loop while y <= r + add.w %d3,%d7 + add.w %d3,%d7 + addq.w #1,%d7 addq.w #1,%d3 - tst.w %d4 - bgt.s .Lfc_decBx_\color - add.w %d3,%d4 - add.w %d3,%d4 - addq.w #1,%d4 - bra.w .Lfc_loop_\color -.Lfc_decBx_\color: - subq.w #1,%d2 - add.w %d3,%d4 - add.w %d3,%d4 - sub.w %d2,%d4 - sub.w %d2,%d4 - addq.w #1,%d4 - bra.w .Lfc_loop_\color + cmp.w SP_FC_R(%sp),%d3 + bls.w .Lfc_loop_\color + bra.w .Lfc_done .endm @@ -243,11 +240,14 @@ _surface68kStFillCircle: ori.l #0xFFFF0000,%d6 .Lfc_hi2: - | Bresenham init: bx=r, by=0, err=1-bx + | Exact-disk init: x = r, y = 0, xx = r2, yy = 0. + | r <= 99 (caller's on-surface guarantee), so r2 fits a + | word and mulu runs once, outside the loop. move.w SP_FC_R(%sp),%d2 moveq #0,%d3 - moveq #1,%d4 - sub.w %d2,%d4 + move.w %d2,%d4 + mulu.w %d2,%d4 | d4 = xx = r * r + move.w %d4,%a0 | a0 = r2 (loop-invariant) | Dispatch on color (low 4 bits) -> 16 specialized loops. | Use a4 (gets overwritten in SPAN_BODY's first lea) as @@ -256,6 +256,7 @@ _surface68kStFillCircle: move.w %d7,%d0 add.w %d0,%d0 add.w %d0,%d0 | * 4 for bra.w table + moveq #0,%d7 | d7 = yy (color consumed) lea .Lfc_table(%pc),%a4 jmp 0(%a4,%d0.w) diff --git a/src/atarist/hal.c b/src/atarist/hal.c index 51a9942..607a966 100644 --- a/src/atarist/hal.c +++ b/src/atarist/hal.c @@ -77,6 +77,17 @@ typedef struct { // Shifter palette registers: 16 words at $FFFF8240..$FFFF825F. #define ST_PALETTE_REGS ((volatile uint16_t *)0xFFFF8240L) +// Shifter resolution ($FF8260, low 2 bits: 0=low 1=med 2=high/mono) and +// sync-mode ($FF820A, bit 1: set=50 Hz PAL, clear=60 Hz NTSC) registers, +// read by jlpFrameHz to report the true refresh instead of assuming PAL. +#define ST_SHIFTER_RES ((volatile uint8_t *)0xFFFF8260L) +#define ST_SYNC_MODE ((volatile uint8_t *)0xFFFF820AL) +#define ST_RES_MONO 0x02u // $FF8260 low-2-bits value for high res (SM124) +#define ST_SYNC_50HZ_BIT 0x02u // $FF820A bit 1: set = 50 Hz +#define ST_HZ_MONO 71u // SM124 mono refresh +#define ST_HZ_PAL 50u +#define ST_HZ_NTSC 60u + // Shifter video base address registers. The shifter latches the base // at the start of each frame's display (during vblank), so a write // here mid-visible-frame takes effect on the NEXT frame -- a deferred, @@ -146,6 +157,38 @@ static inline __attribute__((always_inline)) uint8_t stPlanarGetPixel(const StPl if (pw[3] & bitMask) { c = (uint8_t)(c | 8u); } return c; } + + +// Flood-fill group decoder (PERF-AUDIT #4). For one 16-pixel group (4 +// interleaved plane words) build the 16-bit mask whose bit +// (15 - (x & 15)) is set exactly where pixel x equals color: invert +// each plane word whose color bit is 0, then AND the four words. Four +// word reads decode 16 pixels, vs 4 reads PER pixel through +// stPlanarGetPixel. +static inline __attribute__((always_inline)) uint16_t stGroupEqBits(const uint16_t *gp, uint8_t color) { + uint16_t p0 = gp[0]; + uint16_t p1 = gp[1]; + uint16_t p2 = gp[2]; + uint16_t p3 = gp[3]; + if ((color & 1u) == 0u) { p0 = (uint16_t)~p0; } + if ((color & 2u) == 0u) { p1 = (uint16_t)~p1; } + if ((color & 4u) == 0u) { p2 = (uint16_t)~p2; } + if ((color & 8u) == 0u) { p3 = (uint16_t)~p3; } + return (uint16_t)(p0 & p1 & p2 & p3); +} + + +// Flood walk-out stop mask for one group: bit set where the walk must +// STOP (pixel is not fillable). matchEqual (jlFloodFill): stop where +// pix != matchColor. Bounded (jlFloodFillBounded): stop where +// pix == matchColor (boundary) or pix == newColor (already filled). +static inline __attribute__((always_inline)) uint16_t stFloodStopBits(const uint8_t *rowBase, uint16_t group, uint8_t matchColor, uint8_t newColor, bool matchEqual) { + const uint16_t *gp = (const uint16_t *)(rowBase + group * ST_BYTES_PER_GROUP); + if (matchEqual) { + return (uint16_t)~stGroupEqBits(gp, matchColor); + } + return (uint16_t)(stGroupEqBits(gp, matchColor) | stGroupEqBits(gp, newColor)); +} static uint16_t quantizeColorToSt(uint16_t orgb); static void flattenScbPalettes(const jlSurfaceT *src); static void setShifterVideoBase(const uint8_t *page); @@ -662,7 +705,7 @@ void jlpPresent(const jlSurfaceT *src) { if (!gPrevAnyDirty) { bool anyDirty = false; for (y = 0; y < SURFACE_HEIGHT; y++) { - if (gStageMinWord[y] <= gStageMaxWord[y]) { + if (!STAGE_DIRTY_ROW_CLEAN(y)) { anyDirty = true; break; } @@ -805,7 +848,7 @@ void jlpPresent(const jlSurfaceT *src) { for (y = 0; y < SURFACE_HEIGHT; y++) { gPrevStageMinWord[y] = gStageMinWord[y]; gPrevStageMaxWord[y] = gStageMaxWord[y]; - if (gStageMinWord[y] <= gStageMaxWord[y]) { + if (!STAGE_DIRTY_ROW_CLEAN(y)) { anyDirty = true; } } @@ -839,10 +882,20 @@ uint16_t jlpFrameCount(void) { uint16_t jlpFrameHz(void) { - /* PAL ST is 50 Hz; NTSC ST and SM124 mono are ~60 / ~70. We - * report 50 as the baseline -- close enough for ops/sec scaling, - * and the actual frame rate is still observable via iter counts. */ - return 50u; + /* Report the REAL refresh, not a hardcoded 50. The old assumption + * silently under-reported ops/sec ~17% under Hatari (which runs the + * ST at 60 Hz) and would be wrong on any NTSC/mono machine. The + * shifter resolution register selects mono vs color; for color the + * sync-mode register's 50/60 bit is authoritative. The program holds + * supervisor mode for its lifetime (jlpInit Super(0L)), so these + * $FFFF82xx I/O reads are legal. */ + if ((*ST_SHIFTER_RES & 0x03u) == ST_RES_MONO) { + return ST_HZ_MONO; + } + if (*ST_SYNC_MODE & ST_SYNC_50HZ_BIT) { + return ST_HZ_PAL; + } + return ST_HZ_NTSC; } @@ -1055,23 +1108,32 @@ static void stPlanarFillSpan(StPlanarT *pd, int16_t x0, int16_t x1, int16_t y, u } +// Exact-disk span walk, same row coverage as src/core/draw.c +// jlFillCircle and the asm surface68kStFillCircle: for each y offset +// the largest x with x*x + y*y <= r*r, tracked incrementally. Defensive +// fallback only -- draw.c routes every off-surface circle through its +// own clipped span loop before jlpFillCircle is ever called, so keep r +// modest (< SURFACE_HEIGHT) if a future caller ever lands here. static void stPlanarCircleFill(StPlanarT *pd, int16_t cx, int16_t cy, uint16_t r, uint8_t color) { - int16_t bx = (int16_t)r; - int16_t by = 0; - int16_t err = (int16_t)(1 - bx); + uint32_t r2 = (uint32_t)r * (uint32_t)r; + uint32_t xx = r2; + uint32_t yy = 0u; + int32_t x = (int32_t)r; + uint16_t y; - while (bx >= by) { - stPlanarFillSpan(pd, (int16_t)(cx - bx), (int16_t)(cx + bx), (int16_t)(cy + by), color); - stPlanarFillSpan(pd, (int16_t)(cx - bx), (int16_t)(cx + bx), (int16_t)(cy - by), color); - stPlanarFillSpan(pd, (int16_t)(cx - by), (int16_t)(cx + by), (int16_t)(cy + bx), color); - stPlanarFillSpan(pd, (int16_t)(cx - by), (int16_t)(cx + by), (int16_t)(cy - bx), color); - by++; - if (err > 0) { - bx--; - err = (int16_t)(err + 2 * (by - bx) + 1); - } else { - err = (int16_t)(err + 2 * by + 1); + for (y = 0u; y <= r; y++) { + uint32_t xxLimit; + + xxLimit = r2 - yy; + while (xx > xxLimit) { + xx = xx - ((uint32_t)x + (uint32_t)x - 1u); + x--; } + stPlanarFillSpan(pd, (int16_t)(cx - (int16_t)x), (int16_t)(cx + (int16_t)x), (int16_t)(cy + (int16_t)y), color); + if (y > 0u) { + stPlanarFillSpan(pd, (int16_t)(cx - (int16_t)x), (int16_t)(cx + (int16_t)x), (int16_t)(cy - (int16_t)y), color); + } + yy = yy + ((uint32_t)y + (uint32_t)y + 1u); } } @@ -1380,67 +1442,64 @@ void jlpTilePaste(jlSurfaceT *dst, uint8_t bx, uint8_t by, const uint8_t *chunky } -/* Planar monochrome paste. monoTile follows the ST tile layout from - * jlpTileSnap: row-major-with-planes-interleaved-per-row, i.e. - * row 0 plane 0 at byte 0 - * row 0 plane 1 at byte 1 - * row 0 plane 2 at byte 2 - * row 0 plane 3 at byte 3 - * row 1 plane 0 at byte 4 ... - * For monochrome tile-bank tiles only plane 0 of each row carries - * the shape. Output writes the same layout to dst's planar buffer - * using the standard formula - * out_plane_k = (shape & mask_fg_k) | (~shape & mask_bg_k). - * The ST hardware planar destination has plane bytes interleaved at - * word granularity inside each 16-pixel group (4 plane words = 8 - * bytes per group), so each plane row byte is written at - * dstAddr + plane*2 - * inside the 16-pixel group starting at (bx/2)*8 bytes from the - * row's base. bx odd selects the second byte of the plane word - * (low 8 pixels of the 16-pixel group). */ +// Planar monochrome paste. monoTile follows the cross-port mono +// contract (include/joey/tile.h + jlpGenericTilePasteMono): 32 chunky +// nibble-pair bytes, row-major, TILE_BYTES_PER_ROW bytes per row, two +// pixels per byte. A pixel renders fgColor when its source nibble is +// nonzero, bgColor when zero; the HIGH nibble is the LEFT pixel. Each +// row's 4 source bytes fold into an 8-bit shape mask (bit 7 = leftmost +// pixel, matching the planar bit order), then each plane k writes +// outPlaneK = (shape & maskFgK) | (~shape & maskBgK) +// at dstAddr + k*2 inside the 16-pixel group (4 plane words = 8 bytes +// per group). bx odd selects the second byte of each plane word (the +// low / right 8 pixels of the group). void jlpTilePasteMono(jlSurfaceT *dst, uint8_t bx, uint8_t by, const uint8_t *monoTile, uint8_t fgColor, uint8_t bgColor) { StPlanarT *pd; uint16_t group; uint8_t *dstAddr; - int16_t row; + uint8_t row; + uint8_t col; uint8_t plane; - uint8_t masks_fg[4]; - uint8_t masks_bg[4]; + uint8_t masksFg[4]; + uint8_t masksBg[4]; if (dst->portData == NULL) { jlpGenericTilePasteMono(dst, bx, by, monoTile, fgColor, bgColor); return; } - if (dst == NULL || monoTile == NULL) { - return; - } pd = (StPlanarT *)dst->portData; - if (pd == NULL) { - return; - } for (plane = 0u; plane < 4u; plane++) { - masks_fg[plane] = (uint8_t)((fgColor & (1u << plane)) ? 0xFFu : 0x00u); - masks_bg[plane] = (uint8_t)((bgColor & (1u << plane)) ? 0xFFu : 0x00u); + masksFg[plane] = (uint8_t)((fgColor & (1u << plane)) ? 0xFFu : 0x00u); + masksBg[plane] = (uint8_t)((bgColor & (1u << plane)) ? 0xFFu : 0x00u); } group = (uint16_t)((uint16_t)bx >> 1); dstAddr = pd->base + (uint16_t)by * 8u * ST_BYTES_PER_ROW + group * ST_BYTES_PER_GROUP + (uint16_t)(bx & 1u); - for (row = 0; row < 8; row++) { - /* monoTile row layout: byte 0 = plane 0 (shape), bytes 1..3 = - * planes 1..3 (zero for monochrome). Apply per-plane mask. */ - uint8_t s = monoTile[0]; - dstAddr[0] = (uint8_t)((uint8_t)(s & masks_fg[0]) | - (uint8_t)((uint8_t)(~s) & masks_bg[0])); - dstAddr[2] = (uint8_t)((uint8_t)(s & masks_fg[1]) | - (uint8_t)((uint8_t)(~s) & masks_bg[1])); - dstAddr[4] = (uint8_t)((uint8_t)(s & masks_fg[2]) | - (uint8_t)((uint8_t)(~s) & masks_bg[2])); - dstAddr[6] = (uint8_t)((uint8_t)(s & masks_fg[3]) | - (uint8_t)((uint8_t)(~s) & masks_bg[3])); - dstAddr += ST_BYTES_PER_ROW; - monoTile += TILE_BYTES_PER_ROW; + for (row = 0u; row < TILE_PIXELS_PER_SIDE; row++) { + uint8_t shape = 0u; + + for (col = 0u; col < TILE_BYTES_PER_ROW; col++) { + uint8_t srcByte = *monoTile++; + + shape = (uint8_t)(shape << 2); + if (srcByte & 0xF0u) { + shape = (uint8_t)(shape | 0x02u); + } + if (srcByte & 0x0Fu) { + shape = (uint8_t)(shape | 0x01u); + } + } + dstAddr[0] = (uint8_t)((uint8_t)(shape & masksFg[0]) | + (uint8_t)((uint8_t)(~shape) & masksBg[0])); + dstAddr[2] = (uint8_t)((uint8_t)(shape & masksFg[1]) | + (uint8_t)((uint8_t)(~shape) & masksBg[1])); + dstAddr[4] = (uint8_t)((uint8_t)(shape & masksFg[2]) | + (uint8_t)((uint8_t)(~shape) & masksBg[2])); + dstAddr[6] = (uint8_t)((uint8_t)(shape & masksFg[3]) | + (uint8_t)((uint8_t)(~shape) & masksBg[3])); + dstAddr += ST_BYTES_PER_ROW; } } @@ -1455,13 +1514,7 @@ void jlpTileSnap(const jlSurfaceT *src, uint8_t bx, uint8_t by, uint8_t *chunkyO jlpGenericTileSnap(src, bx, by, chunkyOut); return; } - if (src == NULL || chunkyOut == NULL) { - return; - } pd = (const StPlanarT *)src->portData; - if (pd == NULL) { - return; - } /* Phase 10.5: write plane-major bytes to jlTileT (4 per row * 8 rows). */ group = (uint16_t)((uint16_t)bx >> 1); srcAddr = pd->base @@ -2006,6 +2059,152 @@ void jlpSpriteRestorePlanes(jlSurfaceT *s, int16_t x, int16_t y, uint16_t w, uin } +// ----- Flood-fill plane hooks (PERF-AUDIT #4) ----- +// +// floodFillInternal's planar walk-out and row-scan tiers, modeled on +// the Amiga versions but against the word-interleaved layout. The +// semantics contract with the core C fallback (bit-for-bit): +// * walk: seed test, then extend left/right while pixels are +// fillable. matchEqual: fillable = (pix == matchColor); bounded: +// fillable = (pix != matchColor && pix != newColor). +// * scan: markBuf[i] = 1 where row pixel (leftX + i) is fillable, +// 0 otherwise, for i in [0 .. rightX - leftX]. +// Both decode a 16-pixel group's 4 plane words ONCE into a stop/mark +// bit mask, then test single register bits per pixel -- and the walk +// skips a whole all-fillable group remainder in one step. + +bool jlpFloodWalkPlanes(const jlSurfaceT *s, int16_t startX, int16_t y, uint8_t matchColor, uint8_t newColor, bool matchEqual, bool *seedMatched, int16_t *leftXOut, int16_t *rightXOut) { + StPlanarT *pd; + const uint8_t *rowBase; + uint16_t seedGroup; + uint16_t seedStopBits; + uint16_t curGroup; + uint16_t stopBits; + uint16_t rem; + uint16_t g; + int16_t leftX; + int16_t rightX; + int16_t prevX; + int16_t nextX; + + pd = (StPlanarT *)s->portData; + if (pd == NULL) { + return false; + } + matchColor = (uint8_t)(matchColor & 0x0Fu); + newColor = (uint8_t)(newColor & 0x0Fu); + rowBase = pd->base + (uint16_t)y * ST_BYTES_PER_ROW; + + seedGroup = (uint16_t)((uint16_t)startX >> 4); + seedStopBits = stFloodStopBits(rowBase, seedGroup, matchColor, newColor, matchEqual); + if (seedStopBits & (uint16_t)(1u << (15u - ((uint16_t)startX & 15u)))) { + *seedMatched = false; + return true; + } + *seedMatched = true; + + // Walk left. Pixel x sits at bit (15 - (x & 15)), so moving left + // means moving toward bit 15: shift the stop mask down so bit 0 is + // the candidate pixel, then consume clear bits upward. + leftX = startX; + curGroup = seedGroup; + stopBits = seedStopBits; + while (leftX > 0) { + prevX = (int16_t)(leftX - 1); + g = (uint16_t)((uint16_t)prevX >> 4); + if (g != curGroup) { + curGroup = g; + stopBits = stFloodStopBits(rowBase, g, matchColor, newColor, matchEqual); + } + rem = (uint16_t)(stopBits >> (15u - ((uint16_t)prevX & 15u))); + if (rem == 0u) { + // Everything from prevX to the group's first pixel is + // fillable: take the rest of the group in one step. + leftX = (int16_t)(g << 4); + continue; + } + while ((rem & 1u) == 0u) { + rem = (uint16_t)(rem >> 1); + leftX--; + } + break; + } + + // Walk right: mirror image, shifting the stop mask up so bit 15 is + // the candidate pixel. + rightX = startX; + curGroup = seedGroup; + stopBits = seedStopBits; + while (rightX < SURFACE_WIDTH - 1) { + nextX = (int16_t)(rightX + 1); + g = (uint16_t)((uint16_t)nextX >> 4); + if (g != curGroup) { + curGroup = g; + stopBits = stFloodStopBits(rowBase, g, matchColor, newColor, matchEqual); + } + rem = (uint16_t)(stopBits << ((uint16_t)nextX & 15u)); + if (rem == 0u) { + // Everything from nextX to the group's last pixel is + // fillable: take the rest of the group in one step. + rightX = (int16_t)((g << 4) + 15); + continue; + } + while ((rem & 0x8000u) == 0u) { + rem = (uint16_t)(rem << 1); + rightX++; + } + break; + } + + *leftXOut = leftX; + *rightXOut = rightX; + return true; +} + + +bool jlpFloodScanRowPlanes(const jlSurfaceT *s, int16_t leftX, int16_t rightX, int16_t scanY, uint8_t matchColor, uint8_t newColor, bool matchEqual, uint8_t *markBuf) { + StPlanarT *pd; + const uint8_t *rowBase; + const uint16_t *gp; + uint8_t *mb; + uint16_t markBits; + uint16_t bit; + int16_t x; + int16_t groupEnd; + + pd = (StPlanarT *)s->portData; + if (pd == NULL) { + return false; + } + matchColor = (uint8_t)(matchColor & 0x0Fu); + newColor = (uint8_t)(newColor & 0x0Fu); + rowBase = pd->base + (uint16_t)scanY * ST_BYTES_PER_ROW; + + mb = markBuf; + x = leftX; + while (x <= rightX) { + gp = (const uint16_t *)(rowBase + (uint16_t)((uint16_t)x >> 4) * ST_BYTES_PER_GROUP); + if (matchEqual) { + markBits = stGroupEqBits(gp, matchColor); + } else { + markBits = (uint16_t)~(stGroupEqBits(gp, matchColor) | stGroupEqBits(gp, newColor)); + } + groupEnd = (int16_t)((uint16_t)x | 15u); + if (groupEnd > rightX) { + groupEnd = rightX; + } + bit = (uint16_t)(1u << (15u - ((uint16_t)x & 15u))); + while (x <= groupEnd) { + *mb = (uint8_t)((markBits & bit) ? 1u : 0u); + mb++; + bit = (uint16_t)(bit >> 1); + x++; + } + } + return true; +} + + // Phase 7: pixel reader. Pre-Phase-9 reads from the chunky shadow // (s->pixels) since that's the source-of-truth during transition. // Once Phase 9 sets s->pixels = NULL the planar shadow becomes diff --git a/src/codegen/spriteCompile.c b/src/codegen/spriteCompile.c index 99502a4..f98a704 100644 --- a/src/codegen/spriteCompile.c +++ b/src/codegen/spriteCompile.c @@ -4,7 +4,8 @@ // sp->slot + sp->routineOffsets. spriteCompiledDraw casts the slot // address to a function pointer and calls it through cdecl. // -// Each per-CPU emitter (src/codegen/spriteEmit{X86,68k,Iigs}.c) +// Each per-CPU emitter (src/dos/spriteEmitX86.c, +// src/iigs/spriteEmitIigs.c, src/m68k/spriteEmit{Planar,Interleaved}68k.c) // just produces bytes; this file is the only consumer of the // codegen arena from the sprite side. @@ -44,10 +45,10 @@ #define SPRITE_EMIT_SCRATCH_BYTES (16384ul) -// Compile-time selection of the per-CPU emitter. One src/codegen/ -// spriteEmit*.c file is built per platform, but the dispatch lives -// in this file so jlSpriteCompile + spriteCompiledDraw aren't -// duplicated three times. +// Compile-time selection of the per-CPU emitter. One per-CPU +// spriteEmit*.c file (src/dos, src/iigs, src/m68k) is built per +// platform, but the dispatch lives in this file so jlSpriteCompile + +// spriteCompiledDraw aren't duplicated per port. static uint32_t emitDrawForTarget(uint8_t *out, uint32_t cap, const jlSpriteT *sp, uint8_t shift) { #if defined(JOEYLIB_PLATFORM_DOS) return spriteEmitDrawX86(out, cap, sp, shift); @@ -141,6 +142,19 @@ static uint32_t emitTotalSize(uint8_t *scratch, const jlSpriteT *sp) { * math -- see spriteCompiledDraw + the prologue/epilogue in spriteEmitIigs.c. * Codegen is ON by default; set JOEY_IIGS_SPRITE_CODEGEN to 0 to force the * interpreter. */ +/* PERF-AUDIT.md #77 -- ROOT-CAUSED 2026-07-04, FIXED toolchain-side + * 2026-07-06 (LLVM816-ASKS.md item 5, JOEYLIB-REPLY.md): the GS/OS + * loader used to Memory-Manager-claim only each segment's IMAGE bytes, + * so the BSS+heap span above the image was MM-free space and the codegen + * arena's NewHandle could land INSIDE the app's own entry-bank BSS + * ($08:4200 empirically), where emitting routines clobbered live app + * state (the emitted code itself was always byte-valid -- the bug was + * arena PLACEMENT). link816/omfEmit now embed each segment's BSS in the + * image so the Loader reserves it in place, and crt0Gsos moves the C + * heap into a locked MM handle -- so NewHandle/malloc no longer collide + * with live state. Codegen re-enabled on IIgs 2026-07-06; the emitted- + * code path needed no JoeyLib-side change (as predicted). Set to 0 to + * fall back to the interpreter. */ #ifndef JOEY_IIGS_SPRITE_CODEGEN #define JOEY_IIGS_SPRITE_CODEGEN 1 #endif @@ -169,13 +183,10 @@ bool jlSpriteCompile(jlSpriteT *sp) { /* #19: leave sp->slot NULL -> interpreter path. See the note above. */ return false; #endif - /* Amiga (post-Phase 9) uses spriteEmitPlanar68k.c which writes - * directly to bitplanes. DRAW emits a unique pre-shifted variant - * per shift in 0..7 (smooth horizontal motion at any pixel x); - * SAVE/RESTORE emit only shift 0 and shift 1 since shifted variants - * 1..7 share identical bytes (plain memcpy of widthTiles+1 plane - * bytes per row). The post-emit pass below aliases slots 2..7 - * for save/restore to slot 1's bytes. */ + /* Amiga (post-Phase 9) uses spriteEmitPlanar68k.c, which writes + * directly to bitplanes; only the shift-0 DRAW is compiled today + * (save/restore and shifted draws run the interpreted planes + * walkers -- see the emitter's header comment). */ scratch = (uint8_t *)jlpBigAlloc(SPRITE_EMIT_SCRATCH_BYTES); if (scratch == NULL) { @@ -201,6 +212,18 @@ bool jlSpriteCompile(jlSpriteT *sp) { slot = codegenArenaAlloc(totalSize); if (slot == NULL) { + // Fragmented arena: total free space may suffice even though no + // single hole does (sprite create/destroy churn across levels). + // Honor the arena's compact-and-retry contract + // (codegenArenaInternal.h) once before giving up -- failure-path + // only, zero hot-path cost (#5). + codegenArenaCompact(); + slot = codegenArenaAlloc(totalSize); + } + if (slot == NULL) { + // Specific message so a swallowed jlSpritePrewarm/jlSpriteCompile + // failure is diagnosable via jlLastError. + coreSetError("jlSpriteCompile: codegen arena full even after compaction; raise jlConfigT.codegenBytes or destroy unused sprites"); jlpBigFree(scratch); return false; } @@ -209,22 +232,33 @@ bool jlSpriteCompile(jlSpriteT *sp) { offset = 0; for (shift = 0; shift < JOEY_SPRITE_SHIFT_COUNT; shift++) { for (op = 0; op < SPRITE_OP_COUNT; op++) { - uint32_t capLeft; - - capLeft = (uint32_t)(totalSize - offset); + // Emit into the roomy scratch with the SAME cap the sizing + // pass used, then memcpy the routine into the arena. The + // emitters' bound checks are deliberately conservative (they + // reserve a worst case per row/iteration), so handing pass 2 + // an exact-fit capLeft made the LAST routine in every slot + // spuriously overflow -- on DOS that demoted every sprite to + // the interpreter (PERF-AUDIT.md #74). Identical arguments + // in both passes make the return values identical by + // construction; one memcpy per routine at one-shot compile + // time is noise. switch (op) { - case SPRITE_OP_DRAW: written = emitDrawForTarget (dst + offset, capLeft, sp, shift); break; - case SPRITE_OP_SAVE: written = emitSaveForTarget (dst + offset, capLeft, sp, shift); break; - case SPRITE_OP_RESTORE: written = emitRestoreForTarget(dst + offset, capLeft, sp, shift); break; - default: written = 0; break; + case SPRITE_OP_DRAW: written = emitDrawForTarget (scratch, SPRITE_EMIT_SCRATCH_BYTES, sp, shift); break; + case SPRITE_OP_SAVE: written = emitSaveForTarget (scratch, SPRITE_EMIT_SCRATCH_BYTES, sp, shift); break; + case SPRITE_OP_RESTORE: written = emitRestoreForTarget(scratch, SPRITE_EMIT_SCRATCH_BYTES, sp, shift); break; + default: written = 0; break; } - if (written == SPRITE_EMIT_OVERFLOW) { + if (written == SPRITE_EMIT_OVERFLOW || (written != 0 && written > (uint32_t)(totalSize - offset))) { /* The second pass disagreed with the sizing pass (must - * not happen, but never write past the slot). Roll back. */ + * not happen with identical args, but never write past + * the slot). Roll back. */ codegenArenaFree(slot); jlpBigFree(scratch); return false; } + if (written != 0) { + memcpy(dst + offset, scratch, (size_t)written); + } /* routineOffsets is uint16_t with 0xFFFF (SPRITE_NOT_COMPILED) * as the "absent" sentinel, so a real offset must stay below * it. totalSize <= 0xFFFF already guarantees this, but guard @@ -238,42 +272,22 @@ bool jlSpriteCompile(jlSpriteT *sp) { } } } -#if defined(JOEYLIB_PLATFORM_AMIGA) - /* Save/restore bytes for any non-zero shift are identical (plain - * memcpy of widthTiles+1 plane bytes per row). The emitter emits - * them once at slot 1; alias slots 2..7 here so the dispatcher - * gate (sprite.c) sees them as compiled. */ - for (shift = 2; shift < JOEY_SPRITE_SHIFT_COUNT; shift++) { - sp->routineOffsets[shift][SPRITE_OP_SAVE] = sp->routineOffsets[1][SPRITE_OP_SAVE]; - sp->routineOffsets[shift][SPRITE_OP_RESTORE] = sp->routineOffsets[1][SPRITE_OP_RESTORE]; - } -#endif sp->slot = slot; + // Registry (#34): every creation path (including jlSpriteBankLoad, + // #32) already registered sp via spriteInitFields; this idempotent + // re-add is a belt-and-braces guard, since compile success is the + // moment sp first holds arena state that jlShutdown must reset. + spriteRegistryAdd(sp); jlpBigFree(scratch); return true; } -#if defined(JOEYLIB_PLATFORM_IIGS) || (!defined(JOEYLIB_PLATFORM_AMIGA) && !defined(JOEYLIB_PLATFORM_ATARIST)) - -// Re-derive the shift a chunky SAVE used from its backup. The shift is -// a property of the save (x & 1) and is NOT stored in the backup, so -// every chunky restore path must re-infer it. Single source of truth -// for the two chunky restore dispatchers (IIgs + x86/68k) so the -// formula cannot drift between them: copyBytes == spriteBytesPerRow -// means shift 0 (even x), otherwise the +1-byte shift-1 routine. -#define SPRITE_CHUNKY_BYTES_PER_TILE_ROW 4u - -static uint8_t spriteChunkyRestoreShift(const jlSpriteT *sp, const jlSpriteBackupT *backup) { - uint16_t copyBytes; - uint16_t spriteBytesPerRow; - - copyBytes = (uint16_t)(backup->width >> 1); - spriteBytesPerRow = (uint16_t)(sp->widthTiles * SPRITE_CHUNKY_BYTES_PER_TILE_ROW); - return (uint8_t)((copyBytes == spriteBytesPerRow) ? 0u : 1u); -} - -#endif +// The restore shift used to be re-derived here from the backup +// (spriteChunkyRestoreShift). The jlSpriteRestoreUnder gate in +// src/core/sprite.c already derives it to pick the routine, so it now +// passes shift (and routeOffset) straight into the callees and the +// re-derivation is gone (#29). #if defined(JOEYLIB_PLATFORM_IIGS) @@ -291,8 +305,7 @@ static uint8_t spriteChunkyRestoreShift(const jlSpriteT *sp, const jlSpriteBacku // have only their MVN bank operands patched per call (a plain C pointer write // into the arena from the dispatcher). No self-modifying call stub, no inline // asm -- the machinery that hid the old un-instrumentable #19 corruption. -void spriteCompiledDraw(jlSurfaceT *dst, const jlSpriteT *sp, int16_t x, int16_t y) { - uint8_t shift; +void spriteCompiledDraw(jlSurfaceT *dst, const jlSpriteT *sp, int16_t x, int16_t y, uint16_t routeOffset) { uint8_t *dstRow0; uint32_t fnAddr; void (*drawFn)(uint8_t *); @@ -303,12 +316,12 @@ void spriteCompiledDraw(jlSurfaceT *dst, const jlSpriteT *sp, int16_t x, int16_t // per-call bank/offset patching (that machinery was the source of the // old un-instrumentable corruption). dstRow0 (the sprite's top-left // screen byte) is passed in A:X; the routine's prologue (spriteEmitIigs.c) - // sets up its own DBR/Y and restores DBR on exit. - shift = (uint8_t)(x & 1); + // sets up its own DBR/Y and restores DBR on exit. routeOffset is the + // gate-derived DRAW offset for this shift (#29). dstRow0 = &dst->pixels[SURFACE_ROW_OFFSET(y) + ((uint16_t)x >> 1)]; fnAddr = codegenArenaBaseAddr() + sp->slot->offset - + (uint32_t)sp->routineOffsets[shift][SPRITE_OP_DRAW]; + + (uint32_t)routeOffset; drawFn = (void (*)(uint8_t *))fnAddr; drawFn(dstRow0); } @@ -349,32 +362,17 @@ static void patchMvnBanks(uint8_t *routine, uint16_t heightPx, uint8_t dstBank, *(_outBank) = spB_[2]; \ } while (0) -// Backup-buffer pointer split cache. backup->bytes is a user-supplied -// buffer (e.g. a static array) and effectively never changes after -// the first call -- caching its split saves both Save and Restore the -// macro expansion per frame. -static const void *gLastBackupBytes = (const void *)0; -static uint16_t gLastBackupBytesLo = 0; -static uint8_t gLastBackupBytesBank = 0; - -#define SPLIT_BACKUP_CACHED(_bytes, _outLo, _outBank) \ - do { \ - if ((const void *)(_bytes) == gLastBackupBytes) { \ - *(_outLo) = gLastBackupBytesLo; \ - *(_outBank) = gLastBackupBytesBank; \ - } else { \ - SPLIT_POINTER((_bytes), (_outLo), (_outBank)); \ - gLastBackupBytes = (const void *)(_bytes); \ - gLastBackupBytesLo = *(_outLo); \ - gLastBackupBytesBank = *(_outBank); \ - } \ - } while (0) +// The backup->bytes pointer is split with SPLIT_POINTER directly. A +// single-entry global split cache used to live here (#59); it assumed +// one backup buffer per program, but the normal pattern is one buffer +// per moving sprite, so with 2+ sprites the cached pointer ping-ponged +// and every call paid a 24-bit compare AND the full split. The split +// itself is ~6 simple loads/stores -- cheaper than the compare on a +// hit -- so the cache was deleted rather than moved per-sprite. -void spriteCompiledSaveUnder(const jlSurfaceT *src, jlSpriteT *sp, int16_t x, int16_t y, jlSpriteBackupT *backup) { - uint8_t shift; +void spriteCompiledSaveUnder(const jlSurfaceT *src, jlSpriteT *sp, int16_t x, int16_t y, jlSpriteBackupT *backup, uint8_t shift, uint16_t routeOffset) { int16_t clippedX; - uint16_t widthPx; uint16_t heightPx; uint16_t copyBytes; uint16_t screenLo; @@ -387,17 +385,17 @@ void spriteCompiledSaveUnder(const jlSurfaceT *src, jlSpriteT *sp, int16_t x, in uint16_t cacheIdx; /* shift * SPRITE_OP_COUNT + SPRITE_OP_SAVE, computed once */ uint8_t *cachedDst; /* &sp->cachedDstBank[0][0] + cacheIdx */ uint8_t *cachedSrc; /* &sp->cachedSrcBank[0][0] + cacheIdx */ - uint16_t routineOffset; /* sp->routineOffsets[shift][SPRITE_OP_SAVE], computed once */ - shift = (uint8_t)(x & 1); + // shift and routeOffset arrive pre-derived from the dispatcher + // gate (#29); widthPx/heightPx are create-time constants cached in + // the sprite struct. clippedX = (int16_t)(x & ~1); - widthPx = (uint16_t)(sp->widthTiles * 8); - heightPx = (uint16_t)(sp->heightTiles * 8); - copyBytes = (uint16_t)((widthPx >> 1) + shift); /* shift is x & 1, exactly 0 or 1 */ + heightPx = sp->heightPx; + copyBytes = (uint16_t)((sp->widthPx >> 1) + shift); /* shift is x & 1, exactly 0 or 1 */ screenPtr = (uint8_t *)&src->pixels[SURFACE_ROW_OFFSET(y) + ((uint16_t)clippedX >> 1)]; - SPLIT_POINTER(screenPtr, &screenLo, &screenBank); - SPLIT_BACKUP_CACHED(backup->bytes, &backupLo, &backupBank); + SPLIT_POINTER(screenPtr, &screenLo, &screenBank); + SPLIT_POINTER(backup->bytes, &backupLo, &backupBank); backup->sprite = sp; backup->x = clippedX; @@ -416,19 +414,16 @@ void spriteCompiledSaveUnder(const jlSurfaceT *src, jlSpriteT *sp, int16_t x, in backup->sizeBytes = *sizeCachePtr; } - /* Compute the 1D index into the cached* / routineOffsets 2D arrays - * once. `shift * SPRITE_OP_COUNT` (where SPRITE_OP_COUNT==3) can - * lower to a multiply helper call; (shift<<1)+shift compiles to - * two ASLs and an ADC, no helper. */ - cacheIdx = (uint16_t)(((uint16_t)shift << 1) + (uint16_t)shift + SPRITE_OP_SAVE); - cachedDst = (uint8_t *)sp->cachedDstBank + cacheIdx; - cachedSrc = (uint8_t *)sp->cachedSrcBank + cacheIdx; - /* Same byte-pointer trick as SURFACE_ROW_OFFSET to dodge ~MUL4. */ - routineOffset = *(uint16_t *)((uint8_t *)sp->routineOffsets + (cacheIdx << 1)); + /* Compute the 1D index into the cached* bank arrays once; + * SPRITE_ROUTINE_INDEX (spriteInternal.h) owns the multiply-free + * derivation. */ + cacheIdx = SPRITE_ROUTINE_INDEX(shift, SPRITE_OP_SAVE); + cachedDst = (uint8_t *)sp->cachedDstBank + cacheIdx; + cachedSrc = (uint8_t *)sp->cachedSrcBank + cacheIdx; fnAddr = codegenArenaBaseAddr() + sp->slot->offset - + (uint32_t)routineOffset; + + (uint32_t)routeOffset; // Patch the MVN bank operands only if the dst/src bank pair changed since // last call (the routine's only dynamic bytes). SAVE copies screen -> @@ -450,8 +445,7 @@ void spriteCompiledSaveUnder(const jlSurfaceT *src, jlSpriteT *sp, int16_t x, in } -void spriteCompiledRestoreUnder(jlSurfaceT *dst, const jlSpriteBackupT *backup) { - uint8_t shift; +void spriteCompiledRestoreUnder(jlSurfaceT *dst, const jlSpriteBackupT *backup, uint8_t shift, uint16_t routeOffset) { uint16_t heightPx; uint16_t screenLo; uint16_t backupLo; @@ -464,26 +458,25 @@ void spriteCompiledRestoreUnder(jlSurfaceT *dst, const jlSpriteBackupT *backup) uint16_t cacheIdx; /* shift * SPRITE_OP_COUNT + SPRITE_OP_RESTORE, computed once */ uint8_t *cachedDst; uint8_t *cachedSrc; - uint16_t routineOffset; - sp = backup->sprite; - heightPx = backup->height; - shift = spriteChunkyRestoreShift(sp, backup); + // shift and routeOffset arrive pre-derived from the + // jlSpriteRestoreUnder gate (#29) -- no per-call re-inference of + // the shift from the backup here. + sp = backup->sprite; + heightPx = backup->height; screenPtr = (uint8_t *)&dst->pixels[SURFACE_ROW_OFFSET(backup->y) + ((uint16_t)backup->x >> 1)]; - SPLIT_POINTER(screenPtr, &screenLo, &screenBank); - SPLIT_BACKUP_CACHED(backup->bytes, &backupLo, &backupBank); + SPLIT_POINTER(screenPtr, &screenLo, &screenBank); + SPLIT_POINTER(backup->bytes, &backupLo, &backupBank); /* Hoist 2D-array indexing -- see save-side comment. */ - cacheIdx = (uint16_t)(((uint16_t)shift << 1) + (uint16_t)shift + SPRITE_OP_RESTORE); - cachedDst = (uint8_t *)sp->cachedDstBank + cacheIdx; - cachedSrc = (uint8_t *)sp->cachedSrcBank + cacheIdx; - /* Same byte-pointer trick as SURFACE_ROW_OFFSET to dodge ~MUL4. */ - routineOffset = *(uint16_t *)((uint8_t *)sp->routineOffsets + (cacheIdx << 1)); + cacheIdx = SPRITE_ROUTINE_INDEX(shift, SPRITE_OP_RESTORE); + cachedDst = (uint8_t *)sp->cachedDstBank + cacheIdx; + cachedSrc = (uint8_t *)sp->cachedSrcBank + cacheIdx; fnAddr = codegenArenaBaseAddr() + sp->slot->offset - + (uint32_t)routineOffset; + + (uint32_t)routeOffset; // Patch the MVN bank operands only if they changed (the routine's only // dynamic bytes). RESTORE copies backup -> screen: dst = screen bank, @@ -506,27 +499,24 @@ void spriteCompiledRestoreUnder(jlSurfaceT *dst, const jlSpriteBackupT *backup) #elif defined(JOEYLIB_PLATFORM_AMIGA) -/* Amiga planar dispatchers. spriteEmitPlanar68k.c emits routines with - * cdecl(p0, p1, p2, p3[, backup]) signatures that write directly to - * bitplanes. Compute byteOff = y*40 + x/8 and pass plane[i]+byteOff - * as the 4 plane args. shift = x % 8 selects the variant; today only - * shift 0 emits non-zero bytes, so callers should already have - * gated on routineOffsets[shift][op] != SPRITE_NOT_COMPILED. - * - * For non-zero shifts (x not 8-px-aligned), the dispatcher in - * src/core/sprite.c (jlSpriteDraw / jlSpriteSaveUnder / jlSpriteRestoreUnder) - * sees SPRITE_NOT_COMPILED for the shift and falls back to the - * interpreter, which handles arbitrary x via jlpSpriteDrawPlanes / - * jlpSpriteSavePlanes / jlpSpriteRestorePlanes. */ +// Amiga planar dispatchers. spriteEmitPlanar68k.c emits DRAW routines +// with a cdecl(p0, p1, p2, p3) signature that write directly to +// bitplanes. Compute byteOff = y*40 + x/8 and pass plane[i]+byteOff +// as the 4 plane args. shift = x % 8 selects the variant; today only +// shift 0 emits non-zero bytes, and every caller gate in +// src/core/sprite.c checks routineOffsets[shift][op] != +// SPRITE_NOT_COMPILED before calling here (#29), so non-zero shifts +// (x not 8-px-aligned) never reach this dispatcher -- they fall back +// to the interpreter, which handles arbitrary x via +// jlpSpriteDrawPlanes / jlpSpriteSavePlanes / jlpSpriteRestorePlanes. // Amiga per-plane row stride: 320 px / 8 px-per-plane-byte = 40. // Derived from surface geometry so it cannot drift if the fixed // surface width ever changes. #define AMIGA_PLANE_BYTES_PER_ROW (SURFACE_WIDTH / 8) -void spriteCompiledDraw(jlSurfaceT *dst, const jlSpriteT *sp, int16_t x, int16_t y) { +void spriteCompiledDraw(jlSurfaceT *dst, const jlSpriteT *sp, int16_t x, int16_t y, uint16_t routeOffset) { typedef void (*DrawFn)(uint8_t *p0, uint8_t *p1, uint8_t *p2, uint8_t *p3); - uint8_t shift; uint16_t byteOff; uint8_t *p0; uint8_t *p1; @@ -534,109 +524,66 @@ void spriteCompiledDraw(jlSurfaceT *dst, const jlSpriteT *sp, int16_t x, int16_t uint8_t *p3; DrawFn fn; - shift = (uint8_t)(x & 7); - /* Only shift 0 (byte-aligned x) is compiled; shifts 1..7 stay - * SPRITE_NOT_COMPILED. jlSpriteDraw gates only on sp->slot != NULL, - * so guard the per-shift offset here and delegate a non-aligned draw - * to the interpreter (COMPILED_SPRITE_WRITES_PLANES==1 means - * jlSpriteDraw suppresses its own planes hook, so we must run it). */ - if (sp->routineOffsets[shift][SPRITE_OP_DRAW] == SPRITE_NOT_COMPILED) { - jlpSpriteDrawPlanes(dst, sp, x, y); + byteOff = (uint16_t)((uint16_t)y * AMIGA_PLANE_BYTES_PER_ROW + ((uint16_t)x >> 3)); + p0 = jlpSurfacePlanePtr(dst, 0); + if (p0 == NULL) { return; } - byteOff = (uint16_t)((uint16_t)y * AMIGA_PLANE_BYTES_PER_ROW + ((uint16_t)x >> 3)); - p0 = jlpSurfacePlanePtr(dst, 0); if (p0 == NULL) return; - p1 = jlpSurfacePlanePtr(dst, 1); - p2 = jlpSurfacePlanePtr(dst, 2); - p3 = jlpSurfacePlanePtr(dst, 3); - fn = (DrawFn)(codegenArenaBase() + sp->slot->offset + sp->routineOffsets[shift][SPRITE_OP_DRAW]); + p1 = jlpSurfacePlanePtr(dst, 1); + p2 = jlpSurfacePlanePtr(dst, 2); + p3 = jlpSurfacePlanePtr(dst, 3); + fn = (DrawFn)(codegenArenaBase() + sp->slot->offset + routeOffset); fn(p0 + byteOff, p1 + byteOff, p2 + byteOff, p3 + byteOff); } -void spriteCompiledSaveUnder(const jlSurfaceT *src, jlSpriteT *sp, int16_t x, int16_t y, jlSpriteBackupT *backup) { - typedef void (*SaveFn)(uint8_t *p0, uint8_t *p1, uint8_t *p2, uint8_t *p3, uint8_t *backup); - uint8_t shift; - int16_t clippedX; - uint16_t widthPx; - uint16_t heightPx; - uint16_t byteOff; - uint8_t *p0; - uint8_t *p1; - uint8_t *p2; - uint8_t *p3; - SaveFn fn; - - shift = (uint8_t)(x & 7); - clippedX = (int16_t)(x & ~7); - widthPx = (uint16_t)(sp->widthTiles * 8); - heightPx = (uint16_t)(sp->heightTiles * 8); - /* Shifts 1..7 spill into one extra plane byte per row (= +8 px). */ - if (shift != 0u) { - widthPx = (uint16_t)(widthPx + 8u); - } - byteOff = (uint16_t)((uint16_t)y * AMIGA_PLANE_BYTES_PER_ROW + ((uint16_t)clippedX >> 3)); - - backup->sprite = sp; - backup->x = clippedX; - backup->y = y; - backup->width = widthPx; - backup->height = heightPx; - /* 4 planes * h * (widthPx/8) bytes = h * widthPx/2. */ - backup->sizeBytes = (uint16_t)((uint16_t)heightPx * (widthPx >> 1)); - - p0 = jlpSurfacePlanePtr(src, 0); if (p0 == NULL) return; - p1 = jlpSurfacePlanePtr(src, 1); - p2 = jlpSurfacePlanePtr(src, 2); - p3 = jlpSurfacePlanePtr(src, 3); - fn = (SaveFn)(codegenArenaBase() + sp->slot->offset + sp->routineOffsets[shift][SPRITE_OP_SAVE]); - fn(p0 + byteOff, p1 + byteOff, p2 + byteOff, p3 + byteOff, backup->bytes); +// Save/restore aren't compiled on the Amiga (spriteEmitPlanar68k.c +// returns 0 for both, so every routineOffsets[*][SPRITE_OP_SAVE / +// _RESTORE] entry is SPRITE_NOT_COMPILED). The sprite.c gates route +// all save/restore work through the interpreted jlpSpriteSavePlanes / +// jlpSpriteRestorePlanes; these stubs exist only to satisfy the linker +// (sprite.c references them unconditionally). +// +// FUTURE (when real Amiga save/restore emitters land): the compiled +// restore gate in jlSpriteRestoreUnder (src/core/sprite.c) only +// accepts chunky copyBytes == spriteBytesPerRow or +1. A planar +// shifted save records backup->width = widthPx + 8, i.e. copyBytes == +// spriteBytesPerRow + 4 -- the gate must learn that width or 7 of 8 +// x-alignments will silently stay on the interpreted path. +void spriteCompiledSaveUnder(const jlSurfaceT *src, jlSpriteT *sp, int16_t x, int16_t y, jlSpriteBackupT *backup, uint8_t shift, uint16_t routeOffset) { + (void)src; + (void)sp; + (void)x; + (void)y; + (void)backup; + (void)shift; + (void)routeOffset; } -void spriteCompiledRestoreUnder(jlSurfaceT *dst, const jlSpriteBackupT *backup) { - typedef void (*RestoreFn)(uint8_t *p0, uint8_t *p1, uint8_t *p2, uint8_t *p3, const uint8_t *backup); - jlSpriteT *sp; - uint8_t shift; - uint16_t byteOff; - uint8_t *p0; - uint8_t *p1; - uint8_t *p2; - uint8_t *p3; - RestoreFn fn; - - sp = backup->sprite; - /* backup->x is 8-px aligned (clippedX from save), so x & 7 is - * useless for picking the original shift. Encode it via - * backup->width: == widthTiles*8 means shift 0; > means shifted. - * Shifted slots 1..7 all alias to the same restore bytes, so - * slot 1 stands in for any non-zero shift. */ - shift = (uint8_t)(backup->width > (uint16_t)(sp->widthTiles * 8) ? 1u : 0u); - byteOff = (uint16_t)((uint16_t)backup->y * AMIGA_PLANE_BYTES_PER_ROW + ((uint16_t)backup->x >> 3)); - - p0 = jlpSurfacePlanePtr(dst, 0); if (p0 == NULL) return; - p1 = jlpSurfacePlanePtr(dst, 1); - p2 = jlpSurfacePlanePtr(dst, 2); - p3 = jlpSurfacePlanePtr(dst, 3); - fn = (RestoreFn)(codegenArenaBase() + sp->slot->offset + sp->routineOffsets[shift][SPRITE_OP_RESTORE]); - fn(p0 + byteOff, p1 + byteOff, p2 + byteOff, p3 + byteOff, backup->bytes); +void spriteCompiledRestoreUnder(jlSurfaceT *dst, const jlSpriteBackupT *backup, uint8_t shift, uint16_t routeOffset) { + (void)dst; + (void)backup; + (void)shift; + (void)routeOffset; } #elif defined(JOEYLIB_PLATFORM_ATARIST) -/* ST word-interleaved planar runtime dispatch. The JIT routine takes - * one arg: groupBase = pd->base + y*160 + (x>>4)*8 (the address of - * the first 16-pixel group the sprite touches). It walks rows by - * adda.w #160 at the end of each row. Per (row, tile_col, plane) it - * emits up to one move.b / clr.b / andi.b+ori.b / ori.b chain at - * d16(a0). - * - * shift selection (in spriteInternal.h SPRITE_SHIFT_INDEX): - * 0 : byte-aligned x with x mod 16 == 0 (first tile col high half) - * 1 : byte-aligned x with x mod 16 == 8 (first tile col low half) - * 2+ : non-byte-aligned x, never compiled (emitter returns 0); the - * per-shift offset is SPRITE_NOT_COMPILED so the dispatcher - * falls back to jlpSpriteDrawPlanes. */ +// ST word-interleaved planar runtime dispatch. The JIT routine takes +// one arg: groupBase = pd->base + y*160 + (x>>4)*8 (the address of +// the first 16-pixel group the sprite touches). It walks rows by +// adda.w #160 at the end of each row. Per (row, tile_col, plane) it +// emits up to one move.b / clr.b / andi.b+ori.b / ori.b chain at +// d16(a0). +// +// shift selection (in spriteInternal.h SPRITE_SHIFT_INDEX): +// 0 : byte-aligned x with x mod 16 == 0 (first tile col high half) +// 1 : byte-aligned x with x mod 16 == 8 (first tile col low half) +// 2+ : non-byte-aligned x, never compiled (emitter returns 0); the +// per-shift offset is SPRITE_NOT_COMPILED so the caller gates +// in src/core/sprite.c (#29) never dispatch here and fall back +// to jlpSpriteDrawPlanes. // ST word-interleaved row stride: 320 px * 4 planes / 8 bits = 160 // bytes/row. Numerically equal to SURFACE_BYTES_PER_ROW but a distinct @@ -645,27 +592,12 @@ void spriteCompiledRestoreUnder(jlSurfaceT *dst, const jlSpriteBackupT *backup) #define ST_GROUP_BYTES_PER_ROW (SURFACE_WIDTH * 4 / 8) #define ST_BYTES_PER_PLANE_GROUP 8u -void spriteCompiledDraw(jlSurfaceT *dst, const jlSpriteT *sp, int16_t x, int16_t y) { +void spriteCompiledDraw(jlSurfaceT *dst, const jlSpriteT *sp, int16_t x, int16_t y, uint16_t routeOffset) { typedef void (*DrawFn)(uint8_t *groupBase); - uint8_t shift; - uint16_t routeOffset; uint8_t *base; uint8_t *groupBase; DrawFn fn; - shift = SPRITE_SHIFT_INDEX(x); - routeOffset = sp->routineOffsets[shift][SPRITE_OP_DRAW]; - if (routeOffset == SPRITE_NOT_COMPILED) { - /* Non-byte-aligned x: cross-platform jlSpriteDraw will call - * jlpSpriteDrawPlanes after this returns (since the dispatcher - * already chose the compiled path based on sp->slot != NULL, - * but COMPILED_SPRITE_WRITES_PLANES is 1 on ST so it normally - * suppresses the planes hook). For non-aligned shifts we - * deliberately want the interpreted planes hook to run, so - * delegate via jlpSpriteDrawPlanes here. */ - jlpSpriteDrawPlanes(dst, sp, x, y); - return; - } base = jlpSurfacePlanePtr(dst, 0); if (base == NULL) { return; @@ -678,31 +610,39 @@ void spriteCompiledDraw(jlSurfaceT *dst, const jlSpriteT *sp, int16_t x, int16_t } -/* Save/Restore aren't compiled on ST yet (emitter returns 0). The - * dispatcher's check on sp->routineOffsets[shift][SPRITE_OP_SAVE/_RESTORE] - * == SPRITE_NOT_COMPILED already routes those through the - * interpreted jlpSpriteSavePlanes / jlpSpriteRestorePlanes. These - * stubs exist only to satisfy the linker. */ -void spriteCompiledSaveUnder(const jlSurfaceT *src, jlSpriteT *sp, int16_t x, int16_t y, jlSpriteBackupT *backup) { - (void)src; (void)sp; (void)x; (void)y; (void)backup; +// Save/Restore aren't compiled on ST yet (emitter returns 0). The +// caller gates in src/core/sprite.c see SPRITE_NOT_COMPILED for +// sp->routineOffsets[shift][SPRITE_OP_SAVE/_RESTORE] and route those +// ops through the interpreted jlpSpriteSavePlanes / +// jlpSpriteRestorePlanes. These stubs exist only to satisfy the +// linker. +void spriteCompiledSaveUnder(const jlSurfaceT *src, jlSpriteT *sp, int16_t x, int16_t y, jlSpriteBackupT *backup, uint8_t shift, uint16_t routeOffset) { + (void)src; + (void)sp; + (void)x; + (void)y; + (void)backup; + (void)shift; + (void)routeOffset; } -void spriteCompiledRestoreUnder(jlSurfaceT *dst, const jlSpriteBackupT *backup) { - (void)dst; (void)backup; +void spriteCompiledRestoreUnder(jlSurfaceT *dst, const jlSpriteBackupT *backup, uint8_t shift, uint16_t routeOffset) { + (void)dst; + (void)backup; + (void)shift; + (void)routeOffset; } #else -void spriteCompiledDraw(jlSurfaceT *dst, const jlSpriteT *sp, int16_t x, int16_t y) { +void spriteCompiledDraw(jlSurfaceT *dst, const jlSpriteT *sp, int16_t x, int16_t y, uint16_t routeOffset) { typedef void (*DrawFn)(uint8_t *destRow); - uint8_t shift; uint8_t *destRow; DrawFn fn; - shift = (uint8_t)(x & 1); destRow = &dst->pixels[(uint16_t)y * SURFACE_BYTES_PER_ROW + ((uint16_t)x >> 1)]; - fn = (DrawFn)(codegenArenaBase() + sp->slot->offset + sp->routineOffsets[shift][SPRITE_OP_DRAW]); + fn = (DrawFn)(codegenArenaBase() + sp->slot->offset + routeOffset); fn(destRow); } @@ -710,22 +650,19 @@ void spriteCompiledDraw(jlSurfaceT *dst, const jlSpriteT *sp, int16_t x, int16_t // x86 / 68k compiled save: bytes are a cdecl // void copy(const uint8_t *src, uint8_t *dst) // that walks heightPx rows of copyBytes from screen (stride -// SURFACE_BYTES_PER_ROW) into the contiguous backup buffer. -void spriteCompiledSaveUnder(const jlSurfaceT *src, jlSpriteT *sp, int16_t x, int16_t y, jlSpriteBackupT *backup) { +// SURFACE_BYTES_PER_ROW) into the contiguous backup buffer. shift and +// routeOffset arrive pre-derived from the dispatcher gate (#29). +void spriteCompiledSaveUnder(const jlSurfaceT *src, jlSpriteT *sp, int16_t x, int16_t y, jlSpriteBackupT *backup, uint8_t shift, uint16_t routeOffset) { typedef void (*CopyFn)(const uint8_t *src, uint8_t *dst); - uint8_t shift; int16_t clippedX; - uint16_t widthPx; uint16_t heightPx; uint16_t copyBytes; uint8_t *screenPtr; CopyFn fn; - shift = (uint8_t)(x & 1); clippedX = (int16_t)(x & ~1); - widthPx = (uint16_t)(sp->widthTiles * 8); - heightPx = (uint16_t)(sp->heightTiles * 8); - copyBytes = (uint16_t)((widthPx >> 1) + shift); /* shift is x & 1, exactly 0 or 1 */ + heightPx = sp->heightPx; + copyBytes = (uint16_t)((sp->widthPx >> 1) + shift); /* shift is x & 1, exactly 0 or 1 */ screenPtr = (uint8_t *)&src->pixels[(uint16_t)y * SURFACE_BYTES_PER_ROW + ((uint16_t)clippedX >> 1)]; @@ -736,7 +673,7 @@ void spriteCompiledSaveUnder(const jlSurfaceT *src, jlSpriteT *sp, int16_t x, in backup->height = heightPx; backup->sizeBytes = (uint16_t)(copyBytes * heightPx); - fn = (CopyFn)(codegenArenaBase() + sp->slot->offset + sp->routineOffsets[shift][SPRITE_OP_SAVE]); + fn = (CopyFn)(codegenArenaBase() + sp->slot->offset + routeOffset); fn(screenPtr, backup->bytes); } @@ -744,20 +681,19 @@ void spriteCompiledSaveUnder(const jlSurfaceT *src, jlSpriteT *sp, int16_t x, in // Mirror of save: caller swaps arg order so the same emitted shape // drives backup -> screen. The screen-side stride lives inside the // emitted bytes, so RESTORE has its own routine bytes (stride is -// applied to dst instead of src). -void spriteCompiledRestoreUnder(jlSurfaceT *dst, const jlSpriteBackupT *backup) { +// applied to dst instead of src). routeOffset arrives pre-derived +// from the jlSpriteRestoreUnder gate (#29); shift is unused here (no +// bank-patch cache off-IIgs) but kept for the shared signature. +void spriteCompiledRestoreUnder(jlSurfaceT *dst, const jlSpriteBackupT *backup, uint8_t shift, uint16_t routeOffset) { typedef void (*CopyFn)(const uint8_t *src, uint8_t *dst); - jlSpriteT *sp; - uint8_t shift; - uint8_t *screenPtr; - CopyFn fn; - - sp = backup->sprite; - shift = spriteChunkyRestoreShift(sp, backup); + jlSpriteT *sp; + uint8_t *screenPtr; + CopyFn fn; + (void)shift; + sp = backup->sprite; screenPtr = (uint8_t *)&dst->pixels[(uint16_t)backup->y * SURFACE_BYTES_PER_ROW + ((uint16_t)backup->x >> 1)]; - - fn = (CopyFn)(codegenArenaBase() + sp->slot->offset + sp->routineOffsets[shift][SPRITE_OP_RESTORE]); + fn = (CopyFn)(codegenArenaBase() + sp->slot->offset + routeOffset); fn(backup->bytes, screenPtr); } diff --git a/src/codegen/spriteEmitChunky.h b/src/codegen/spriteEmitChunky.h new file mode 100644 index 0000000..e27da28 --- /dev/null +++ b/src/codegen/spriteEmitChunky.h @@ -0,0 +1,94 @@ +// Shared emit-time source-pixel helpers for the chunky 4bpp sprite +// emitters -- spriteEmitX86.c (DOS) and spriteEmitIigs.c. These two +// functions ARE the transparency/shift contract that the emitted +// routines must share with the interpreted walkers bit-for-bit, so +// they exist exactly once: a fix applied here reaches both CPUs. +// +// Emit-time only (jlSpriteCompile), never on a draw hot path, so plain +// static functions are acceptable here despite the general +// macros-over-inline preference for shared headers. Tile geometry +// comes from joey/tile.h; TRANSPARENT_NIBBLE from spriteInternal.h. + +#ifndef JOEYLIB_SPRITE_EMIT_CHUNKY_H +#define JOEYLIB_SPRITE_EMIT_CHUNKY_H + +#include "joey/sprite.h" +#include "joey/tile.h" +#include "spriteInternal.h" + + +static void shiftedByteAt(const jlSpriteT *sp, uint16_t row, uint16_t col, uint8_t shift, uint16_t spriteBytesPerRow, uint8_t *outValue, uint8_t *outOpaqueMask); +static uint8_t spriteSourceByte(const jlSpriteT *sp, uint16_t row, uint16_t col); + + +// Decompose a destination byte's contribution from the sprite into +// (value, opaqueMask) for shift in {0, 1}. opaqueMask high nibble +// 0xF0 means high dest nibble is opaque; 0x0F means low is opaque; +// 0x00 means both transparent. value's transparent nibbles are 0. +static void shiftedByteAt(const jlSpriteT *sp, uint16_t row, uint16_t col, uint8_t shift, uint16_t spriteBytesPerRow, uint8_t *outValue, uint8_t *outOpaqueMask) { + uint8_t srcByte; + uint8_t hi; + uint8_t lo; + bool hasLeft; + bool hasRight; + + *outValue = 0; + *outOpaqueMask = 0; + + if (shift == 0) { + if (col >= spriteBytesPerRow) { + return; + } + srcByte = spriteSourceByte(sp, row, col); + hi = (uint8_t)((srcByte >> 4) & 0x0Fu); + lo = (uint8_t)(srcByte & 0x0Fu); + if (hi != TRANSPARENT_NIBBLE) { + *outValue |= (uint8_t)(hi << 4); + *outOpaqueMask |= 0xF0u; + } + if (lo != TRANSPARENT_NIBBLE) { + *outValue |= lo; + *outOpaqueMask |= 0x0Fu; + } + return; + } + // shift = 1 + hasLeft = (col >= 1) && ((uint16_t)(col - 1) < spriteBytesPerRow); + hasRight = (col < spriteBytesPerRow); + if (hasLeft) { + srcByte = spriteSourceByte(sp, row, (uint16_t)(col - 1)); + hi = (uint8_t)(srcByte & 0x0Fu); // sprite byte's LOW nibble + if (hi != TRANSPARENT_NIBBLE) { + *outValue |= (uint8_t)(hi << 4); + *outOpaqueMask |= 0xF0u; + } + } + if (hasRight) { + srcByte = spriteSourceByte(sp, row, col); + lo = (uint8_t)((srcByte >> 4) & 0x0Fu); // sprite byte's HIGH nibble + if (lo != TRANSPARENT_NIBBLE) { + *outValue |= lo; + *outOpaqueMask |= 0x0Fu; + } + } +} + + +// Sample a sprite tile-data byte at (row, col) where col is in +// sprite-byte coordinates (0..spriteBytesPerRow-1). +static uint8_t spriteSourceByte(const jlSpriteT *sp, uint16_t row, uint16_t col) { + uint16_t tileX; + uint16_t tileY; + uint16_t inTileX; + uint16_t inTileY; + const uint8_t *tile; + + tileX = (uint16_t)(col / TILE_BYTES_PER_ROW); + tileY = (uint16_t)(row / TILE_PIXELS_PER_SIDE); + inTileX = (uint16_t)(col & (TILE_BYTES_PER_ROW - 1)); + inTileY = (uint16_t)(row & (TILE_PIXELS_PER_SIDE - 1)); + tile = sp->tileData + ((uint32_t)(tileY * sp->widthTiles + tileX)) * TILE_BYTES; + return tile[inTileY * TILE_BYTES_PER_ROW + inTileX]; +} + +#endif diff --git a/src/codegen/spriteEmitter.h b/src/codegen/spriteEmitter.h index 34b4037..3189d5f 100644 --- a/src/codegen/spriteEmitter.h +++ b/src/codegen/spriteEmitter.h @@ -1,12 +1,10 @@ // Internal interface for per-CPU sprite emitters. // -// Each src/codegen/spriteEmit.c file implements its own emit -// function. jlSpriteCompile.c picks the right one at compile time -// (via #ifdef on JOEYLIB_PLATFORM_*) for the runtime build. The -// planar 68k emitters now return 0 bytes after Phase 11 -- the -// jlpSpriteDrawPlanes walker reads chunky tile data and synthesizes -// plane bytes at draw time -- but the entry points are kept so the -// dispatcher API stays uniform across ports. +// Each per-CPU emitter file (src/dos/spriteEmitX86.c, +// src/iigs/spriteEmitIigs.c, src/m68k/spriteEmitPlanar68k.c, +// src/m68k/spriteEmitInterleaved68k.c) implements its own emit +// functions. spriteCompile.c picks the right one at compile time +// (via #ifdef on JOEYLIB_PLATFORM_*) for the runtime build. // // Each emit function takes the sprite + shift variant and a capacity // `cap` (bytes available at `out`), and writes position-independent @@ -55,13 +53,12 @@ #elif defined(JOEYLIB_PLATFORM_IIGS) #define SPRITE_EMIT_MAX_BYTES_PER_DESTBYTE SPRITE_EMIT_MAX_BYTES_PER_DESTBYTE_IIGS #else -// 68k chunky (unused) / planar / interleaved walkers emit 0 bytes; -// the 68k chunky worst case is the safe upper bound for sizing. +// The planar (Amiga) and interleaved (ST) emitters share the same +// worst-case mixed-RMW shape (4 instructions * 4 bytes per dest byte). #define SPRITE_EMIT_MAX_BYTES_PER_DESTBYTE SPRITE_EMIT_MAX_BYTES_PER_DESTBYTE_68K #endif uint32_t spriteEmitDrawX86 (uint8_t *out, uint32_t cap, const jlSpriteT *sp, uint8_t shift); -uint32_t spriteEmitDraw68k (uint8_t *out, uint32_t cap, const jlSpriteT *sp, uint8_t shift); uint32_t spriteEmitDrawIigs(uint8_t *out, uint32_t cap, const jlSpriteT *sp, uint8_t shift); // Save-under and restore-under emitters. Both copy a byte-aligned @@ -72,28 +69,27 @@ uint32_t spriteEmitDrawIigs(uint8_t *out, uint32_t cap, const jlSpriteT *sp, uin // Per-CPU emitters return 0 to mean "not implemented" -- the runtime // dispatch falls back to the interpreted path in that case. // -// IIgs uses a self-modifying MVN-stub on top of these bytes; x86 and -// 68k use a plain cdecl `void copy(const uint8_t *src, uint8_t *dst)` +// x86 emits a plain cdecl `void copy(const uint8_t *src, uint8_t *dst)` // where the caller swaps args between SAVE (screen->backup) and -// RESTORE (backup->screen). +// RESTORE (backup->screen). IIgs emits a C-ABI +// `void copy(uint32_t packed)` (packed = srcOff | (dstOff << 16)) of +// unrolled MVN rows whose bank operand bytes the dispatcher patches +// per call -- see spriteEmitIigs.c's header for the full contract. uint32_t spriteEmitSaveIigs (uint8_t *out, uint32_t cap, const jlSpriteT *sp, uint8_t shift); uint32_t spriteEmitRestoreIigs(uint8_t *out, uint32_t cap, const jlSpriteT *sp, uint8_t shift); uint32_t spriteEmitSaveX86 (uint8_t *out, uint32_t cap, const jlSpriteT *sp, uint8_t shift); uint32_t spriteEmitRestoreX86 (uint8_t *out, uint32_t cap, const jlSpriteT *sp, uint8_t shift); -uint32_t spriteEmitSave68k (uint8_t *out, uint32_t cap, const jlSpriteT *sp, uint8_t shift); -uint32_t spriteEmitRestore68k (uint8_t *out, uint32_t cap, const jlSpriteT *sp, uint8_t shift); -// Planar 68k emitters (Amiga). Distinct from the chunky 68k emitters -// above because the destination addressing is across 4 separate -// bitplane buffers, not a single packed-pixel surface. Calling -// convention for the emitted bytes (cdecl): -// void draw (uint8_t *p0, uint8_t *p1, uint8_t *p2, uint8_t *p3); -// void save (uint8_t *p0, uint8_t *p1, uint8_t *p2, uint8_t *p3, uint8_t *backup); -// void restore (uint8_t *p0, uint8_t *p1, uint8_t *p2, uint8_t *p3, const uint8_t *backup); +// Planar 68k emitters (Amiga). Destination addressing is across 4 +// separate bitplane buffers, not a single packed-pixel surface. +// Calling convention for the emitted DRAW bytes (cdecl): +// void draw(uint8_t *p0, uint8_t *p1, uint8_t *p2, uint8_t *p3); // Each pi is plane_base + byteOff (= y*40 + x/8 already added by the -// dispatcher). Returns 0 for shifts not yet implemented (today only -// shift 0 == byte-aligned x is emitted; shifts 1..7 fall back to the -// cross-platform interpreter). +// dispatcher). DRAW returns 0 for shifts not yet implemented (today +// only shift 0 == byte-aligned x is emitted; shifts 1..7 fall back to +// the cross-platform interpreter). SAVE/RESTORE always return 0 -- +// not compiled on the Amiga yet; the interpreted jlpSpriteSavePlanes / +// jlpSpriteRestorePlanes path handles them. uint32_t spriteEmitDrawPlanar68k (uint8_t *out, uint32_t cap, const jlSpriteT *sp, uint8_t shift); uint32_t spriteEmitSavePlanar68k (uint8_t *out, uint32_t cap, const jlSpriteT *sp, uint8_t shift); uint32_t spriteEmitRestorePlanar68k (uint8_t *out, uint32_t cap, const jlSpriteT *sp, uint8_t shift); @@ -101,10 +97,11 @@ uint32_t spriteEmitRestorePlanar68k (uint8_t *out, uint32_t cap, const jlSpriteT // Word-interleaved planar 68k emitter (ST). Calling convention for // the emitted bytes: // void draw(uint8_t *groupBase); -// where groupBase = pd->base + y*160 + (x>>4)*8. Shifts 0 and 1 emit -// real bytes (x mod 16 == 0 for shift 0, x mod 16 == 8 for shift 1); -// other shifts return 0 so the cross-platform dispatcher falls back -// to jlpSpriteDrawPlanes. +// where groupBase = pd->base + y*160 + (x>>4)*8. DRAW shifts 0 and 1 +// emit real bytes (x mod 16 == 0 for shift 0, x mod 16 == 8 for +// shift 1); other shifts return 0 so the cross-platform dispatcher +// falls back to jlpSpriteDrawPlanes. SAVE/RESTORE always return 0 -- +// not compiled on the ST; the interpreted planes path handles them. uint32_t spriteEmitDrawInterleaved68k (uint8_t *out, uint32_t cap, const jlSpriteT *sp, uint8_t shift); uint32_t spriteEmitSaveInterleaved68k (uint8_t *out, uint32_t cap, const jlSpriteT *sp, uint8_t shift); uint32_t spriteEmitRestoreInterleaved68k (uint8_t *out, uint32_t cap, const jlSpriteT *sp, uint8_t shift); diff --git a/src/core/assetLoad.c b/src/core/assetLoad.c index d216484..085a3f1 100644 --- a/src/core/assetLoad.c +++ b/src/core/assetLoad.c @@ -24,29 +24,52 @@ #include "joey/sprite.h" #include "joey/tile.h" +#include "port.h" #include "spriteInternal.h" -// Push the file-IO out of the root segment on the IIgs. Every -// fopen / fread / fclose call drags a chunk of the stdio cluster -// in; concentrating them in one named segment keeps the root bank -// from overflowing past 64 KB (the failure mode is the linker's -// "Code exceeds code bank size" / "Address is not in current bank" -// errors documented in the feedback memory). +// IIgs segment placement (PERF-AUDIT #60): this TU carries NO named +// segment directive, deliberately. An earlier comment here claimed the +// file-IO was concentrated in a named segment to keep the stdio +// cluster from overflowing the root bank -- that directive never +// existed, and the failure mode it guarded against ("Code exceeds code +// bank size" / "Address is not in current bank") was a problem of the +// ORCA-Linker era; the clang/OMF pipeline splits oversized segments +// automatically. Do not add a segment directive here without link-map +// evidence -- root-segment packing is pressure-sensitive (see the +// bank-overflow notes in the feedback memory). // ----- Constants ----- #define ASSET_HEADER_BYTES 44 #define ASSET_PALETTE_OFFSET 12 #define ASSET_PALETTE_ENTRIES 16 -#define ASSET_TILE_DATA_BYTES 32 // 4bpp packed: 4 bytes/row * 8 rows -// Largest legitimately drawable sprite cel: the surface is 40x25 tiles -// (320/8 x 200/8), so any cel larger than that can never be fully -// on-screen. Reject crafted/truncated headers claiming more, which -// would otherwise force an unbounded per-cel malloc (DoS) and feed the -// downstream emitters/draw walkers out-of-range dimensions. -#define ASSET_MAX_TILES_W 40 -#define ASSET_MAX_TILES_H 25 +// Tile-bank payload reads are batched: jlTileT is exactly +// TILE_BYTES with no padding (compile-time-checked below), so up to +// ASSET_TILE_READ_BATCH tiles stream in one fread. The old per-tile +// loop issued 1000 32-byte freads for a full font bank -- on the IIgs +// each fread is a GS/OS round trip through SmartPort firmware, which +// dominated (and on heavily-sparse .tbk files wedged) asset loading. +// 256 tiles = 8KB per call, safely inside 16-bit size_t on every port. +#define ASSET_TILE_READ_BATCH 256u + +// Compile-time guard for the batched read: consecutive jlTileT +// entries must be contiguous pixel payloads (no padding), or the +// bulk fread would misalign every tile after the first. +typedef char AssetTileSizeCheckT[(sizeof(jlTileT) == TILE_BYTES) ? 1 : -1]; + +// Tile geometry comes from joey/tile.h, the single source of truth for +// the 4bpp packed layout (PERF-AUDIT #30): the per-tile payload read +// below is TILE_BYTES -- exactly what jlTileT.pixels holds -- and the +// largest legitimately drawable sprite cel is the full surface grid, +// TILE_BLOCKS_PER_ROW x TILE_BLOCKS_PER_COL (SURFACE_WIDTH/HEIGHT over +// TILE_PIXELS_PER_SIDE, 40x25 on the 320x200 surface). Any cel larger +// than that can never be fully on-screen, so reject crafted/truncated +// headers claiming more, which would otherwise force an unbounded +// per-cel allocation (DoS) and feed the downstream emitters/draw +// walkers out-of-range dimensions. +#define ASSET_MAX_TILES_W TILE_BLOCKS_PER_ROW +#define ASSET_MAX_TILES_H TILE_BLOCKS_PER_COL // Per-platform expected target byte for .tbk files. .spr files carry // target=0 (cross-platform chunky) and pass on every build. @@ -156,7 +179,7 @@ uint16_t jlSpriteBankLoad(const char *path, jlSpriteT **outCels, uint16_t maxCel return 0u; } - tileBytes = (uint32_t)widthTiles * (uint32_t)heightTiles * (uint32_t)ASSET_TILE_DATA_BYTES; + tileBytes = (uint32_t)widthTiles * (uint32_t)heightTiles * (uint32_t)TILE_BYTES; toLoad = (cellCount < maxCels) ? cellCount : maxCels; loaded = 0u; @@ -164,30 +187,26 @@ uint16_t jlSpriteBankLoad(const char *path, jlSpriteT **outCels, uint16_t maxCel uint8_t *buf; jlSpriteT *sp; - buf = (uint8_t *)malloc((size_t)tileBytes); +// jlpBigAlloc for the same #31/#81 reasons as sprite.c: these + // buffers overflow the tiny IIgs C heap, whose malloc returns + // garbage instead of NULL at exhaustion. + buf = (uint8_t *)jlpBigAlloc(tileBytes); if (buf == NULL) { break; } if (fread(buf, 1, (size_t)tileBytes, fp) != (size_t)tileBytes) { - free(buf); + jlpBigFree(buf); break; } - sp = (jlSpriteT *)malloc(sizeof(jlSpriteT)); + sp = (jlSpriteT *)jlpBigAlloc((uint32_t)sizeof(jlSpriteT)); if (sp == NULL) { - free(buf); + jlpBigFree(buf); break; } - sp->tileData = buf; - sp->widthTiles = widthTiles; - sp->heightTiles = heightTiles; - sp->ownsTileData = true; - sp->slot = NULL; - // 0xFF == SPRITE_NOT_COMPILED for every routine offset (0 is a - // valid offset, so zero-fill would alias a real entry). - memset(sp->routineOffsets, 0xFF, sizeof(sp->routineOffsets)); - memset(sp->cachedDstBank, 0xFF, sizeof(sp->cachedDstBank)); - memset(sp->cachedSrcBank, 0xFF, sizeof(sp->cachedSrcBank)); - memset(sp->cachedSizeBytes, 0, sizeof(sp->cachedSizeBytes)); + // Shared init path (spriteInternal.h, PERF-AUDIT.md #32): sets + // the 0xFF/0 compiled-state sentinels and registers the sprite + // for shutdown. ownsTileData true: the cel owns its buf copy. + spriteInitFields(sp, buf, widthTiles, heightTiles, true); outCels[i] = sp; loaded++; } @@ -209,6 +228,9 @@ uint16_t jlTileBankLoad(const char *path, jlTileT *outTiles, uint16_t maxTiles, uint16_t i; uint16_t loaded; uint16_t toLoad; + uint16_t batch; + uint16_t gotTiles; + size_t got; if (path == NULL || outTiles == NULL || maxTiles == 0u) { return 0u; @@ -233,15 +255,82 @@ uint16_t jlTileBankLoad(const char *path, jlTileT *outTiles, uint16_t maxTiles, toLoad = (tileCount < maxTiles) ? tileCount : maxTiles; + // Batched payload stream (ASSET_TILE_READ_BATCH). jlTileT is + // pure pixels[TILE_BYTES], so consecutive tiles are contiguous + // and a short final fread loads exactly the whole tiles it + // returned -- same truncated-file contract as the old per-tile + // loop. loaded = 0u; - for (i = 0; i < toLoad; i++) { - if (fread(outTiles[i].pixels, 1, ASSET_TILE_DATA_BYTES, fp) != ASSET_TILE_DATA_BYTES) { + while (loaded < toLoad) { + batch = (uint16_t)(toLoad - loaded); + if (batch > ASSET_TILE_READ_BATCH) { + batch = ASSET_TILE_READ_BATCH; + } + got = fread(outTiles[loaded].pixels, 1, (size_t)batch * TILE_BYTES, fp); + gotTiles = (uint16_t)(got / TILE_BYTES); + if (outValid != NULL) { + for (i = 0; i < gotTiles; i++) { + outValid[loaded + i] = true; + } + } + loaded = (uint16_t)(loaded + gotTiles); + if (gotTiles < batch) { + break; + } + } + fclose(fp); + return loaded; +} + + +// Streaming variant of jlTileBankLoad: pastes tiles onto dst's block +// grid as they arrive instead of filling a caller buffer, so the +// caller needs NO tile array at all (contract in joey/tile.h). The +// scratch is a small static -- 8 tiles per fread keeps the read count +// low without stressing the 65816 soft stack. +uint16_t jlTileBankLoadToSurface(const char *path, jlSurfaceT *dst, uint16_t *outPalette) { + static jlTileT scratch[8]; + FILE *fp; + uint8_t header[ASSET_HEADER_BYTES]; + uint16_t tileCount; + uint16_t toLoad; + uint16_t loaded; + uint16_t batch; + uint16_t gotTiles; + uint16_t i; + uint8_t bx; + uint8_t by; + size_t got; + + if (path == NULL || dst == NULL) { + return 0u; + } + if (!openReadValidateHeader(path, "JTB1", EXPECTED_TILE_TARGET, header, outPalette, &fp)) { + return 0u; + } + tileCount = (uint16_t)(header[6] | (header[7] << 8)); + toLoad = (uint16_t)(TILE_BLOCKS_PER_ROW * TILE_BLOCKS_PER_COL); + if (tileCount < toLoad) { + toLoad = tileCount; + } + + loaded = 0u; + while (loaded < toLoad) { + batch = (uint16_t)(toLoad - loaded); + if (batch > (uint16_t)(sizeof(scratch) / sizeof(scratch[0]))) { + batch = (uint16_t)(sizeof(scratch) / sizeof(scratch[0])); + } + got = fread(scratch[0].pixels, 1, (size_t)batch * TILE_BYTES, fp); + gotTiles = (uint16_t)(got / TILE_BYTES); + for (i = 0; i < gotTiles; i++) { + bx = (uint8_t)((loaded + i) % TILE_BLOCKS_PER_ROW); + by = (uint8_t)((loaded + i) / TILE_BLOCKS_PER_ROW); + jlTilePaste(dst, bx, by, &scratch[i]); + } + loaded = (uint16_t)(loaded + gotTiles); + if (gotTiles < batch) { break; } - if (outValid != NULL) { - outValid[i] = true; - } - loaded++; } fclose(fp); return loaded; diff --git a/src/core/audioSfxMix.c b/src/core/audioSfxMix.c index ed5b5ee..095977e 100644 --- a/src/core/audioSfxMix.c +++ b/src/core/audioSfxMix.c @@ -5,26 +5,190 @@ #include "audioSfxMixInternal.h" -// interp()/mix arithmetic (delta * frac up to ~16.7M, and the >> 16 on -// a possibly-negative value) requires a >= 32-bit int with arithmetic -// right shift. DOS (DJGPP) and ST (m68k gcc) both satisfy this; the -// file is excluded from the IIgs build (16-bit int) by +// interp()/mix arithmetic (delta * fracHalf up to ~8.4M, and the +// >> 15 on a possibly-negative value) requires a >= 32-bit int with +// arithmetic right shift. DOS (DJGPP) and ST (m68k gcc) both satisfy +// this; the file is excluded from the IIgs build (16-bit int) by // make/iigs.mk. Fail loudly if anyone ever shares this mixer with a // 16-bit-int target instead of producing silent garbage SFX. #if INT_MAX < 0x7FFFFFFF #error "audioSfxMix requires >= 32-bit int (DOS/ST only); IIgs must use NTPstreamsound" #endif +// Prototypes (file-local, alphabetical). +static int interp(int8_t s0, int8_t s1, uint16_t frac); +static bool streamRefill(AudioSfxSlotT *slot); + + +void audioSfxOverlayMix(volatile uint8_t *dst, uint16_t len, AudioSfxSlotT *slots, uint8_t numSlots) { + AudioSfxSlotT *slot; + const uint8_t *smp; + const int8_t *streamBuf; + uint32_t idx; + uint32_t stepIdx; + uint32_t length; + uint16_t frac; + uint16_t stepFrac; + uint16_t streamLen; + uint16_t i; + uint8_t s; + int sample; + int mixed; + int8_t s0; + int8_t s1; + + for (s = 0; s < numSlots; s++) { + slot = &slots[s]; + if (!slot->active) { + continue; + } + // Split the 16.16 step once per slot: the inner loops advance + // a uint32_t sample index + uint16_t fraction with a carry + // compare instead of 64-bit fixed-point math per sample. + stepIdx = slot->stepFp >> 16; + stepFrac = (uint16_t)(slot->stepFp & 0xFFFFu); + idx = slot->posIdx; + frac = slot->posFrac; + // The streaming and fixed-buffer cases are two separate inner + // loops so the loop-invariant isStream test runs once per slot + // and the slot fields stay cached in locals across the + // volatile dst stores (which would otherwise force per-sample + // re-reads); the short mix body is intentionally duplicated + // between them. + if (slot->fill != NULL) { + // Streaming: refill the prefetch buffer when drained. + streamBuf = slot->streamBuf; + streamLen = slot->streamLen; + for (i = 0; i < len; i++) { + if (idx >= streamLen) { + if (!streamRefill(slot)) { + slot->active = false; + break; + } + // streamRefill rewound the slot position; re-read + // the fields it updated. + idx = slot->posIdx; + frac = slot->posFrac; + streamLen = slot->streamLen; + } + s0 = streamBuf[idx]; + s1 = (idx + 1u < streamLen) ? streamBuf[idx + 1u] : s0; + sample = interp(s0, s1, frac); + mixed = (int)dst[i] - 128 + sample; + if (mixed > 127) { + mixed = 127; + } else if (mixed < -128) { + mixed = -128; + } + dst[i] = (uint8_t)(mixed + 128); + frac = (uint16_t)(frac + stepFrac); + if (frac < stepFrac) { + idx++; + } + idx += stepIdx; + } + } else { + // Fixed buffer. + smp = slot->sample; + length = slot->length; + for (i = 0; i < len; i++) { + if (idx >= length) { + slot->active = false; + break; + } + s0 = (int8_t)smp[idx]; + s1 = (idx + 1u < length) ? (int8_t)smp[idx + 1u] : s0; + sample = interp(s0, s1, frac); + mixed = (int)dst[i] - 128 + sample; + if (mixed > 127) { + mixed = 127; + } else if (mixed < -128) { + mixed = -128; + } + dst[i] = (uint8_t)(mixed + 128); + frac = (uint16_t)(frac + stepFrac); + if (frac < stepFrac) { + idx++; + } + idx += stepIdx; + } + } + slot->posIdx = idx; + slot->posFrac = frac; + } +} + + +void audioSfxSlotArm(AudioSfxSlotT *slot, const uint8_t *sample, uint32_t length, uint16_t rateHz, uint32_t outputRate) { + if (rateHz == 0 || outputRate == 0) { + return; + } + slot->sample = sample; + slot->length = length; + slot->posIdx = 0u; + slot->posFrac = 0u; + slot->stepFp = ((uint32_t)rateHz << 16) / outputRate; + slot->fill = NULL; + slot->fillCtx = NULL; + slot->streamLen = 0u; + slot->streamEof = false; + slot->active = true; +} + + +void audioSfxSlotArmStream(AudioSfxSlotT *slot, jlAudioStreamFillT fill, void *ctx, uint16_t rateHz, uint32_t outputRate) { + if (rateHz == 0 || outputRate == 0 || fill == NULL) { + return; + } + slot->sample = NULL; + slot->length = 0u; + slot->posIdx = 0u; + slot->posFrac = 0u; + slot->stepFp = ((uint32_t)rateHz << 16) / outputRate; + slot->fill = fill; + slot->fillCtx = ctx; + slot->streamLen = 0u; + slot->streamEof = false; + slot->active = true; + // Prime the first chunk so the mixer doesn't refill mid-mix on + // its very first sample. + (void)streamRefill(slot); +} + + +// Linear interpolation between two adjacent signed 8-bit samples. +// `frac` is the 16-bit fractional part of the read position; 0 = +// exactly at s0, 0xFFFF = nearly s1. Result is a signed int in +// [-128, +127]. +// +// Formulated as a 16x16->32 signed multiply with explicit int16_t +// locals so m68k gcc matches its mulhisi3 pattern and emits a single +// muls.w instead of a __mulsi3 library call (~250-350 cycles per +// sample on the 68000). Dropping frac's low bit means the result can +// differ from the exact delta * frac >> 16 by at most 1 LSB of 8-bit +// audio -- an accepted, inaudible deviation. The >> 15 of a possibly- +// negative int32_t relies on arithmetic shift, which holds on every +// target this file builds for (DOS/ST). +static int interp(int8_t s0, int8_t s1, uint16_t frac) { + int16_t delta; + int16_t fracHalf; + + delta = (int16_t)(s1 - s0); + fracHalf = (int16_t)(uint16_t)(frac >> 1); + return (int)s0 + (int)(((int32_t)delta * fracHalf) >> 15); +} + // Refill a streaming slot's prefetch buffer. Preserves the very last // sample of the previous chunk at streamBuf[0] so linear interp can // step across the chunk boundary without a discontinuity click. The // new chunk fills streamBuf[1..]. On a real refill (a previous chunk -// existed) pos resets to 0 so the first read interpolates the carried -// old-last sample at streamBuf[0] into the new chunk's first sample at -// streamBuf[1], bridging the boundary. On the very first prime there -// is no real previous sample, so pos resets to "after byte 0" and the -// first read starts at the new chunk's first sample (index 1). +// existed) the position resets to 0 so the first read interpolates the +// carried old-last sample at streamBuf[0] into the new chunk's first +// sample at streamBuf[1], bridging the boundary. On the very first +// prime there is no real previous sample, so the position resets to +// "after byte 0" and the first read starts at the new chunk's first +// sample (index 1). // // Returns true if more samples are available, false if the stream // has ended (caller should mark the slot inactive). @@ -59,123 +223,13 @@ static bool streamRefill(AudioSfxSlotT *slot) { // read interpolates streamBuf[0] (old last sample) -> [1] // (new first sample), replaying one sample with no audible // discontinuity click. - slot->posFp = 0; + slot->posIdx = 0u; + slot->posFrac = 0u; } else { // First prime: streamBuf[0] is a synthetic zero, not a real // sample. Skip it so it isn't played as a leading sample. - slot->posFp = (uint64_t)1u << 16; + slot->posIdx = 1u; + slot->posFrac = 0u; } return true; } - - -// Linear interpolation between two adjacent signed 8-bit samples. -// `frac` is the 16-bit fractional part of pos; 0 = exactly at s0, -// 0xFFFF = nearly s1. Result is a signed int in [-128, +127]. -static int interp(int8_t s0, int8_t s1, uint32_t frac) { - int32_t delta; - - // delta (up to +/-255) * frac (up to 65535) is ~16.7M -- needs the - // explicit 32-bit width so it can't overflow a 16-bit int. The - // >> 16 of a possibly-negative int32_t relies on arithmetic shift, - // which holds on every target this file builds for (DOS/ST). - delta = (int32_t)s1 - (int32_t)s0; - return (int)s0 + (int)((delta * (int32_t)frac) >> 16); -} - - -void audioSfxOverlayMix(volatile uint8_t *dst, uint16_t len, AudioSfxSlotT *slots, uint8_t numSlots) { - AudioSfxSlotT *slot; - uint64_t pos; - uint32_t step; - uint32_t idx; - uint32_t frac; - uint16_t i; - uint8_t s; - int sample; - int mixed; - int8_t s0; - int8_t s1; - bool isStream; - - for (s = 0; s < numSlots; s++) { - slot = &slots[s]; - if (!slot->active) { - continue; - } - pos = slot->posFp; - step = slot->stepFp; - isStream = (slot->fill != NULL); - for (i = 0; i < len; i++) { - idx = (uint32_t)(pos >> 16); - frac = (uint32_t)(pos & 0xFFFFu); - if (isStream) { - // Streaming: refill the prefetch buffer when drained. - if (idx >= slot->streamLen) { - if (!streamRefill(slot)) { - slot->active = false; - break; - } - pos = slot->posFp; - idx = (uint32_t)(pos >> 16); - frac = (uint32_t)(pos & 0xFFFFu); - } - s0 = slot->streamBuf[idx]; - s1 = (idx + 1u < slot->streamLen) ? slot->streamBuf[idx + 1u] : s0; - } else { - // Fixed buffer. - if (idx >= slot->length) { - slot->active = false; - break; - } - s0 = (int8_t)slot->sample[idx]; - s1 = (idx + 1u < slot->length) ? (int8_t)slot->sample[idx + 1u] : s0; - } - sample = interp(s0, s1, frac); - mixed = (int)dst[i] - 128 + sample; - if (mixed > 127) { - mixed = 127; - } else if (mixed < -128) { - mixed = -128; - } - dst[i] = (uint8_t)(mixed + 128); - pos += (uint64_t)step; - } - slot->posFp = pos; - } -} - - -void audioSfxSlotArm(AudioSfxSlotT *slot, const uint8_t *sample, uint32_t length, uint16_t rateHz, uint32_t outputRate) { - if (rateHz == 0 || outputRate == 0) { - return; - } - slot->sample = sample; - slot->length = length; - slot->posFp = 0; - slot->stepFp = ((uint32_t)rateHz << 16) / outputRate; - slot->fill = NULL; - slot->fillCtx = NULL; - slot->streamLen = 0u; - slot->streamEof = false; - slot->active = true; -} - - -void audioSfxSlotArmStream(AudioSfxSlotT *slot, jlAudioStreamFillT fill, void *ctx, uint16_t rateHz, uint32_t outputRate) { - if (rateHz == 0 || outputRate == 0 || fill == NULL) { - return; - } - slot->sample = NULL; - slot->length = 0u; - slot->posFp = 0; - slot->stepFp = ((uint32_t)rateHz << 16) / outputRate; - slot->fill = fill; - slot->fillCtx = ctx; - slot->streamLen = 0u; - slot->streamEof = false; - slot->active = true; - // Prime the first chunk so the mixer doesn't refill mid-mix on - // its very first sample. - (void)streamRefill(slot); -} diff --git a/src/core/audioSfxMixInternal.h b/src/core/audioSfxMixInternal.h index a1b143e..e4ff308 100644 --- a/src/core/audioSfxMixInternal.h +++ b/src/core/audioSfxMixInternal.h @@ -32,13 +32,15 @@ typedef struct { const uint8_t *sample; // fixed buffer: signed 8-bit PCM, caller-owned. NULL for streaming. uint32_t length; // fixed buffer: sample bytes. Unused for streaming. - // 48.16 fixed-point read position (uint64_t). The wider counter - // lifts the old uint32_t cap (~65 535 input samples = 3 sec at - // 22 kHz / 8 sec at 8 kHz before pos wraps and the slot loops - // forever); AGI v2 sounds run 15-48 sec and need the headroom. - // For streaming slots, pos is RELATIVE to the current chunk - // (resets on each refill). - uint64_t posFp; + // Read position split as a 32-bit sample index plus a 16-bit + // fraction so the per-sample advance is 16/32-bit adds and a + // carry compare -- no 64-bit fixed-point ops on the 68000. The + // uint32_t index covers 4G input samples, far beyond the old + // 48.16 counter's rationale (AGI v2 sounds run 15-48 sec and + // need > 65535 samples). For streaming slots the position is + // RELATIVE to the current chunk (resets on each refill). + uint32_t posIdx; + uint16_t posFrac; uint32_t stepFp; // (rateHz << 16) / outputRate bool active; diff --git a/src/core/codegenArena.c b/src/core/codegenArena.c index 341592b..36b3129 100644 --- a/src/core/codegenArena.c +++ b/src/core/codegenArena.c @@ -17,6 +17,7 @@ #include #define attrPage 0x0004 #define attrNoCross 0x0010 +#define attrNoSpec 0x0020 #define attrFixed 0x4000 #define attrLocked 0x8000 #endif @@ -226,6 +227,15 @@ bool codegenArenaInit(uint32_t totalBytes) { return false; } #if defined(JOEYLIB_PLATFORM_IIGS) + // attrNoCross forbids the block from crossing a 64KB bank boundary, + // so any request larger than one bank (65536 bytes) is unsatisfiable + // and NewHandle would always return NULL. Clamp to a single bank. + // The clamped request can still fail below -- a 64KB attrNoCross + // block needs a completely free bank -- and the caller's error + // message covers that. + if (totalBytes > 65536UL) { + totalBytes = 65536UL; + } // MMStartUp() returns this application's master user ID (idempotent; // the system starts the Memory Manager before the app runs). A NULL // handle is the failure signal. diff --git a/src/core/debug.c b/src/core/debug.c index 45aae4d..a06472d 100644 --- a/src/core/debug.c +++ b/src/core/debug.c @@ -1,35 +1,151 @@ -// Cross-platform "where did it hang?" logger. Holds joeylog.txt open -// across calls and FLUSHES AFTER EVERY LINE: safe logs are more -// important than fast logs. A crash, hang, or hard kill therefore -// always leaves every line emitted so far on disk -- which is the whole -// point of a "where did it hang?" logger. +// Cross-platform "where did it hang?" logger. // -// The cost is one host FST WRITE per line (seconds each through the -// emulator's ProDOS FST emulation; that host-IO time is not tracked -// by the IIgs VBL counter, so wall-time totals computed from the frame -// counter slightly underreport). The 16 KB full buffer below still -// coalesces each line's individual vfprintf writes into ONE FST write, -// so the flush is per-line, not per-fragment. +// On DOS/Amiga/ST: joeylog.txt is held open across calls and FLUSHED +// AFTER EVERY LINE -- safe logs are more important than fast logs. A +// crash, hang, or hard kill therefore always leaves every line emitted +// so far on disk. // -// (Earlier revs opened+closed per call, then buffered the whole run and -// only flushed at the atexit fclose -- fast, but a mid-run crash lost -// the buffered tail, exactly when you need it most. Safety wins.) +// On the IIgs the per-line-flush design is a triple failure +// (PERF-AUDIT.md #80/#82): the libc fflush does not commit through the +// GS/OS FST (nothing persists until fclose), each real FST write costs +// seconds of emulated floppy time, and a write can wedge indefinitely +// inside SmartPort firmware retries -- one such wedge stalled the UBER +// benchmark for 18+ emulated minutes. So on IIgs the lines land in a +// RAM ring instead: gJoeyLogRing/gJoeyLogRingHead are non-static so +// headless-MAME Lua probes can drain the log LIVE from emulated memory +// (scripts/bench-iigs.sh), and jlLogFlush() writes the accumulated ring +// to joeylog.txt in one open/write/close pass (fclose is the only +// operation that actually commits) for on-hardware persistence. -#include #include +#include +#include +#include #include "joey/platform.h" #include "joey/debug.h" static const char *kLogPath = "joeylog.txt"; -static FILE *gLogFp = NULL; + +#ifdef JOEYLIB_PLATFORM_IIGS + +// Ring capacity. Head is a free-running total-bytes-written counter +// (uint16_t, wraps at 65536); the ring index is head % size, so the +// size must divide 65536 for the wrap to stay aligned. A Lua reader +// tracks its own last-read count and detects overruns by delta > size. +#define JOEY_LOG_RING_BYTES 8192u + +uint8_t gJoeyLogRing[JOEY_LOG_RING_BYTES]; +uint16_t gJoeyLogRingHead = 0; + +// ----- Prototypes ----- + +static void ringAppend(const char *text); + + +static void ringAppend(const char *text) { + uint16_t head; + uint16_t idx; + + // Explicit read-modify-write of the global (never `++`/`+=` on a + // global -- the 65816 inc-abs/DBR trap). + head = gJoeyLogRingHead; + while (*text != '\0') { + idx = (uint16_t)(head & (JOEY_LOG_RING_BYTES - 1u)); + gJoeyLogRing[idx] = (uint8_t)*text; + text++; + head = (uint16_t)(head + 1u); + } + gJoeyLogRingHead = head; +} + + +void jlLog(const char *msg) { + if (msg == NULL) { + return; + } + ringAppend(msg); + ringAppend("\n"); +} + + +void jlLogF(const char *fmt, ...) { + char line[200]; + va_list args; + + if (fmt == NULL) { + return; + } + va_start(args, fmt); + (void)vsnprintf(line, sizeof(line), fmt, args); + va_end(args); + ringAppend(line); + // Callers' formats mostly end in \n already; jlLog adds one for + // plain strings, so nothing is appended here. +} + + +// One-shot dump of everything the ring has seen to joeylog.txt. +// open/write/CLOSE per call: fclose is the only point the FST commits +// (#80), and batching all lines into one pass keeps the floppy cost to +// a single visit instead of one per line. Mode "w": the dump always +// contains the ENTIRE surviving ring, so truncate-on-flush both makes +// repeated flushes idempotent and lets jlLogReset stay disk-free (see +// below). +void jlLogFlush(void) { + FILE *fp; + uint16_t head; + uint16_t start; + uint16_t count; + uint16_t i; + uint16_t idx; + + head = gJoeyLogRingHead; + if (head == 0u) { + return; + } + fp = fopen(kLogPath, "w"); + if (fp == NULL) { + return; + } + // If the ring wrapped, the oldest surviving byte is head - size. + count = head; + start = 0; + if (count > JOEY_LOG_RING_BYTES) { + start = (uint16_t)(head - JOEY_LOG_RING_BYTES); + count = JOEY_LOG_RING_BYTES; + } + for (i = 0; i < count; i++) { + idx = (uint16_t)((uint16_t)(start + i) & (JOEY_LOG_RING_BYTES - 1u)); + fputc((int)gJoeyLogRing[idx], fp); + } + fclose(fp); +} + + +// Ring-only: NO disk I/O here. The old truncate write (fopen "w" + +// fclose) was the first floppy WRITE any app performed and wedged +// forever in SmartPort firmware retries under headless MAME -- the +// same #82 failure class the RAM ring exists to avoid. UBER never +// calls jlLogReset, so the write path lay unexercised until the +// spacetaxi acceptance test hit it (hang before the first log line, +// PC spinning in the ROM WAIT loop). jlLogFlush opens with "w" now, +// so the on-disk file still ends up truncated-to-current on the next +// flush. +void jlLogReset(void) { + gJoeyLogRingHead = 0; +} + +#else /* not JOEYLIB_PLATFORM_IIGS */ + +static FILE *gLogFp = NULL; /* 16 KB full buffer so a single log line's many small vfprintf writes - * coalesce into one host FST WRITE; jlLog/jlLogF then fflush once per + * coalesce into one host write; jlLog/jlLogF then fflush once per * line. Default stdio buffers are only ~512 bytes, which would split a - * long line across multiple FST writes. */ + * long line across multiple writes. */ #define JOEY_LOG_BUF_BYTES 16384 -static char gLogBuf[JOEY_LOG_BUF_BYTES]; +static char gLogBuf[JOEY_LOG_BUF_BYTES]; /* Lazy-open. Returns NULL if the open failed (silently disable). */ @@ -98,3 +214,5 @@ void jlLogReset(void) { } } } + +#endif /* JOEYLIB_PLATFORM_IIGS */ diff --git a/src/core/draw.c b/src/core/draw.c index 546636a..ac67a6d 100644 --- a/src/core/draw.c +++ b/src/core/draw.c @@ -21,12 +21,18 @@ // On overflow the fill silently truncates rather than crashing. #define FLOOD_STACK_SIZE 512 -// Sentinel extents for an empty accumulated dirty bounding box. min* seed -// above any valid coordinate and max* below any valid coordinate so the -// first plotted point sets every edge, and a box that never receives an -// on-surface point collapses to a no-op after clamping (min > max). -#define DIRTY_BOX_MIN_INIT ((int16_t)0x7FFF) -#define DIRTY_BOX_MAX_INIT ((int16_t)0x8000) +// Flood-fill fallback pixel read. On chunky ports (no +// JL_HAS_SAMPLE_PIXEL override) the nibble read inlines via +// SURFACE_READ_NIBBLE instead of paying the jlpGenericSamplePixel +// function hop per walked pixel (PERF-AUDIT #14/#24); planar ports keep +// their platform jlpSamplePixel (plane decode). Every call site passes +// side-effect-free arguments -- SURFACE_READ_NIBBLE evaluates its x +// argument twice. +#if !defined(JL_HAS_SAMPLE_PIXEL) + #define floodReadPixel(_s, _x, _y) SURFACE_READ_NIBBLE((_s), (_x), (_y)) +#else + #define floodReadPixel(_s, _x, _y) jlpSamplePixel((_s), (_x), (_y)) +#endif // ----- Prototypes ----- @@ -88,7 +94,6 @@ static void floodFillInternal(jlSurfaceT *s, int16_t startX, int16_t startY, uin int16_t leftX; int16_t rightX; uint8_t pix; - bool pixMatch; uint8_t newNibble; newNibble = (uint8_t)(newColor & 0x0F); @@ -136,7 +141,8 @@ static void floodFillInternal(jlSurfaceT *s, int16_t startX, int16_t startY, uin // Walk-out: find the matching run containing the seed. Planar ports // (Amiga) override with a plane-aware walk; everyone else uses the - // portable jlpSamplePixel walk, which works on chunky and planar. + // portable floodReadPixel walk (inline nibble read on chunky, + // platform jlpSamplePixel on planar). { #if defined(JL_HAS_FLOOD_WALK_PLANES) bool seedMatched; @@ -148,38 +154,64 @@ static void floodFillInternal(jlSurfaceT *s, int16_t startX, int16_t startY, uin } else #endif { - pix = jlpSamplePixel(s, x, y); - pixMatch = (pix == matchColor); + // matchEqual is loop-invariant across the whole fill, so + // fork the walk ONCE per seed pop instead of re-branching + // on it per walked pixel (PERF-AUDIT #35). The two + // variants differ only in the stop test -- (pix != + // matchColor) vs (pix == matchColor || pix == newNibble) + // -- and the span fill / scan / push code below stays + // shared, so the duplication is confined to these small + // walk loops. if (matchEqual) { - if (!pixMatch) { + pix = floodReadPixel(s, x, y); + if (pix != matchColor) { continue; } + + // Walk left to find the start of the matching run. + leftX = x; + while (leftX > 0) { + pix = floodReadPixel(s, (int16_t)(leftX - 1), y); + if (pix != matchColor) { + break; + } + leftX--; + } + + // Walk right to find the end. + rightX = x; + while (rightX < SURFACE_WIDTH - 1) { + pix = floodReadPixel(s, (int16_t)(rightX + 1), y); + if (pix != matchColor) { + break; + } + rightX++; + } } else { - if (pixMatch || pix == newNibble) { + pix = floodReadPixel(s, x, y); + if (pix == matchColor || pix == newNibble) { continue; } - } - // Walk left to find the start of the matching run. - leftX = x; - while (leftX > 0) { - pix = jlpSamplePixel(s, (int16_t)(leftX - 1), y); - pixMatch = (pix == matchColor); - if (matchEqual ? !pixMatch : (pixMatch || pix == newNibble)) { - break; + // Walk left to find the start of the fillable run. + leftX = x; + while (leftX > 0) { + pix = floodReadPixel(s, (int16_t)(leftX - 1), y); + if (pix == matchColor || pix == newNibble) { + break; + } + leftX--; } - leftX--; - } - // Walk right to find the end. - rightX = x; - while (rightX < SURFACE_WIDTH - 1) { - pix = jlpSamplePixel(s, (int16_t)(rightX + 1), y); - pixMatch = (pix == matchColor); - if (matchEqual ? !pixMatch : (pixMatch || pix == newNibble)) { - break; + // Walk right to find the end. + rightX = x; + while (rightX < SURFACE_WIDTH - 1) { + pix = floodReadPixel(s, (int16_t)(rightX + 1), y); + if (pix == matchColor || pix == newNibble) { + break; + } + rightX++; } - rightX++; } } } @@ -201,7 +233,7 @@ static void floodFillInternal(jlSurfaceT *s, int16_t startX, int16_t startY, uin // 1/0 per pixel, then walk it for run-edge transitions and push a // seed at each run's right edge. Planar ports (Amiga) override the // markBuf fill with a plane-aware scan; everyone else fills it via - // the portable jlpSamplePixel walk. + // the portable floodReadPixel walk. { int16_t i; int16_t spanLen; @@ -232,13 +264,20 @@ static void floodFillInternal(jlSurfaceT *s, int16_t startX, int16_t startY, uin true #endif ) { - // C fallback: fill markBuf the slow way. - for (i = 0; i < spanLen; i++) { - pix = jlpSamplePixel(s, (int16_t)(leftX + i), scanY); - pixMatch = (pix == matchColor); - floodMarkBuf[i] = (uint8_t)(matchEqual - ? (pixMatch ? 1 : 0) - : ((!pixMatch && pix != newNibble) ? 1 : 0)); + // C fallback: fill markBuf the slow way. Same + // loop-invariant matchEqual fork as the walk-out + // above (PERF-AUDIT #35) -- one branch per row scan + // instead of one per scanned pixel. + if (matchEqual) { + for (i = 0; i < spanLen; i++) { + pix = floodReadPixel(s, (int16_t)(leftX + i), scanY); + floodMarkBuf[i] = (uint8_t)((pix == matchColor) ? 1 : 0); + } + } else { + for (i = 0; i < spanLen; i++) { + pix = floodReadPixel(s, (int16_t)(leftX + i), scanY); + floodMarkBuf[i] = (uint8_t)((pix != matchColor && pix != newNibble) ? 1 : 0); + } } } // Walk markBuf for run-edge transitions. @@ -267,15 +306,17 @@ static void floodFillInternal(jlSurfaceT *s, int16_t startX, int16_t startY, uin } -// Plot a single clipped pixel WITHOUT marking the dirty rect. Shared by -// the public jlDrawPixel (which marks per call afterward) and the -// Bresenham line/circle fallbacks (which accumulate one bounding box and -// mark it once after their loop). Mirrors jlDrawPixel's NULL + bounds -// check, jlpDrawPixel dispatch, and chunky nibble RMW fallback. +// Plot a single clipped pixel WITHOUT marking the dirty rect. Used by +// the Bresenham line/circle fallbacks, which mark one precomputed +// bounding box after their loop (the public jlDrawPixel clips inline +// and calls jlpDrawPixel directly on every platform). +// +// CONTRACT (PERF-AUDIT #61): s is non-NULL -- both callers +// (jlDrawCircle, jlDrawLine) validate it once at API entry, so there +// is no per-pixel NULL re-check here. The per-pixel BOUNDS check is +// load-bearing and stays: it IS the off-surface clipping for these +// fallbacks, matching jlDrawPixel's clip. static void plotPixelNoMark(jlSurfaceT *s, int16_t x, int16_t y, uint8_t colorIndex) { - if (s == NULL) { - return; - } if (x < 0 || x >= SURFACE_WIDTH || y < 0 || y >= SURFACE_HEIGHT) { return; } @@ -291,10 +332,10 @@ void jlDrawCircle(jlSurfaceT *s, int16_t cx, int16_t cy, uint16_t r, uint8_t col int16_t x; int16_t y; int16_t err; - int16_t minX; - int16_t minY; - int16_t maxX; - int16_t maxY; + int32_t minX; + int32_t minY; + int32_t maxX; + int32_t maxY; if (s == NULL) { return; @@ -325,31 +366,49 @@ void jlDrawCircle(jlSurfaceT *s, int16_t cx, int16_t cy, uint16_t r, uint8_t col } #endif + // Known limitation (PERF-AUDIT #12): for r >= 32768 the (int16_t)r + // cast below would wrap negative, `while (x >= y)` would be false + // immediately, and the outline draws NOTHING. That is silent but + // harmless: no pixel is plotted, so nothing must be marked either + // -- return explicitly (the old accumulated box stayed empty and + // collapsed to the same no-op). A huge-r outline CAN legitimately + // cross the surface (e.g. cx=-32768, r=33000 passes through + // x ~= 232), but drawing it correctly needs 32-bit Bresenham state + // throughout; that fix is deliberately applied only to + // jlFillCircle, where coverage (not the perimeter) is what + // matters. Accepted as a documented no-op here. + if (r > INT16_MAX) { + return; + } + // Bresenham midpoint: maintain (x, y) on the perimeter, eight- // octant symmetry plots all 8 reflections each iteration. Plots // through plotPixelNoMark so off-surface pixels clip individually // (same per-pixel clip as jlDrawPixel) without paying the - // cross-segment dirty mark per pixel; one accumulated bounding box - // is marked after the loop instead. - minX = DIRTY_BOX_MIN_INIT; - minY = DIRTY_BOX_MIN_INIT; - maxX = DIRTY_BOX_MAX_INIT; - maxY = DIRTY_BOX_MAX_INIT; + // cross-segment dirty mark per pixel; one bounding box is marked + // after the loop instead. + // + // The dirty box is statically known BEFORE the loop (PERF-AUDIT + // #36/#37): the first iteration runs with x == r, y == 0 and its + // eight reflection folds set exactly [cx - r, cx + r] x + // [cy - r, cy + r]; no later iteration can widen it (both offsets + // never exceed r), and the old accumulation folded every + // reflection whether or not its pixel landed on-surface. So the + // precomputed box, clamped after the loop exactly as before, marks + // the identical region the deleted per-iteration folds produced -- + // including the collapse to a no-op mark for a fully off-surface + // circle. Computed in int32: cx +/- r overflows int16 for centers + // near the int16 limits, and an int16 wrap here could under-mark + // (stale rows on present), which clamping int32 values can never + // do. + minX = (int32_t)cx - (int32_t)r; + minY = (int32_t)cy - (int32_t)r; + maxX = (int32_t)cx + (int32_t)r; + maxY = (int32_t)cy + (int32_t)r; x = (int16_t)r; y = 0; err = (int16_t)(1 - x); while (x >= y) { - // The 8 octants span columns cx +/- x and cx +/- y, and rows - // cy +/- y and cy +/- x. Fold all reflections into the box in - // one shot, then clamp once after the loop. - if (cx + x > maxX) { maxX = (int16_t)(cx + x); } - if (cx - x < minX) { minX = (int16_t)(cx - x); } - if (cx + y > maxX) { maxX = (int16_t)(cx + y); } - if (cx - y < minX) { minX = (int16_t)(cx - y); } - if (cy + x > maxY) { maxY = (int16_t)(cy + x); } - if (cy - x < minY) { minY = (int16_t)(cy - x); } - if (cy + y > maxY) { maxY = (int16_t)(cy + y); } - if (cy - y < minY) { minY = (int16_t)(cy - y); } plotPixelNoMark(s, (int16_t)(cx + x), (int16_t)(cy + y), colorIndex); plotPixelNoMark(s, (int16_t)(cx - x), (int16_t)(cy + y), colorIndex); plotPixelNoMark(s, (int16_t)(cx + x), (int16_t)(cy - y), colorIndex); @@ -369,15 +428,17 @@ void jlDrawCircle(jlSurfaceT *s, int16_t cx, int16_t cy, uint16_t r, uint8_t col } } - // Clamp the accumulated box to the surface and mark it once. If the - // circle was entirely off-surface no pixel was plotted, so minX/minY - // stay above maxX/maxY (after clamping) and nothing is marked. + // Clamp the precomputed box to the surface and mark it once. For a + // circle entirely off one side of the surface the clamp leaves + // min > max on that axis and nothing is marked (same collapse the + // old accumulated box produced). The clamped values fit int16_t, + // so the narrowing casts into the mark are safe. if (minX < 0) { minX = 0; } if (minY < 0) { minY = 0; } if (maxX > SURFACE_WIDTH - 1) { maxX = SURFACE_WIDTH - 1; } if (maxY > SURFACE_HEIGHT - 1) { maxY = SURFACE_HEIGHT - 1; } if (minX <= maxX && minY <= maxY) { - surfaceMarkDirtyRect(s, minX, minY, (int16_t)(maxX - minX + 1), (int16_t)(maxY - minY + 1)); + surfaceMarkDirtyRect(s, (int16_t)minX, (int16_t)minY, (int16_t)(maxX - minX + 1), (int16_t)(maxY - minY + 1)); } } @@ -411,10 +472,20 @@ void jlDrawLine(jlSurfaceT *s, int16_t x0, int16_t y0, int16_t x1, int16_t y1, u } // Compute the span in 32-bit so far-apart off-surface endpoints // (e.g. x0=-20000, x1=20000) do not overflow the 16-bit int the - // subtraction would otherwise promote to on a 16-bit-int target; - // clamp to the surface width so the downstream uint16_t narrowing - // is safe. + // subtraction would otherwise promote to on a 16-bit-int target. + // Clip the START into range BEFORE clamping the length: clamping + // first anchored a surface-width span at a far-off-surface x0, + // so a line crossing the whole visible row clipped to nothing + // (PERF-AUDIT #72). The final clamp keeps the downstream + // uint16_t narrowing safe. span = (int32_t)x1 - (int32_t)x0 + 1; + if (x0 < 0) { + span = span + x0; + x0 = 0; + } + if (span <= 0) { + return; + } if (span > SURFACE_WIDTH) { span = SURFACE_WIDTH; } @@ -436,7 +507,16 @@ void jlDrawLine(jlSurfaceT *s, int16_t x0, int16_t y0, int16_t x1, int16_t y1, u y0 = y1; y1 = tmp; } + // Same start-clip-then-clamp ordering as the horizontal path + // (PERF-AUDIT #72), mirrored for y0/SURFACE_HEIGHT. span = (int32_t)y1 - (int32_t)y0 + 1; + if (y0 < 0) { + span = span + y0; + y0 = 0; + } + if (span <= 0) { + return; + } if (span > SURFACE_HEIGHT) { span = SURFACE_HEIGHT; } @@ -469,22 +549,31 @@ void jlDrawLine(jlSurfaceT *s, int16_t x0, int16_t y0, int16_t x1, int16_t y1, u // Diagonal fallback: plot through plotPixelNoMark so each pixel still // clips off-surface individually (matching jlDrawPixel), but skip the - // per-pixel cross-segment dirty mark. Accumulate one bounding box over - // the plotted points and mark it once after the loop. - minX = DIRTY_BOX_MIN_INIT; - minY = DIRTY_BOX_MIN_INIT; - maxX = DIRTY_BOX_MAX_INIT; - maxY = DIRTY_BOX_MAX_INIT; + // per-pixel cross-segment dirty mark; one bounding box is marked once + // after the loop. + // + // The dirty box is statically known BEFORE the loop (PERF-AUDIT + // #9/#37): Bresenham walks the monotone path from (x0, y0) to + // (x1, y1) and terminates exactly at (x1, y1), so the visited-point + // bounding box is the endpoint bounding box -- the same four + // ternaries the JL_HAS_DRAW_LINE fast path computes. The old + // per-pixel accumulation folded every VISITED point (clipped or + // not), so after the post-loop clamp this precomputed box marks the + // identical region it did. For endpoints running off-surface the + // clamped endpoint box is a SUPERSET of the box of the actually + // PLOTTED (clipped) pixels, which is safe: over-marking only makes + // present copy more; under-marking would leave stale rows. Computed + // from the ORIGINAL endpoints here, before the loop mutates x0/y0. + minX = (x0 < x1) ? x0 : x1; + minY = (y0 < y1) ? y0 : y1; + maxX = (x0 > x1) ? x0 : x1; + maxY = (y0 > y1) ? y0 : y1; dx = (int16_t)((x1 > x0) ? (x1 - x0) : (x0 - x1)); dy = (int16_t)(-((y1 > y0) ? (y1 - y0) : (y0 - y1))); sx = (int16_t)((x0 < x1) ? 1 : -1); sy = (int16_t)((y0 < y1) ? 1 : -1); err = (int16_t)(dx + dy); while (1) { - if (x0 < minX) { minX = x0; } - if (x0 > maxX) { maxX = x0; } - if (y0 < minY) { minY = y0; } - if (y0 > maxY) { maxY = y0; } plotPixelNoMark(s, x0, y0, colorIndex); if (x0 == x1 && y0 == y1) { break; @@ -500,10 +589,10 @@ void jlDrawLine(jlSurfaceT *s, int16_t x0, int16_t y0, int16_t x1, int16_t y1, u } } - // Clamp the accumulated box to the surface and mark it once. A line - // entirely off-surface still ran the loop (so the box holds its raw - // extents); clamping collapses it to a no-op when nothing landed on - // the surface. + // Clamp the endpoint box to the surface and mark it once. For a line + // entirely off one side of the surface the clamp leaves min > max on + // that axis and nothing is marked -- exactly how the old accumulated + // box (which held the same raw endpoint extents) collapsed. if (minX < 0) { minX = 0; } if (minY < 0) { minY = 0; } if (maxX > SURFACE_WIDTH - 1) { maxX = SURFACE_WIDTH - 1; } @@ -518,36 +607,30 @@ void jlDrawPixel(jlSurfaceT *s, int16_t x, int16_t y, uint8_t colorIndex) { if (s == NULL) { return; } -#ifdef JOEYLIB_PLATFORM_IIGS - // Hot per-pixel path: the asm plot (jlpDrawPixel -> iigsDrawPixelInner) - // plus an inline dirty mark, with NO plotPixelNoMark / surfaceMarkDirtyRect - // call layers. NB: an all-C inline RMW here (using the gRowOffsetLut table, - // no multiply) was measured SLOWER on hardware (1875 -> 1672 ops/sec) -- - // the far-pointer code for s->pixels[...] costs more than the - // cross-segment JSL into the hand-tuned asm, so the asm plot stays. The - // single-row dirty mark is widened inline, only for the stage. + // Clip BEFORE plotting and marking (the unsigned casts fold both + // bounds tests per axis). Marking with the raw coords after + // plotPixelNoMark silently clipped violated surfaceMarkDirtyRect's + // already-clipped contract and scribbled outside the stage + // dirty-band arrays for off-surface draws (PERF-AUDIT #1). Calling + // jlpDrawPixel directly also drops plotPixelNoMark's redundant + // bounds re-check on this path; the helper itself stays for the + // Bresenham fallbacks. + // + // This is ONE body for every port: jlpDrawPixel is the compile-time + // selected plot (IIgs asm / planar / generic chunky RMW), and the + // surfaceMarkDirtyRect macro runs the stage compare (direct gStage + // load, no jlStageGet() hop -- PERF-AUDIT #10/#17) and the + // single-row widen inline with no call layers (PERF-AUDIT #20), + // which is exactly the code the old hand-written IIgs branch spelled + // out. NB (IIgs): an all-C inline RMW plot here (gRowOffsetLut, no + // multiply) was measured SLOWER on hardware (1875 -> 1672 ops/sec) + // -- the far-pointer code for s->pixels[...] costs more than the + // cross-segment JSL into the hand-tuned asm, so the asm plot stays. if ((uint16_t)x >= SURFACE_WIDTH || (uint16_t)y >= SURFACE_HEIGHT) { return; } jlpDrawPixel(s, (uint16_t)x, (uint16_t)y, colorIndex); - if (s == jlStageGet()) { - uint8_t word = SURFACE_WORD_INDEX(x); - - if (word < gStageMinWord[y]) { - gStageMinWord[y] = word; - } - if (word > gStageMaxWord[y]) { - gStageMaxWord[y] = word; - } - } -#else - plotPixelNoMark(s, x, y, colorIndex); - // Public single-pixel path keeps the original per-call dirty mark - // with the raw (x, y); only the Bresenham fallbacks batch the mark - // into one accumulated box. plotPixelNoMark already no-op'd the - // pixel itself if it was off-surface. surfaceMarkDirtyRect(s, x, y, 1, 1); -#endif } @@ -568,38 +651,78 @@ void jlDrawRect(jlSurfaceT *s, int16_t x, int16_t y, uint16_t w, uint16_t h, uin // Fast path: when the OUTER rect is fully on-surface every one of // the four edge spans is also fully on-surface, so each can skip // jlFillRect's 32-bit clip and route straight through the shared - // on-surface tail. Test in 32-bit -- w/h are uint16_t, so x + w can - // exceed int16_t range. Output is identical because on an - // on-surface span jlFillRect's clip is a no-op. - if (x >= 0 && y >= 0 && - (int32_t)x + (int32_t)w <= SURFACE_WIDTH && - (int32_t)y + (int32_t)h <= SURFACE_HEIGHT) { + // on-surface tail. The shared 16-bit predicate (PERF-AUDIT #62) + // is semantically identical to the 32-bit x + w <= SURFACE_WIDTH + // test it replaces for every rect that reaches it: w/h >= 2 here + // (w == 0 / h == 0 returned above, w == 1 / h == 1 routed to + // jlFillRect, satisfying the macro's w/h >= 1 contract); any + // w > SURFACE_WIDTH -- w == 65535 included -- fails the macro's + // guarded first compare exactly as x + w <= SURFACE_WIDTH would + // for x >= 0; and for w <= SURFACE_WIDTH the guarded subtraction + // cannot wrap, so x <= SURFACE_WIDTH - w IS x + w <= SURFACE_WIDTH + // (boundary proof at the macro definition: w == 320 passes only + // x == 0; x == 319 / w == 1 passes; x == -1 fails). + if (SURFACE_RECT_ON_SURFACE(x, y, w, h)) { // Top edge. fillRectOnSurface(s, x, y, (int16_t)w, 1, colorIndex); // Bottom edge. fillRectOnSurface(s, x, (int16_t)(y + (int16_t)h - 1), (int16_t)w, 1, colorIndex); - // Left edge (interior only -- top and bottom corners already drawn). - fillRectOnSurface(s, x, (int16_t)(y + 1), 1, (int16_t)(h - 2), colorIndex); - // Right edge (interior only). - fillRectOnSurface(s, (int16_t)(x + (int16_t)w - 1), (int16_t)(y + 1), 1, (int16_t)(h - 2), colorIndex); + // Interior vertical edges only exist when h > 2 (an h == 2 rect + // is just the two horizontal edges). Skipping them keeps h - 2 + // == 0 rects out of fillRectOnSurface, whose contract requires + // positive dimensions (PERF-AUDIT #65 verifier correction). + if (h > 2) { + // Left edge (interior only -- top and bottom corners already drawn). + fillRectOnSurface(s, x, (int16_t)(y + 1), 1, (int16_t)(h - 2), colorIndex); + // Right edge (interior only). + fillRectOnSurface(s, (int16_t)(x + (int16_t)w - 1), (int16_t)(y + 1), 1, (int16_t)(h - 2), colorIndex); + } return; } // General (possibly clipped) case: let jlFillRect clip each edge. - // Top edge. - jlFillRect(s, x, y, w, 1, colorIndex); - // Bottom edge. - jlFillRect(s, x, (int16_t)(y + (int16_t)h - 1), w, 1, colorIndex); - // Left edge (interior only -- top and bottom corners already drawn). - jlFillRect(s, x, (int16_t)(y + 1), 1, (uint16_t)(h - 2), colorIndex); - // Right edge (interior only). - jlFillRect(s, (int16_t)(x + (int16_t)w - 1), (int16_t)(y + 1), 1, (uint16_t)(h - 2), colorIndex); + // Edge coordinates are computed in 32-bit: w and h are uint16_t, so + // y + h - 1 (or x + w - 1) can reach 65733, and narrowing that + // through an int16_t cast wrapped it back on-surface, drawing + // phantom edges (PERF-AUDIT #11). An edge whose true coordinate is + // off-surface is skipped instead -- exactly what jlFillRect's clip + // would do if it received the unwrapped value. + { + int32_t bottom; + int32_t right; + int32_t innerY; + + bottom = (int32_t)y + (int32_t)h - 1; + right = (int32_t)x + (int32_t)w - 1; + innerY = (int32_t)y + 1; + // Top edge. + jlFillRect(s, x, y, w, 1, colorIndex); + // Bottom edge. + if (bottom >= 0 && bottom < SURFACE_HEIGHT) { + jlFillRect(s, x, (int16_t)bottom, w, 1, colorIndex); + } + // Interior vertical edges: skip when h == 2 (same contract note + // as the fast path above) and when their top row is already + // below the surface (innerY can be 32768 for y == INT16_MAX -- + // the int16_t cast would wrap it on-surface). + if (h > 2 && innerY < SURFACE_HEIGHT) { + // Left edge (interior only -- top and bottom corners already drawn). + jlFillRect(s, x, (int16_t)innerY, 1, (uint16_t)(h - 2), colorIndex); + // Right edge (interior only). + if (right >= 0 && right < SURFACE_WIDTH) { + jlFillRect(s, (int16_t)right, (int16_t)innerY, 1, (uint16_t)(h - 2), colorIndex); + } + } + } } void jlFillCircle(jlSurfaceT *s, int16_t cx, int16_t cy, uint16_t r, uint8_t colorIndex) { - int16_t y; - int16_t x; int16_t ir; + int32_t x; + int32_t yLo; + int32_t yHi; + int32_t lim; + uint16_t y; uint32_t xx; uint32_t yy; uint32_t r2; @@ -616,16 +739,20 @@ void jlFillCircle(jlSurfaceT *s, int16_t cx, int16_t cy, uint16_t r, uint8_t col // Fast path: a circle with r >= SURFACE_HEIGHT can never fit fully // on-surface, and casting such an r to int16_t can wrap negative // (r > 32767), spuriously passing the bounds test and feeding the - // asm / dirty-rect garbage. Reject it here before the cast. + // asm / dirty-rect garbage. Reject it here before the cast. ir's + // init is only for the compiler's flow analysis -- it is read only + // under onSurface, which guarantees the real assignment below ran. onSurface = false; + ir = 0; if (r < SURFACE_HEIGHT) { ir = (int16_t)r; if (cx - ir >= 0 && cx + ir < SURFACE_WIDTH && cy - ir >= 0 && cy + ir < SURFACE_HEIGHT) { // The whole bounding circle is on-surface, so every scanline // span below is too. Remember this so the C fallback can - // route spans through the lighter on-surface fill instead of - // re-clipping each one through jlFillRect. + // route spans through the raw jlpFillRect (one batched dirty + // mark after the loop) instead of re-clipping and re-marking + // each one through jlFillRect. onSurface = true; #if defined(JL_HAS_FILL_CIRCLE) // Stage/portData-gated fast path (returns bool). Compiled out where @@ -639,43 +766,180 @@ void jlFillCircle(jlSurfaceT *s, int16_t cx, int16_t cy, uint16_t r, uint8_t col } } - // For each y from 0 to r, find the largest x such that x*x + y*y - // <= r*r and emit a horizontal span. Maintain xx=x*x, yy=y*y - // incrementally so the hot loop never does a 32-bit multiply -- - // critical on 65816 / 68000 / 286 where mul is slow or absent. + r2 = (uint32_t)r * (uint32_t)r; + + // Full-coverage test (PERF-AUDIT #12): if the farthest surface + // corner from (cx, cy) lies inside the disk, the circle covers the + // whole surface -- fill it in one call. This is also the only path + // that can produce the correct full fill for r >= 32768, which the + // old (int16_t)r cast wrapped negative (drawing nothing). Skipped + // when onSurface: a fully on-surface circle can never reach a + // corner, so the test could not pass. All math is 32-bit: the + // farthest corner delta is at most 33087 per axis, so the squared + // sum (~2.2e9) and r*r (~4.3e9) both fit uint32_t. + if (!onSurface) { + int32_t farDx; + int32_t farDy; + + farDx = (int32_t)cx; + if (farDx < 0) { + farDx = -farDx; + } + lim = (int32_t)(SURFACE_WIDTH - 1) - (int32_t)cx; + if (lim < 0) { + lim = -lim; + } + if (lim > farDx) { + farDx = lim; + } + farDy = (int32_t)cy; + if (farDy < 0) { + farDy = -farDy; + } + lim = (int32_t)(SURFACE_HEIGHT - 1) - (int32_t)cy; + if (lim < 0) { + lim = -lim; + } + if (lim > farDy) { + farDy = lim; + } + if ((uint32_t)farDx * (uint32_t)farDx + (uint32_t)farDy * (uint32_t)farDy <= r2) { + jlFillRect(s, 0, 0, SURFACE_WIDTH, SURFACE_HEIGHT, colorIndex); + return; + } + } + + // Clamp the span loop to the y offsets whose rows can intersect the + // surface (PERF-AUDIT #12): row cy + y is on-surface for y in + // [-cy, SURFACE_HEIGHT-1 - cy] and row cy - y for y in + // [cy - (SURFACE_HEIGHT-1), cy], so the union hull is + // yLo = max(0, -cy, cy - (SURFACE_HEIGHT-1)) and + // yHi = min(r, max(cy, SURFACE_HEIGHT-1 - cy)). Computed once in + // int32; for a fully on-surface circle this yields exactly [0, r]. + // A huge clipped r now costs at most SURFACE_HEIGHT iterations + // instead of r+1. + yLo = -(int32_t)cy; + lim = (int32_t)cy - (int32_t)(SURFACE_HEIGHT - 1); + if (lim > yLo) { + yLo = lim; + } + if (yLo < 0) { + yLo = 0; + } + yHi = (int32_t)cy; + lim = (int32_t)(SURFACE_HEIGHT - 1) - (int32_t)cy; + if (lim > yHi) { + yHi = lim; + } + if (yHi > (int32_t)r) { + yHi = (int32_t)r; + } + if (yLo > yHi) { + return; + } + + // For each y offset from yLo to yHi, find the largest x such that + // x*x + y*y <= r*r and emit a horizontal span. Maintain xx=x*x, + // yy=y*y incrementally so the hot loop never does a 32-bit multiply + // -- critical on 65816 / 68000 / 286 where mul is slow or absent. // (y+1)^2 = y^2 + 2y + 1; (x-1)^2 = x^2 - 2x + 1. xx, yy and r2 are // uint32_t so r*r does not wrap for r >= 256 (a uint16_t product - // overflows at r == 256 -> r2 == 0). + // overflows at r == 256 -> r2 == 0). yy is seeded with yLo*yLo (the + // incremental chain breaks on a jump), and the first iteration's + // while walks x down from r to the yLo row's span in one go -- O(r) + // once per call, accepted for this cold clipped path. The walk + // condition is xx > r2 - yy rather than xx + yy > r2 because xx + + // yy can exceed uint32_t range for r >= 32768 (yy <= r2 always, so + // the subtraction never underflows). The loop variable is uint16_t: + // yHi <= max(cy, SURFACE_HEIGHT-1 - cy) <= 32967, so y never wraps. /* Same `+ +` pattern as jlDrawCircle so the compiler doesn't emit * multiply helpers for the `2 * ...` constants. spanWidth is hoisted - * because both jlFillRect calls in the body need it. */ - xx = (uint32_t)r * (uint32_t)r; - r2 = xx; - yy = 0; - x = (int16_t)r; - for (y = 0; y <= (int16_t)r; y++) { - uint16_t spanWidth; + * because both span fills in each branch need it. */ + xx = r2; + yy = (uint32_t)yLo * (uint32_t)yLo; + x = (int32_t)r; + for (y = (uint16_t)yLo; y <= (uint16_t)yHi; y++) { + uint32_t xxLimit; - while (xx + yy > r2) { - xx = xx - (uint32_t)((uint16_t)x + (uint16_t)x - 1u); + xxLimit = r2 - yy; + while (xx > xxLimit) { + xx = xx - ((uint32_t)x + (uint32_t)x - 1u); x--; } - spanWidth = (uint16_t)((uint16_t)x + (uint16_t)x + 1u); if (onSurface) { // 0 <= cy +/- y <= cy + ir < SURFACE_HEIGHT and // 0 <= cx - x ... cx + x < SURFACE_WIDTH for every span, so - // jlFillRect's clip would be a no-op -- skip it. - fillRectOnSurface(s, (int16_t)(cx - x), (int16_t)(cy + y), (int16_t)spanWidth, 1, colorIndex); + // jlFillRect's clip would be a no-op -- skip it. x <= r < + // SURFACE_HEIGHT here, so the int16_t narrowing is safe. + // + // Spans go through the RAW fill op, not fillRectOnSurface + // (which is jlpFillRect + surfaceMarkDirtyRect), so the + // per-span dirty mark is batched into ONE bounding-square + // mark after the loop (PERF-AUDIT #38). jlpFillRect is the + // planar-aware op on planar ports (the JL_HAS_FILL_RECT + // function override writes the bitplanes), so calling it + // directly preserves the planar write -- pixel output is + // bit-identical to the fillRectOnSurface routing. + uint16_t spanWidth; + + spanWidth = (uint16_t)((uint16_t)x + (uint16_t)x + 1u); + jlpFillRect(s, (int16_t)(cx - (int16_t)x), (int16_t)(cy + (int16_t)y), (int16_t)spanWidth, 1, colorIndex); if (y > 0) { - fillRectOnSurface(s, (int16_t)(cx - x), (int16_t)(cy - y), (int16_t)spanWidth, 1, colorIndex); + jlpFillRect(s, (int16_t)(cx - (int16_t)x), (int16_t)(cy - (int16_t)y), (int16_t)spanWidth, 1, colorIndex); } } else { - jlFillRect(s, (int16_t)(cx - x), (int16_t)(cy + y), spanWidth, 1, colorIndex); - if (y > 0) { - jlFillRect(s, (int16_t)(cx - x), (int16_t)(cy - y), spanWidth, 1, colorIndex); + // Clipped case: clip the span in 32-bit HERE instead of + // narrowing cx - x / 2x + 1 into jlFillRect's int16/uint16 + // parameters -- for huge r those narrowings wrap (e.g. + // cx=-32000, r=32767 puts cx - x at -64767, which wrapped + // back on-surface) and either drop visible spans or draw + // phantom ones. The row test replaces jlFillRect's y clip + // the same way. For spans that never wrapped this is + // bit-identical to the old jlFillRect routing (same clip, + // same fillRectOnSurface tail). + int32_t spanLeft; + int32_t spanRight; + int32_t row; + + spanLeft = (int32_t)cx - x; + spanRight = (int32_t)cx + x; + if (spanLeft < 0) { + spanLeft = 0; + } + if (spanRight > (int32_t)(SURFACE_WIDTH - 1)) { + spanRight = (int32_t)(SURFACE_WIDTH - 1); + } + if (spanLeft <= spanRight) { + int16_t spanWidth; + + spanWidth = (int16_t)(spanRight - spanLeft + 1); + row = (int32_t)cy + (int32_t)y; + if (row >= 0 && row < SURFACE_HEIGHT) { + fillRectOnSurface(s, (int16_t)spanLeft, (int16_t)row, spanWidth, 1, colorIndex); + } + if (y > 0) { + row = (int32_t)cy - (int32_t)y; + if (row >= 0 && row < SURFACE_HEIGHT) { + fillRectOnSurface(s, (int16_t)spanLeft, (int16_t)row, spanWidth, 1, colorIndex); + } + } } } - yy = yy + (uint32_t)((uint16_t)y + (uint16_t)y + 1u); + yy = yy + ((uint32_t)y + (uint32_t)y + 1u); + } + + // One bounding-square dirty mark for the whole on-surface disk + // (PERF-AUDIT #38) instead of 2r+1 per-span marks. onSurface + // guarantees the loop above ran the full y = 0..r range (yLo/yHi + // resolve to exactly [0, r] for a fully on-surface circle), so the + // square covers every filled span. Rows away from the center are + // over-marked out to the full circle width -- exactly the mark the + // JL_HAS_FILL_CIRCLE fast path issues -- and over-marking is safe + // (present copies more; under-marking would leave stale rows). The + // clipped (!onSurface) spans above keep their per-span marks via + // fillRectOnSurface. + if (onSurface) { + surfaceMarkDirtyRect(s, (int16_t)(cx - ir), (int16_t)(cy - ir), (int16_t)(2 * ir + 1), (int16_t)(2 * ir + 1)); } } @@ -694,12 +958,31 @@ void jlFillRect(jlSurfaceT *s, int16_t x, int16_t y, uint16_t w, uint16_t h, uin return; } - // Clip in 32-bit so any int16_t x/y combined with any uint16_t w/h - // is handled without overflow (a uint16_t w > 32767 would wrap - // negative if narrowed to int16_t before clipping, and a negative x - // with a large w must still fill out to the right surface edge). - // The surface is only 320x200, so the clipped result always fits - // int16_t. + // 16-bit on-surface fast path (PERF-AUDIT #13): the overwhelmingly + // common case (HUD fills, tile-sized fills) is fully on-surface and + // needs no 32-bit math -- every int32 op below is a multi- + // instruction carry-chained sequence on the 16-bit CPUs. The shared + // predicate (PERF-AUDIT #62) expands to exactly the guarded compare + // chain this test spelled out inline -- w/h upper bounds first so + // the SURFACE_WIDTH - w subtraction cannot wrap for w up to 65535, + // then the x/y compares (boundary proof at the macro definition: + // w == 65535 fails the guard; w == 320 passes only x == 0; + // x == 319 / w == 1 passes; x == -1 fails). w > 0 && h > 0 keeps + // empty rects out of fillRectOnSurface, whose contract requires + // positive dimensions (and satisfies the macro's w/h >= 1 + // contract); they fall through to the slow path's empty-clip + // return instead. + if (w > 0 && h > 0 && SURFACE_RECT_ON_SURFACE(x, y, w, h)) { + fillRectOnSurface(s, x, y, (int16_t)w, (int16_t)h, colorIndex); + return; + } + + // Slow path: clip in 32-bit so any int16_t x/y combined with any + // uint16_t w/h is handled without overflow (a uint16_t w > 32767 + // would wrap negative if narrowed to int16_t before clipping, and a + // negative x with a large w must still fill out to the right + // surface edge). The surface is only 320x200, so the clipped result + // always fits int16_t. left = (int32_t)x; top = (int32_t)y; right = left + (int32_t)w; @@ -770,10 +1053,19 @@ uint8_t jlSamplePixel(const jlSurfaceT *s, int16_t x, int16_t y) { if (x < 0 || x >= SURFACE_WIDTH || y < 0 || y >= SURFACE_HEIGHT) { return 0; } - /* jlpSamplePixel reads from whichever storage the port uses -- - * chunky ports return a nibble extracted from s->pixels; planar - * ports read 4 plane bits and assemble the nibble. */ +#if !defined(JL_HAS_SAMPLE_PIXEL) + // Chunky ports (no JL_HAS_SAMPLE_PIXEL override): inline the nibble + // read. The out-of-line jlpGenericSamplePixel hop re-marshalled + // s/x/y just to run this same macro, and its body paid the y * 160 + // software multiply on IIgs (PERF-AUDIT #14/#24). x and y are plain + // locals here, so the macro's double x evaluation is side-effect + // free. + return SURFACE_READ_NIBBLE(s, x, y); +#else + // Planar ports: the platform jlpSamplePixel decodes the portData + // planes (4 plane bits assembled into the nibble). return jlpSamplePixel(s, x, y); +#endif } diff --git a/src/core/init.c b/src/core/init.c index b3a6f8d..246bf52 100644 --- a/src/core/init.c +++ b/src/core/init.c @@ -11,13 +11,30 @@ #include "joey/core.h" #include "codegenArenaInternal.h" #include "port.h" +#include "spriteInternal.h" #include "surfaceInternal.h" -// 8 KB fits the largest typical sprite working set (~3-4 KB per -// 32x32 sprite at all opaque) and keeps malloc requests small enough -// for the IIgs heap to satisfy them. -#define DEFAULT_CODEGEN_BYTES (8u * 1024u) +// Default codegen arena size when jlConfigT.codegenBytes == 0. +// Per-platform because emitter code density differs ~4-8x across CPUs +// (PERF-AUDIT.md #39): +// +// IIgs (65816): ~3-4 KB per all-opaque 32x32 sprite, so 8 KB fits +// the typical working set and keeps the attrNoCross Memory Manager +// request small. Requests above one 64 KB bank are clamped in +// codegenArenaInit (#8). +// +// Amiga/ST/DOS: the 68k/x86 emitters are far less dense -- an +// all-opaque 32x32 costs ~6.4 KB on ST (both shifts) and a +// substantially-mixed one ~8-16 KB by itself, so an 8 KB default +// silently demoted sprites to the interpreter. 32 KB matches what +// the sprite-heavy examples (uber, sprite, spacetaxi) already pass +// explicitly, and these ports have real heaps. +#if defined(JOEYLIB_PLATFORM_IIGS) +#define DEFAULT_CODEGEN_BYTES (8UL * 1024UL) +#else +#define DEFAULT_CODEGEN_BYTES (32UL * 1024UL) +#endif // ----- Prototypes ----- @@ -126,6 +143,11 @@ void jlShutdown(void) { return; } jlpInputShutdown(); + // Reset compiled-sprite state (slot pointers, routine offsets, patch + // caches) in every registered sprite BEFORE the arena teardown frees + // the slot structs -- see spriteSystemShutdown in spriteInternal.h + // (PERF-AUDIT.md #34). + spriteSystemShutdown(); codegenArenaShutdown(); stageFree(); jlpShutdown(); diff --git a/src/core/input.c b/src/core/input.c index 70f395f..cae4168 100644 --- a/src/core/input.c +++ b/src/core/input.c @@ -88,7 +88,14 @@ void jlWaitForAnyKey(void) { * and upper-bound check (`>= COUNT`) into a single unsigned compare. * Index 0 (KEY_NONE / MOUSE_BUTTON_NONE) is a sentinel that no HAL * ever writes, so reading gKeyState[0] / gMouseButtonState[0] is - * always 0 -- the predicate result is unchanged. */ + * always 0 -- the predicate result is unchanged. + * + * DECIDED (PERF-AUDIT #40, Phase 8): these stay out-of-line functions. + * Macro/inline forms in joey/input.h would expose the state arrays in + * a public header and multi-evaluate args, to shave call overhead off + * predicates already measured at 70K+ ops/sec post-clock-fix -- input + * is nowhere near any real game's frame profile. Revisit only if a + * profiled game shows the predicates in its frame budget. */ bool jlKeyDown(jlKeyE key) { if ((uint16_t)key >= (uint16_t)KEY_COUNT) { return false; diff --git a/src/core/palette.c b/src/core/palette.c index fb60cc1..b78cbb0 100644 --- a/src/core/palette.c +++ b/src/core/palette.c @@ -84,9 +84,7 @@ void jlPaletteGet(const jlSurfaceT *s, uint8_t paletteIndex, uint16_t *out16) { void jlPaletteSet(jlSurfaceT *s, uint8_t paletteIndex, const uint16_t *colors16) { - uint8_t i; - uint16_t *row; - const uint16_t *src; + uint16_t *row; if (s == NULL || colors16 == NULL) { return; @@ -95,20 +93,34 @@ void jlPaletteSet(jlSurfaceT *s, uint8_t paletteIndex, const uint16_t *colors16) return; } - /* Compute the row pointer via byte-pointer math + a single shift - * (16 entries * 2 bytes = 32 = 1 << 5) so the 2D-array indexing - * avoids a multiply helper. Then walk both arrays with - * post-increment pointers so the inner loop avoids the index - * multiply for every `row[i]` / `colors16[i]` too. */ - row = (uint16_t *)((uint8_t *)s->palette + ((uint16_t)paletteIndex << 5)); - src = colors16; - - *row++ = 0x0000; - src++; - for (i = 1; i < SURFACE_COLORS_PER_PALETTE; i++) { - *row++ = (uint16_t)(*src++ & 0x0FFF); - } - if (s == jlStageGet()) { + // Compute the row pointer via byte-pointer math + a single shift + // (16 entries * 2 bytes = 32 = 1 << 5) so the 2D-array indexing + // avoids a multiply helper. The caller contract (joey/palette.h) + // guarantees $0RGB input, so copy the row wholesale and force + // color 0 to black -- the only documented silent rewrite. The copy + // is an unrolled sequence of word assignments, NOT memcpy: the + // clang IIgs libc memcpy is a ~30 cyc/byte byte loop (finding #79) + // and measurably REGRESSED this op when tried (1443 -> 603 + // ops/sec); 15 direct word stores beat both that and the old + // masked loop on every port. + row = (uint16_t *)((uint8_t *)s->palette + ((uint16_t)paletteIndex << 5)); + row[0] = 0x0000; + row[1] = colors16[1]; + row[2] = colors16[2]; + row[3] = colors16[3]; + row[4] = colors16[4]; + row[5] = colors16[5]; + row[6] = colors16[6]; + row[7] = colors16[7]; + row[8] = colors16[8]; + row[9] = colors16[9]; + row[10] = colors16[10]; + row[11] = colors16[11]; + row[12] = colors16[12]; + row[13] = colors16[13]; + row[14] = colors16[14]; + row[15] = colors16[15]; + if (s == gStage) { gStagePaletteDirty = true; } } diff --git a/src/core/port.h b/src/core/port.h index f8f07f5..b356d9e 100644 --- a/src/core/port.h +++ b/src/core/port.h @@ -56,13 +56,14 @@ extern void iigsDrawPixelInner (uint8_t *pixels, uint16_t x, uint16_t y, uint16_t nibble); extern void iigsDrawLineInner (uint8_t *pixels, uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1, uint16_t nibble); extern void iigsDrawCircleInner (uint8_t *pixels, uint16_t cx, uint16_t cy, uint16_t r, uint16_t nibble); -extern void iigsFillCircleInner (uint8_t *pixels, uint16_t cx, uint16_t cy, uint16_t r, uint16_t fillWord); +extern void iigsFillCircleInner (uint8_t *pixels, uint16_t cx, uint16_t cy, uint16_t r, uint16_t nibble); extern void iigsSurfaceClearInner(uint8_t *pixels, uint16_t fillWord); extern void iigsSurfaceClearFastInner(uint8_t *pixels, uint16_t fillWord); -extern void iigsTileFillInner (uint8_t *dstRow0, uint16_t fillWord); +extern void iigsTileFillInner (uint8_t *dstRow0, uint16_t nibble); extern void iigsTileCopyInner (uint8_t *dstRow0, const uint8_t *srcRow0); extern void iigsTileCopyMaskedInner(uint8_t *dstRow0, const uint8_t *srcRow0, uint16_t transparent); extern void iigsTilePasteInner (uint8_t *dstRow0, const uint8_t *srcTilePixels); +extern void iigsTilePasteMonoInner(uint8_t *dstRow0, const uint8_t *monoTile, uint16_t fgColor, uint16_t bgColor); extern void iigsTileSnapInner (uint8_t *dstTilePixels, const uint8_t *srcRow0); extern void iigsFillRectInner (uint8_t *pixels, uint16_t x, uint16_t y, uint16_t w, uint16_t h, uint16_t nibble); extern void iigsFloodWalkAndScansInner(uint8_t *pixels, uint16_t x, uint16_t y, uint16_t matchColor, uint16_t newColor, uint16_t matchEqual, int16_t *stackX, int16_t *stackY, uint16_t *spInOut, uint16_t maxSp); @@ -70,6 +71,15 @@ extern uint16_t gFloodSeedMatch; extern uint16_t gFloodLeftX; extern uint16_t gFloodRightX; +// Stage identity for the stage-gated macros below: a direct extern +// read of surface.c's gStage (also declared in surfaceInternal.h -- +// duplicated here because port.h is included by TUs that never pull in +// surfaceInternal.h) instead of the jlStageGet() accessor, which costs +// a cross-segment JSL per primitive/span (PERF-AUDIT #10/#17). Same +// direct-extern precedent as codegenArenaInternal.h's arena globals. +// Read-only here. +extern jlSurfaceT *gStage; + // drawPixel: migrated to jlpDrawPixel. The asm plots into s->pixels for any // IIgs (chunky) surface; no generic defer needed. #define jlpDrawPixel(_s, _x, _y, _c) \ @@ -87,23 +97,23 @@ extern uint16_t gFloodRightX; iigsDrawCircleInner((_s)->pixels, (uint16_t)(_cx), (uint16_t)(_cy), \ (_r), (uint16_t)((_c) & 0x0F)) -// fillWord = doubled byte * $0101 = (nib*$11) * $101 = nib * $1111. -// Compile-time arithmetic when caller passes a constant; at most a -// single multiply when the nibble is variable (still cheaper than -// the wrapper's three sequential ORs / shifts). // fillCircle migrated to jlpFillCircle: stage-only asm, returns bool so the core // falls to the C span fill for non-stage / off-surface. JL_HAS_FILL_CIRCLE-gated. +// The asm derives its fill bytes from the raw nibble (PERF-AUDIT #41): the +// macro passes (c & 0x0F), never the old * 0x1111 fill word, which lowered +// to a software-multiply helper call for a runtime color (the only call +// site, draw.c, always passes a variable). The jlp layer owns the & 0x0F. #define jlpFillCircle(_s, _cx, _cy, _r, _c) \ - ((_s) == jlStageGet() \ + ((_s) == gStage \ ? (iigsFillCircleInner((_s)->pixels, (uint16_t)(_cx), (uint16_t)(_cy), \ - (_r), (uint16_t)(((_c) & 0x0F) * 0x1111)), \ + (_r), (uint16_t)((_c) & 0x0F)), \ true) \ : false) // surfaceClear: migrated to the jlp override model. The asm clears the // stage surface; non-stage surfaces defer to the portable-C generic default. #define jlpSurfaceClear(_s, _d) \ - ((_s) == jlStageGet() \ + ((_s) == gStage \ ? iigsSurfaceClearFastInner((_s)->pixels, \ (uint16_t)((uint16_t)(_d) | ((uint16_t)(_d) << 8))) \ : jlpGenericSurfaceClear((_s), (_d))) @@ -113,7 +123,7 @@ extern uint16_t gFloodRightX; // CORESYS load-segment migration drained _ROOT; earlier the macro form retripped // the bank-packing fragility.) Saves ~80 cyc/call. #define jlpFillRect(_s, _x, _y, _w, _h, _c) \ - ((_s) == jlStageGet() \ + ((_s) == gStage \ ? iigsFillRectInner((_s)->pixels, (uint16_t)(_x), (uint16_t)(_y), \ (uint16_t)(_w), (uint16_t)(_h), \ (uint16_t)((_c) & 0x0F)) \ @@ -123,11 +133,13 @@ extern uint16_t gFloodRightX; // forward the args. by/bx are tile coords -> bx*4 + by*8*160 byte // offset within the surface. Use SURFACE_ROW_OFFSET (LUT lookup) to // dodge a software multiply helper for the *160 multiply. -// tileFill: takes colorIndex; the asm derives the fill word ((c&0x0F) * 0x1111). +// tileFill: the macro passes the raw nibble (the jlp layer owns the & 0x0F); +// the asm derives the $NNNN fill word (PERF-AUDIT #41 -- the old C-side +// * 0x1111 lowered to a software-multiply helper for a runtime color). #define jlpTileFill(_s, _bx, _by, _c) \ iigsTileFillInner(&(_s)->pixels[SURFACE_ROW_OFFSET((uint16_t)(_by) << 3) \ + ((uint16_t)(_bx) << 2)], \ - (uint16_t)(((_c) & 0x0F) * 0x1111)) + (uint16_t)((_c) & 0x0F)) // tileCopy / tileCopyMasked: take surface+tile-coords; the asm macro derives // row-0 pointers (row = by*8 -> SURFACE_ROW_OFFSET(by<<3); col byte = bx*4 -> bx<<2). @@ -148,10 +160,23 @@ extern uint16_t gFloodRightX; #define jlpTileSnap(_s, _bx, _by, _out) \ iigsTileSnapInner((_out), &(_s)->pixels[SURFACE_ROW_OFFSET((uint16_t)(_by) << 3) + ((uint16_t)(_bx) << 2)]) +// tilePasteMono: fused mono colorize + paste in one asm pass (PERF-AUDIT #3) +// -- no intermediate jlTileT and no second paste call. Stage-only like the +// other stage-gated macros; non-stage surfaces defer to the portable-C +// generic (colorize + jlpTilePaste). fg/bg arrive PRE-MASKED to 4 bits (the +// public jlTilePasteMono wrapper owns the & 0x0F, PERF-AUDIT #69), so they +// are passed through un-re-masked per the dispatch-stanza contract below. +#define jlpTilePasteMono(_d, _bx, _by, _mt, _fg, _bg) \ + ((_d) == gStage \ + ? iigsTilePasteMonoInner(&(_d)->pixels[SURFACE_ROW_OFFSET((uint16_t)(_by) << 3) + ((uint16_t)(_bx) << 2)], \ + (_mt), (uint16_t)(_fg), (uint16_t)(_bg)) \ + : jlpGenericTilePasteMono((_d), (_bx), (_by), (_mt), (_fg), (_bg))) + // Flood: IIgs overrides only the all-in-one tier (JL_HAS_FLOOD_WALK_AND_SCANS). // The dispatch tail leaves jlpFloodWalkAndScans defined here; the planar flood -// hooks (jlpFloodWalkPlanes / jlpFloodScanRowPlanes) are Amiga-only and IIgs -// never declares them (their call sites are #if'd out for non-Amiga). +// hooks (jlpFloodWalkPlanes / jlpFloodScanRowPlanes) belong to the planar 68k +// ports (Amiga + Atari ST) and IIgs never declares them (their call sites are +// #if'd out on ports without the JL_HAS_FLOOD_*_PLANES flags). // // Tier-1 flood: multi-output. Asm sets gFloodSeedMatch / gFloodLeftX / // gFloodRightX; macro reads those into the caller's out-ptrs. @@ -176,10 +201,11 @@ extern uint16_t gFloodRightX; // it #define-s them to ((void)0): the call site is deleted at preprocess time // rather than paying a cross-segment JSL/RTL for a no-op. Arg lists match each // op's arity so the preprocessor parses and discards the exact call; all args -// are plain lvalues / field reads, so dropping them changes no behavior. (DOS -// is also chunky but links the real no-op generic -- it is not the perf -// reference. The tile/fillRect planar work is folded into each jlp op, so -// there are no separate planar hooks left to elide for those.) +// are plain lvalues / field reads, so dropping them changes no behavior. (The +// other chunky ports get the same elision from the shared chunky block after +// the IIgs section -- PERF-AUDIT #42. The tile/fillRect planar work is folded +// into each jlp op, so there are no separate planar hooks left to elide +// for those.) // ===================================================================== #define jlpSurfaceCopyPlanes(_dst, _src) ((void)0) @@ -189,8 +215,109 @@ extern uint16_t gFloodRightX; #define jlpSpriteSavePlanes(_s, _x, _y, _w, _h, _dst) ((void)0) #define jlpSpriteRestorePlanes(_s, _x, _y, _w, _h, _src) ((void)0) +// frameCount: straight to the GetTick asm wrapper (peislam.s). The hal.c +// C wrapper this replaces was one of two pure forwarding layers between +// jlFrameCount and the Misc Toolset GetTick call -- ~60-80 cycles each on +// a clock that game loops (and the UBER bench loop) poll constantly. +extern uint16_t iigsGetTickWord(void); +#define jlpFrameCount() iigsGetTickWord() + +// ===================================================================== +// JL_HAS registry consistency checks (PERF-AUDIT #26). +// +// The dispatch stanzas below short-circuit on #if !defined(jlp) +// before the JL_HAS_ flag is ever tested, and the core fast-path +// gates (JL_HAS_DRAW_LINE / DRAW_CIRCLE / FILL_CIRCLE / +// FLOOD_WALK_AND_SCANS in core/draw.c) consult only the flag. So a +// macro override above whose flag is missing from the +// include/joey/platform.h registry either turns that registry into +// false documentation or silently drops a core fast path back to +// generic C on the perf-reference machine. These checks make a +// missing/removed flag a compile failure instead: one #error per +// macro override defined above. (The reverse drift -- flag present, +// macro removed -- already fails loudly: the dispatch stanza then +// declares a function override that nothing defines.) Extend this +// list whenever a new IIgs macro override is added. +// +// Deliberately unchecked: jlpFrameCount (registry documents it as +// macro-only, no JL_HAS_FRAME_COUNT on IIgs) and jlpSurfaceCopyPlanes +// (JL_HAS_SURFACE_COPY_PLANES means a real planar function override; +// the IIgs elision above pairs with the JL_HAS_SPRITE_* trio instead). +// ===================================================================== + +#if !defined(JL_HAS_DRAW_PIXEL) + #error "IIgs jlpDrawPixel macro without JL_HAS_DRAW_PIXEL in platform.h" +#endif +#if !defined(JL_HAS_DRAW_LINE) + #error "IIgs jlpDrawLine macro without JL_HAS_DRAW_LINE in platform.h" +#endif +#if !defined(JL_HAS_DRAW_CIRCLE) + #error "IIgs jlpDrawCircle macro without JL_HAS_DRAW_CIRCLE in platform.h" +#endif +#if !defined(JL_HAS_FILL_CIRCLE) + #error "IIgs jlpFillCircle macro without JL_HAS_FILL_CIRCLE in platform.h" +#endif +#if !defined(JL_HAS_SURFACE_CLEAR) + #error "IIgs jlpSurfaceClear macro without JL_HAS_SURFACE_CLEAR in platform.h" +#endif +#if !defined(JL_HAS_FILL_RECT) + #error "IIgs jlpFillRect macro without JL_HAS_FILL_RECT in platform.h" +#endif +#if !defined(JL_HAS_TILE_FILL) + #error "IIgs jlpTileFill macro without JL_HAS_TILE_FILL in platform.h" +#endif +#if !defined(JL_HAS_TILE_COPY) + #error "IIgs jlpTileCopy macro without JL_HAS_TILE_COPY in platform.h" +#endif +#if !defined(JL_HAS_TILE_COPY_MASKED) + #error "IIgs jlpTileCopyMasked macro without JL_HAS_TILE_COPY_MASKED in platform.h" +#endif +#if !defined(JL_HAS_TILE_PASTE) + #error "IIgs jlpTilePaste macro without JL_HAS_TILE_PASTE in platform.h" +#endif +#if !defined(JL_HAS_TILE_PASTE_MONO) + #error "IIgs jlpTilePasteMono macro without JL_HAS_TILE_PASTE_MONO in platform.h" +#endif +#if !defined(JL_HAS_TILE_SNAP) + #error "IIgs jlpTileSnap macro without JL_HAS_TILE_SNAP in platform.h" +#endif +#if !defined(JL_HAS_SPRITE_DRAW) + #error "IIgs jlpSpriteDrawPlanes elision without JL_HAS_SPRITE_DRAW in platform.h" +#endif +#if !defined(JL_HAS_SPRITE_SAVE) + #error "IIgs jlpSpriteSavePlanes elision without JL_HAS_SPRITE_SAVE in platform.h" +#endif +#if !defined(JL_HAS_SPRITE_RESTORE) + #error "IIgs jlpSpriteRestorePlanes elision without JL_HAS_SPRITE_RESTORE in platform.h" +#endif +#if !defined(JL_HAS_FLOOD_WALK_AND_SCANS) + #error "IIgs jlpFloodWalkAndScans macro without JL_HAS_FLOOD_WALK_AND_SCANS in platform.h" +#endif + #endif /* JOEYLIB_PLATFORM_IIGS */ +// ===================================================================== +// Chunky planar-hook elision for the other chunky ports (PERF-AUDIT #42). +// +// DOS and the blank template are chunky-native like the IIgs: the planar +// dual-write hooks have no bitplanes to mirror into. Elide them to +// ((void)0) exactly like the IIgs block above instead of paying a real +// call into the empty generic bodies (genericSprite.c and +// jlpGenericSurfaceCopyPlanes) on every sprite draw/save/restore and +// surface copy. Those generic no-op bodies stay in the build only as the +// link-time safety net for a new planar port that has not implemented +// its hooks yet. +// ===================================================================== + +#if defined(JOEYLIB_NATIVE_CHUNKY) && !defined(JOEYLIB_PLATFORM_IIGS) + +#define jlpSurfaceCopyPlanes(_dst, _src) ((void)0) +#define jlpSpriteDrawPlanes(_s, _sp, _x, _y) ((void)0) +#define jlpSpriteSavePlanes(_s, _x, _y, _w, _h, _dst) ((void)0) +#define jlpSpriteRestorePlanes(_s, _x, _y, _w, _h, _src) ((void)0) + +#endif /* JOEYLIB_NATIVE_CHUNKY && !JOEYLIB_PLATFORM_IIGS */ + // ===================================================================== // Migrated-op dispatch (generic default unless a machine overrides). // @@ -289,6 +416,11 @@ void jlpGenericTileSnap(const jlSurfaceT *src, uint8_t bx, uint8_t by, uint8_t * #endif #endif +// tilePasteMono color-masking contract (PERF-AUDIT #69): fgColor/bgColor +// arrive already masked to 4 bits -- the public jlTilePasteMono wrapper owns +// the & 0x0F. Neither the generic nor a machine override may re-mask. +// (tileFill is the opposite: its wrapper does NOT mask, so the jlp layer owns +// the & 0x0F there -- see the IIgs macro above and the planar overrides.) void jlpGenericTilePasteMono(jlSurfaceT *dst, uint8_t bx, uint8_t by, const uint8_t *monoTile, uint8_t fgColor, uint8_t bgColor); #if !defined(jlpTilePasteMono) #if defined(JL_HAS_TILE_PASTE_MONO) diff --git a/src/core/present.c b/src/core/present.c index 92ab005..f1ae6f4 100644 --- a/src/core/present.c +++ b/src/core/present.c @@ -17,7 +17,9 @@ void jlStagePresent(void) { jlSurfaceT *stage; - stage = jlStageGet(); + // Direct extern read (finding #10/#17 convention): once per present, + // so this is consistency rather than a hot-path win. + stage = gStage; if (stage == NULL) { return; } diff --git a/src/core/random.c b/src/core/random.c index e9eeef6..d824b22 100644 --- a/src/core/random.c +++ b/src/core/random.c @@ -13,11 +13,43 @@ // Initialized to 1 so jlRandom() works without an explicit seed call // and never starts from xorshift's degenerate all-zero state. +// +// IIgs stores the state as two uint16_t halves (PERF-AUDIT #43): the +// 65816 has no 32-bit shifts, so the 13/17/5 triple lowered to per-bit +// shift helper loops (~400-700 cyc/call). The identical update on +// 16-bit halves is ~2-3x cheaper and BIT-IDENTICAL (host-proven across +// the UBER golden sequence + a 50M-state sweep), preserving the +// cross-port determinism contract in core.h. GCC ports keep the 32-bit +// form: 68000 lsl.l/lsr.l are native, so the halves form is a +// wash-to-slight-loss there (audit verifier correction). +#if defined(JOEYLIB_PLATFORM_IIGS) +static uint16_t gRngLo = 1u; +static uint16_t gRngHi = 0u; +#else static uint32_t gRngState = 1u; +#endif // ----- Public API (alphabetical) ----- uint32_t jlRandom(void) { +#if defined(JOEYLIB_PLATFORM_IIGS) + uint16_t lo = gRngLo; + uint16_t hi = gRngHi; + + // x ^= x << 13: (x << 13) halves are { hi<<13 | lo>>3, lo<<13 }. + // Each step updates hi BEFORE lo -- the hi identity reads the + // step's incoming lo. + hi = (uint16_t)(hi ^ (uint16_t)((uint16_t)(hi << 13) | (uint16_t)(lo >> 3))); + lo = (uint16_t)(lo ^ (uint16_t)(lo << 13)); + // x ^= x >> 17: (x >> 17) halves are { 0, hi>>1 } -- low half only. + lo = (uint16_t)(lo ^ (uint16_t)(hi >> 1)); + // x ^= x << 5: (x << 5) halves are { hi<<5 | lo>>11, lo<<5 }. + hi = (uint16_t)(hi ^ (uint16_t)((uint16_t)(hi << 5) | (uint16_t)(lo >> 11))); + lo = (uint16_t)(lo ^ (uint16_t)(lo << 5)); + gRngLo = lo; + gRngHi = hi; + return ((uint32_t)hi << 16) | lo; +#else uint32_t x = gRngState; x ^= x << 13; @@ -25,6 +57,7 @@ uint32_t jlRandom(void) { x ^= x << 5; gRngState = x; return x; +#endif } @@ -42,5 +75,13 @@ uint16_t jlRandomRange(uint16_t bound) { void jlRandomSeed(uint32_t seed) { - gRngState = (seed == 0u) ? 1u : seed; + if (seed == 0u) { + seed = 1u; + } +#if defined(JOEYLIB_PLATFORM_IIGS) + gRngLo = (uint16_t)seed; + gRngHi = (uint16_t)(seed >> 16); +#else + gRngState = seed; +#endif } diff --git a/src/core/scb.c b/src/core/scb.c index 42490cd..ebd8b0f 100644 --- a/src/core/scb.c +++ b/src/core/scb.c @@ -28,7 +28,7 @@ void jlScbSet(jlSurfaceT *s, uint16_t line, uint8_t paletteIndex) { return; } s->scb[line] = paletteIndex; - if (s == jlStageGet()) { + if (s == gStage) { gStageScbDirty = true; } } @@ -61,7 +61,7 @@ void jlScbSetRange(jlSurfaceT *s, uint16_t firstLine, uint16_t lastLine, uint8_t // Amiga/ST/DOS libc memset is already vectorized. Either way, much // tighter than the C loop. memset(&s->scb[firstLine], paletteIndex, (size_t)(last - firstLine + 1)); - if (s == jlStageGet()) { + if (s == gStage) { gStageScbDirty = true; } } diff --git a/src/core/sprite.c b/src/core/sprite.c index 30d1324..f2c0931 100644 --- a/src/core/sprite.c +++ b/src/core/sprite.c @@ -15,11 +15,13 @@ #include "surfaceInternal.h" + // 8x8 tile geometry constants come from joey/tile.h -- one source of // truth for the 4bpp packed layout (32 bytes/tile, 4 bytes/row, 8 px/side). -// Color 0 is always transparent for sprites (DESIGN.md contract). -#define TRANSPARENT_NIBBLE 0 +// Color 0 is always transparent for sprites (DESIGN.md contract); +// TRANSPARENT_NIBBLE comes from spriteInternal.h, shared with every +// per-CPU emitter. // On Amiga (post-Phase 9 / Phase 6 redux) the compiled sprite emitter // writes directly to the bitplanes, so the jlpSprite*Planes hooks are @@ -40,13 +42,33 @@ #endif +// ----- Sprite registry (PERF-AUDIT.md #34) ----- +// +// jlShutdown frees the codegen arena (codegenArenaShutdown) while +// user-owned sprites may still hold ArenaSlotT pointers. If such a +// sprite survives a shutdown -> re-init cycle, jlSpriteDestroy would +// free a stale slot (heap corruption) and jlSpriteDraw would execute +// stale offsets inside the NEW arena. The registry tracks live +// sprites so spriteSystemShutdown (called from jlShutdown BEFORE +// codegenArenaShutdown) can reset each one's compiled-path state; the +// arena bytes themselves are released by codegenArenaShutdown. +// +// Fixed cap; registration is best-effort. Sprites beyond the cap are +// simply not registered and MUST be destroyed before jlShutdown -- +// which matches the implicit contract every sprite had before the +// registry existed. +#define SPRITE_REGISTRY_CAP 64 + +static jlSpriteT *gSpriteRegistry[SPRITE_REGISTRY_CAP]; + + // ----- Prototypes ----- -static void writeDstNibble(uint8_t *row, int16_t x, uint8_t nibble); static bool clipRect(int16_t *dstX, int16_t *dstY, int16_t *srcX, int16_t *srcY, int16_t *w, int16_t *h); static bool isFullyOnSurface(int16_t x, int16_t y, uint16_t widthPx, uint16_t heightPx); -static void spriteInitFields(jlSpriteT *sp, const uint8_t *tileData, uint8_t widthTiles, uint8_t heightTiles, bool ownsTileData); static void spriteDrawInterpreted(jlSurfaceT *s, jlSpriteT *sp, int16_t x, int16_t y); +static void spriteRegistryRemove(jlSpriteT *sp); +static void spriteResetCompiledState(jlSpriteT *sp); // ----- Internal helpers (alphabetical) ----- @@ -84,46 +106,15 @@ static bool clipRect(int16_t *dstX, int16_t *dstY, int16_t *srcX, int16_t *srcY, } -static void writeDstNibble(uint8_t *row, int16_t x, uint8_t nibble) { - uint8_t *byte; - - byte = &row[x >> 1]; - if (x & 1) { - *byte = (uint8_t)((*byte & 0xF0) | (nibble & 0x0F)); - } else { - *byte = (uint8_t)((*byte & 0x0F) | ((nibble & 0x0F) << 4)); - } -} - - +// Delegates to the shared 16-bit on-surface predicate (PERF-AUDIT +// #44/#62) instead of the old 32-bit add+compare pair. Safe within the +// macro's contract: every sprite creation path rejects 0 tiles, so +// widthPx/heightPx are at least 8 (w >= 1 holds), and they are bounded +// by 2040 (255 tiles * 8), so the macro's guarded w <= SURFACE_WIDTH / +// h <= SURFACE_HEIGHT form rejects oversized sprites before the +// 16-bit subtractions could wrap. static bool isFullyOnSurface(int16_t x, int16_t y, uint16_t widthPx, uint16_t heightPx) { - if (x < 0 || y < 0) { - return false; - } - if ((int32_t)x + widthPx > SURFACE_WIDTH) { - return false; - } - if ((int32_t)y + heightPx > SURFACE_HEIGHT) { - return false; - } - return true; -} - - -// Initialize a freshly-allocated jlSpriteT's fields to the -// uncompiled/default state. routineOffsets is filled with 0xFF so -// every (shift, op) entry reads as SPRITE_NOT_COMPILED (0xFFFF); 0 -// would be a VALID offset and could dispatch into stale arena bytes. -static void spriteInitFields(jlSpriteT *sp, const uint8_t *tileData, uint8_t widthTiles, uint8_t heightTiles, bool ownsTileData) { - sp->tileData = tileData; - sp->widthTiles = widthTiles; - sp->heightTiles = heightTiles; - sp->ownsTileData = ownsTileData; - sp->slot = NULL; - memset(sp->routineOffsets, 0xFF, sizeof(sp->routineOffsets)); - memset(sp->cachedDstBank, 0xFF, sizeof(sp->cachedDstBank)); - memset(sp->cachedSrcBank, 0xFF, sizeof(sp->cachedSrcBank)); - memset(sp->cachedSizeBytes, 0, sizeof(sp->cachedSizeBytes)); + return SURFACE_RECT_ON_SURFACE(x, y, widthPx, heightPx); } @@ -132,71 +123,195 @@ static void spriteInitFields(jlSpriteT *sp, const uint8_t *tileData, uint8_t wid // extend off the surface. // // The source sprite is tile-major (8x8 tiles, 4 bytes/row, consecutive -// tile columns 32 bytes apart). Rather than recompute tile/in-tile -// coordinates per pixel (the old srcNibble did two divisions + a -// multiply EVERY pixel), this walks one source-tile-row pointer that -// advances by TILE_BYTES each time the column crosses an 8-pixel tile -// boundary, so the inner loop is a byte read + nibble extract with no -// division. ~5x on the interpreted path (the every-edge-clip fallback). +// tile columns 32 bytes apart). All row-invariant source math is +// hoisted above the row loop (#45): the tile-band byte offset is +// seeded with the single (sy >> 3) * bandStride multiply, then +// maintained incrementally as inTileY wraps, so no multiply helper +// runs per row. The destination byte pointer and nibble parity are +// carried incrementally per pixel (#2) instead of being rederived +// through a per-pixel function call. +// +// When source and destination share nibble parity (the aligned phase, +// #18) the inner loop runs byte-at-a-time, chunked per tile column +// (up to TILE_BYTES_PER_ROW contiguous source bytes, then +// srcTileRow += TILE_BYTES): a 0x00 source byte skips both pixels, a +// both-nibbles-opaque byte is one whole-byte store, and a single +// opaque nibble merges read-modify-write. The odd leading/trailing +// pixels are handled outside the byte loop. The misaligned phase +// keeps the per-pixel walker. +#if TRANSPARENT_NIBBLE != 0 +#error "spriteDrawInterpreted's aligned byte path assumes TRANSPARENT_NIBBLE == 0" +#endif static void spriteDrawInterpreted(jlSurfaceT *s, jlSpriteT *sp, int16_t x, int16_t y) { - int16_t dx; - int16_t dy; - int16_t sx; - int16_t sy; - int16_t w; - int16_t h; - int16_t row; - uint8_t wTiles; + int16_t dx; + int16_t dy; + int16_t sx; + int16_t sy; + int16_t w; + int16_t h; dx = x; dy = y; - w = (int16_t)(sp->widthTiles * TILE_PIXELS_PER_SIDE); - h = (int16_t)(sp->heightTiles * TILE_PIXELS_PER_SIDE); + w = (int16_t)sp->widthPx; + h = (int16_t)sp->heightPx; if (!clipRect(&dx, &dy, &sx, &sy, &w, &h)) { return; } - wTiles = sp->widthTiles; - /* Skip the chunky write loop on planar ports (s->pixels == NULL). - * jlpSpriteDrawPlanes is called by the jlSpriteDraw caller and does - * its own clip + plane write, so the dirty mark + planar update - * happen there. Phase 9 dropped the chunky shadow on Amiga. */ + // Skip the chunky write loop on planar ports (s->pixels == NULL). + // jlpSpriteDrawPlanes is called by the jlSpriteDraw caller and does + // its own clip + plane write, so the dirty mark + planar update + // happen there. Phase 9 dropped the chunky shadow on Amiga. if (s->pixels != NULL) { + uint16_t bandStride; + uint16_t bandBase; + uint16_t inTileY; + uint16_t inTileXStart; + uint16_t srcColBase; + uint16_t dstByteStart; + uint16_t leadOdd; + uint16_t trailing; + int16_t pairCount; + int16_t row; + bool aligned; + + // Row-invariant hoists (#45). Shifts dodge multiply helpers + // (TILE_BYTES == 1<<5, TILE_BYTES_PER_ROW == 1<<2); only the + // bandBase seed pays a multiply, once per draw. + bandStride = (uint16_t)((uint16_t)sp->widthTiles << 5); + bandBase = (uint16_t)((uint16_t)((uint16_t)sy >> 3) * bandStride); + inTileY = (uint16_t)((uint16_t)sy & 7u); + inTileXStart = (uint16_t)((uint16_t)sx & 7u); + srcColBase = (uint16_t)(((uint16_t)((uint16_t)sx >> 3)) << 5); + dstByteStart = (uint16_t)((uint16_t)dx >> 1); + // Aligned phase (#18): src and dst nibble parity match for the + // whole draw, so source pixel pairs map to whole dst bytes. + // leadOdd/pairCount/trailing split each row into an odd + // leading pixel, whole bytes, and an odd trailing pixel. + aligned = (((dx ^ sx) & 1) == 0); + leadOdd = (uint16_t)((uint16_t)dx & 1u); + pairCount = (int16_t)((w - (int16_t)leadOdd) >> 1); + trailing = (uint16_t)((uint16_t)(w - (int16_t)leadOdd) & 1u); + for (row = 0; row < h; row++) { - uint8_t *dstRow; const uint8_t *srcTileRow; - uint16_t srcY; - uint16_t tileRowBase; - uint16_t inTileX; - int16_t col; + uint8_t *dst; - dstRow = &s->pixels[SURFACE_ROW_OFFSET((uint16_t)(dy + row))]; - srcY = (uint16_t)(sy + row); - /* Byte offset to tile-column 0's data for this pixel row: - * (tileY * wTiles) tiles in, each TILE_BYTES, plus the - * in-tile row. Shifts dodge multiply helpers - * (TILE_BYTES == 1<<5, TILE_BYTES_PER_ROW == 1<<2). */ - tileRowBase = (uint16_t)((((uint16_t)(srcY >> 3) * wTiles) << 5) - + ((srcY & 7u) << 2)); - inTileX = (uint16_t)((uint16_t)sx & 7u); - srcTileRow = sp->tileData + tileRowBase - + (((uint16_t)((uint16_t)sx >> 3)) << 5); + srcTileRow = sp->tileData + bandBase + (inTileY << 2) + srcColBase; + dst = &s->pixels[SURFACE_ROW_OFFSET((uint16_t)(dy + row)) + dstByteStart]; - for (col = 0; col < w; col++) { - uint8_t byte; - uint8_t nibble; + if (aligned) { + uint16_t inTileX; + int16_t pairsLeft; - byte = srcTileRow[inTileX >> 1]; - nibble = (inTileX & 1u) ? (uint8_t)(byte & 0x0Fu) - : (uint8_t)(byte >> 4); - if (nibble != TRANSPARENT_NIBBLE) { - writeDstNibble(dstRow, (int16_t)(dx + col), nibble); + inTileX = inTileXStart; + // Odd leading pixel: low nibble of both src and dst. + if (leadOdd != 0u) { + uint8_t nibble; + + nibble = (uint8_t)(srcTileRow[inTileX >> 1] & 0x0Fu); + if (nibble != TRANSPARENT_NIBBLE) { + *dst = (uint8_t)((*dst & 0xF0u) | nibble); + } + dst++; + inTileX++; + if (inTileX == TILE_PIXELS_PER_SIDE) { + inTileX = 0; + srcTileRow += TILE_BYTES; + } } - inTileX++; - if (inTileX == TILE_PIXELS_PER_SIDE) { - inTileX = 0; - srcTileRow += TILE_BYTES; + // Byte loop, chunked per tile column: up to 4 + // contiguous source bytes, then hop to the next tile. + pairsLeft = pairCount; + while (pairsLeft > 0) { + const uint8_t *src; + int16_t run; + int16_t i; + + run = (int16_t)((TILE_PIXELS_PER_SIDE - inTileX) >> 1); + if (run > pairsLeft) { + run = pairsLeft; + } + src = srcTileRow + (inTileX >> 1); + for (i = 0; i < run; i++) { + uint8_t b; + + b = *src++; + if (b == 0x00u) { + // Two TRANSPARENT_NIBBLEs: skip both. + dst++; + } else if ((b & 0xF0u) != 0u && (b & 0x0Fu) != 0u) { + // Both nibbles opaque: whole-byte store. + *dst = b; + dst++; + } else if ((b & 0x0Fu) != 0u) { + // Low nibble opaque (high is 0, so | b works). + *dst = (uint8_t)((*dst & 0xF0u) | b); + dst++; + } else { + // High nibble opaque (low is 0). + *dst = (uint8_t)((*dst & 0x0Fu) | b); + dst++; + } + } + pairsLeft = (int16_t)(pairsLeft - run); + inTileX = (uint16_t)(inTileX + ((uint16_t)run << 1)); + if (inTileX == TILE_PIXELS_PER_SIDE) { + inTileX = 0; + srcTileRow += TILE_BYTES; + } } + // Even trailing pixel: high nibble of both src and dst. + if (trailing != 0u) { + uint8_t b; + + b = (uint8_t)(srcTileRow[inTileX >> 1] & 0xF0u); + if (b != 0u) { + *dst = (uint8_t)((*dst & 0x0Fu) | b); + } + } + } else { + // Misaligned phase: per-pixel walker with the dst byte + // pointer and parity carried incrementally (#2). + uint16_t inTileX; + uint16_t dstOdd; + int16_t col; + + inTileX = inTileXStart; + dstOdd = leadOdd; + for (col = 0; col < w; col++) { + uint8_t byte; + uint8_t nibble; + + byte = srcTileRow[inTileX >> 1]; + nibble = (inTileX & 1u) ? (uint8_t)(byte & 0x0Fu) : (uint8_t)(byte >> 4); + if (nibble != TRANSPARENT_NIBBLE) { + if (dstOdd != 0u) { + *dst = (uint8_t)((*dst & 0xF0u) | nibble); + } else { + *dst = (uint8_t)((*dst & 0x0Fu) | (uint8_t)(nibble << 4)); + } + } + if (dstOdd != 0u) { + dst++; + dstOdd = 0; + } else { + dstOdd = 1; + } + inTileX++; + if (inTileX == TILE_PIXELS_PER_SIDE) { + inTileX = 0; + srcTileRow += TILE_BYTES; + } + } + } + + // Advance the tile band incrementally (#45): the bandBase + // multiply above never reruns. + inTileY++; + if (inTileY == TILE_PIXELS_PER_SIDE) { + inTileY = 0; + bandBase = (uint16_t)(bandBase + bandStride); } } } @@ -204,6 +319,36 @@ static void spriteDrawInterpreted(jlSurfaceT *s, jlSpriteT *sp, int16_t x, int16 } +// Drop a sprite from the registry. No-op if it was never registered +// (registry full at creation time). +static void spriteRegistryRemove(jlSpriteT *sp) { + uint8_t i; + + for (i = 0; i < SPRITE_REGISTRY_CAP; i++) { + if (gSpriteRegistry[i] == sp) { + gSpriteRegistry[i] = NULL; + return; + } + } +} + + +// Reset a sprite's compiled-path state to "never compiled". +// routineOffsets is filled with 0xFF so every (shift, op) entry reads +// as SPRITE_NOT_COMPILED (0xFFFF); 0 would be a VALID offset and could +// dispatch into stale arena bytes. The IIgs MVN bank caches return to +// 0xFF ("never patched") and cachedSizeBytes to 0 ("uncached") so a +// recompile after arena teardown cannot skip a patch based on stale +// cache state. +static void spriteResetCompiledState(jlSpriteT *sp) { + sp->slot = NULL; + memset(sp->routineOffsets, 0xFF, sizeof(sp->routineOffsets)); + memset(sp->cachedDstBank, 0xFF, sizeof(sp->cachedDstBank)); + memset(sp->cachedSrcBank, 0xFF, sizeof(sp->cachedSrcBank)); + memset(sp->cachedSizeBytes, 0, sizeof(sp->cachedSizeBytes)); +} + + // ----- Public API (alphabetical) ----- jlSpriteT *jlSpriteCreate(const uint8_t *tileData, uint8_t widthTiles, uint8_t heightTiles) { @@ -212,7 +357,12 @@ jlSpriteT *jlSpriteCreate(const uint8_t *tileData, uint8_t widthTiles, uint8_t h if (tileData == NULL || widthTiles == 0 || heightTiles == 0) { return NULL; } - sp = (jlSpriteT *)malloc(sizeof(jlSpriteT)); + /* jlpBigAlloc, NOT the C heap: the IIgs C heap is a few hundred + * bytes of unprotected bank-0 leftover; at exhaustion its malloc + * returns garbage instead of NULL (PERF-AUDIT.md #81). Other ports + * map jlpBigAlloc to malloc. Applies to every sprite-struct and + * owned-tileData allocation in this file (finding #31). */ + sp = (jlSpriteT *)jlpBigAlloc((uint32_t)sizeof(jlSpriteT)); if (sp == NULL) { return NULL; } @@ -252,7 +402,7 @@ jlSpriteT *jlSpriteCreateFromSurface(const jlSurfaceT *src, int16_t x, int16_t y } tileBytes = (uint32_t)widthTiles * heightTiles * TILE_BYTES; - buf = (uint8_t *)malloc(tileBytes); + buf = (uint8_t *)jlpBigAlloc(tileBytes); if (buf == NULL) { return NULL; } @@ -303,9 +453,9 @@ jlSpriteT *jlSpriteCreateFromSurface(const jlSurfaceT *src, int16_t x, int16_t y } } - sp = (jlSpriteT *)malloc(sizeof(jlSpriteT)); + sp = (jlSpriteT *)jlpBigAlloc((uint32_t)sizeof(jlSpriteT)); if (sp == NULL) { - free(buf); + jlpBigFree(buf); return NULL; } spriteInitFields(sp, buf, widthTiles, heightTiles, true); @@ -317,14 +467,15 @@ void jlSpriteDestroy(jlSpriteT *sp) { if (sp == NULL) { return; } + spriteRegistryRemove(sp); if (sp->slot != NULL) { codegenArenaFree(sp->slot); sp->slot = NULL; } if (sp->ownsTileData) { - free((void *)sp->tileData); + jlpBigFree((void *)sp->tileData); } - free(sp); + jlpBigFree(sp); } @@ -335,21 +486,37 @@ void jlSpriteDraw(jlSurfaceT *s, jlSpriteT *sp, int16_t x, int16_t y) { if (s == NULL || sp == NULL) { return; } - widthPx = (uint16_t)(sp->widthTiles * TILE_PIXELS_PER_SIDE); - heightPx = (uint16_t)(sp->heightTiles * TILE_PIXELS_PER_SIDE); + widthPx = sp->widthPx; + heightPx = sp->heightPx; // Fast path: compiled bytes + fully on surface. Off-surface draws // fall back to the interpreter so the compiled routines never - // need clip math (they walk fixed offsets). On IIgs sp->slot is left - // NULL by jlSpriteCompile (see #19 there), so this is never taken and - // every op runs the interpreter. + // need clip math (they walk fixed offsets). The DRAW offset for + // this shift is read once and gated against SPRITE_NOT_COMPILED + // (#46): a slot whose DRAW emitter returned 0 bytes for this shift + // must never be jumped into (spriteCompiledDraw adds the offset to + // the slot address unchecked on IIgs/DOS). Mirrors the SaveUnder / + // RestoreUnder gates. This compiled branch is the production path + // on every port with a wired emitter; the interpreter is only the + // uncompiled/clip-edge fallback (#63). Exception, temporary: while + // JOEY_IIGS_SPRITE_CODEGEN is 0 (toolchain-owned arena-placement + // bug, see #19 / #77 in spriteCompile.c) jlSpriteCompile leaves + // sp->slot NULL on IIgs, so that port interprets until the fix + // lands -- do NOT treat this branch as dead code there. if (sp->slot != NULL && isFullyOnSurface(x, y, widthPx, heightPx)) { - spriteCompiledDraw(s, sp, x, y); - if (!COMPILED_SPRITE_WRITES_PLANES) { - jlpSpriteDrawPlanes(s, sp, x, y); + uint8_t shift; + uint16_t routeOffset; + + shift = SPRITE_SHIFT_INDEX(x); + routeOffset = SPRITE_ROUTE_OFFSET(sp, shift, SPRITE_OP_DRAW); + if (routeOffset != SPRITE_NOT_COMPILED) { + spriteCompiledDraw(s, sp, x, y, routeOffset); + if (!COMPILED_SPRITE_WRITES_PLANES) { + jlpSpriteDrawPlanes(s, sp, x, y); + } + surfaceMarkDirtyRect(s, x, y, (int16_t)widthPx, (int16_t)heightPx); + return; } - surfaceMarkDirtyRect(s, x, y, (int16_t)widthPx, (int16_t)heightPx); - return; } spriteDrawInterpreted(s, sp, x, y); jlpSpriteDrawPlanes(s, sp, x, y); @@ -377,10 +544,10 @@ uint32_t jlSpriteCompiledSize(const jlSpriteT *sp) { void jlSpriteSaveAndDraw(jlSurfaceT *s, jlSpriteT *sp, int16_t x, int16_t y, jlSpriteBackupT *backup) { uint16_t widthPx; uint16_t heightPx; - uint8_t wTiles; - uint8_t hTiles; ArenaSlotT *slot; uint8_t shift; + uint16_t saveOffset; + uint16_t drawOffset; if (s == NULL || sp == NULL || backup == NULL) { return; @@ -388,31 +555,23 @@ void jlSpriteSaveAndDraw(jlSurfaceT *s, jlSpriteT *sp, int16_t x, int16_t y, jlS backup->sprite = sp; backup->sizeBytes = 0; - wTiles = sp->widthTiles; - hTiles = sp->heightTiles; - slot = sp->slot; - - widthPx = (uint16_t)(wTiles * TILE_PIXELS_PER_SIDE); - heightPx = (uint16_t)(hTiles * TILE_PIXELS_PER_SIDE); + slot = sp->slot; + widthPx = sp->widthPx; + heightPx = sp->heightPx; // Fast path: compiled bytes available, fully on surface, backup // buffer supplied. Save fills out backup->{x,y,width,height, // sizeBytes}; draw reuses (x,y,widthPx,heightPx) for the dirty // mark. One mark instead of two (save doesn't dirty -- it's a - // read; only draw dirties). + // read; only draw dirties). shift and both route offsets are + // derived once here and handed to the callees (#29). if (slot != NULL && backup->bytes != NULL && isFullyOnSurface(x, y, widthPx, heightPx)) { - /* Byte-pointer arithmetic dodges ~MUL4 for 2D-array indexing. */ - uint16_t saveIdx; - uint16_t drawIdx; - uint8_t *offsetsBase; - shift = SPRITE_SHIFT_INDEX(x); - saveIdx = (uint16_t)(((uint16_t)shift << 1) + (uint16_t)shift + SPRITE_OP_SAVE); - drawIdx = (uint16_t)(((uint16_t)shift << 1) + (uint16_t)shift + SPRITE_OP_DRAW); - offsetsBase = (uint8_t *)sp->routineOffsets; - if (*(uint16_t *)(offsetsBase + (saveIdx << 1)) != SPRITE_NOT_COMPILED && - *(uint16_t *)(offsetsBase + (drawIdx << 1)) != SPRITE_NOT_COMPILED) { - spriteCompiledSaveUnder(s, sp, x, y, backup); - spriteCompiledDraw (s, sp, x, y); + shift = SPRITE_SHIFT_INDEX(x); + saveOffset = SPRITE_ROUTE_OFFSET(sp, shift, SPRITE_OP_SAVE); + drawOffset = SPRITE_ROUTE_OFFSET(sp, shift, SPRITE_OP_DRAW); + if (saveOffset != SPRITE_NOT_COMPILED && drawOffset != SPRITE_NOT_COMPILED) { + spriteCompiledSaveUnder(s, sp, x, y, backup, shift, saveOffset); + spriteCompiledDraw (s, sp, x, y, drawOffset); if (!COMPILED_SPRITE_WRITES_PLANES) { jlpSpriteSavePlanes(s, backup->x, backup->y, backup->width, backup->height, backup->bytes); jlpSpriteDrawPlanes(s, sp, x, y); @@ -481,10 +640,9 @@ void jlSpriteRestoreUnder(jlSurfaceT *s, const jlSpriteBackupT *backup) { } sp = backup->sprite; - if (sp != NULL && sp->slot != NULL && bh == sp->heightTiles * TILE_PIXELS_PER_SIDE) { - uint16_t routeIdx; + if (sp != NULL && sp->slot != NULL && bh == sp->heightPx) { uint16_t routeOffset; - spriteBytesPerRow = (uint16_t)(sp->widthTiles * TILE_BYTES_PER_ROW); + spriteBytesPerRow = (uint16_t)(sp->widthPx >> 1); copyBytes = (int16_t)(bw >> 1); // The compiled restore routine walks a FIXED per-row byte count // derived purely from the chosen shift (spriteBytesPerRow for @@ -495,12 +653,10 @@ void jlSpriteRestoreUnder(jlSurfaceT *s, const jlSpriteBackupT *backup) { // fall through to the interpreted memcpy that honors copyBytes. if (copyBytes == (int16_t)spriteBytesPerRow || copyBytes == (int16_t)(spriteBytesPerRow + 1)) { - shift = (copyBytes == (int16_t)spriteBytesPerRow) ? 0 : 1; - /* Byte-pointer arithmetic dodges ~MUL4 for 2D-array indexing. */ - routeIdx = (uint16_t)(((uint16_t)shift << 1) + (uint16_t)shift + SPRITE_OP_RESTORE); - routeOffset = *(uint16_t *)((uint8_t *)sp->routineOffsets + (routeIdx << 1)); + shift = (copyBytes == (int16_t)spriteBytesPerRow) ? 0 : 1; + routeOffset = SPRITE_ROUTE_OFFSET(sp, shift, SPRITE_OP_RESTORE); if (routeOffset != SPRITE_NOT_COMPILED) { - spriteCompiledRestoreUnder(s, backup); + spriteCompiledRestoreUnder(s, backup, shift, routeOffset); if (!COMPILED_SPRITE_WRITES_PLANES) { jlpSpriteRestorePlanes(s, bx, by, bw, bh, backup->bytes); } @@ -514,17 +670,21 @@ void jlSpriteRestoreUnder(jlSurfaceT *s, const jlSpriteBackupT *backup) { * the port has no chunky shadow (Phase 9 Amiga: s->pixels NULL); * jlpSpriteRestorePlanes below does the planar restore. */ if (s->pixels != NULL) { - int16_t row; - int16_t byteStart; - uint8_t *dstRow; + int16_t row; + uint8_t *dstRow; + const uint8_t *srcRow; - byteStart = (int16_t)(bx >> 1); + // #19: seed both row cursors once -- SURFACE_ROW_OFFSET is the + // blessed row-address mechanism (LUT read on IIgs, shift-add + // elsewhere) -- then walk them with constant-stride adds so no + // multiply helper runs per row. copyBytes = (int16_t)(bw >> 1); + dstRow = &s->pixels[SURFACE_ROW_OFFSET((uint16_t)by) + (uint16_t)(bx >> 1)]; + srcRow = backup->bytes; for (row = 0; row < (int16_t)bh; row++) { - dstRow = &s->pixels[(by + row) * SURFACE_BYTES_PER_ROW]; - memcpy(&dstRow[byteStart], - &backup->bytes[(uint16_t)row * (uint16_t)copyBytes], - (size_t)copyBytes); + memcpy(dstRow, srcRow, (size_t)copyBytes); + dstRow += SURFACE_BYTES_PER_ROW; + srcRow += copyBytes; } } jlpSpriteRestorePlanes(s, bx, by, bw, bh, backup->bytes); @@ -548,25 +708,19 @@ void jlSpriteSaveUnder(const jlSurfaceT *s, jlSpriteT *sp, int16_t x, int16_t y, backup->sizeBytes = 0; slot = sp->slot; - widthPx = (uint16_t)(sp->widthTiles * TILE_PIXELS_PER_SIDE); - heightPx = (uint16_t)(sp->heightTiles * TILE_PIXELS_PER_SIDE); + widthPx = sp->widthPx; + heightPx = sp->heightPx; // Compiled fast path: fully on surface and the platform emitted // bytes for SAVE at this shift. The compiled routine assumes a // full-size, unclipped rectangle, so anything off-edge falls // through to the interpreted memcpy loop below. - // - // The routineOffsets[shift][SPRITE_OP_SAVE] access is rewritten as - // explicit byte-pointer arithmetic to dodge the multiply helper - // that gets emitted for `uint16_t arr[N][M]` indexing. if (backup->bytes != NULL && slot != NULL && isFullyOnSurface(x, y, widthPx, heightPx)) { - uint16_t routeIdx; uint16_t routeOffset; shift = SPRITE_SHIFT_INDEX(x); - routeIdx = (uint16_t)(((uint16_t)shift << 1) + (uint16_t)shift + SPRITE_OP_SAVE); - routeOffset = *(uint16_t *)((uint8_t *)sp->routineOffsets + (routeIdx << 1)); + routeOffset = SPRITE_ROUTE_OFFSET(sp, shift, SPRITE_OP_SAVE); if (routeOffset != SPRITE_NOT_COMPILED) { - spriteCompiledSaveUnder(s, sp, x, y, backup); + spriteCompiledSaveUnder(s, sp, x, y, backup, shift, routeOffset); if (!COMPILED_SPRITE_WRITES_PLANES) { jlpSpriteSavePlanes(s, backup->x, backup->y, backup->width, backup->height, backup->bytes); } @@ -628,13 +782,87 @@ void jlSpriteSaveUnder(const jlSurfaceT *s, jlSpriteT *sp, int16_t x, int16_t y, /* Chunky save path: skip on planar ports (s->pixels NULL). * jlpSpriteSavePlanes below covers the planar case. */ if (s->pixels != NULL) { + uint8_t *dstBytes; + + // #19: seed both row cursors once (SURFACE_ROW_OFFSET is the + // blessed row-address mechanism) and walk them with + // constant-stride adds; no per-row multiply helpers. + srcRow = &s->pixels[SURFACE_ROW_OFFSET((uint16_t)dy) + (uint16_t)byteStart]; + dstBytes = backup->bytes; for (row = 0; row < h; row++) { - srcRow = &s->pixels[(dy + row) * SURFACE_BYTES_PER_ROW]; - memcpy(&backup->bytes[(uint16_t)row * (uint16_t)copyBytes], - &srcRow[byteStart], - (size_t)copyBytes); + memcpy(dstBytes, srcRow, (size_t)copyBytes); + srcRow += SURFACE_BYTES_PER_ROW; + dstBytes += copyBytes; } } jlpSpriteSavePlanes(s, clippedX, dy, (uint16_t)clippedW, (uint16_t)h, backup->bytes); } /* end slow path */ } + + +// ----- Internal cross-TU entry points (spriteInternal.h) ----- + +// Initialize a freshly-allocated jlSpriteT's fields to the +// uncompiled/default state and register it for spriteSystemShutdown +// (see the registry comment near the top of this file). The single +// init path for every sprite: jlSpriteCreate, jlSpriteCreateFromSurface, +// and jlSpriteBankLoad (assetLoad.c) all route through here so the +// 0xFF/0 sentinel rules in spriteResetCompiledState have one source +// of truth (PERF-AUDIT.md #32). +void spriteInitFields(jlSpriteT *sp, const uint8_t *tileData, uint8_t widthTiles, uint8_t heightTiles, bool ownsTileData) { + sp->tileData = tileData; + sp->widthTiles = widthTiles; + sp->heightTiles = heightTiles; + sp->widthPx = (uint16_t)(widthTiles * TILE_PIXELS_PER_SIDE); + sp->heightPx = (uint16_t)(heightTiles * TILE_PIXELS_PER_SIDE); + sp->ownsTileData = ownsTileData; + spriteResetCompiledState(sp); + spriteRegistryAdd(sp); +} + + +// Register a sprite for spriteSystemShutdown. Idempotent (re-adding a +// registered sprite is a no-op); every creation path registers via +// spriteInitFields, and jlSpriteCompile re-registers on success as a +// belt-and-braces guard since compile is the moment the sprite first +// holds arena state that jlShutdown must reset. If the registry is +// full the sprite is silently not registered -- see the cap comment +// above. +void spriteRegistryAdd(jlSpriteT *sp) { + uint8_t i; + int16_t freeIdx; + + freeIdx = -1; + for (i = 0; i < SPRITE_REGISTRY_CAP; i++) { + if (gSpriteRegistry[i] == sp) { + return; + } + if (freeIdx < 0 && gSpriteRegistry[i] == NULL) { + freeIdx = (int16_t)i; + } + } + if (freeIdx >= 0) { + gSpriteRegistry[freeIdx] = sp; + } +} + + +// Called from jlShutdown BEFORE codegenArenaShutdown (#34). Resets +// every registered sprite's compiled-path state so a sprite surviving +// a shutdown -> re-init cycle can neither free a stale ArenaSlotT +// (heap corruption) nor execute stale offsets inside the new arena. +// Does NOT free anything -- codegenArenaShutdown releases the slot +// structs and arena bytes right after. Sprites stay registered: they +// remain live user objects and may be recompiled after re-init. +void spriteSystemShutdown(void) { + uint8_t i; + + for (i = 0; i < SPRITE_REGISTRY_CAP; i++) { + jlSpriteT *sp; + + sp = gSpriteRegistry[i]; + if (sp != NULL) { + spriteResetCompiledState(sp); + } + } +} diff --git a/src/core/spriteInternal.h b/src/core/spriteInternal.h index 6a6fa4f..552e293 100644 --- a/src/core/spriteInternal.h +++ b/src/core/spriteInternal.h @@ -35,12 +35,49 @@ // is valid for the first emitted op (typically DRAW shift 0). #define SPRITE_NOT_COMPILED 0xFFFFu +// The 4bpp nibble value treated as transparent by the interpreters +// (sprite.c) and every per-CPU emitter. Single definition so the +// transparency contract cannot drift between the compiled and +// interpreted paths. +#define TRANSPARENT_NIBBLE 0 + +// 1D index into the [JOEY_SPRITE_SHIFT_COUNT][SPRITE_OP_COUNT] tables +// (routineOffsets, cachedDstBank, cachedSrcBank). Spelled as +// (shift << 1) + shift instead of shift * SPRITE_OP_COUNT because the +// multiply lowers to a helper call (~MUL4) on the 65816; the shift/add +// form is two ASLs and an ADC. Must stay in lockstep with +// SPRITE_OP_COUNT == 3. +#define SPRITE_ROUTINE_INDEX(_shift, _op) ((uint16_t)((((uint16_t)(_shift) << 1) + (uint16_t)(_shift)) + (_op))) + +// routineOffsets read at a precomputed SPRITE_ROUTINE_INDEX. The +// byte-pointer arithmetic dodges the multiply helper that plain +// `uint16_t arr[N][M]` indexing emits on the 65816. Yields the stored +// uint16_t offset (SPRITE_NOT_COMPILED when the op is absent for that +// shift). +#define SPRITE_ROUTE_OFFSET_AT(_sp, _idx) (*(uint16_t *)((uint8_t *)(_sp)->routineOffsets + ((uint16_t)((_idx) << 1)))) + +// routineOffsets[shift][op] in one step -- the form every dispatcher +// gate and spriteCompiled* callee uses. Use SPRITE_ROUTINE_INDEX + +// SPRITE_ROUTE_OFFSET_AT instead when the index is also needed for the +// bank-patch cache pointers (IIgs save/restore). +#define SPRITE_ROUTE_OFFSET(_sp, _shift, _op) SPRITE_ROUTE_OFFSET_AT((_sp), SPRITE_ROUTINE_INDEX((_shift), (_op))) + struct jlSpriteT { - const uint8_t *tileData; // wTiles * hTiles * 32 bytes; NULL for loaded sprites + // wTiles * hTiles * 32 bytes; never NULL for a live sprite. + // Caller-owned for jlSpriteCreate; a sprite-owned copy + // (ownsTileData true) for jlSpriteCreateFromSurface and + // jlSpriteBankLoad, freed by jlSpriteDestroy. + const uint8_t *tileData; uint8_t widthTiles; uint8_t heightTiles; bool ownsTileData; // true if jlSpriteDestroy must free tileData + // Create-time pixel dimensions (widthTiles * 8 / heightTiles * 8), + // cached by spriteInitFields so the per-call dispatchers and + // spriteCompiled* callees never re-derive them (#29). + uint16_t widthPx; + uint16_t heightPx; + // Compiled-path state. slot==NULL means not yet compiled (or // compile failed); jlSpriteDraw falls back to the interpreter. // The fn-call address for (shift, op) is computed at draw time: @@ -68,14 +105,48 @@ struct jlSpriteT { }; // Compiled entry points. Implemented alongside jlSpriteCompile in -// src/codegen/jlSpriteCompile.c. Each handles the per-platform calling -// convention the emitted bytes use (cdecl on x86, stack args on 68k, -// inline asm + self-modifying stub on IIgs). The dispatchers in -// src/core/sprite.c call these when sp->slot is non-NULL, the -// matching routineOffsets entry is not SPRITE_NOT_COMPILED, and the -// draw/save/restore is fully on-surface. -void spriteCompiledDraw (jlSurfaceT *dst, const jlSpriteT *sp, int16_t x, int16_t y); -void spriteCompiledSaveUnder (const jlSurfaceT *src, jlSpriteT *sp, int16_t x, int16_t y, jlSpriteBackupT *backup); -void spriteCompiledRestoreUnder (jlSurfaceT *dst, const jlSpriteBackupT *backup); +// src/codegen/spriteCompile.c. Each handles the per-platform calling +// convention the emitted bytes use (cdecl stack args on x86 and 68k; +// C-ABI function pointers with per-call MVN bank patching on IIgs). +// The dispatchers in src/core/sprite.c call these when sp->slot is +// non-NULL, the matching routineOffsets entry is not +// SPRITE_NOT_COMPILED, and the draw/save/restore is fully on-surface. +// The gate already derived shift (SPRITE_SHIFT_INDEX) and routeOffset +// (SPRITE_ROUTE_OFFSET) to make that decision, so it passes them in +// and the callees never re-derive them (#29). routeOffset is the +// gate-checked entry for the callee's own op; shift feeds the IIgs +// bank-patch cache index and the chunky save copyBytes (draw needs +// neither, so it only takes routeOffset). +void spriteCompiledDraw (jlSurfaceT *dst, const jlSpriteT *sp, int16_t x, int16_t y, uint16_t routeOffset); +void spriteCompiledSaveUnder (const jlSurfaceT *src, jlSpriteT *sp, int16_t x, int16_t y, jlSpriteBackupT *backup, uint8_t shift, uint16_t routeOffset); +void spriteCompiledRestoreUnder (jlSurfaceT *dst, const jlSpriteBackupT *backup, uint8_t shift, uint16_t routeOffset); + +// Initialize a freshly-allocated jlSpriteT to the uncompiled/default +// state (tile-data fields, 0xFF routine-offset fill, bank/size cache +// sentinels) and register it for spriteSystemShutdown. The single +// init path for every sprite -- jlSpriteCreate and +// jlSpriteCreateFromSurface in sprite.c plus jlSpriteBankLoad in +// assetLoad.c -- so the sentinel rules live in one place +// (PERF-AUDIT.md #32). Defined in sprite.c. +void spriteInitFields(jlSpriteT *sp, const uint8_t *tileData, uint8_t widthTiles, uint8_t heightTiles, bool ownsTileData); + +// Sprite registry (sprite.c, PERF-AUDIT.md #34). spriteInitFields +// registers every sprite it initializes; jlSpriteCompile re-registers +// on success (idempotent) as a belt-and-braces guard, since compile +// is the moment the sprite first holds an arena slot. +// jlSpriteDestroy unregisters. The registry holds at most +// SPRITE_REGISTRY_CAP sprites (see sprite.c); beyond that, +// registration is silently skipped and the sprite MUST be destroyed +// before jlShutdown -- the same implicit contract that applied to +// every sprite before the registry existed. +void spriteRegistryAdd(jlSpriteT *sp); + +// Called from jlShutdown BEFORE codegenArenaShutdown: resets every +// registered sprite's compiled-path state (slot pointer, routine +// offsets, patch caches) so a sprite surviving a shutdown -> re-init +// cycle can neither free a stale ArenaSlotT (heap corruption) nor +// execute stale offsets inside the new arena. Frees nothing -- +// codegenArenaShutdown releases the slots right after. +void spriteSystemShutdown(void); #endif diff --git a/src/core/surface.c b/src/core/surface.c index d16c23f..f70f7f1 100644 --- a/src/core/surface.c +++ b/src/core/surface.c @@ -10,10 +10,6 @@ #include "port.h" #include "surfaceInternal.h" -#ifdef JOEYLIB_PLATFORM_IIGS -extern void iigsMarkDirtyRowsInner(uint16_t yStart, uint16_t yEnd, uint16_t minWord, uint16_t maxWord); -#endif - #define SURFACE_PALETTE_BYTES (SURFACE_PALETTE_ENTRIES * (uint32_t)sizeof(uint16_t)) #define SURFACE_FILE_BYTES (SURFACE_PIXELS_SIZE + SURFACE_HEIGHT + SURFACE_PALETTE_BYTES) @@ -24,7 +20,10 @@ extern void iigsMarkDirtyRowsInner(uint16_t yStart, uint16_t yEnd, uint16_t minW // ----- Module state ----- -static jlSurfaceT *gStage = NULL; +// Non-static: hot paths read it through surfaceInternal.h's extern to +// skip the jlStageGet() cross-TU call (PERF-AUDIT #10/#17). Written +// only by stageAlloc/stageFree below. +jlSurfaceT *gStage = NULL; uint8_t gStageMinWord[SURFACE_HEIGHT]; uint8_t gStageMaxWord[SURFACE_HEIGHT]; @@ -44,6 +43,10 @@ bool gStagePaletteDirty = true; // ----- Internal helpers (alphabetical) ----- +// widenRow serves only the non-IIgs surfaceMarkDirtyRows loop; the +// IIgs multi-row path is the asm marker and the single-row path is +// inlined by the surfaceMarkDirtyRect macro (surfaceInternal.h). +#ifndef JOEYLIB_PLATFORM_IIGS static void widenRow(int16_t y, uint8_t minWord, uint8_t maxWord) { if (minWord < gStageMinWord[y]) { gStageMinWord[y] = minWord; @@ -52,6 +55,7 @@ static void widenRow(int16_t y, uint8_t minWord, uint8_t maxWord) { gStageMaxWord[y] = maxWord; } } +#endif // ----- Public API (alphabetical) ----- @@ -82,11 +86,17 @@ void jlSurfaceCopy(jlSurfaceT *dst, const jlSurfaceT *src) { jlSurfaceT *jlSurfaceCreate(void) { jlSurfaceT *s; - s = (jlSurfaceT *)calloc(1, sizeof(jlSurfaceT)); + // jlpBigAlloc, NOT the C heap: on IIgs the C heap is a few hundred + // bytes of bank-0 leftover that is NEITHER big enough for this + // struct NOR reserved from the Memory Manager (Phase 1 IIgs + // stabilization; PERF-AUDIT.md #77/#81). Other ports map + // jlpBigAlloc to malloc, so behavior is unchanged there. + s = (jlSurfaceT *)jlpBigAlloc((uint32_t)sizeof(jlSurfaceT)); if (s == NULL) { coreSetError("out of memory allocating surface"); return NULL; } + memset(s, 0, sizeof(jlSurfaceT)); // jlpSurfaceAllocPixels returns NULL on planar ports (Amiga); the // primary storage is the port-allocated planes via portData below. // Classify NULL by the build's storage model: chunky ports need @@ -97,14 +107,14 @@ jlSurfaceT *jlSurfaceCreate(void) { #ifdef JOEYLIB_NATIVE_CHUNKY if (s->pixels == NULL) { jlpSurfaceFreePortData(s, false, s->portData); - free(s); + jlpBigFree(s); coreSetError("out of memory allocating surface pixels"); return NULL; } #else if (s->portData == NULL) { jlpSurfaceFreePixels(s->pixels); - free(s); + jlpBigFree(s); coreSetError("out of memory allocating surface planes"); return NULL; } @@ -123,7 +133,7 @@ void jlSurfaceDestroy(jlSurfaceT *s) { } jlpSurfaceFreePortData(s, false, s->portData); jlpSurfaceFreePixels(s->pixels); - free(s); + jlpBigFree(s); } @@ -227,47 +237,19 @@ void surfaceMarkDirtyAll(const jlSurfaceT *s) { } -// Drawing primitives pass the rect they actually wrote (already -// clipped to surface bounds, w and h positive). For non-stage surfaces -// the call is a no-op so primitives can call unconditionally without -// branching themselves. -void surfaceMarkDirtyRect(const jlSurfaceT *s, int16_t x, int16_t y, int16_t w, int16_t h) { - int16_t yEnd; - uint8_t minWord; - uint8_t maxWord; +// Multi-row arm of the surfaceMarkDirtyRect macro (surfaceInternal.h): +// widen rows y..yEnd-1 to cover [minWord, maxWord]. IIgs builds never +// compile this -- the macro routes them straight to the asm marker +// iigsMarkDirtyRowsInner instead. #ifndef JOEYLIB_PLATFORM_IIGS - int16_t row; -#endif +void surfaceMarkDirtyRows(uint16_t y, uint16_t yEnd, uint8_t minWord, uint8_t maxWord) { + uint16_t row; - if (s != gStage) { - return; - } - if (w <= 0 || h <= 0) { - return; - } - /* Clipped x/w are non-negative; SURFACE_WORD_INDEX casts to uint16_t - * before `>> 2` so the shift lowers to a pair of LSRs instead of a - * signed-shift helper. */ - minWord = SURFACE_WORD_INDEX(x); - maxWord = SURFACE_WORD_INDEX(x + w - 1); - yEnd = y + h; -#ifdef JOEYLIB_PLATFORM_IIGS - // Single-row marks (every jlDrawPixel, every horizontal span) widen - // inline in C; this is the common case and avoids the cross-segment - // JSL into iigsMarkDirtyRowsInner. Multi-row marks still use the asm - // marker, where the per-call JSL is amortized across the rows. - if (h == 1) { - widenRow(y, minWord, maxWord); - } else { - iigsMarkDirtyRowsInner((uint16_t)y, (uint16_t)yEnd, - (uint16_t)minWord, (uint16_t)maxWord); - } -#else for (row = y; row < yEnd; row++) { - widenRow(row, minWord, maxWord); + widenRow((int16_t)row, minWord, maxWord); } -#endif } +#endif // ----- Internal (alphabetical) ----- @@ -276,10 +258,13 @@ bool stageAlloc(void) { if (gStage != NULL) { return true; } - gStage = (jlSurfaceT *)calloc(1, sizeof(jlSurfaceT)); + // jlpBigAlloc for the same reason as jlSurfaceCreate: the IIgs C + // heap can neither hold nor protect this struct. + gStage = (jlSurfaceT *)jlpBigAlloc((uint32_t)sizeof(jlSurfaceT)); if (gStage == NULL) { return false; } + memset(gStage, 0, sizeof(jlSurfaceT)); // jlpStageAllocPixels returns NULL on planar ports (Amiga) where // the chunky shadow doesn't exist; the planes from portData are // the source of truth. On chunky ports NULL pixels means the @@ -293,14 +278,14 @@ bool stageAlloc(void) { #ifdef JOEYLIB_NATIVE_CHUNKY if (gStage->pixels == NULL) { jlpSurfaceFreePortData(gStage, true, gStage->portData); - free(gStage); + jlpBigFree(gStage); gStage = NULL; return false; } #else if (gStage->portData == NULL) { jlpStageFreePixels(gStage->pixels); - free(gStage); + jlpBigFree(gStage); gStage = NULL; return false; } @@ -312,12 +297,12 @@ bool stageAlloc(void) { void stageDirtyClearAll(void) { - int16_t row; - - for (row = 0; row < SURFACE_HEIGHT; row++) { - gStageMinWord[row] = STAGE_DIRTY_CLEAN_MIN; - gStageMaxWord[row] = STAGE_DIRTY_CLEAN_MAX; - } + // Constant fill of both band arrays, same memset form as its + // sibling surfaceMarkDirtyAll: lowers to a tight fill (MVN-seeded + // on IIgs) instead of a 200-iteration indexed-store loop, and this + // runs after every jlStagePresent. + memset(gStageMinWord, STAGE_DIRTY_CLEAN_MIN, SURFACE_HEIGHT); + memset(gStageMaxWord, STAGE_DIRTY_CLEAN_MAX, SURFACE_HEIGHT); } @@ -327,6 +312,6 @@ void stageFree(void) { } jlpSurfaceFreePortData(gStage, true, gStage->portData); jlpStageFreePixels(gStage->pixels); - free(gStage); + jlpBigFree(gStage); gStage = NULL; } diff --git a/src/core/surfaceInternal.h b/src/core/surfaceInternal.h index 01d262b..2ccb08f 100644 --- a/src/core/surfaceInternal.h +++ b/src/core/surfaceInternal.h @@ -33,7 +33,8 @@ struct jlSurfaceT { // 4 px per 16-bit word. SURFACE_BYTES_PER_ROW (160) / 2 = 80 words. // Dirty tracking grain is 16-bit words because that matches the IIgs // PEI / PHA slam unit and the Amiga / ST c2p group is 16 px = 4 words. -#define SURFACE_PIXELS_PER_WORD 4 +// (The 4-px-per-word fact is baked into SURFACE_WORD_INDEX's >> 2 below; +// PERF-AUDIT #66 deleted the unused SURFACE_PIXELS_PER_WORD spelling.) #define SURFACE_WORDS_PER_ROW (SURFACE_BYTES_PER_ROW / 2) // x pixel -> dirty word index (4 px per 16-bit word). x is non-negative @@ -51,8 +52,19 @@ struct jlSurfaceT { #define STAGE_DIRTY_CLEAN_MAX 0x00u // True when row `y`'s dirty band is the CLEAN sentinel (min > max). +// This is the canonical per-row copy test (the IIgs present asm +// implements the same compare against these sentinels). #define STAGE_DIRTY_ROW_CLEAN(_y) (gStageMinWord[_y] > gStageMaxWord[_y]) +// True when row `y` has been marked since the last stageDirtyClearAll. +// Single-load form (used by the Amiga present's idle-frame early-out +// and any-dirty scan). Valid ONLY under the clipped-marks invariant: +// every real mark widens minWord to <= STAGE_DIRTY_FULL_MAX (79), so +// min != 0xFF exactly when the row was touched. Use +// STAGE_DIRTY_ROW_CLEAN for the per-row copy decision; use this +// cheaper test where one load per row matters and the invariant holds. +#define STAGE_DIRTY_ROW_TOUCHED(_y) (gStageMinWord[_y] != STAGE_DIRTY_CLEAN_MIN) + // The per-row dirty bands are stored as uint8_t, which is only valid // because SURFACE_WORDS_PER_ROW - 1 (79) fits in a byte. A future width // change that pushes the word count past 255 must fail to compile here @@ -66,6 +78,15 @@ typedef char surfaceDirtyBandFitsByte[(SURFACE_WORDS_PER_ROW - 1) <= 0xFF ? 1 : extern uint8_t gStageMinWord[SURFACE_HEIGHT]; extern uint8_t gStageMaxWord[SURFACE_HEIGHT]; +// The library-owned stage surface, defined in surface.c (written only +// by stageAlloc/stageFree). Direct extern -- matching the band arrays +// above and codegenArenaInternal.h's direct-extern globals -- so hot +// paths (the port.h IIgs macros, the dirty-mark macro below, the +// palette/scb stage checks) compare surface identity with a plain load +// instead of a cross-TU jlStageGet() call. jlStageGet() stays as the +// public accessor. Treat as read-only outside surface.c. +extern jlSurfaceT *gStage; + // Per-byte mixer for jlSurfaceHash. Two-stream: lo *= 31 + b, hi *= 251 + b. // Strength-reduced to shifts so the compiler doesn't emit a software // multiply (~150 cyc per call); 32 KB hashed twice -> ~5 minutes per @@ -86,12 +107,35 @@ extern uint8_t gStageMaxWord[SURFACE_HEIGHT]; extern bool gStageScbDirty; extern bool gStagePaletteDirty; +// Multi-row arm of surfaceMarkDirtyRect below: widen rows y..yEnd-1 +// (yEnd exclusive) to cover [minWord, maxWord]. On IIgs this routes +// straight to the asm marker so a multi-row mark stays a single JSL; +// elsewhere it is the C loop in surface.c. +#ifdef JOEYLIB_PLATFORM_IIGS +void iigsMarkDirtyRowsInner(uint16_t yStart, uint16_t yEnd, uint16_t minWord, uint16_t maxWord); +#define surfaceMarkDirtyRows(_y, _yEnd, _min, _max) iigsMarkDirtyRowsInner((_y), (_yEnd), (uint16_t)(_min), (uint16_t)(_max)) +#else +void surfaceMarkDirtyRows(uint16_t y, uint16_t yEnd, uint8_t minWord, uint8_t maxWord); +#endif + // Drawing primitives call this with their already-clipped destination // rect. If `s` is the stage, the affected rows' [minWord, maxWord] // bands are widened to cover the rect. If `s` is any other surface, // the call is a no-op -- non-stage surfaces never get presented, so // they don't carry dirty state. // +// CONTRACT: the rect is already clipped to surface bounds and w and h +// are POSITIVE. There is deliberately no w/h <= 0 guard here -- every +// caller proves positive dimensions before marking (jlDrawRect's +// h == 2 interior edges were the last violator, fixed per PERF-AUDIT +// #65's verifier correction), so empty ranges cannot reach this macro. +// +// Macro, not a function (ORCA/C-era rule: no `inline` in headers the +// IIgs build shares): the stage test and the h == 1 single-row widen +// run inline at the call site; only multi-row marks pay a call, into +// surfaceMarkDirtyRows above. Every argument is evaluated exactly +// once, but keep call-site arguments side-effect free anyway. +// // Planar ports rely on the chunky shadow + c2p path through Phase 8. // Planar-native primitives (Phases 3+) dual-write: they update both // the chunky pixels and the bitplanes in the same call, so c2p at @@ -100,7 +144,25 @@ extern bool gStagePaletteDirty; // per-row planar-vs-chunky tracking even be a possible question, and // the plan is to avoid it entirely there too (planes become the only // source of truth). -void surfaceMarkDirtyRect(const jlSurfaceT *s, int16_t x, int16_t y, int16_t w, int16_t h); +#define surfaceMarkDirtyRect(_s, _x, _y, _w, _h) do { \ + if ((_s) == gStage) { \ + int16_t markX_ = (_x); \ + int16_t markY_ = (_y); \ + int16_t markH_ = (_h); \ + uint8_t markMin_ = SURFACE_WORD_INDEX(markX_); \ + uint8_t markMax_ = SURFACE_WORD_INDEX(markX_ + (_w) - 1); \ + if (markH_ == 1) { \ + if (markMin_ < gStageMinWord[markY_]) { \ + gStageMinWord[markY_] = markMin_; \ + } \ + if (markMax_ > gStageMaxWord[markY_]) { \ + gStageMaxWord[markY_] = markMax_; \ + } \ + } else { \ + surfaceMarkDirtyRows((uint16_t)markY_, (uint16_t)(markY_ + markH_), markMin_, markMax_); \ + } \ + } \ +} while (0) // Shorthand for "every row, full width" -- used by jlSurfaceClear and // the bulk-replace paths (jlSurfaceCopy, jlSurfaceLoadFile). No-op if `s` @@ -122,13 +184,55 @@ void stageDirtyClearAll(void); // Other ports get the straight multiply -- gcc / OpenWatcom optimize // the constant 160 to a shift+add chain. #ifdef JOEYLIB_PLATFORM_IIGS -extern const uint16_t gRowOffsetLut[200]; +extern const uint16_t gRowOffsetLut[SURFACE_HEIGHT]; #define SURFACE_ROW_OFFSET(_y) \ (*((const uint16_t *)((const uint8_t *)gRowOffsetLut + ((uint16_t)(_y) << 1)))) #else #define SURFACE_ROW_OFFSET(_y) ((uint16_t)((uint16_t)(_y) * SURFACE_BYTES_PER_ROW)) #endif +// Chunky 4bpp nibble read: the color index of pixel (x, y) in +// s->pixels. Callers must have already bounds-checked x/y +// (non-negative, on-surface). Macro (not a function -- same ORCA/C-era +// rule as above) so jlSamplePixel, jlpGenericSamplePixel, and the +// flood-fill C fallback walks all inline the read; SURFACE_ROW_OFFSET +// dodges the y * 160 software-multiply helper on IIgs. Only the taken +// parity arm's byte load executes, so each call reads one byte. `_x` +// is evaluated twice -- pass side-effect-free arguments. +#define SURFACE_READ_NIBBLE(_s, _x, _y) \ + (((_x) & 1) \ + ? (uint8_t)((_s)->pixels[SURFACE_ROW_OFFSET(_y) + ((uint16_t)(_x) >> 1)] & 0x0Fu) \ + : (uint8_t)((_s)->pixels[SURFACE_ROW_OFFSET(_y) + ((uint16_t)(_x) >> 1)] >> 4)) + +// True when the rect (x, y) .. (x + w - 1, y + h - 1) lies fully +// on-surface: the shared fast-path gate for jlFillRect, jlDrawRect, +// and sprite.c's isFullyOnSurface (#44) -- one source of +// truth for the on-surface clip invariant (PERF-AUDIT #62). All 16-bit +// on purpose: the int32 form of this test is a multi-instruction +// carry-chained sequence per compare on the 65816/8086. Guard order is +// load-bearing: w <= SURFACE_WIDTH and h <= SURFACE_HEIGHT are tested +// BEFORE the SURFACE_WIDTH - w / SURFACE_HEIGHT - h subtractions so +// those stay in int16_t range (an unguarded 320 - 65535 would wrap +// positive and falsely pass). +// +// Intended argument types: int16_t x/y, uint16_t w/h. CONTRACT: w >= 1 +// and h >= 1 -- for w == 0 the macro would accept x == SURFACE_WIDTH, +// so callers exclude empty rects first (jlFillRect tests w > 0 && +// h > 0; jlDrawRect returns early on w == 0 || h == 0). Boundary +// proof: w == 65535 -> the first compare fails and && short-circuits +// before the subtraction could wrap; w == 320 -> passes only for +// x == 0 (x <= 320 - 320); x == 319 with w == 1 -> passes (319 <= 319, +// the rightmost column); x == -1 -> x >= 0 fails. For w in +// [1, SURFACE_WIDTH] and x >= 0 the subtraction cannot wrap, so +// x <= SURFACE_WIDTH - w is exactly x + w <= SURFACE_WIDTH -- the +// 32-bit form this replaces. Arguments are evaluated up to twice -- +// pass side-effect-free expressions. +#define SURFACE_RECT_ON_SURFACE(_x, _y, _w, _h) \ + ((_w) <= SURFACE_WIDTH && (_h) <= SURFACE_HEIGHT && \ + (_x) >= 0 && (_y) >= 0 && \ + (_x) <= (int16_t)(SURFACE_WIDTH - (int16_t)(_w)) && \ + (_y) <= (int16_t)(SURFACE_HEIGHT - (int16_t)(_h))) + // Record a human-readable error string for jlLastError(). Defined in // init.c; usable by other core TUs (e.g. surface.c on allocation / // limit failure). The string must have static lifetime. diff --git a/src/core/tile.c b/src/core/tile.c index a17fc15..3cc7537 100644 --- a/src/core/tile.c +++ b/src/core/tile.c @@ -3,10 +3,10 @@ // stack jlTileT buffer); no separate tileset container, no allocator. // // Block coords (bx, by) map to pixel (bx*8, by*8). At 4bpp packed -// each tile row is 4 bytes wide, so byte-aligned memcpy is the inner -// loop for everything except the masked / transparent variant, which -// has to read-modify-write each byte to preserve destination pixels -// under transparent (color-0 by convention) source nibbles. +// each tile row is 4 bytes wide, so byte-aligned row copies are the +// inner loop for everything except the masked / transparent variant, +// which has to read-modify-write each byte to preserve destination +// pixels under transparent (color-0 by convention) source nibbles. #include @@ -14,20 +14,48 @@ #include "port.h" #include "surfaceInternal.h" -// (No -- the 4-byte-per-row inner copies are spelled out -// inline below. Avoiding memcpy / memset from the DRAWPRIMS load -// segment keeps cross-bank relocation references out of 13/SysLib.) +// The chunky tile inner loops live in src/generic/genericTile.c (the +// jlpGenericTile* ops); this TU is validation, dispatch, and dirty +// marking only -- it never touches pixel bytes. + +// Block -> pixel coordinate for the dirty-rect bookkeeping below. +// Spelled as a shift because ORCA-C does not strength-reduce the *8 +// into one -- it emits the ~150-cycle software multiply helper, the +// same trap SURFACE_ROW_OFFSET in surfaceInternal.h documents +// (PERF-AUDIT #48). The shift count 3 assumes TILE_PIXELS_PER_SIDE is +// 8; the guard keeps the two in sync. +#if TILE_PIXELS_PER_SIDE != 8 +#error "TILE_BLOCK_TO_PIXEL assumes TILE_PIXELS_PER_SIDE == 8" +#endif +#define TILE_BLOCK_TO_PIXEL(_b) ((uint16_t)((uint16_t)(_b) << 3)) + +// Prototype (helper below is the only static in this TU). +static void drawTextUnion(uint8_t cx, uint8_t cy, uint8_t *bounds); -// ----- Prototypes ----- - -// (copyTileOpaque / copyTileMasked moved to src/generic/genericTile.c as -// jlpGenericTileCopy / jlpGenericTileCopyMasked.) - -// ----- Internal helpers (alphabetical) ----- - -// (the chunky tile copy/masked-copy helpers now live in -// src/generic/genericTile.c as jlpGenericTileCopy / jlpGenericTileCopyMasked.) +// jlDrawText's per-glyph dirty-union tracking, hoisted out of the +// glyph loop into a noinline helper: keeping five live union locals +// inside the loop overflows the llvm-mos w65816 register allocator +// ("ran out of registers" hard error). bounds[] = {minCx, maxCx, +// minCy, maxCy, drewAny}. noinline is portable gcc/clang syntax. +static void __attribute__((noinline)) drawTextUnion(uint8_t cx, uint8_t cy, uint8_t *bounds) { + if (bounds[4] == 0u) { + bounds[4] = 1u; + bounds[0] = cx; + bounds[1] = cx; + bounds[2] = cy; + } else { + if (cx < bounds[0]) { + bounds[0] = cx; + } + if (cx > bounds[1]) { + bounds[1] = cx; + } + } + // cy never decreases, so the last drawn glyph's row is always the + // union's bottom edge. + bounds[3] = cy; +} // ----- Public API (alphabetical) ----- @@ -39,20 +67,28 @@ void jlDrawText(jlSurfaceT *dst, uint8_t bx, uint8_t by, const jlSurfaceT *fontS uint8_t ch; uint8_t srcBx; uint8_t srcBy; + uint8_t bounds[5]; if (dst == NULL || fontSurface == NULL || asciiMap == NULL || str == NULL) { return; } cx = bx; cy = by; - while (*str != '\0') { - ch = (uint8_t)*str++; - entry = asciiMap[ch]; - if (entry != TILE_NO_GLYPH) { - srcBx = (uint8_t)(entry & 0x00FFu); - srcBy = (uint8_t)((entry >> 8) & 0x00FFu); - jlTileCopyMasked(dst, cx, cy, fontSurface, srcBx, srcBy, 0u); + // Entry sanitation (PERF-AUDIT #22 verifier correction): the main + // loop below hands cx/cy straight to jlpTileCopyMasked, which does + // no bounds checking, so an out-of-range start must be disposed of + // here -- the wrap logic only guarantees in-range cx/cy after the + // first wrap. The historical contract is preserved bit-for-bit + // (the chk:drawText-offgrid golden covers it): characters landing + // on an out-of-range position are consumed without drawing while + // the cursor advances and wraps exactly as for drawn characters. + // For the documented bx in [0,39] / by in [0,24] inputs this whole + // loop costs two compares. + while (cx >= TILE_BLOCKS_PER_ROW || cy >= TILE_BLOCKS_PER_COL) { + if (*str == '\0') { + return; } + str++; cx++; if (cx >= TILE_BLOCKS_PER_ROW) { cx = 0; @@ -62,6 +98,40 @@ void jlDrawText(jlSurfaceT *dst, uint8_t bx, uint8_t by, const jlSurfaceT *fontS } } } + bounds[4] = 0u; + while (*str != '\0') { + ch = (uint8_t)*str++; + entry = asciiMap[ch]; + if (entry != TILE_NO_GLYPH) { + srcBx = (uint8_t)(entry & 0x00FFu); + srcBy = (uint8_t)((entry >> 8) & 0x00FFu); + // Only the asciiMap-supplied source block still needs + // validation per glyph: cx/cy are in range by the entry + // sanitation + wrap invariant and the NULL checks ran once + // above, so the public jlTileCopyMasked wrapper (which + // would re-check all of it and issue a dirty mark per + // glyph) is skipped (PERF-AUDIT #22). + if (srcBx < TILE_BLOCKS_PER_ROW && srcBy < TILE_BLOCKS_PER_COL) { + jlpTileCopyMasked(dst, cx, cy, fontSurface, srcBx, srcBy, 0u); + drawTextUnion(cx, cy, bounds); + } + } + cx++; + if (cx >= TILE_BLOCKS_PER_ROW) { + cx = 0; + cy++; + if (cy >= TILE_BLOCKS_PER_COL) { + break; + } + } + } + // One union dirty mark for the whole string instead of an 8-row + // mark per glyph (PERF-AUDIT #22). Over-approximates when the + // string wraps rows, which is safe: dirty state only widens what + // present uploads. + if (bounds[4] != 0u) { + surfaceMarkDirtyRect(dst, (int16_t)TILE_BLOCK_TO_PIXEL(bounds[0]), (int16_t)TILE_BLOCK_TO_PIXEL(bounds[2]), TILE_BLOCK_TO_PIXEL(bounds[1] - bounds[0] + 1u), TILE_BLOCK_TO_PIXEL(bounds[3] - bounds[2] + 1u)); + } } @@ -76,8 +146,8 @@ void jlTileCopy(jlSurfaceT *dst, uint8_t dstBx, uint8_t dstBy, const jlSurfaceT srcBx >= TILE_BLOCKS_PER_ROW || srcBy >= TILE_BLOCKS_PER_COL) { return; } - dstPixelX = (uint16_t)((uint16_t)dstBx * TILE_PIXELS_PER_SIDE); - dstPixelY = (uint16_t)((uint16_t)dstBy * TILE_PIXELS_PER_SIDE); + dstPixelX = TILE_BLOCK_TO_PIXEL(dstBx); + dstPixelY = TILE_BLOCK_TO_PIXEL(dstBy); // Single op: machine asm/planar override or the generic chunky default. jlpTileCopy(dst, dstBx, dstBy, src, srcBx, srcBy); surfaceMarkDirtyRect(dst, (int16_t)dstPixelX, (int16_t)dstPixelY, @@ -96,8 +166,8 @@ void jlTileCopyMasked(jlSurfaceT *dst, uint8_t dstBx, uint8_t dstBy, const jlSur srcBx >= TILE_BLOCKS_PER_ROW || srcBy >= TILE_BLOCKS_PER_COL) { return; } - dstPixelX = (uint16_t)((uint16_t)dstBx * TILE_PIXELS_PER_SIDE); - dstPixelY = (uint16_t)((uint16_t)dstBy * TILE_PIXELS_PER_SIDE); + dstPixelX = TILE_BLOCK_TO_PIXEL(dstBx); + dstPixelY = TILE_BLOCK_TO_PIXEL(dstBy); jlpTileCopyMasked(dst, dstBx, dstBy, src, srcBx, srcBy, transparentIndex); surfaceMarkDirtyRect(dst, (int16_t)dstPixelX, (int16_t)dstPixelY, TILE_PIXELS_PER_SIDE, TILE_PIXELS_PER_SIDE); @@ -114,8 +184,8 @@ void jlTileFill(jlSurfaceT *s, uint8_t bx, uint8_t by, uint8_t colorIndex) { if (bx >= TILE_BLOCKS_PER_ROW || by >= TILE_BLOCKS_PER_COL) { return; } - pixelX = (uint16_t)((uint16_t)bx * TILE_PIXELS_PER_SIDE); - pixelY = (uint16_t)((uint16_t)by * TILE_PIXELS_PER_SIDE); + pixelX = TILE_BLOCK_TO_PIXEL(bx); + pixelY = TILE_BLOCK_TO_PIXEL(by); // Single compile-time-selected op: machine asm/planar override or the // generic chunky default. jlpTileFill(s, bx, by, colorIndex); @@ -124,26 +194,6 @@ void jlTileFill(jlSurfaceT *s, uint8_t bx, uint8_t by, uint8_t colorIndex) { } -void jlTilePasteMono(jlSurfaceT *dst, uint8_t bx, uint8_t by, - const jlTileT *in, uint8_t fgColor, uint8_t bgColor) { - if (dst == NULL || in == NULL) { - return; - } - if (bx >= TILE_BLOCKS_PER_ROW || by >= TILE_BLOCKS_PER_COL) { - return; - } - fgColor &= 0x0Fu; - bgColor &= 0x0Fu; - // Single compile-time op: planar ports colorize in their own tile format - // (Amiga/ST); chunky ports colorize into a chunky tile then jlpTilePaste. - // Replaces the old runtime pixels==NULL planar/chunky branch. - jlpTilePasteMono(dst, bx, by, &in->pixels[0], fgColor, bgColor); - surfaceMarkDirtyRect(dst, (int16_t)((uint16_t)bx * TILE_PIXELS_PER_SIDE), - (int16_t)((uint16_t)by * TILE_PIXELS_PER_SIDE), - TILE_PIXELS_PER_SIDE, TILE_PIXELS_PER_SIDE); -} - - void jlTilePaste(jlSurfaceT *dst, uint8_t bx, uint8_t by, const jlTileT *in) { uint16_t pixelX; uint16_t pixelY; @@ -154,8 +204,8 @@ void jlTilePaste(jlSurfaceT *dst, uint8_t bx, uint8_t by, const jlTileT *in) { if (bx >= TILE_BLOCKS_PER_ROW || by >= TILE_BLOCKS_PER_COL) { return; } - pixelX = (uint16_t)((uint16_t)bx * TILE_PIXELS_PER_SIDE); - pixelY = (uint16_t)((uint16_t)by * TILE_PIXELS_PER_SIDE); + pixelX = TILE_BLOCK_TO_PIXEL(bx); + pixelY = TILE_BLOCK_TO_PIXEL(by); // Single op: machine asm/planar override or the generic chunky default. jlpTilePaste(dst, bx, by, &in->pixels[0]); surfaceMarkDirtyRect(dst, (int16_t)pixelX, (int16_t)pixelY, @@ -163,6 +213,29 @@ void jlTilePaste(jlSurfaceT *dst, uint8_t bx, uint8_t by, const jlTileT *in) { } +void jlTilePasteMono(jlSurfaceT *dst, uint8_t bx, uint8_t by, const jlTileT *in, uint8_t fgColor, uint8_t bgColor) { + uint16_t pixelX; + uint16_t pixelY; + + if (dst == NULL || in == NULL) { + return; + } + if (bx >= TILE_BLOCKS_PER_ROW || by >= TILE_BLOCKS_PER_COL) { + return; + } + fgColor &= 0x0Fu; + bgColor &= 0x0Fu; + pixelX = TILE_BLOCK_TO_PIXEL(bx); + pixelY = TILE_BLOCK_TO_PIXEL(by); + // Single compile-time op: planar ports colorize in their own tile format + // (Amiga/ST); chunky ports colorize into a chunky tile then jlpTilePaste. + // Replaces the old runtime pixels==NULL planar/chunky branch. + jlpTilePasteMono(dst, bx, by, &in->pixels[0], fgColor, bgColor); + surfaceMarkDirtyRect(dst, (int16_t)pixelX, (int16_t)pixelY, + TILE_PIXELS_PER_SIDE, TILE_PIXELS_PER_SIDE); +} + + void jlTileSnap(const jlSurfaceT *src, uint8_t bx, uint8_t by, jlTileT *out) { if (src == NULL || out == NULL) { return; diff --git a/src/dos/hal.c b/src/dos/hal.c index aad22ef..d346096 100644 --- a/src/dos/hal.c +++ b/src/dos/hal.c @@ -51,11 +51,8 @@ static uint8_t *gVgaMem = NULL; static bool gNearEnabled = false; static FILE *gCrashLog = NULL; -// Frame counter via $3DA bit 3 polling; rising edge marks the start -// of vertical retrace. Caller polls fast enough that no edge is -// missed (UBER's hot loop is far below 70 Hz period even on a 386). -static uint16_t gFrameCount; -static uint8_t gPrevInVret; +// (The old $3DA edge-poll frame-counter state lived here; removed with +// the poll-rate-dependent counter itself -- see jlpFrameCount.) // Cached palette from the last present. VGA DAC programming is ~768 // outportb calls; skip it when the source palette is unchanged. @@ -283,11 +280,13 @@ void jlpPresent(const jlSurfaceT *src) { // bytes, so pixelX = minWord*4 and pixelW = (maxWord-minWord+1)*4 // gives the byte range expandAndWriteLine needs. for (y = 0; y < SURFACE_HEIGHT; y++) { - minWord = gStageMinWord[y]; - maxWord = gStageMaxWord[y]; - if (minWord > maxWord) { + // Canonical clean-row test (finding #47): min > max is the CLEAN + // sentinel state; see STAGE_DIRTY_ROW_CLEAN in surfaceInternal.h. + if (STAGE_DIRTY_ROW_CLEAN(y)) { continue; } + minWord = gStageMinWord[y]; + maxWord = gStageMaxWord[y]; // Defense-in-depth: an upstream clipping bug that wrote // maxWord >= SURFACE_WORDS_PER_ROW would push the expand copy // past the 320-byte VGA row into the next scanline. Clamp to @@ -318,15 +317,16 @@ void jlpWaitVBL(void) { } +// Frame count derived from the millisecond clock (PIT ISR fires or +// uclock -- see jlpMillisElapsed below): frames = ms * 70 / 1000. The +// old implementation edge-polled $3DA bit 3 and only advanced when a +// call happened to land DURING the ~1ms retrace pulse, so any caller +// doing more than ~1ms of work between polls silently lost frames +// (PERF-AUDIT.md finding #73 -- UBER's batched timing loop stalled on +// it outright). This form is poll-rate-independent and monotonic; +// jlpWaitVBL above still synchronizes to the real retrace. uint16_t jlpFrameCount(void) { - uint8_t now; - - now = (uint8_t)(inportb(VGA_INPUT_STAT_1) & VGA_VRETRACE_BIT); - if (now && !gPrevInVret) { - gFrameCount++; - } - gPrevInVret = now; - return gFrameCount; + return (uint16_t)(((uint64_t)jlpMillisElapsed() * 70u) / 1000u); } @@ -411,8 +411,9 @@ void jlpShutdown(void) { // nearest-neighbor copy to VGA RAM. -// surfaceCopyPlanes migrated: DOS defines no JL_HAS_SURFACE_COPY_PLANES, so it -// links the no-op generic jlpGenericSurfaceCopyPlanes. +// surfaceCopyPlanes (and the sprite *Planes hooks): elided to ((void)0) by +// port.h's shared chunky block (PERF-AUDIT #42) -- DOS has no bitplanes to +// mirror into, so the former real calls into the no-op generics are gone. /* Phase 9 reader hooks: chunky ports use the original s->pixels-based * paths. */ diff --git a/src/dos/spriteEmitX86.c b/src/dos/spriteEmitX86.c index f788764..a6e627e 100644 --- a/src/dos/spriteEmitX86.c +++ b/src/dos/spriteEmitX86.c @@ -27,16 +27,16 @@ #include "joey/sprite.h" #include "joey/surface.h" +#include "joey/tile.h" +#include "spriteEmitChunky.h" #include "spriteEmitter.h" #include "spriteInternal.h" // ----- Constants ----- -#define TILE_PIXELS 8 -#define TILE_BYTES 32 -#define TILE_BYTES_PER_ROW 4 -#define TRANSPARENT_NIBBLE 0 +// Tile geometry comes from joey/tile.h; TRANSPARENT_NIBBLE from +// spriteInternal.h. // Stack-arg displacements for the emitted cdecl routines, after the // prologue saves esi (+esi/edi for save/restore): @@ -64,11 +64,11 @@ // ----- Prototypes ----- +// shiftedByteAt / spriteSourceByte -- the transparency contract shared +// with the IIgs emitter -- come from spriteEmitChunky.h. static uint32_t emitCopyBodyX86(uint8_t *out, uint32_t cap, uint32_t cursor, uint16_t heightPx, uint16_t copyBytes, bool strideOnSrc); static uint32_t emitEsiOperand(uint8_t *out, uint32_t cursor, uint16_t col); static uint32_t esiOperandLen(uint16_t col); -static void shiftedByteAt(const jlSpriteT *sp, uint16_t row, uint16_t col, uint8_t shift, uint16_t spriteBytesPerRow, uint8_t *outValue, uint8_t *outOpaqueMask); -static uint8_t spriteSourceByte(const jlSpriteT *sp, uint16_t row, uint16_t col); // ----- Emit helpers (alphabetical) ----- @@ -161,77 +161,6 @@ static uint32_t esiOperandLen(uint16_t col) { } -// Decompose a destination byte's contribution from the sprite into -// (value, opaqueMask) for shift in {0, 1}. opaqueMask high nibble -// 0xF0 means high dest nibble is opaque; 0x0F means low is opaque; -// 0x00 means both transparent. value's transparent nibbles are 0. -static void shiftedByteAt(const jlSpriteT *sp, uint16_t row, uint16_t col, uint8_t shift, uint16_t spriteBytesPerRow, uint8_t *outValue, uint8_t *outOpaqueMask) { - uint8_t srcByte; - uint8_t hi; - uint8_t lo; - bool hasLeft; - bool hasRight; - - *outValue = 0; - *outOpaqueMask = 0; - - if (shift == 0) { - if (col >= spriteBytesPerRow) { - return; - } - srcByte = spriteSourceByte(sp, row, col); - hi = (uint8_t)((srcByte >> 4) & 0x0Fu); - lo = (uint8_t)(srcByte & 0x0Fu); - if (hi != TRANSPARENT_NIBBLE) { - *outValue |= (uint8_t)(hi << 4); - *outOpaqueMask |= 0xF0u; - } - if (lo != TRANSPARENT_NIBBLE) { - *outValue |= lo; - *outOpaqueMask |= 0x0Fu; - } - return; - } - // shift = 1 - hasLeft = (col >= 1) && ((uint16_t)(col - 1) < spriteBytesPerRow); - hasRight = (col < spriteBytesPerRow); - if (hasLeft) { - srcByte = spriteSourceByte(sp, row, (uint16_t)(col - 1)); - hi = (uint8_t)(srcByte & 0x0Fu); // sprite byte's LOW nibble - if (hi != TRANSPARENT_NIBBLE) { - *outValue |= (uint8_t)(hi << 4); - *outOpaqueMask |= 0xF0u; - } - } - if (hasRight) { - srcByte = spriteSourceByte(sp, row, col); - lo = (uint8_t)((srcByte >> 4) & 0x0Fu); // sprite byte's HIGH nibble - if (lo != TRANSPARENT_NIBBLE) { - *outValue |= lo; - *outOpaqueMask |= 0x0Fu; - } - } -} - - -// Sample a sprite tile-data byte at (row, col) where col is in -// sprite-byte coordinates (0..spriteBytesPerRow-1). -static uint8_t spriteSourceByte(const jlSpriteT *sp, uint16_t row, uint16_t col) { - uint16_t tileX; - uint16_t tileY; - uint16_t inTileX; - uint16_t inTileY; - const uint8_t *tile; - - tileX = (uint16_t)(col / TILE_BYTES_PER_ROW); - tileY = (uint16_t)(row / TILE_PIXELS); - inTileX = (uint16_t)(col & (TILE_BYTES_PER_ROW - 1)); - inTileY = (uint16_t)(row & (TILE_PIXELS - 1)); - tile = sp->tileData + ((uint32_t)(tileY * sp->widthTiles + tileX)) * TILE_BYTES; - return tile[inTileY * TILE_BYTES_PER_ROW + inTileX]; -} - - // Emit a draw routine for one shift variant. Returns bytes written, or // SPRITE_EMIT_OVERFLOW if the routine would exceed `cap`. // Routine signature: void f(uint8_t *dst). @@ -256,7 +185,7 @@ uint32_t spriteEmitDrawX86(uint8_t *out, uint32_t cap, const jlSpriteT *sp, uint } cursor = 0; - heightPx = (uint16_t)(sp->heightTiles * TILE_PIXELS); + heightPx = (uint16_t)(sp->heightTiles * TILE_PIXELS_PER_SIDE); spriteBytesPerRow = (uint16_t)(sp->widthTiles * TILE_BYTES_PER_ROW); destBytesPerRow = (uint16_t)(spriteBytesPerRow + shift); /* shift is 0 or 1 */ @@ -396,7 +325,7 @@ uint32_t spriteEmitRestoreX86(uint8_t *out, uint32_t cap, const jlSpriteT *sp, u } cursor = 0; - heightPx = (uint16_t)(sp->heightTiles * TILE_PIXELS); + heightPx = (uint16_t)(sp->heightTiles * TILE_PIXELS_PER_SIDE); copyBytes = (uint16_t)(sp->widthTiles * TILE_BYTES_PER_ROW + shift); /* shift is 0 or 1 */ // Prologue: push esi; push edi; mov esi,[esp+12]; mov edi,[esp+16] @@ -435,7 +364,7 @@ uint32_t spriteEmitSaveX86(uint8_t *out, uint32_t cap, const jlSpriteT *sp, uint } cursor = 0; - heightPx = (uint16_t)(sp->heightTiles * TILE_PIXELS); + heightPx = (uint16_t)(sp->heightTiles * TILE_PIXELS_PER_SIDE); copyBytes = (uint16_t)(sp->widthTiles * TILE_BYTES_PER_ROW + shift); /* shift is 0 or 1 */ if (cursor + 10u > cap) { diff --git a/src/generic/genericDraw.c b/src/generic/genericDraw.c index a661e30..305bbd3 100644 --- a/src/generic/genericDraw.c +++ b/src/generic/genericDraw.c @@ -24,35 +24,50 @@ void jlpGenericSurfaceClear(jlSurfaceT *s, uint8_t doubled) { // Chunky 4bpp on-surface rect fill. Caller has proven the rect is fully on // surface (x,y >= 0; x+w <= WIDTH; y+h <= HEIGHT; w,h > 0). Planar ports have // NULL s->pixels and override jlpFillRect with their plane fill, so this is the -// chunky default (IIgs non-stage / DOS). +// chunky default (IIgs non-stage / DOS). The span geometry (edge nibbles + +// whole-byte middle run) depends only on x/w, so it is derived once up front +// and the row loop just advances one pointer by the row stride (PERF-AUDIT #49). void jlpGenericFillRect(jlSurfaceT *s, int16_t x, int16_t y, int16_t w, int16_t h, uint8_t colorIndex) { - uint8_t nibble = colorIndex & 0x0F; - uint8_t doubled = (uint8_t)((nibble << 4) | nibble); - int16_t row; - uint16_t pxStart; - uint16_t pxEnd; + uint8_t nibble = colorIndex & 0x0F; + uint8_t doubled = (uint8_t)((nibble << 4) | nibble); + uint16_t pxStart = (uint16_t)x; + uint16_t pxEnd = (uint16_t)(x + w); + bool hasLeft = false; + bool hasRight = false; + uint16_t leftByte; + uint16_t midByte; uint16_t midBytes; + uint16_t rightByte; uint8_t *line; + int16_t row; if (s->pixels == NULL) { return; } + leftByte = (uint16_t)(pxStart >> 1); + if (pxStart & 1u) { + hasLeft = true; + pxStart++; + } + midByte = (uint16_t)(pxStart >> 1); + midBytes = (uint16_t)((pxEnd - pxStart) >> 1); + pxStart = (uint16_t)(pxStart + (midBytes << 1)); + if (pxStart < pxEnd) { + hasRight = true; + } + rightByte = (uint16_t)(pxStart >> 1); + line = &s->pixels[SURFACE_ROW_OFFSET(y)]; for (row = 0; row < h; row++) { - line = &s->pixels[SURFACE_ROW_OFFSET(y + row)]; - pxStart = (uint16_t)x; - pxEnd = (uint16_t)(x + w); - if (pxStart & 1u) { - line[pxStart >> 1] = (uint8_t)((line[pxStart >> 1] & 0xF0) | nibble); - pxStart++; + if (hasLeft) { + line[leftByte] = (uint8_t)((line[leftByte] & 0xF0) | nibble); } - midBytes = (uint16_t)((pxEnd - pxStart) >> 1); if (midBytes > 0u) { - memset(&line[pxStart >> 1], doubled, (size_t)midBytes); - pxStart = (uint16_t)(pxStart + (midBytes << 1)); + memset(&line[midByte], doubled, (size_t)midBytes); } - if (pxStart < pxEnd) { - line[pxStart >> 1] = (uint8_t)((line[pxStart >> 1] & 0x0F) | (nibble << 4)); + if (hasRight) { + line[rightByte] = (uint8_t)((line[rightByte] & 0x0F) | (nibble << 4)); } + line += SURFACE_BYTES_PER_ROW; } } diff --git a/src/generic/genericPort.c b/src/generic/genericPort.c index 023ca45..ee7e98d 100644 --- a/src/generic/genericPort.c +++ b/src/generic/genericPort.c @@ -36,8 +36,65 @@ void jlpGenericWaitVBL(void) { // Approximate elapsed milliseconds from the frame counter and refresh rate. // This is the IIgs/Amiga path; ports with a real hardware timer (DOS uclock, // ST 200 Hz tick) override via JL_HAS_MILLIS_ELAPSED. +// +// Milliseconds accumulate incrementally from uint16_t frame DELTAS, so the +// wrapping 16-bit hardware counter never runs the clock backward: the clock +// is monotonic out to the natural uint32_t limit (~49.7 days) PROVIDED this +// is called at least once every 65535 frames (~18 min at 60Hz, ~22 min at +// 50Hz) -- any per-frame caller satisfies that trivially. The fractional- +// frame remainder is carried across calls, so there is no long-term drift. +// The 50/60Hz fast paths avoid the generic 32-bit divide (hundreds of +// cycles on the 65816). A change in jlpFrameHz() re-bases the clock to 0 +// (the ms-per-frame basis and remainder units change; no real port switches +// refresh rate at runtime). Statics are updated via explicit local +// read-modify-write (ORCA-C lowers ++/+= on file-scope data to inc abs, a +// DBR trap on the 65816). uint32_t jlpGenericMillisElapsed(void) { - return (uint32_t)((uint32_t)jlpFrameCount() * 1000u / (uint32_t)jlpFrameHz()); + static uint16_t gLastFrame = 0u; + static uint16_t gLastHz = 0u; + static uint32_t gMillis = 0u; + static uint16_t gRemainder = 0u; + + uint16_t now; + uint16_t hz; + uint16_t delta; + uint16_t rem; + uint32_t ms; + uint32_t scaled; + + now = jlpFrameCount(); + hz = jlpFrameHz(); + if (hz != gLastHz) { + gLastHz = hz; + gLastFrame = now; + gMillis = 0u; + gRemainder = 0u; + return 0u; + } + delta = (uint16_t)(now - gLastFrame); + if (delta == 0u) { + return gMillis; + } + ms = gMillis; + rem = gRemainder; + if (hz == 50u) { + // Exact: 20 ms per frame, no fractional remainder. + ms = ms + (uint32_t)delta * 20u; + } else if (hz == 60u) { + // Exact: 50/3 ms per frame, mod-3 remainder carried across calls. + scaled = (uint32_t)delta * 50u + (uint32_t)rem; + ms = ms + scaled / 3u; + rem = (uint16_t)(scaled % 3u); + } else { + // General fallback: 1000/hz ms per frame, mod-hz remainder carried. + scaled = (uint32_t)delta * 1000u + (uint32_t)rem; + ms = ms + scaled / (uint32_t)hz; + rem = (uint16_t)(scaled % (uint32_t)hz); + } + gLastFrame = now; + gMillis = ms; + gRemainder = rem; + return ms; } diff --git a/src/generic/genericSprite.c b/src/generic/genericSprite.c index 1bb8994..907ccfe 100644 --- a/src/generic/genericSprite.c +++ b/src/generic/genericSprite.c @@ -2,7 +2,9 @@ // core/sprite.c (spriteDrawInterpreted) and the per-platform sprite codegen does // the fast path; jlpSprite*Planes is the PLANAR mirror/interpreter, which has no // work to do on a chunky target -- so the generic default is a no-op. Planar -// machines (Amiga/ST) override it; IIgs overrides it with a no-op macro (perf). +// machines (Amiga/ST) override it; every chunky port (IIgs/DOS/blank) elides +// the hooks to ((void)0) macros in port.h (PERF-AUDIT #42), so these bodies +// link only for a new planar port that has not implemented its hooks yet. #include "port.h" diff --git a/src/generic/genericSurface.c b/src/generic/genericSurface.c index a0d8b97..08f0a7b 100644 --- a/src/generic/genericSurface.c +++ b/src/generic/genericSurface.c @@ -23,17 +23,23 @@ void jlpGenericSurfaceCopyChunky(jlSurfaceT *dst, const jlSurfaceT *src) { } -// Release a per-surface chunky pixel buffer. free(NULL) is a no-op, so this is -// safe on planar ports whose surfaces carry no chunky shadow. +// Release a per-surface chunky pixel buffer. Must mirror +// jlpGenericSurfaceAllocPixels' allocator exactly: handing a +// jlpBigAlloc pointer to free() corrupts the IIgs heap +// (BIG_ALLOC_HDR offset, finding #31). NULL is a no-op, so this +// stays safe on planar ports whose surfaces carry no chunky shadow. void jlpGenericSurfaceFreePixels(uint8_t *pixels) { - free(pixels); + if (pixels != NULL) { + jlpBigFree(pixels); + } } // jlSurfaceCopy's planar dual-write hook. No-op default: chunky ports carry no -// bitplanes (the chunky payload is jlpSurfaceCopyChunky's job), and a blank/ -// template port links this until it provides its own. Planar ports (Amiga, ST) -// override via JL_HAS_SURFACE_COPY_PLANES. +// bitplanes (the chunky payload is jlpSurfaceCopyChunky's job) and elide the +// call outright via port.h's chunky ((void)0) macros (PERF-AUDIT #42), so this +// body links only for a new planar port that has not provided its own yet. +// Planar ports (Amiga, ST) override via JL_HAS_SURFACE_COPY_PLANES. void jlpGenericSurfaceCopyPlanes(jlSurfaceT *dst, const jlSurfaceT *src) { (void)dst; (void)src; @@ -52,9 +58,11 @@ const char *jlpGenericLastError(void) { uint8_t jlpGenericSamplePixel(const jlSurfaceT *s, int16_t x, int16_t y) { - uint8_t byte = s->pixels[(uint16_t)y * SURFACE_BYTES_PER_ROW + ((uint16_t)x >> 1)]; - if (x & 1) return (uint8_t)(byte & 0x0Fu); - return (uint8_t)((byte & 0xF0u) >> 4); + // SURFACE_READ_NIBBLE routes the row offset through + // SURFACE_ROW_OFFSET, dropping the y * 160 software-multiply helper + // this body used to pay on IIgs (PERF-AUDIT #14/#24). Identical + // pixel result on every port. + return SURFACE_READ_NIBBLE(s, x, y); } @@ -115,10 +123,25 @@ void jlpGenericStageFreePixels(uint8_t *pixels) { } -// Per-surface chunky pixels: chunky ports calloc (zero-filled); planar ports -// override to return NULL (storage is the portData planes). +// Per-surface chunky pixels, zero-filled; planar ports override to +// return NULL (storage is the portData planes). jlpBigAlloc, NOT +// calloc: the IIgs C heap is a few hundred bytes of bank-0 leftover +// and its calloc RETURNS 1 (not NULL) at exhaustion (LLVM816-ASKS +// item 4), so the old calloc here zero-filled 32000 bytes through +// pointer 1 and shredded bank 0 on the first OFFSCREEN +// jlSurfaceCreate -- the stage never hit it (pinned $01:2000 path), +// which is why UBER could pass while spacetaxi died in +// loadFontSheet. Other ports map jlpBigAlloc to malloc, so behavior +// is unchanged there (finding #31 pattern). uint8_t *jlpGenericSurfaceAllocPixels(void) { - return (uint8_t *)calloc(1, SURFACE_PIXELS_SIZE); + uint8_t *pixels; + + pixels = (uint8_t *)jlpBigAlloc((uint32_t)SURFACE_PIXELS_SIZE); + if (pixels == NULL) { + return NULL; + } + memset(pixels, 0, SURFACE_PIXELS_SIZE); + return pixels; } diff --git a/src/generic/genericTile.c b/src/generic/genericTile.c index 1fe040c..d1127ed 100644 --- a/src/generic/genericTile.c +++ b/src/generic/genericTile.c @@ -3,6 +3,8 @@ // JL_HAS_ for the tile op; machine overrides (IIgs asm macros, Amiga/ST // planar functions) live in the machine folders. +#include + #include "joey/tile.h" #include "surfaceInternal.h" #include "port.h" @@ -12,22 +14,34 @@ #define GENERIC_TILE_ROW0(_pix, _bx, _by) \ (&(_pix)[SURFACE_ROW_OFFSET((uint16_t)(_by) << 3) + ((uint16_t)(_bx) << 2)]) +// Copy TILE_PIXELS_PER_SIDE rows of TILE_BYTES_PER_ROW bytes from _s to _d, +// advancing each pointer by its own stride -- the one row-copy inner loop +// shared by TileCopy / TilePaste / TileSnap (PERF-AUDIT #50). memcpy on +// purpose: the previous byte-at-a-time form let gcc assume d/s alias, which +// blocked store merging; memcpy asserts non-overlap and compiles to one +// 32-bit move per row on DOS (DJGPP). Only DOS/BLANK run these generics -- +// IIgs uses asm macros and Amiga/ST override every tile op -- so the IIgs +// byte-loop-libc memcpy caveat (PERF-AUDIT #79) does not apply here. +#define GENERIC_TILE_COPY_ROWS(_d, _dStride, _s, _sStride) do { \ + uint8_t copyRow_; \ + for (copyRow_ = 0; copyRow_ < TILE_PIXELS_PER_SIDE; copyRow_++) { \ + memcpy((_d), (_s), TILE_BYTES_PER_ROW); \ + (_d) += (_dStride); \ + (_s) += (_sStride); \ + } \ +} while (0) + void jlpGenericTileCopy(jlSurfaceT *dst, uint8_t dstBx, uint8_t dstBy, const jlSurfaceT *src, uint8_t srcBx, uint8_t srcBy) { uint8_t *d; const uint8_t *s; - uint8_t row; if (dst->pixels == NULL || src->pixels == NULL) { return; } d = GENERIC_TILE_ROW0(dst->pixels, dstBx, dstBy); s = GENERIC_TILE_ROW0(src->pixels, srcBx, srcBy); - for (row = 0; row < TILE_PIXELS_PER_SIDE; row++) { - d[0] = s[0]; d[1] = s[1]; d[2] = s[2]; d[3] = s[3]; - d += SURFACE_BYTES_PER_ROW; - s += SURFACE_BYTES_PER_ROW; - } + GENERIC_TILE_COPY_ROWS(d, SURFACE_BYTES_PER_ROW, s, SURFACE_BYTES_PER_ROW); } @@ -37,7 +51,6 @@ void jlpGenericTileCopyMasked(jlSurfaceT *dst, uint8_t dstBx, uint8_t dstBy, con uint8_t row; uint8_t col; uint8_t srcByte; - uint8_t dstByte; uint8_t transHi = (uint8_t)((transparent & 0x0F) << 4); uint8_t transLo = (uint8_t)(transparent & 0x0F); uint8_t srcHi; @@ -53,17 +66,18 @@ void jlpGenericTileCopyMasked(jlSurfaceT *dst, uint8_t dstBx, uint8_t dstBy, con srcByte = s[col]; srcHi = (uint8_t)(srcByte & 0xF0); srcLo = (uint8_t)(srcByte & 0x0F); - if (srcHi == transHi && srcLo == transLo) { - continue; - } - dstByte = d[col]; if (srcHi != transHi) { - dstByte = (uint8_t)((dstByte & 0x0F) | srcHi); + if (srcLo != transLo) { + // Both nibbles opaque (the common case in solid glyph + // strokes / tileset art): the merge result is exactly + // srcByte, so skip the destination read (PERF-AUDIT #51). + d[col] = srcByte; + } else { + d[col] = (uint8_t)((d[col] & 0x0F) | srcHi); + } + } else if (srcLo != transLo) { + d[col] = (uint8_t)((d[col] & 0xF0) | srcLo); } - if (srcLo != transLo) { - dstByte = (uint8_t)((dstByte & 0xF0) | srcLo); - } - d[col] = dstByte; } d += SURFACE_BYTES_PER_ROW; s += SURFACE_BYTES_PER_ROW; @@ -74,6 +88,9 @@ void jlpGenericTileCopyMasked(jlSurfaceT *dst, uint8_t dstBx, uint8_t dstBy, con // Colorize a 1bpp-ish mono shape (non-zero nibble -> fg, zero -> bg) into a // chunky tile, then paste via the jlpTilePaste op (IIgs asm / DOS generic). The // caller (jlTilePasteMono) marks the rect dirty, so this does not. +// PRECONDITION: fgColor/bgColor arrive already masked to 4 bits -- the public +// jlTilePasteMono wrapper owns the & 0x0F (PERF-AUDIT #69; contract documented +// at the jlpTilePasteMono dispatch stanza in port.h). No re-mask here. void jlpGenericTilePasteMono(jlSurfaceT *dst, uint8_t bx, uint8_t by, const uint8_t *monoTile, uint8_t fgColor, uint8_t bgColor) { jlTileT colored; uint8_t i; @@ -81,8 +98,6 @@ void jlpGenericTilePasteMono(jlSurfaceT *dst, uint8_t bx, uint8_t by, const uint uint8_t hi; uint8_t lo; - fgColor &= 0x0Fu; - bgColor &= 0x0Fu; for (i = 0u; i < TILE_BYTES; i++) { srcByte = monoTile[i]; hi = (uint8_t)((srcByte >> 4) ? fgColor : bgColor); @@ -97,17 +112,12 @@ void jlpGenericTilePasteMono(jlSurfaceT *dst, uint8_t bx, uint8_t by, const uint void jlpGenericTilePaste(jlSurfaceT *dst, uint8_t bx, uint8_t by, const uint8_t *chunkyTile) { uint8_t *d; const uint8_t *s = chunkyTile; - uint8_t row; if (dst->pixels == NULL) { return; } d = GENERIC_TILE_ROW0(dst->pixels, bx, by); - for (row = 0; row < TILE_PIXELS_PER_SIDE; row++) { - d[0] = s[0]; d[1] = s[1]; d[2] = s[2]; d[3] = s[3]; - d += SURFACE_BYTES_PER_ROW; - s += TILE_BYTES_PER_ROW; - } + GENERIC_TILE_COPY_ROWS(d, SURFACE_BYTES_PER_ROW, s, TILE_BYTES_PER_ROW); } @@ -115,31 +125,24 @@ void jlpGenericTilePaste(jlSurfaceT *dst, uint8_t bx, uint8_t by, const uint8_t void jlpGenericTileSnap(const jlSurfaceT *src, uint8_t bx, uint8_t by, uint8_t *chunkyOut) { const uint8_t *s; uint8_t *d = chunkyOut; - uint8_t row; if (src->pixels == NULL) { return; } s = GENERIC_TILE_ROW0(src->pixels, bx, by); - for (row = 0; row < TILE_PIXELS_PER_SIDE; row++) { - d[0] = s[0]; d[1] = s[1]; d[2] = s[2]; d[3] = s[3]; - s += SURFACE_BYTES_PER_ROW; - d += TILE_BYTES_PER_ROW; - } + GENERIC_TILE_COPY_ROWS(d, TILE_BYTES_PER_ROW, s, SURFACE_BYTES_PER_ROW); } void jlpGenericTileFill(jlSurfaceT *s, uint8_t bx, uint8_t by, uint8_t colorIndex) { uint8_t doubled = (uint8_t)(((colorIndex & 0x0F) << 4) | (colorIndex & 0x0F)); - uint16_t pixelX = (uint16_t)((uint16_t)bx * TILE_PIXELS_PER_SIDE); - uint16_t pixelY = (uint16_t)((uint16_t)by * TILE_PIXELS_PER_SIDE); uint8_t *row; uint8_t i; if (s->pixels == NULL) { return; } - row = &s->pixels[SURFACE_ROW_OFFSET(pixelY) + (pixelX >> 1)]; + row = GENERIC_TILE_ROW0(s->pixels, bx, by); for (i = 0; i < TILE_PIXELS_PER_SIDE; i++) { row[0] = doubled; row[1] = doubled; diff --git a/src/generic/spriteEmitStub.c b/src/generic/spriteEmitStub.c index a31b114..afda7af 100644 --- a/src/generic/spriteEmitStub.c +++ b/src/generic/spriteEmitStub.c @@ -15,11 +15,12 @@ bool jlSpriteCompile(jlSpriteT *sp) { } -void spriteCompiledDraw(jlSurfaceT *dst, const jlSpriteT *sp, int16_t x, int16_t y) { +void spriteCompiledDraw(jlSurfaceT *dst, const jlSpriteT *sp, int16_t x, int16_t y, uint16_t routeOffset) { (void)dst; (void)sp; (void)x; (void)y; + (void)routeOffset; // unreachable: jlSpriteDraw guards on sp->slot != NULL, which the // stub never sets. Body is here only to satisfy the linker. } diff --git a/src/iigs/audio_full.c b/src/iigs/audio_full.c index 65487a6..1be9a09 100644 --- a/src/iigs/audio_full.c +++ b/src/iigs/audio_full.c @@ -25,6 +25,7 @@ typedef void *Pointer; #define _toolErr 0 #define attrPage 0x0004 #define attrNoCross 0x0010 +#define attrNoSpec 0x0020 #define attrFixed 0x4000 #define attrLocked 0x8000 diff --git a/src/iigs/hal.c b/src/iigs/hal.c index fc6c2d5..45b183a 100644 --- a/src/iigs/hal.c +++ b/src/iigs/hal.c @@ -39,6 +39,8 @@ #include typedef void *Handle; #define _ownerid (MMStartUp()) +#define attrAddr 0x0002 +#define attrNoSpec 0x0020 #define attrFixed 0x4000 #define attrLocked 0x8000 // Leading bookkeeping slot: jlAlloc/jlFree traffic in plain pointers but @@ -69,18 +71,23 @@ extern void iigsSurfaceClearInner(uint8_t *pixels, uint16_t fillWord); // Full-fill asm helper (partial leading byte + middle MVN + partial // trailing byte). Called by the jlpFillRect macro (core/port.h). extern void iigsFillRectInner(uint8_t *pixels, uint16_t x, uint16_t y, uint16_t w, uint16_t h, uint16_t nibble); -// 16 STA abs,X stores at fixed offsets along a 160-byte stride. -// ~120 cyc per call. -extern void iigsTileFillInner(uint8_t *dstRow0, uint16_t fillWord); +// 16 fixed-offset word stores along a 160-byte stride. Takes the raw +// fill nibble; the asm derives the $NNNN fill word so C never pays the +// * 0x1111 software-multiply helper (PERF-AUDIT #41). ~120 cyc per call. +extern void iigsTileFillInner(uint8_t *dstRow0, uint16_t nibble); // Tile copy / paste / snap inner loops. All take 4-byte large- // model pointers; bank may differ between dst and src (heap // surface vs stage). Stride contracts: // tileCopyInner / tileCopyMaskedInner: dst 160, src 160 -// tilePasteInner: dst 160, src 4 +// tilePasteInner / tilePasteMonoInner: dst 160, src 4 // tileSnapInner: dst 4, src 160 +// tilePasteMonoInner is the fused mono colorize + paste (PERF-AUDIT #3): +// per-byte 2-bit opacity index (static table) into four per-call fg/bg +// combo bytes; fg/bg arrive pre-masked to 4 bits (PERF-AUDIT #69). extern void iigsTileCopyInner(uint8_t *dstRow0, const uint8_t *srcRow0); extern void iigsTileCopyMaskedInner(uint8_t *dstRow0, const uint8_t *srcRow0, uint16_t transparent); extern void iigsTilePasteInner(uint8_t *dstRow0, const uint8_t *srcTilePixels); +extern void iigsTilePasteMonoInner(uint8_t *dstRow0, const uint8_t *monoTile, uint16_t fgColor, uint16_t bgColor); extern void iigsTileSnapInner(uint8_t *dstTilePixels, const uint8_t *srcRow0); // Single-pixel and Bresenham line plot. jlDrawLine inner takes // pre-clipped endpoints (caller validates against surface bounds); @@ -90,12 +97,13 @@ extern void iigsDrawLineInner(uint8_t *pixels, uint16_t x0, uint16_t y0, uint16_ // Bresenham midpoint circle outline. Caller has verified the entire // bbox is on-surface so no per-pixel clip. extern void iigsDrawCircleInner(uint8_t *pixels, uint16_t cx, uint16_t cy, uint16_t r, uint16_t nibble); -// Stage-to-SHR full upload: pixels (MVN $01->$E1), SCB, palette. -// Asm uses post-MVN DBR=$E1 to do sta abs,Y for SCB/palette. -// The asm performs the upload directly rather than a C-side memcpy, -// which is unreliable when called from jlpPresent (DBR-state quirk -// after prior asm primitives). -extern void iigsBlitStageToShr(const uint8_t *scbPtr, const uint16_t *palettePtr, uint16_t uploadFlags); +// Stage-to-SHR pixel upload: per-row dirty-skipped PEI slam (wide +// rows) / exact-extent MVN (narrow rows), $01 -> $E1. SCB/palette +// upload is NOT here: uploadScbAndPaletteIfNeeded (below) owns it via +// dirty-flag-gated C memcpy, and the asm's old scbPtr/palettePtr/ +// uploadFlags args plus their dead MVN upload paths were removed +// (PERF-AUDIT #52). +extern void iigsBlitStageToShr(void); // jlFloodFill walk results: written by iigsFloodWalkAndScansInner, // read back by the jlpFloodWalkAndScans macro (core/port.h). extern uint16_t gFloodSeedMatch; @@ -108,7 +116,9 @@ extern void iigsFloodWalkAndScansInner(uint8_t *pixels, uint16_t x, uint16_t y, // One-shot init for the y*160 lookup table (gRowOffsetLut, 400 bytes // in DRAWPRIMS data). Called once from jlpInit. After this returns, // every asm primitive that needs row offset can do `lda >lut,x` instead -// of the 7-instruction shift-add. +// of the 7-instruction shift-add. Also builds tpmNibPairIdx (256 +// bytes), iigsTilePasteMonoInner's mono-byte -> opacity-pair index +// table (PERF-AUDIT #3). extern void iigsInitRowLut(void); // Per-row MVN blit from $01:srcOffset to $E1:srcOffset for partial- // screen presents (the partial-rect present). srcOffset is the byte offset @@ -121,9 +131,9 @@ extern void iigsBlitRectStageToShr(uint16_t srcOffset, uint16_t copyBytes, uint1 // (caller / dispatcher checks). For sprite-rect presents (typical // 8 bytes wide x 16 rows) saves ~600 cyc/frame vs the MVN form. extern void iigsBlitRectStageToShrPEI(uint16_t srcOffset, uint16_t copyBytes, uint16_t rowsLeft); -// Filled circle, scanline-style. fillWord low byte is the doubled -// nibble (e.g., 0x33 for nibble 3). -extern void iigsFillCircleInner(uint8_t *pixels, uint16_t cx, uint16_t cy, uint16_t r, uint16_t fillWord); +// Filled circle, scanline-style. Takes the raw fill nibble; the asm +// derives the doubled fill byte and RMW nibble bytes (PERF-AUDIT #41). +extern void iigsFillCircleInner(uint8_t *pixels, uint16_t cx, uint16_t cy, uint16_t r, uint16_t nibble); // ----- Hardware addresses (24-bit / long pointers) ----- @@ -256,8 +266,11 @@ void jlpBigFree(void *p) { } +static void claimStagePixels(void); + bool jlpInit(const jlConfigT *config) { (void)config; + claimStagePixels(); gPreviousNewVideo = *IIGS_NEWVIDEO_REG; gPreviousBorder = *IIGS_BORDER_REG; gPreviousShadow = *IIGS_SHADOW_REG; @@ -267,10 +280,11 @@ bool jlpInit(const jlConfigT *config) { // write to $01/2000-9FFF mirrors to $E1 and the off-screen-buffer // illusion breaks (the user would see drawing in progress). *IIGS_SHADOW_REG = (uint8_t)(gPreviousShadow | SHADOW_INHIBIT_SHR_MASK); - // SCB and palette are uploaded by jlpPresent's iigsBlitStageToShr - // (asm path, MVN to bank $E1). C-side memset/memcpy to bank $E1 - // is unreliable from jlpInit's calling context, so we don't try - // it here -- the first present will set up SCB to 320 mode. + // SCB and palette are uploaded by jlpPresent's C-side + // uploadScbAndPaletteIfNeeded (the dirty flags start true, so the + // first present sets up SCB to 320 mode). C-side memset/memcpy to + // bank $E1 is unreliable from jlpInit's calling context, so we + // don't try it here. iigsInitRowLut(); gFrameHz = (iigsReadHzParam() == 1u) ? 50u : 60u; gModeSet = true; @@ -283,19 +297,16 @@ void jlpPresent(const jlSurfaceT *src) { return; } // SCB + palette upload via the C-side memcpy to bank $E1, then pixels. - // The asm palette MVN in iigsBlitStageToShr never wrote $E1:9E00 -- the - // SCB MVN landed but colors showed the GS/OS default (pixels/shapes were - // correct, every color wrong). The C memcpy is reliable now that the - // far-const-pointer bank-drop miscompile is fixed (it formerly hit bank - // $00). uploadScbAndPaletteIfNeeded clears the dirty bits, so the asm - // blit (uploadFlags == 0) does pixels only. The asm's now-unreached - // SCB/palette MVN code is dead -- a removal candidate. + // (The asm's old SCB/palette MVN paths never wrote $E1:9E00 reliably and + // were dead once this C path landed; PERF-AUDIT #52 removed them along + // with the per-present scbPtr/palettePtr/uploadFlags marshalling, so + // iigsBlitStageToShr is pixels-only and takes no args.) // KNOWN RESIDUAL: on a DRAW the PEI-slam corrupts $E1:9E20+ (palettes // 1-15; palette 0 and pixels stay correct) -- a peislam.s shadow / soft- // switch spill, independent of upload order. Single-palette apps are // unaffected; multi-palette colors above index 0 need the slam fix. uploadScbAndPaletteIfNeeded(src); - iigsBlitStageToShr(src->scb, &src->palette[0][0], 0); + iigsBlitStageToShr(); } @@ -316,6 +327,56 @@ void jlpShutdown(void) { // iigs*Inner asm entry points above, so no C wrapper functions live here. +// Memory Manager reservation for the pinned framebuffer. Without it the +// MM considers $01:2000-$9CFF free and can place ANY handle inside the +// display -- jlpBigAlloc'd sprite/surface structs landed there and were +// painted over by surface clears (PERF-AUDIT.md #85, the root of the +// #84 sprite pathology). attrAddr claims the exact address; the range +// is in special memory (bank $01) by definition. The claim MUST happen +// in jlpInit, BEFORE the first jlpBigAlloc: stageAlloc allocates the +// stage struct before it calls jlpStageAllocPixels, so claiming here +// would already be too late to keep that struct out of the display. +static void *gStagePixelsHandle = NULL; + +// Bank-0 region the linker uses without telling the Memory Manager: +// the C heap ($BC67-$BF00 in the current layout) AND the bank-0 +// ALIASES of joeyDraw.s's D-relative pointer-scratch symbols +// (dpxlScratch/dlnScratch/dcPixPtr/fcPixPtr, ~$BBB8-$BC66) -- the +// [pix],y pattern reads/writes bank 0 at the symbol's OFFSET. If an MM +// handle (codegen arena, jlpBigAlloc scratch) lands there, every draw +// primitive plots through stomped pointers (PERF-AUDIT.md #88, the +// codegen-on showcase hang). Claim generously: $00:B900-$BEFF. +#define IIGS_BANK0_CLAIM_BASE ((void *)0x00B900L) +#define IIGS_BANK0_CLAIM_BYTES 0x0600ul + +static void *gBank0ScratchHandle = NULL; + +static void claimStagePixels(void) { + if (gStagePixelsHandle == NULL) { + gStagePixelsHandle = NewHandle((unsigned long)SURFACE_PIXELS_SIZE, + _ownerid, + attrAddr | attrFixed | attrLocked, + (void *)IIGS_STAGE_PIXELS); + if (gStagePixelsHandle == NULL) { + // The range is already owned (another app / a prior claim): + // proceed rather than fail init, but leave a breadcrumb for + // jlLastError. (No _toolErr shim in this toolchain yet; a + // NULL handle is the only failure signal.) + coreSetError("stage framebuffer MM reservation failed"); + } + } + if (gBank0ScratchHandle == NULL) { + gBank0ScratchHandle = NewHandle(IIGS_BANK0_CLAIM_BYTES, + _ownerid, + attrAddr | attrFixed | attrLocked, + IIGS_BANK0_CLAIM_BASE); + if (gBank0ScratchHandle == NULL) { + coreSetError("bank-0 heap/scratch MM reservation failed"); + } + } +} + + uint8_t *jlpStageAllocPixels(void) { return IIGS_STAGE_PIXELS; } @@ -323,7 +384,15 @@ uint8_t *jlpStageAllocPixels(void) { void jlpStageFreePixels(uint8_t *pixels) { (void)pixels; - // Backing memory is hardware-pinned; nothing to free. + // Backing memory is hardware-pinned; release only the MM claims. + if (gStagePixelsHandle != NULL) { + DisposeHandle(gStagePixelsHandle); + gStagePixelsHandle = NULL; + } + if (gBank0ScratchHandle != NULL) { + DisposeHandle(gBank0ScratchHandle); + gBank0ScratchHandle = NULL; + } } @@ -341,46 +410,29 @@ void jlpStageFreePixels(uint8_t *pixels) { * the legacy paths did. Same logic as the DOS port. */ -// $C019 RDVBLBAR: bit 7 = 0 during vertical blank, 1 during active -// scan. To produce a rising-edge wait (one VBL per call), first spin -// while VBL is currently active (bit 7 = 0), then spin until VBL -// fires again (bit 7 returns to 0). The IIgs SHR refresh is 60 Hz. +// Block until the next VBL boundary by waiting for the firmware tick +// (GetTick, incremented by the VBL interrupt handler) to advance. The +// old form polled $C019's VBL status bit, which never reads high under +// MAME's apple2gs (PERF-AUDIT.md finding #76): the wait-for-scan loop +// spun forever, hanging every program that paces with jlWaitVBL. UBER +// is the only example that calls it, so no verification gate ever +// caught this. Tick granularity IS the VBL boundary, so semantics are +// unchanged on real hardware; the caller must not hold SEI (a masked +// VBL interrupt freezes the tick, exactly as it froze the old bit). void jlpWaitVBL(void) { - while ((*IIGS_VBL_STATUS & VBL_BAR_BIT) == 0) { - /* already in VBL: wait for active scan */; - } - while ((*IIGS_VBL_STATUS & VBL_BAR_BIT) != 0) { - /* scanning: wait for next VBL */; + uint16_t tick; + + tick = iigsGetTickWord(); + while (iigsGetTickWord() == tick) { + /* wait for the VBL interrupt to advance the tick */; } } -// Frame counter via $C019 polling. Edge-detected on each call: the -// caller (UBER, animation loops) polls fast enough that we never -// miss a VBL transition. No IRQ involvement; safe in the S16 takeover -// context where ToolBox interrupt setup would be intrusive. -// -// gFrameCount uses an explicit lda+adc+sta read-modify-write rather -// than `gFrameCount++` because the post-increment can lower to -// `inc |gFrameCount` (the only INC abs form on 65816 -- there is no -// INC long-abs). With this file in the DRAWPRIMS load segment but -// jlpFrameCount called from CORESYS via JSL, DBR isn't pointing at -// DRAWPRIMS's data bank, so the abs INC silently mutates the wrong -// byte and the counter never advances. The explicit lda > / sta > -// pattern uses long-mode addressing throughout, which is -// DBR-independent. -uint16_t jlpFrameCount(void) { - return iigsGetTickWord(); -} +// (jlpFrameCount is a port.h macro straight to iigsGetTickWord -- the +// GetTick asm wrapper in peislam.s -- so no C wrapper lives here.) uint16_t jlpFrameHz(void) { return gFrameHz; } - - -// IIgs: frame-counter approximation. time() is avoided here, so -// we approximate wall-clock ms from the 16-bit frame counter at -// gFrameHz (60 Hz). The 16-bit counter wraps every ~1090 sec, so -// callers tracking deltas should use this for short intervals only -// (sound event scheduling is fine). diff --git a/src/iigs/joeyDraw.s b/src/iigs/joeyDraw.s index adf2b86..fe74fdf 100644 --- a/src/iigs/joeyDraw.s +++ b/src/iigs/joeyDraw.s @@ -33,6 +33,11 @@ ; this once from jlpInit; afterwards every primitive that needs y -> ; row byte offset can do `lda gRowOffsetLut,x` (X = y*2) in a few cyc ; instead of a shift-add chain. 200 entries x 2 bytes = 400 bytes. +; +; Also builds tpmNibPairIdx (256 bytes) for iigsTilePasteMonoInner: +; tpmNibPairIdx[b] = ((b & 0xF0) ? 2 : 0) | ((b & 0x0F) ? 1 : 0), the +; 2-bit "which nibbles are opaque" index into the per-call fg/bg combo +; bytes (PERF-AUDIT #3). ; ---------------------------------------------------------------- .section .text.iigsInitRowLut,"ax" .globl iigsInitRowLut @@ -51,6 +56,34 @@ initLutLoop: inx cpx #400 bcc initLutLoop + +; tpmNibPairIdx build: byte stores, so M=8 (X stays 16 for the index). + sep #0x20 + .a8 + ldx #0 ; X = b +nibLutLoop: + txa ; A = b (low 8 bits of X) + and #0xF0 + beq nibLutHiZero + lda #2 + bra nibLutHiDone +nibLutHiZero: + lda #0 +nibLutHiDone: + sta tpmNibPairIdx, x + txa + and #0x0F + beq nibLutNext + lda tpmNibPairIdx, x + ora #1 + sta tpmNibPairIdx, x +nibLutNext: + inx + cpx #256 + bcc nibLutLoop + rep #0x20 + .a16 + .a8 .i8 plp @@ -335,16 +368,19 @@ tileScratchPtrs: ; ==================================================================== -; iigsTileFillInner(uint8_t *dstRow0, uint16_t fillWord) +; iigsTileFillInner(uint8_t *dstRow0, uint16_t nibble) ; ; Original: 16 STA abs,X stores at fixed offsets along a 160-byte ; stride (DBR set to the dst bank via PHB/PLB so abs,X rode the right ; bank). ~120 cyc per call vs the C version's ~300. ; ; ABI: arg0 dstRow0 in A:X (A = dst low16, X = dst high16 incl. bank). -; arg1 fillWord (word) on the stack: @4,s on JSL entry; after -; PHP+PHB+PHD(+4) it sits @8,s. Only fillWord is on the stack and -; dst comes in A:X. +; arg1 nibble (word, pre-masked to 0x0..0xF by the jlpTileFill +; macro) on the stack: @4,s on JSL entry; after PHP+PHB+PHD(+4) it +; sits @8,s. Only the nibble is on the stack and dst comes in A:X. +; The $NNNN fill word is derived HERE from the nibble (shift/ORA +; chain, ~26 cyc) so C never pays the * 0x1111 software-multiply +; helper for a runtime color (PERF-AUDIT #41). ; ; PORTING NOTE (addressing mode): the original used `sta |off,x` ; (16-bit absolute,X, DBR = dst bank). llvm-mc here unconditionally @@ -380,7 +416,18 @@ iigsTileFillInner: tsc inc a ; A = SP+1 tcd ; D -> stacked dst; [0] = dst - lda 12,s ; fillWord (8,s + 4 just pushed) + +; Derive fillWord = nibble * 0x1111 in asm (#41). tileScratchPtrs+4 is +; free scratch here: tileFill stages no src pointer. + lda 12,s ; nibble (8,s + 4 just pushed) + asl a + asl a + asl a + asl a ; A = nibble << 4 + ora 12,s ; A = nibble * 0x11 (doubled byte) + sta tileScratchPtrs+4 + xba ; A = doubled byte << 8 + ora tileScratchPtrs+4 ; A = nibble * 0x1111 = fillWord ldy #0 sta [0], y @@ -799,6 +846,413 @@ iigsTileSnapInner: rtl +; ==================================================================== +; iigsTilePasteMonoInner(uint8_t *dstRow0, const uint8_t *monoTile, +; uint16_t fgColor, uint16_t bgColor) +; +; Fused mono colorize + paste (PERF-AUDIT #3): expands a 32-byte mono +; tile (4 bytes/row * 8 rows, tight) straight into surface rows at +; `dstRow0` (stride 160) -- no intermediate jlTileT colorize pass and +; no second jlpTilePaste call. Contract is jlpGenericTilePasteMono +; (src/generic/genericTile.c): per source byte, a NON-ZERO high nibble +; selects fgColor for the left pixel (else bgColor), a NON-ZERO low +; nibble selects fgColor for the right pixel (else bgColor). fgColor/ +; bgColor arrive PRE-MASKED to 4 bits (the public jlTilePasteMono +; wrapper owns the & 0x0F, PERF-AUDIT #69) -- no re-mask here. +; +; Design: each source byte maps to one of only FOUR output bytes +; (bg/bg, bg/fg, fg/bg, fg/fg). A static 256-byte table +; (tpmNibPairIdx, built once in iigsInitRowLut) collapses the byte to +; its 2-bit opacity-pair index; the four combo bytes are built per +; call (4 bytes, ~30 cyc) into the bank-0 stack D-frame. The body is +; then branch-free: load src byte -> index the static table -> index +; the combo bytes -> store. ~33 cyc/byte. This beats both a per-call +; 16-word LUT (word-at-a-time needs a ~300-cyc per-call build AND two +; table hops per word anyway) and inline branch selection on the +; nibble tests (~58 cyc/byte, data-dependent). +; +; ABI: arg0 dstRow0 in A:X. arg1 monoTile (4-byte ptr), arg2 fgColor +; (word), arg3 bgColor (word) on the stack. JSL entry: mono.lo +; @4,s, mono.bank @6,s, fg @8,s, bg @10,s. No PHB (DBR is never +; written), so after PHP+PHD(+3): mono.lo @7,s, mono.bank @9,s, +; fg @11,s, bg @13,s; after the comboHi push (+2): fg @13,s, +; bg @15,s. Same bank-0 stack D-frame as iigsTilePasteInner, two +; words wider: +; D+0..3 dst far ptr -> [tpmDst],y +; D+4..7 src far ptr -> [tpmSrc],y +; D+8..11 combo bytes -> lda tpmCombo,x (dp,x; idx 0..3) +; combo[idx]: bit1 of idx = hi nibble opaque -> fg in high output +; nibble; bit0 = lo nibble opaque -> fg in low output nibble: +; D+8 = bg:bg D+9 = bg:fg D+10 = fg:bg D+11 = fg:fg +; ==================================================================== +.section .text.iigsTilePasteMonoInner,"ax" +.globl iigsTilePasteMonoInner +iigsTilePasteMonoInner: +tpmDst = 0 +tpmSrc = 4 +tpmCombo = 8 + + php + phd ; save caller D + rep #0x30 + .a16 + .i16 + + ; Stage dst (A:X) and src (stack) exactly like + ; iigsTilePasteInner: tileScratchPtrs is LOAD-bank BSS, so it + ; is only a staging area -- the D-frame the indirects read + ; through is rebuilt on the bank-0 stack below. + sta tileScratchPtrs+0 ; dst low16 + stx tileScratchPtrs+2 ; dst high16 (bank + pad) + lda 7,s ; src low16 + sta tileScratchPtrs+4 + lda 9,s ; src bank + sta tileScratchPtrs+6 + + ; Build the four combo bytes. fg/bg are pre-masked words + ; (high byte 0), so every intermediate below has a clean + ; high byte for the XBA merges. + lda 11,s ; fg + asl a + asl a + asl a + asl a + sta tpmFgHi ; fg << 4 + lda 13,s ; bg + asl a + asl a + asl a + asl a + sta tpmBgHi ; bg << 4 + ; comboHi word: high byte fg:fg (idx 3), low byte fg:bg (idx 2) + lda tpmFgHi + ora 11,s ; A = fg:fg + xba ; A = fg:fg << 8 + ora tpmFgHi + ora 13,s ; low byte = fg:bg + pha ; -> D+10..11 (args now +2: fg @13,s bg @15,s) + ; comboLo word: high byte bg:fg (idx 1), low byte bg:bg (idx 0) + lda tpmBgHi + ora 13,s ; A = bg:fg + xba ; A = bg:fg << 8 + ora tpmBgHi + ora 15,s ; low byte = bg:bg + pha ; -> D+8..9 + + ; Stack D-frame: {dst, src} under the combo words. + lda tileScratchPtrs+6 + pha ; src bank -> D+6 + lda tileScratchPtrs+4 + pha ; src low16 -> D+4 + lda tileScratchPtrs+2 + pha ; dst high16 -> D+2 + lda tileScratchPtrs+0 + pha ; dst low16 -> D+0 + tsc + inc a ; A = SP+1 + tcd ; D -> stacked {dst,src,combos} + + lda #0 ; B := 0 so M=8 TAX gives a clean 16-bit index + sep #0x20 ; M=8, X/Y stay 16 + .a8 + +; 32 unrolled byte expansions: src offset = row*4 + col (packed), +; dst offset = row*160 + col. M=8 loads never touch B, so every TAX +; below transfers 0:byte. + ; Row 0: src 0..3 == dst 0..3, so one Y serves both. + ldy #0 + lda [tpmSrc], y + tax + lda tpmNibPairIdx, x + tax + lda tpmCombo, x + sta [tpmDst], y + ldy #1 + lda [tpmSrc], y + tax + lda tpmNibPairIdx, x + tax + lda tpmCombo, x + sta [tpmDst], y + ldy #2 + lda [tpmSrc], y + tax + lda tpmNibPairIdx, x + tax + lda tpmCombo, x + sta [tpmDst], y + ldy #3 + lda [tpmSrc], y + tax + lda tpmNibPairIdx, x + tax + lda tpmCombo, x + sta [tpmDst], y + ; Row 1: src 4..7, dst 160..163 + ldy #4 + lda [tpmSrc], y + tax + lda tpmNibPairIdx, x + tax + lda tpmCombo, x + ldy #160 + sta [tpmDst], y + ldy #5 + lda [tpmSrc], y + tax + lda tpmNibPairIdx, x + tax + lda tpmCombo, x + ldy #161 + sta [tpmDst], y + ldy #6 + lda [tpmSrc], y + tax + lda tpmNibPairIdx, x + tax + lda tpmCombo, x + ldy #162 + sta [tpmDst], y + ldy #7 + lda [tpmSrc], y + tax + lda tpmNibPairIdx, x + tax + lda tpmCombo, x + ldy #163 + sta [tpmDst], y + ; Row 2: src 8..11, dst 320..323 + ldy #8 + lda [tpmSrc], y + tax + lda tpmNibPairIdx, x + tax + lda tpmCombo, x + ldy #320 + sta [tpmDst], y + ldy #9 + lda [tpmSrc], y + tax + lda tpmNibPairIdx, x + tax + lda tpmCombo, x + ldy #321 + sta [tpmDst], y + ldy #10 + lda [tpmSrc], y + tax + lda tpmNibPairIdx, x + tax + lda tpmCombo, x + ldy #322 + sta [tpmDst], y + ldy #11 + lda [tpmSrc], y + tax + lda tpmNibPairIdx, x + tax + lda tpmCombo, x + ldy #323 + sta [tpmDst], y + ; Row 3: src 12..15, dst 480..483 + ldy #12 + lda [tpmSrc], y + tax + lda tpmNibPairIdx, x + tax + lda tpmCombo, x + ldy #480 + sta [tpmDst], y + ldy #13 + lda [tpmSrc], y + tax + lda tpmNibPairIdx, x + tax + lda tpmCombo, x + ldy #481 + sta [tpmDst], y + ldy #14 + lda [tpmSrc], y + tax + lda tpmNibPairIdx, x + tax + lda tpmCombo, x + ldy #482 + sta [tpmDst], y + ldy #15 + lda [tpmSrc], y + tax + lda tpmNibPairIdx, x + tax + lda tpmCombo, x + ldy #483 + sta [tpmDst], y + ; Row 4: src 16..19, dst 640..643 + ldy #16 + lda [tpmSrc], y + tax + lda tpmNibPairIdx, x + tax + lda tpmCombo, x + ldy #640 + sta [tpmDst], y + ldy #17 + lda [tpmSrc], y + tax + lda tpmNibPairIdx, x + tax + lda tpmCombo, x + ldy #641 + sta [tpmDst], y + ldy #18 + lda [tpmSrc], y + tax + lda tpmNibPairIdx, x + tax + lda tpmCombo, x + ldy #642 + sta [tpmDst], y + ldy #19 + lda [tpmSrc], y + tax + lda tpmNibPairIdx, x + tax + lda tpmCombo, x + ldy #643 + sta [tpmDst], y + ; Row 5: src 20..23, dst 800..803 + ldy #20 + lda [tpmSrc], y + tax + lda tpmNibPairIdx, x + tax + lda tpmCombo, x + ldy #800 + sta [tpmDst], y + ldy #21 + lda [tpmSrc], y + tax + lda tpmNibPairIdx, x + tax + lda tpmCombo, x + ldy #801 + sta [tpmDst], y + ldy #22 + lda [tpmSrc], y + tax + lda tpmNibPairIdx, x + tax + lda tpmCombo, x + ldy #802 + sta [tpmDst], y + ldy #23 + lda [tpmSrc], y + tax + lda tpmNibPairIdx, x + tax + lda tpmCombo, x + ldy #803 + sta [tpmDst], y + ; Row 6: src 24..27, dst 960..963 + ldy #24 + lda [tpmSrc], y + tax + lda tpmNibPairIdx, x + tax + lda tpmCombo, x + ldy #960 + sta [tpmDst], y + ldy #25 + lda [tpmSrc], y + tax + lda tpmNibPairIdx, x + tax + lda tpmCombo, x + ldy #961 + sta [tpmDst], y + ldy #26 + lda [tpmSrc], y + tax + lda tpmNibPairIdx, x + tax + lda tpmCombo, x + ldy #962 + sta [tpmDst], y + ldy #27 + lda [tpmSrc], y + tax + lda tpmNibPairIdx, x + tax + lda tpmCombo, x + ldy #963 + sta [tpmDst], y + ; Row 7: src 28..31, dst 1120..1123 + ldy #28 + lda [tpmSrc], y + tax + lda tpmNibPairIdx, x + tax + lda tpmCombo, x + ldy #1120 + sta [tpmDst], y + ldy #29 + lda [tpmSrc], y + tax + lda tpmNibPairIdx, x + tax + lda tpmCombo, x + ldy #1121 + sta [tpmDst], y + ldy #30 + lda [tpmSrc], y + tax + lda tpmNibPairIdx, x + tax + lda tpmCombo, x + ldy #1122 + sta [tpmDst], y + ldy #31 + lda [tpmSrc], y + tax + lda tpmNibPairIdx, x + tax + lda tpmCombo, x + ldy #1123 + sta [tpmDst], y + + rep #0x30 ; M=16 before the epilogue math + .a16 + .i16 + tsc + clc + adc #12 ; drop the {dst,src,combos} D-frame + tcs + .a8 + .i8 + pld + plp + rtl + + +; iigsTilePasteMonoInner per-call scratch (fg<<4 / bg<<4 staging). +.section .bss.tpmFgHi,"aw" +.globl tpmFgHi +tpmFgHi: +.zero 2 + +.section .bss.tpmBgHi,"aw" +.globl tpmBgHi +tpmBgHi: +.zero 2 + +; Static mono-byte -> 2-bit opacity-pair index table (built once in +; iigsInitRowLut): tpmNibPairIdx[b] = ((b&0xF0)?2:0) | ((b&0x0F)?1:0). +.section .bss.tpmNibPairIdx,"aw" +.globl tpmNibPairIdx +tpmNibPairIdx: +.zero 256 + + ; ==================================================================== ; iigsTileCopyMaskedInner(uint8_t *dst, const uint8_t *src, ; uint16_t transparent) @@ -1399,10 +1853,11 @@ friSlamWords: ; struct (dpxlScratch): store pixels (A:X) at dpxlScratch+0..2 and use ; [pix],y with pix=0. Because D no longer overlays the stack frame, the ; x,y,nibble args are read stack-relative. -; Prologue pushes: php(+1)+phb(+1)+phd(+2)=+4. On JSL entry x@4,s y@6,s -; nib@8,s; after the 4 pushes: x@8,s y@10,s nib@12,s. +; No PHB/PLB: DBR is never written here (PERF-AUDIT #53), so the frame +; is php(+1)+phd(+2)=+3. On JSL entry x@4,s y@6,s nib@8,s; after the 3 +; push bytes: x@7,s y@9,s nib@11,s. ; pix=0 (D-relative, = dpxlScratch+0) -; xx=8,s yy=10,s nib=12,s +; xx=7,s yy=9,s nib=11,s ; pix is a 3-byte far pointer (A low16 + X bank byte); we store A then X ; as words -> a harmless 4th filler byte in the 4-byte scratch. ; ==================================================================== @@ -1413,7 +1868,6 @@ iigsDrawPixelInner: pix = 0 ; pixels far ptr (D+0..2 = dpxlScratch) php - phb phd rep #0x30 .a16 @@ -1422,7 +1876,7 @@ pix = 0 ; pixels far ptr (D+0..2 = dpxlScra ; Stash the pixels far pointer into the DP scratch via D-RELATIVE stores ; (AFTER tcd): a plain `sta dpxlScratch` writes the LOAD bank (DBR) while ; [pix],y reads through D, which is ALWAYS bank 0 -- the two disagreed and the -; plot pointer was garbage. phx/pha preserve pix (A:X survive php/phb/phd) +; plot pointer was garbage. phx/pha preserve pix (A:X survive php/phd) ; across the `lda #dpxlScratch` that clobbers A; pulled back so the N,s arg ; offsets below keep their values. phx ; save pix bank @@ -1434,27 +1888,26 @@ pix = 0 ; pixels far ptr (D+0..2 = dpxlScra pla ; pix bank sta pix+2 ; D-relative -> bank0:dpxlScratch+2 -; Compute byte offset = y*160 + (x>>1) into A. Use the LUT. - lda 10,s ; y +; Compute byte offset = y*160 + (x>>1) into A. Fold the LUT row directly +; into the add (adc gRowOffsetLut,x) -- no scratch round-trip (#53). + lda 9,s ; y asl a ; A = y*2 (LUT byte offset) tax - lda gRowOffsetLut,x ; A = y*160 - sta dpxlTmp - lda 8,s ; x + lda 7,s ; x lsr a ; A = x >> 1 clc - adc dpxlTmp ; A = byte offset within surface + adc gRowOffsetLut,x ; A = x>>1 + y*160 tay sep #0x20 ; M=8 .a8 - lda 8,s ; A = x low byte (parity in LSB) + lda 7,s ; A = x low byte (parity in LSB) and #1 bne dpxlOdd ; Even x -> high nibble. - lda 12,s ; nibble + lda 11,s ; nibble asl a asl a asl a @@ -1468,7 +1921,7 @@ pix = 0 ; pixels far ptr (D+0..2 = dpxlScra dpxlOdd: ; Odd x -> low nibble. - lda 12,s ; nibble + lda 11,s ; nibble and #0x0F sta dpxlNibPart lda [pix],y @@ -1482,15 +1935,10 @@ dpxlDone: .a8 .i8 pld - plb plp rtl -.section .bss.dpxlTmp,"aw" -.globl dpxlTmp -dpxlTmp: - .zero 2 .section .bss.dpxlNibPart,"aw" .globl dpxlNibPart dpxlNibPart: @@ -1529,14 +1977,7 @@ dpxlScratch: .section .text.iigsDrawLineInner,"ax" .globl iigsDrawLineInner iigsDrawLineInner: -pix = 0 ; pixels far ptr (D+0..2 = dlnScratch) - -; Stash pixels far pointer (A:X) into bank-0 scratch before any push. - rep #0x30 - .a16 - .i16 - sta dlnScratch ; low 16 bits of pixels - stx dlnScratch+2 ; bank byte (+ filler) +pix = 0 ; pixels far ptr (D+0..2 = bank0 scratch) php phb @@ -1545,9 +1986,21 @@ pix = 0 ; pixels far ptr (D+0..2 = dlnScrat .a16 .i16 -; Repoint D at the scratch struct so [pix],y (pix=0) is DP-indirect-long. +; Stash the pixels far pointer via D-RELATIVE stores (AFTER tcd): a plain +; `sta dlnScratch` writes the LOAD bank (DBR) while [pix],y reads through +; D, which is ALWAYS bank 0 -- the two disagreed, so every plot went +; through a garbage bank-0 pointer (PERF-AUDIT.md #83: wrong pixels +; always; #86: a layout shift let the plots stomp the dln* walk state, +; hanging the Bresenham). Identical fix to iigsDrawPixelInner / +; iigsDrawCircleInner; phx/pha keep the N,s arg offsets unchanged. + phx ; save pix bank + pha ; save pix offset lda #dlnScratch - tcd + tcd ; D = dlnScratch (bank 0); [pix],y == [0],y + pla + sta pix+0 ; D-relative -> bank0 scratch+0 + pla + sta pix+2 ; D-relative -> bank0 scratch+2 ; dx = |x1 - x0|, sx = sign(x1 - x0) lda 12,s ; x1 @@ -1645,10 +2098,13 @@ dlnLoop: sta [pix],y bra dlnPlotDone dlnPlotOdd: -; Odd x -> low nibble. Mask keeps high nibble of target byte and the -; whole adjacent high byte; OR sets nib into the low nibble. +; Odd x -> low nibble. 65816 is little-endian: byte[Y] is the LOW byte +; of A, so $FFF0 clears the target's low nibble and preserves the whole +; adjacent byte at Y+1; OR sets nib into the low nibble. (The old $F0FF +; had the bytes swapped: it never cleared the target nibble and erased +; the neighbor's low nibble -- PERF-AUDIT.md #87.) lda [pix],y - and #0xF0FF + and #0xFFF0 ora dlnNibLo sta [pix],y dlnPlotDone: @@ -1893,11 +2349,13 @@ dcLoopBody: ; 4. test col & 1 (via txa); do high or low nibble RMW ; ; All 8 octant plots stay in M=16 -- no per-plot SEP/REP flip. The -; nibble RMW reads the target byte plus its adjacent high byte (16-bit -; load), masks so only the target nibble changes ($FF0F keeps low -; nibble + whole high byte for even/high-nibble x, $F0FF keeps high -; nibble + whole high byte for odd/low-nibble x), then writes both -; bytes back. +; nibble RMW reads the target byte plus its adjacent byte at Y+1 +; (16-bit load; the 65816 is little-endian so byte[Y] is the LOW byte +; of A), masks so only the target nibble changes ($FF0F clears the +; target's high nibble for even x, $FFF0 clears the target's low +; nibble for odd x -- both preserve the whole adjacent byte), then +; writes both bytes back. (The old odd mask $F0FF had its bytes +; swapped and corrupted the neighbor -- PERF-AUDIT.md #87.) ; Octants 1-4: y-row pair (cx +/- x, cy +/- y). ; Plot 1: (cx+x, cy+y) @@ -1919,7 +2377,7 @@ dcLoopBody: bra dcDone1 dcOdd1: lda [pix], y - and #0xF0FF + and #0xFFF0 ora dcNibLo sta [pix], y dcDone1: @@ -1943,7 +2401,7 @@ dcDone1: bra dcDone2 dcOdd2: lda [pix], y - and #0xF0FF + and #0xFFF0 ora dcNibLo sta [pix], y dcDone2: @@ -1967,7 +2425,7 @@ dcDone2: bra dcDone3 dcOdd3: lda [pix], y - and #0xF0FF + and #0xFFF0 ora dcNibLo sta [pix], y dcDone3: @@ -1991,7 +2449,7 @@ dcDone3: bra dcDone4 dcOdd4: lda [pix], y - and #0xF0FF + and #0xFFF0 ora dcNibLo sta [pix], y dcDone4: @@ -2016,7 +2474,7 @@ dcDone4: bra dcDone5 dcOdd5: lda [pix], y - and #0xF0FF + and #0xFFF0 ora dcNibLo sta [pix], y dcDone5: @@ -2040,7 +2498,7 @@ dcDone5: bra dcDone6 dcOdd6: lda [pix], y - and #0xF0FF + and #0xFFF0 ora dcNibLo sta [pix], y dcDone6: @@ -2064,7 +2522,7 @@ dcDone6: bra dcDone7 dcOdd7: lda [pix], y - and #0xF0FF + and #0xFFF0 ora dcNibLo sta [pix], y dcDone7: @@ -2088,7 +2546,7 @@ dcDone7: bra dcDone8 dcOdd8: lda [pix], y - and #0xF0FF + and #0xFFF0 ora dcNibLo sta [pix], y dcDone8: @@ -2156,7 +2614,7 @@ dcExit: ; ================================================================ ; iigsFillCircleInner(uint8_t *pixels, uint16_t cx, uint16_t cy, -; uint16_t r, uint16_t fillWord) +; uint16_t r, uint16_t nibble) ; ; Filled circle with horizontal-span scanline output. Caller has ; verified the bbox fits inside the surface, so the inner loop fills @@ -2164,17 +2622,20 @@ dcExit: ; incrementally so the hot path uses only 16-bit add/sub/cmp -- no ; 65816 multiply at all (other than one r*r at setup). ; -; fillWord is the doubled byte (low byte) replicated, e.g. nibble 3 -; -> $3333 (we only use the low byte for nibble RMW). +; nibble is the raw fill color (pre-masked to 0x0..0xF by the +; jlpFillCircle macro); the doubled fill byte and the two RMW nibble +; bytes are derived HERE (shift/ORA, cheaper than it was to unpack the +; old pre-multiplied fillWord arg) so C never pays the * 0x1111 +; software-multiply helper for a runtime color (PERF-AUDIT #41). ; ; ABI (llvm-mos w65816 cdecl): ; pix -> arg0 in A:X. USED via [fpix],y, so stashed into bank-0 ; scratch fcPixPtr (offset@+0, bank@+2) and D pointed at ; it; fpix equ 0 -> [D+0],y. -; cx,cy,r,fillWord -> on the stack. JSL entry cx@4,s cy@6,s r@8,s -; fill@10,s. After php+phb+phd (+4): cx@8,s cy@10,s -; r@12,s fill@14,s. S is constant in the outer body; the -; jsr-called helpers (fcMul16/fcDoSpan) only touch +; cx,cy,r,nibble -> on the stack. JSL entry cx@4,s cy@6,s r@8,s +; nibble@10,s. After php+phb+phd (+4): cx@8,s cy@10,s +; r@12,s nibble@14,s. S is constant in the outer body; +; the jsr-called helpers (fcMul16/fcDoSpan) only touch ; scratch globals + [fpix],y, never N,s, so the inner ; jsr-pushed return PC never disturbs an arg read. ; ================================================================ @@ -2184,7 +2645,7 @@ fpix = 0 ; D-relative: D points at fcPixPtr fcArgCx = 8 ; cx @ 8,s after +4 prologue fcArgCy = 10 ; cy @ 10,s fcArgR = 12 ; r @ 12,s -fcArgFill = 14 ; fillWord @ 14,s +fcArgFill = 14 ; nibble @ 14,s iigsFillCircleInner: php phb @@ -2193,30 +2654,32 @@ iigsFillCircleInner: .a16 .i16 -; Stash the pix far pointer (A:X) into bank-0 scratch and point D at it. - sta fcPixPtr ; offset low 16 bits - txa - sta fcPixPtr+2 ; low byte = bank +; Stash the pix far pointer via D-RELATIVE stores (AFTER tcd) -- a plain +; `sta fcPixPtr` writes the LOAD bank (DBR) while [fpix],y reads through +; D = bank 0 (the #83/#86 garbage-plot-pointer bug; same fix as +; iigsDrawPixelInner). phx/pha keep the N,s arg offsets unchanged. + phx ; save pix bank + pha ; save pix offset lda #fcPixPtr - tcd ; D = &fcPixPtr ; [fpix],y == [0],y + tcd ; D = fcPixPtr (bank 0); [fpix],y == [0],y + pla + sta 0 ; D-relative -> bank0 scratch+0 (fpix lo16) + pla + sta 2 ; D-relative -> bank0 scratch+2 (bank byte) -; Cache fill nibble bytes. ffill low byte = doubled byte (e.g., $33). -; fcFillByte = doubled low byte (full byte fills + low nibble). +; Derive the fill bytes from the raw nibble arg (#41). +; fcFillByte = doubled byte (full byte fills + low nibble), e.g. $33. ; fcFillHi = (nibble << 4) only (high nibble RMW). ; fcFillLo = nibble only (low nibble RMW). - lda fcArgFill, s - and #0x00FF - sta fcFillByte ; doubled byte e.g. $33 - lsr a - lsr a - lsr a - lsr a ; A = nibble + lda fcArgFill, s ; A = nibble (pre-masked 0x0..0xF) sta fcFillLo asl a asl a asl a asl a ; A = nibble << 4 sta fcFillHi + ora fcFillLo + sta fcFillByte ; doubled byte e.g. $33 ; Compute r*r via 16-bit shift-and-add. Only done once. lda fcArgR, s @@ -2699,42 +3162,27 @@ fcFillHi: ; ==================================================================== -; iigsBlitStageToShr(uint8_t *scbPtr, uint16_t *palettePtr, -; uint16_t uploadFlags) +; iigsBlitStageToShr(void) ; -; SHR upload: optional SCB (200 bytes) and palette (512 bytes) uploads -; via direct-$E1 MVN, then a per-row dirty-skipped, chunked-SEI PEI -; slam of the 32000-byte pixel buffer from the bank-$01 back buffer to -; the $E1 SHR frame buffer. WIDE dirty rows take the unrolled 80-PEI -; full-row slam; NARROW dirty rows take an exact-extent MVN. +; SHR pixel upload: a per-row dirty-skipped, chunked-SEI PEI slam of +; the 32000-byte pixel buffer from the bank-$01 back buffer to the $E1 +; SHR frame buffer. WIDE dirty rows take the unrolled 80-PEI full-row +; slam; NARROW dirty rows take an exact-extent MVN. ; -; Original args (D-frame, D=SP+8, every arg on stack): -; scbPtr D+0..3 (long ptr) -; palettePtr D+4..7 (long ptr) -; uploadFlags D+8..9 (uint16_t bitmask) +; SCB/palette upload does NOT live here: uploadScbAndPaletteIfNeeded +; (hal.c) owns it via dirty-flag-gated C memcpy before this is called. +; The old scbPtr/palettePtr/uploadFlags args and their flag-gated MVN +; upload blocks were dead (uploadFlags was hard-coded 0 at the only +; call site) and were deleted along with the per-present argument +; marshalling they required (PERF-AUDIT #52). ; -; ABI: under clang scbPtr (arg0, ptr) arrives in A:X and is NOT on the -; stack; palettePtr (arg1, ptr) and uploadFlags (arg2, word) are on the -; stack. JSL entry: palettePtr @4,s, uploadFlags @8,s; after -; php+phb+phd (+4): palettePtr @8,s, uploadFlags @12,s. -; -; The original read scbPtr/palettePtr/uploadFlags via a D-frame -; (bscb=D+0, bpal=D+4, bflags=D+8). We rebuild that exact frame in a -; private bank-0 scratch block (bsFrame) -- D+0..3 must be writable -; scratch and cannot overlap the saved registers / return address the -; way the original's stack args did -- copy scbPtr from A:X to +0..3, -; palettePtr from the stack to +4..7, uploadFlags to +8, then point D -; at bsFrame so all `bscb`/`bpal`/`bflags` reads work unchanged. Phase -; 3 (the slam) clobbers D freely via TCD to row bases (exactly as the -; original); the final PLD restores the caller's D (saved by our PHD) -; AND therefore restores DBR=0 expectations -- DBR is restored by PLB. +; ABI: no args. Phase 3 (the slam) clobbers D freely via TCD to row +; bases; the final PLD restores the caller's D (saved by our PHD) and +; PLB restores DBR (the narrow-row MVNs leave DBR=$E1). ; ==================================================================== .section .text.iigsBlitStageToShr,"ax" .globl iigsBlitStageToShr iigsBlitStageToShr: -bscb = 0 -bpal = 4 -bflags = 8 ; A dirty row with (maxWord-minWord) < PEI_NARROW_MAX uses an exact MVN ; instead of the full-row PEI slam. 32 keeps a safe margin below the ; ~34-word MVN/PEI break-even. @@ -2747,35 +3195,21 @@ PEI_NARROW_MAX = 32 .a16 .i16 - ; DBR must be the CALLER's data bank (= the bank our globals / - ; D-frame live in). crt0 set it and a JSL preserves it across - ; the cross-seg call, so at entry DBR already equals that bank. - ; We must NOT force DBR := PBR (`phk`): under MULTI-SEGMENT this - ; asm can land in a different bank (seg2) than the globals - ; (seg1), so PBR != globals bank and phk would point DBR at the - ; wrong bank -> garbage dirty-band / gPei* reads -> corrupted - ; present. (Two earlier GS/OS traps this path also fixes: args - ; were read back via a DP frame under DBR=$08 -> wrong bank, now - ; read via the SAME absolute addressing; and each MVN leaves - ; DBR=$E1, undone after every MVN -- see below.) + ; DBR must be the CALLER's data bank (= the bank our globals + ; live in). crt0 set it and a JSL preserves it across the + ; cross-seg call, so at entry DBR already equals that bank. + ; We must NOT force DBR := PBR (`phk`): under MULTI-SEGMENT + ; this asm can land in a different bank (seg2) than the + ; globals (seg1), so PBR != globals bank and phk would point + ; DBR at the wrong bank -> garbage dirty-band / gPei* reads + ; -> corrupted present. ; - ; Stash args into absolute scratch (DBR = caller/globals bank). - ; scbPtr in A:X; palettePtr/flags on stack (post php+phb+phd - ; +4: @8,s @12,s). - sta bsFrame+0 ; scbPtr.lo (A) - stx bsFrame+2 ; scbPtr.hi (X, bank+pad) - lda 8,s ; palettePtr.lo - sta bsFrame+4 - lda 10,s ; palettePtr.hi (bank+pad) - sta bsFrame+6 - lda 12,s ; uploadFlags - sta bsFrame+8 ; Capture the caller's DBR (= globals bank, saved by phb at - ; 3,s) into a FIXED bank-$E1 byte. Each MVN below leaves - ; DBR=$E1; we reload this byte (long-addressed, so it is both - ; DBR- and SP-independent -- the PEI slam relocates SP) to put - ; DBR back at the globals bank. $E1:9DFE is in the unused SCB - ; tail ($9DC8-$9DFF), never touched by the SCB/palette/pixel + ; 3,s) into a FIXED bank-$E1 byte. Each narrow-row MVN below + ; leaves DBR=$E1; we reload this byte (long-addressed, so it + ; is both DBR- and SP-independent -- the PEI slam relocates + ; SP) to put DBR back at the globals bank. $E1:9DFE is in the + ; unused SCB tail ($9DC8-$9DFF), never touched by the pixel ; MVNs. sep #0x20 .a8 @@ -2784,81 +3218,7 @@ PEI_NARROW_MAX = 32 rep #0x20 .a16 -; 1. SCB upload (200 bytes) via MVN, only when the SCB dirty bit is -; set. Source bank is runtime-patched into the MVN instruction -; (encoding: $54 dst src, so byte +2 is src). - lda bsFrame+8 ; uploadFlags (absolute, DBR=PBR) - and #1 - beq bsSkipScb - sep #0x20 - .a8 - lda bsFrame+2 ; scbPtr bank byte (DBR = globals) - ; Self-mod the MVN src-bank byte. The store must reach the MVN - ; instruction in THIS code's bank (PBR), not DBR (globals): - ; under multi-seg this asm can land in seg2 while DBR=seg1, so a - ; DBR-relative `sta mvnScbInst+2` would patch the wrong bank and - ; leave the MVN src=$00 -> garbage SCB upload. phk/plb makes - ; DBR:=PBR for the store, then we restore DBR := globals. - phk - plb - sta mvnScbInst+2 - lda 0xE19DFE ; globals bank (long: DBR/SP-indep) - pha - plb - rep #0x20 - .a16 - lda bsFrame+0 ; scbPtr offset - tax - ldy #0x9D00 - lda #199 -mvnScbInst: mvn 0xE1, 0x00 ; mvn dst,src (src bank runtime-patched) - ; MVN left DBR=$E1; restore DBR := globals bank (caller's, - ; stashed at $E1:9DFE). NOT phk -- under multi-seg PBR is this - ; asm's bank, not the globals bank. A is dead here. - sep #0x20 - .a8 - lda 0xE19DFE - pha - plb - rep #0x20 - .a16 -bsSkipScb: - -; 2. Palette upload (512 bytes) via MVN, only when the palette dirty -; bit is set. Same trick. - lda bsFrame+8 ; uploadFlags (absolute, DBR=PBR) - and #2 - beq bsSkipPal - sep #0x20 - .a8 - lda bsFrame+6 ; palettePtr bank byte (DBR = globals) - ; Self-mod the MVN src-bank byte via DBR:=PBR (see the SCB MVN - ; above) so the patch reaches the instruction in THIS code's - ; bank, then restore DBR := globals. - phk - plb - sta mvnPalInst+2 - lda 0xE19DFE ; globals bank (long: DBR/SP-indep) - pha - plb - rep #0x20 - .a16 - lda bsFrame+4 ; palettePtr offset - tax - ldy #0x9E00 - lda #511 -mvnPalInst: mvn 0xE1, 0x00 - ; MVN left DBR=$E1; restore DBR := globals bank (see SCB MVN). - sep #0x20 - .a8 - lda 0xE19DFE - pha - plb - rep #0x20 - .a16 -bsSkipPal: - -; 3. Pixel blit via PEI-slam, with per-row dirty skip and chunked SEI. +; Pixel blit via PEI-slam, with per-row dirty skip and chunked SEI. tsc sta gPeiOrigSp sep #0x20 @@ -2900,6 +3260,10 @@ peiNotAllDone: peiCheckDirty: sep #0x20 .a8 +; Sentinel contract pin (finding #47): a CLEAN row has min > max +; (STAGE_DIRTY_CLEAN_MIN=0xFF / STAGE_DIRTY_CLEAN_MAX=0x00 in +; surfaceInternal.h). This walk's min/max compare relies on that +; invariant; do not change the sentinels without updating this asm. lda gStageMinWord,x cmp gStageMaxWord,x rep #0x20 @@ -4414,12 +4778,6 @@ wsPushSkip: ; are defined ELSEWHERE (asm or C); they are left as externs. ; ==================================================================== -; Private bank-0 D-frame for iigsBlitStageToShr (scbPtr/palettePtr/flags). -.section .bss.bsFrame,"aw" -.globl bsFrame -bsFrame: -.zero 10 - ; Private bank-0 D-frame for iigsFloodWalkAndScansInner (28 args bytes; ; pad to a clean 32). .section .bss.wsFrame,"aw" @@ -4498,3 +4856,4 @@ wsDoubledByte: .globl wsHasTrail wsHasTrail: .zero 2 + diff --git a/src/iigs/spriteEmitIigs.c b/src/iigs/spriteEmitIigs.c index f973d9c..db3a3db 100644 --- a/src/iigs/spriteEmitIigs.c +++ b/src/iigs/spriteEmitIigs.c @@ -1,117 +1,65 @@ -// IIgs (65816) sprite codegen. Emits PIC draw routines that write -// 4bpp packed surface bytes via abs,Y indexed addressing. +// IIgs (65816) sprite codegen. Emits position-independent, C-ABI +// routines that jlSpriteCompile places in the codegen arena and the +// IIgs dispatchers in spriteCompile.c invoke through ordinary C +// function pointers -- the compiler emits the cross-bank JSL itself. +// There is NO self-modifying call stub and NO inline asm (that +// machinery was deleted in the #19 rewrite; see the note in +// spriteCompile.c). // -// CALLING CONVENTION: NOT a C fn-pointer convention. The -// runtime never calls these routines via a C cast -- instead, -// spriteCompiledDraw (in jlSpriteCompile.c, gated on -// JOEYLIB_PLATFORM_IIGS) builds a self-modifying JSL stub that -// loads Y with destRow then JSLs the routine. The routine assumes: -// - M = 8-bit (set by stub before JSL) -// - X = 16-bit (set by stub) -// - Y = destRow (loaded by stub from immediate) -// - DBR = program bank -// No stack arg, no prologue. Body executes directly. +// CALLING CONVENTION (llvm-mos w65816 cdecl; M=16, X=16 at entry and +// exit): // -// Routine shape (per-byte emit, no PEA optimization yet): -// ... per byte: -// lda #imm ; A = pixel-pair byte (opaque) -// sta abs,Y ; write to dst[abs] -// ... mixed: -// lda abs,Y; and #~mask; ora #val; sta abs,Y -// rtl +// DRAW: void draw(uint8_t *dstRow0); +// dstRow0 (the sprite's top-left screen byte) is a 24-bit pointer +// arriving as A = low 16 bits, X = bank. The emitted prologue +// saves the caller's DBR (PHB), moves the offset into Y, drops to +// M=8, and loads DBR with the destination bank so the body's +// `sta abs,Y` stores land in the screen bank. The epilogue +// restores M=16, pulls the caller's DBR back (PLB), and RTLs. // -// Position-independent: only abs constants are dest-byte offsets -// (small, baked at emit time); Y holds the runtime dst pointer. +// SAVE/RESTORE: void copy(uint32_t packed); +// packed = srcOff | (dstOff << 16), arriving as A = srcOff, +// X = dstOff. The routine is an unrolled chain of MVN row copies; +// the only runtime-dynamic bytes are the MVN bank operands, which +// the dispatcher patches per call with a plain C pointer write +// into the arena (patchMvnBanks in spriteCompile.c). The caller's +// DBR is preserved via PHB/PLB (MVN leaves DBR = dst bank); +// returns via RTL with M=16. +// +// DRAW body shape (see spriteEmitDrawIigs for the M=8/M=16 dual-path +// details): +// opaque byte: lda #imm; sta abs,Y +// mixed byte: lda abs,Y; and #~mask; ora #val; sta abs,Y +// opaque run: rep #$20; lda #imm16; sta abs,Y; ...; sep #$20 +// +// Position-independent: the only abs operands are dest-byte offsets +// baked at emit time; Y carries the runtime dst offset. // // All multi-byte operands are little-endian, written low byte first // into the output stream. #include "joey/sprite.h" #include "joey/surface.h" +#include "joey/tile.h" +#include "spriteEmitChunky.h" #include "spriteEmitter.h" #include "spriteInternal.h" // ----- Constants ----- -#define TILE_PIXELS 8 -#define TILE_BYTES 32 -#define TILE_BYTES_PER_ROW 4 -#define TRANSPARENT_NIBBLE 0 +// Tile geometry comes from joey/tile.h; TRANSPARENT_NIBBLE from +// spriteInternal.h. // ----- Prototypes ----- +// shiftedByteAt / spriteSourceByte -- the transparency contract shared +// with the x86 emitter -- come from spriteEmitChunky.h. static uint32_t emitMvnCabiRoutine(uint8_t *out, uint32_t cap, uint16_t heightPx, uint16_t copyBytes, bool advanceX); -static void shiftedByteAt(const jlSpriteT *sp, uint16_t row, uint16_t col, uint8_t shift, uint16_t spriteBytesPerRow, uint8_t *outValue, uint8_t *outOpaqueMask); -static uint8_t spriteSourceByte(const jlSpriteT *sp, uint16_t row, uint16_t col); -// ----- Emit helpers (alphabetical) ----- - -static void shiftedByteAt(const jlSpriteT *sp, uint16_t row, uint16_t col, uint8_t shift, uint16_t spriteBytesPerRow, uint8_t *outValue, uint8_t *outOpaqueMask) { - uint8_t srcByte; - uint8_t hi; - uint8_t lo; - bool hasLeft; - bool hasRight; - - *outValue = 0; - *outOpaqueMask = 0; - - if (shift == 0) { - if (col >= spriteBytesPerRow) { - return; - } - srcByte = spriteSourceByte(sp, row, col); - hi = (uint8_t)((srcByte >> 4) & 0x0Fu); - lo = (uint8_t)(srcByte & 0x0Fu); - if (hi != TRANSPARENT_NIBBLE) { - *outValue |= (uint8_t)(hi << 4); - *outOpaqueMask |= 0xF0u; - } - if (lo != TRANSPARENT_NIBBLE) { - *outValue |= lo; - *outOpaqueMask |= 0x0Fu; - } - return; - } - hasLeft = (col >= 1) && ((uint16_t)(col - 1) < spriteBytesPerRow); - hasRight = (col < spriteBytesPerRow); - if (hasLeft) { - srcByte = spriteSourceByte(sp, row, (uint16_t)(col - 1)); - hi = (uint8_t)(srcByte & 0x0Fu); - if (hi != TRANSPARENT_NIBBLE) { - *outValue |= (uint8_t)(hi << 4); - *outOpaqueMask |= 0xF0u; - } - } - if (hasRight) { - srcByte = spriteSourceByte(sp, row, col); - lo = (uint8_t)((srcByte >> 4) & 0x0Fu); - if (lo != TRANSPARENT_NIBBLE) { - *outValue |= lo; - *outOpaqueMask |= 0x0Fu; - } - } -} - - -static uint8_t spriteSourceByte(const jlSpriteT *sp, uint16_t row, uint16_t col) { - uint16_t tileX; - uint16_t tileY; - uint16_t inTileX; - uint16_t inTileY; - const uint8_t *tile; - - tileX = (uint16_t)(col / TILE_BYTES_PER_ROW); - tileY = (uint16_t)(row / TILE_PIXELS); - inTileX = (uint16_t)(col & (TILE_BYTES_PER_ROW - 1)); - inTileY = (uint16_t)(row & (TILE_PIXELS - 1)); - tile = sp->tileData + ((uint32_t)(tileY * sp->widthTiles + tileX)) * TILE_BYTES; - return tile[inTileY * TILE_BYTES_PER_ROW + inTileX]; -} - +// ----- Emit helpers ----- // writeLE16 was inlined at every call site. Inlining cuts a JSL/RTL // per emitted 16-bit immediate (4 instructions per byte * 12 sites). @@ -135,20 +83,13 @@ static uint8_t spriteSourceByte(const jlSpriteT *sp, uint16_t row, uint16_t col) // end: // RTL // -// The bank operand bytes (offset 4 and 5 within each MVN row) are -// patched per call by spriteCompiledSaveUnder / spriteCompiledRestoreUnder -// so the routine works regardless of where the surface and backup -// happen to live in memory. -// -// Layout: -// row 0 (6 bytes): A9 lo hi 54 db sb -// row R (12 bytes, R>=1): 8A/98 18 69 lo hi AA/A8 A9 lo hi 54 db sb -// end (1 byte): 6B -// -// The MVN at row R has its dstbk at routine offset (12*R + 4) and -// srcbk at (12*R + 5). +// The bank operand bytes are patched per call by +// spriteCompiledSaveUnder / spriteCompiledRestoreUnder so the routine +// works regardless of where the surface and backup happen to live in +// memory; the authoritative byte layout (including the patch offsets) +// is in the C-ABI comment below. // Worst-case bytes emitted per MVN copy row: row 0 = 6 bytes, any -// later row = 12. Plus the trailing RTL. +// later row = 12. Plus the prologue and the trailing PLB/RTL. #define IIGS_MVN_MAX_BYTES_PER_ROW 12u // C-ABI MVN copy routine. Called via a normal C function pointer: @@ -221,11 +162,11 @@ static uint32_t emitMvnCabiRoutine(uint8_t *out, uint32_t cap, uint16_t heightPx } -// SAVE (screen -> backup). Stub passes X = screen low offset (the -// source), Y = backup low offset (the destination); MVN advances both -// by copyBytes per row. Backup rows are contiguous in memory so Y is -// already correct for the next row; screen rows are SURFACE_BYTES_PER_ROW -// apart so X needs an explicit ADC between rows. +// SAVE (screen -> backup). The prologue leaves X = screen low offset +// (the source), Y = backup low offset (the destination); MVN advances +// both by copyBytes per row. Backup rows are contiguous in memory so Y +// is already correct for the next row; screen rows are +// SURFACE_BYTES_PER_ROW apart so X needs an explicit ADC between rows. uint32_t spriteEmitSaveIigs(uint8_t *out, uint32_t cap, const jlSpriteT *sp, uint8_t shift) { uint16_t heightPx; uint16_t spriteBytesPerRow; @@ -235,7 +176,7 @@ uint32_t spriteEmitSaveIigs(uint8_t *out, uint32_t cap, const jlSpriteT *sp, uin return 0u; } - heightPx = (uint16_t)(sp->heightTiles * TILE_PIXELS); + heightPx = (uint16_t)(sp->heightTiles * TILE_PIXELS_PER_SIDE); spriteBytesPerRow = (uint16_t)(sp->widthTiles * TILE_BYTES_PER_ROW); copyBytes = (uint16_t)(spriteBytesPerRow + shift); /* shift is 0 or 1 */ @@ -245,9 +186,10 @@ uint32_t spriteEmitSaveIigs(uint8_t *out, uint32_t cap, const jlSpriteT *sp, uin } -// RESTORE (backup -> screen). Stub passes X = backup low offset, -// Y = screen low offset. Backup is contiguous so X advances correctly -// via MVN; screen needs explicit advance, so Y is the one we ADC. +// RESTORE (backup -> screen). The prologue leaves X = backup low +// offset, Y = screen low offset. Backup is contiguous so X advances +// correctly via MVN; screen needs explicit advance, so Y is the one +// we ADC. uint32_t spriteEmitRestoreIigs(uint8_t *out, uint32_t cap, const jlSpriteT *sp, uint8_t shift) { uint16_t heightPx; uint16_t spriteBytesPerRow; @@ -257,7 +199,7 @@ uint32_t spriteEmitRestoreIigs(uint8_t *out, uint32_t cap, const jlSpriteT *sp, return 0u; } - heightPx = (uint16_t)(sp->heightTiles * TILE_PIXELS); + heightPx = (uint16_t)(sp->heightTiles * TILE_PIXELS_PER_SIDE); spriteBytesPerRow = (uint16_t)(sp->widthTiles * TILE_BYTES_PER_ROW); copyBytes = (uint16_t)(spriteBytesPerRow + shift); /* shift is 0 or 1 */ @@ -272,7 +214,7 @@ uint32_t spriteEmitRestoreIigs(uint8_t *out, uint32_t cap, const jlSpriteT *sp, // // Two emission paths share the body: // -// * M=8 byte path (default; matches the stub-set entry mode): +// * M=8 byte path (default; the emitted prologue enters M=8): // opaque: A9 vv LDA #vv ; 2c // 99 lo hi STA abs,Y ; 5c // ; 7c / 5 bytes per byte @@ -318,7 +260,7 @@ uint32_t spriteEmitDrawIigs(uint8_t *out, uint32_t cap, const jlSpriteT *sp, uin } cursor = 0; - heightPx = (uint16_t)(sp->heightTiles * TILE_PIXELS); + heightPx = (uint16_t)(sp->heightTiles * TILE_PIXELS_PER_SIDE); spriteBytesPerRow = (uint16_t)(sp->widthTiles * TILE_BYTES_PER_ROW); destBytesPerRow = (uint16_t)(spriteBytesPerRow + shift); /* shift is 0 or 1 */ wide = false; diff --git a/src/m68k/spriteEmit68k.c b/src/m68k/spriteEmit68k.c deleted file mode 100644 index 23d8867..0000000 --- a/src/m68k/spriteEmit68k.c +++ /dev/null @@ -1,331 +0,0 @@ -// 68k sprite codegen (Amiga + Atari ST). Emits SysV-ish cdecl- -// callable PIC draw / save / restore routines that read or write -// 4bpp packed surface bytes via d16(a0) chains. Same shape as the -// x86 emitter; only the instruction encoding differs. -// -// Calling convention (m68k gcc / mintlib): -// void draw(uint8_t *dst); -- arg in 4(sp) -// void save/restore(const uint8_t *src, uint8_t *dst); -- args in 4(sp)/8(sp) -// a0/a1/d0/d1 are caller-saved. -// -// Per-byte emit (no run coalescing yet): -// - all-transparent: skip -// - all-opaque: move.b #imm, d16(a0) (6 bytes encoded) -// - mixed: move.b d16(a0),d0; andi.b #~mask,d0; -// ori.b #val,d0; move.b d0,d16(a0) (4*4 = 16 bytes) -// Per row (after first): adda.w #SURFACE_BYTES_PER_ROW, a0 -// (4 bytes encoded) -// Prologue: movea.l 4(sp), a0 (4 bytes) -// Epilogue: rts (2 bytes) -// -// All multi-byte instruction fields are big-endian; the emit writes -// high-byte-first into the output stream so the target reads them -// in native order. - -#include "joey/sprite.h" -#include "joey/surface.h" -#include "spriteEmitter.h" -#include "spriteInternal.h" - - -// ----- Constants ----- - -#define TILE_PIXELS 8 -#define TILE_BYTES 32 -#define TILE_BYTES_PER_ROW 4 -#define TRANSPARENT_NIBBLE 0 - -// Stack-arg displacements for the m68k cdecl calling convention used by -// the emitted routines (args pushed right-to-left, return PC at 0(sp)): -// draw(dst) -> dst at 4(sp) -// save/restore(src, dst) -> src at 4(sp), dst at 8(sp) -#define STACK_ARG0_DISP 0x0004u -#define STACK_ARG1_DISP 0x0008u - -// Worst-case emitted bytes for one draw dest byte (mixed RMW: move.b -// d16(a0),d0; andi.b; ori.b; move.b d0,d16(a0) = 4 instrs * 4 bytes). -#define DRAW_MAX_BYTES_PER_DESTBYTE SPRITE_EMIT_MAX_BYTES_PER_DESTBYTE_68K -// Bytes for the per-row adda.w stride advance. -#define ROW_STRIDE_ADVANCE_BYTES 4u -// Bytes for the rts epilogue. -#define EPILOGUE_BYTES 2u - - -// ----- Prototypes ----- - -static uint32_t emitCopyBody68k(uint8_t *out, uint32_t cap, uint32_t cursor, uint16_t heightPx, uint16_t copyBytes, bool strideOnSrc); -static void shiftedByteAt(const jlSpriteT *sp, uint16_t row, uint16_t col, uint8_t shift, uint16_t spriteBytesPerRow, uint8_t *outValue, uint8_t *outOpaqueMask); -static uint8_t spriteSourceByte(const jlSpriteT *sp, uint16_t row, uint16_t col); -static uint16_t writeBE16(uint8_t *out, uint16_t value); - - -// ----- Emit helpers (alphabetical) ----- - -// Shared body for save/restore. Walks heightPx rows of copyBytes -// using `move.b (a0)+, (a1)+` byte-wise (safe regardless of pointer -// alignment, since the screen-side x can land on an odd byte). After -// each row except the last, advances either a0 (SAVE: src=screen) or -// a1 (RESTORE: dst=screen) by (SURFACE_BYTES_PER_ROW - copyBytes) so -// the strided side lines up with the next scanline; the contiguous -// side advances naturally via the post-increment. -// -// strideOnSrc=true -> source has the screen stride (SAVE) -// strideOnSrc=false -> destination has the screen stride (RESTORE) -static uint32_t emitCopyBody68k(uint8_t *out, uint32_t cap, uint32_t cursor, uint16_t heightPx, uint16_t copyBytes, bool strideOnSrc) { - uint16_t row; - uint16_t col; - uint16_t advance; - uint32_t rowBytes; - - advance = (uint16_t)(SURFACE_BYTES_PER_ROW - copyBytes); - - for (row = 0; row < heightPx; row++) { - // One row: copyBytes * 2 (move.b) + the per-row adda stride. - rowBytes = (uint32_t)copyBytes * 2u + ROW_STRIDE_ADVANCE_BYTES; - if (cursor + rowBytes > cap) { - return SPRITE_EMIT_OVERFLOW; - } - // Unrolled: move.b (a0)+, (a1)+ -- 0x12D8. - for (col = 0; col < copyBytes; col++) { - cursor += writeBE16(out + cursor, 0x12D8u); - } - if (row + 1u < heightPx) { - // adda.w #advance, a0 (0xD0FC) for SAVE - // adda.w #advance, a1 (0xD2FC) for RESTORE - cursor += writeBE16(out + cursor, strideOnSrc ? 0xD0FCu : 0xD2FCu); - cursor += writeBE16(out + cursor, advance); - } - } - return cursor; -} - -// Same logic as the x86 shiftedByteAt -- per-byte transparency -// decomposition for shift in {0,1}. opaqueMask high nibble 0xF0 if -// dest high nibble is opaque, 0x0F if low is opaque. -static void shiftedByteAt(const jlSpriteT *sp, uint16_t row, uint16_t col, uint8_t shift, uint16_t spriteBytesPerRow, uint8_t *outValue, uint8_t *outOpaqueMask) { - uint8_t srcByte; - uint8_t hi; - uint8_t lo; - bool hasLeft; - bool hasRight; - - *outValue = 0; - *outOpaqueMask = 0; - - if (shift == 0) { - if (col >= spriteBytesPerRow) { - return; - } - srcByte = spriteSourceByte(sp, row, col); - hi = (uint8_t)((srcByte >> 4) & 0x0Fu); - lo = (uint8_t)(srcByte & 0x0Fu); - if (hi != TRANSPARENT_NIBBLE) { - *outValue |= (uint8_t)(hi << 4); - *outOpaqueMask |= 0xF0u; - } - if (lo != TRANSPARENT_NIBBLE) { - *outValue |= lo; - *outOpaqueMask |= 0x0Fu; - } - return; - } - hasLeft = (col >= 1) && ((uint16_t)(col - 1) < spriteBytesPerRow); - hasRight = (col < spriteBytesPerRow); - if (hasLeft) { - srcByte = spriteSourceByte(sp, row, (uint16_t)(col - 1)); - hi = (uint8_t)(srcByte & 0x0Fu); - if (hi != TRANSPARENT_NIBBLE) { - *outValue |= (uint8_t)(hi << 4); - *outOpaqueMask |= 0xF0u; - } - } - if (hasRight) { - srcByte = spriteSourceByte(sp, row, col); - lo = (uint8_t)((srcByte >> 4) & 0x0Fu); - if (lo != TRANSPARENT_NIBBLE) { - *outValue |= lo; - *outOpaqueMask |= 0x0Fu; - } - } -} - - -static uint8_t spriteSourceByte(const jlSpriteT *sp, uint16_t row, uint16_t col) { - uint16_t tileX; - uint16_t tileY; - uint16_t inTileX; - uint16_t inTileY; - const uint8_t *tile; - - tileX = (uint16_t)(col / TILE_BYTES_PER_ROW); - tileY = (uint16_t)(row / TILE_PIXELS); - inTileX = (uint16_t)(col & (TILE_BYTES_PER_ROW - 1)); - inTileY = (uint16_t)(row & (TILE_PIXELS - 1)); - tile = sp->tileData + ((uint32_t)(tileY * sp->widthTiles + tileX)) * TILE_BYTES; - return tile[inTileY * TILE_BYTES_PER_ROW + inTileX]; -} - - -// Emit a 16-bit big-endian value into the output stream. Returns 2. -static uint16_t writeBE16(uint8_t *out, uint16_t value) { - out[0] = (uint8_t)((value >> 8) & 0xFFu); - out[1] = (uint8_t)(value & 0xFFu); - return 2; -} - - -// 68k draw emit. Returns bytes written, or SPRITE_EMIT_OVERFLOW if the -// routine would exceed `cap`. -uint32_t spriteEmitDraw68k(uint8_t *out, uint32_t cap, const jlSpriteT *sp, uint8_t shift) { - uint32_t cursor; - uint16_t row; - uint16_t col; - uint16_t heightPx; - uint16_t spriteBytesPerRow; - uint16_t destBytesPerRow; - uint8_t value; - uint8_t opaqueMask; - - // Chunky 4bpp has only two nibble-alignment positions; the - // dispatcher uses x & 1 so shifts 2..7 are unreachable. Bail - // early so the arena slot stays SPRITE_NOT_COMPILED. - if (shift > 1u) { - return 0u; - } - - cursor = 0; - heightPx = (uint16_t)(sp->heightTiles * TILE_PIXELS); - spriteBytesPerRow = (uint16_t)(sp->widthTiles * TILE_BYTES_PER_ROW); - destBytesPerRow = (uint16_t)(spriteBytesPerRow + shift); /* shift is 0 or 1 */ - - // Prologue: movea.l 4(sp), a0 - if (cursor + 4u > cap) { - return SPRITE_EMIT_OVERFLOW; - } - cursor += writeBE16(out + cursor, 0x206Fu); - cursor += writeBE16(out + cursor, STACK_ARG0_DISP); - - for (row = 0; row < heightPx; row++) { - if (row > 0) { - // adda.w #SURFACE_BYTES_PER_ROW, a0 - if (cursor + ROW_STRIDE_ADVANCE_BYTES > cap) { - return SPRITE_EMIT_OVERFLOW; - } - cursor += writeBE16(out + cursor, 0xD0FCu); - cursor += writeBE16(out + cursor, (uint16_t)SURFACE_BYTES_PER_ROW); - } - for (col = 0; col < destBytesPerRow; col++) { - shiftedByteAt(sp, row, col, shift, spriteBytesPerRow, &value, &opaqueMask); - if (opaqueMask == 0x00) { - continue; - } - // Reserve the worst case for one dest byte (16-byte mixed - // RMW) plus the rts epilogue. - if (cursor + DRAW_MAX_BYTES_PER_DESTBYTE + EPILOGUE_BYTES > cap) { - return SPRITE_EMIT_OVERFLOW; - } - if (opaqueMask == 0xFFu) { - // move.b #imm, d16(a0) - // Opcode 0x117C: bits 11-9 = dst reg (0=a0), bits 8-6 = - // dst mode (101 = an+d16), bits 5-3 = src mode (111), - // bits 2-0 = src reg (100 = immediate). Source - // extension (imm word, byte in low half) comes BEFORE - // dest extension (d16) in the instruction stream. - cursor += writeBE16(out + cursor, 0x117Cu); - cursor += writeBE16(out + cursor, (uint16_t)value); - cursor += writeBE16(out + cursor, col); - } else { - // move.b d16(a0), d0 - cursor += writeBE16(out + cursor, 0x1028u); - cursor += writeBE16(out + cursor, col); - // andi.b #~opaqueMask, d0 - cursor += writeBE16(out + cursor, 0x0200u); - cursor += writeBE16(out + cursor, (uint16_t)(~opaqueMask & 0xFFu)); - // ori.b #value, d0 - cursor += writeBE16(out + cursor, 0x0000u); - cursor += writeBE16(out + cursor, (uint16_t)value); - // move.b d0, d16(a0) - cursor += writeBE16(out + cursor, 0x1140u); - cursor += writeBE16(out + cursor, col); - } - } - } - - // Epilogue: rts - if (cursor + EPILOGUE_BYTES > cap) { - return SPRITE_EMIT_OVERFLOW; - } - cursor += writeBE16(out + cursor, 0x4E75u); - return cursor; -} - - -// RESTORE: copy backup -> screen. Destination has the screen stride. -uint32_t spriteEmitRestore68k(uint8_t *out, uint32_t cap, const jlSpriteT *sp, uint8_t shift) { - uint32_t cursor; - uint16_t heightPx; - uint16_t copyBytes; - - if (shift > 1u) { - return 0u; - } - - cursor = 0; - heightPx = (uint16_t)(sp->heightTiles * TILE_PIXELS); - copyBytes = (uint16_t)(sp->widthTiles * TILE_BYTES_PER_ROW + shift); /* shift is 0 or 1 */ - - // Prologue: movea.l 4(sp), a0 (src); movea.l 8(sp), a1 (dst). - if (cursor + 8u > cap) { - return SPRITE_EMIT_OVERFLOW; - } - cursor += writeBE16(out + cursor, 0x206Fu); - cursor += writeBE16(out + cursor, STACK_ARG0_DISP); - cursor += writeBE16(out + cursor, 0x226Fu); - cursor += writeBE16(out + cursor, STACK_ARG1_DISP); - - cursor = emitCopyBody68k(out, cap, cursor, heightPx, copyBytes, false); - if (cursor == SPRITE_EMIT_OVERFLOW) { - return SPRITE_EMIT_OVERFLOW; - } - - if (cursor + EPILOGUE_BYTES > cap) { - return SPRITE_EMIT_OVERFLOW; - } - cursor += writeBE16(out + cursor, 0x4E75u); - return cursor; -} - - -// SAVE: copy screen -> backup. Source has the screen stride. -uint32_t spriteEmitSave68k(uint8_t *out, uint32_t cap, const jlSpriteT *sp, uint8_t shift) { - uint32_t cursor; - uint16_t heightPx; - uint16_t copyBytes; - - if (shift > 1u) { - return 0u; - } - - cursor = 0; - heightPx = (uint16_t)(sp->heightTiles * TILE_PIXELS); - copyBytes = (uint16_t)(sp->widthTiles * TILE_BYTES_PER_ROW + shift); /* shift is 0 or 1 */ - - if (cursor + 8u > cap) { - return SPRITE_EMIT_OVERFLOW; - } - cursor += writeBE16(out + cursor, 0x206Fu); - cursor += writeBE16(out + cursor, STACK_ARG0_DISP); - cursor += writeBE16(out + cursor, 0x226Fu); - cursor += writeBE16(out + cursor, STACK_ARG1_DISP); - - cursor = emitCopyBody68k(out, cap, cursor, heightPx, copyBytes, true); - if (cursor == SPRITE_EMIT_OVERFLOW) { - return SPRITE_EMIT_OVERFLOW; - } - - if (cursor + EPILOGUE_BYTES > cap) { - return SPRITE_EMIT_OVERFLOW; - } - cursor += writeBE16(out + cursor, 0x4E75u); - return cursor; -} diff --git a/src/m68k/spriteEmitC2p.h b/src/m68k/spriteEmitC2p.h new file mode 100644 index 0000000..48912eb --- /dev/null +++ b/src/m68k/spriteEmitC2p.h @@ -0,0 +1,63 @@ +// Shared 68k emit-time helpers: chunky->planar column conversion and +// big-endian opcode-word emission. Included (as static functions) by +// the two 68k emitters -- spriteEmitPlanar68k.c (Amiga) and +// spriteEmitInterleaved68k.c (ST) -- so the c2p pixel walk that must +// match the interpreted planes walkers bit-for-bit (jlpSpriteDrawPlanes +// on Amiga, stSpriteDrawByteAligned on ST) exists exactly once. +// +// Emit-time only (jlSpriteCompile), never on a draw hot path, so plain +// static functions are acceptable here despite the general +// macros-over-inline preference for shared headers. + +#ifndef JOEYLIB_SPRITE_EMIT_C2P_H +#define JOEYLIB_SPRITE_EMIT_C2P_H + +#include + +#include "spriteInternal.h" + + +static void c2pColumn(const uint8_t *trp, uint8_t plane[4], uint8_t *opaqueOut); +static uint32_t putWord(uint8_t *out, uint32_t cursor, uint16_t w); + + +// Convert one 8-pixel sprite tile column (4 chunky 4bpp bytes) into 4 +// plane bytes plus an opaque mask, bit 7 = leftmost pixel. Pixel order +// is hi(b0),lo(b0),hi(b1),lo(b1),hi(b2),lo(b2),hi(b3),lo(b3) at bit +// positions 7..0. +static void c2pColumn(const uint8_t *trp, uint8_t plane[4], uint8_t *opaqueOut) { + uint8_t opaque; + uint8_t i; + + plane[0] = 0u; + plane[1] = 0u; + plane[2] = 0u; + plane[3] = 0u; + opaque = 0u; + for (i = 0u; i < 8u; i++) { + uint8_t nibble; + uint8_t bit; + + nibble = (i & 1u) ? (uint8_t)(trp[i >> 1] & 0x0Fu) + : (uint8_t)(trp[i >> 1] >> 4); + if (nibble != TRANSPARENT_NIBBLE) { + bit = (uint8_t)(0x80u >> i); + opaque = (uint8_t)(opaque | bit); + if (nibble & 1u) { plane[0] = (uint8_t)(plane[0] | bit); } + if (nibble & 2u) { plane[1] = (uint8_t)(plane[1] | bit); } + if (nibble & 4u) { plane[2] = (uint8_t)(plane[2] | bit); } + if (nibble & 8u) { plane[3] = (uint8_t)(plane[3] | bit); } + } + } + *opaqueOut = opaque; +} + + +// Append a big-endian 16-bit word at cursor; returns the new cursor. +static uint32_t putWord(uint8_t *out, uint32_t cursor, uint16_t w) { + out[cursor] = (uint8_t)(w >> 8); + out[cursor + 1u] = (uint8_t)(w & 0xFFu); + return cursor + 2u; +} + +#endif diff --git a/src/m68k/spriteEmitInterleaved68k.c b/src/m68k/spriteEmitInterleaved68k.c index 23f0b74..5308141 100644 --- a/src/m68k/spriteEmitInterleaved68k.c +++ b/src/m68k/spriteEmitInterleaved68k.c @@ -34,15 +34,16 @@ #include "joey/sprite.h" #include "joey/surface.h" +#include "joey/tile.h" +#include "spriteEmitC2p.h" #include "spriteEmitter.h" #include "spriteInternal.h" // ----- Constants ----- -#define TILE_BYTES 32u // bytes per 8x8 4bpp tile -#define TILE_BYTES_PER_ROW 4u // 8 pixels * 4 bits / 8 -#define TILE_PIXELS 8u +// Tile geometry (TILE_BYTES / TILE_BYTES_PER_ROW / TILE_PIXELS_PER_SIDE) +// comes from joey/tile.h. #define ST_PLANAR_STRIDE 160u // 320 px * 4 planes / 8 bits #define ST_GROUP_BYTES 8u // 4 plane words per 16-px group @@ -68,52 +69,11 @@ #define EPILOGUE_BYTES 2u -// ----- Prototypes ----- - -static void c2pColumn(const uint8_t *trp, uint8_t plane[4], uint8_t *opaqueOut); -static uint32_t putWord(uint8_t *out, uint32_t cursor, uint16_t w); - - // ----- Emit helpers ----- -// Convert one 8-pixel sprite tile column (4 chunky 4bpp bytes) into 4 -// plane bytes plus an opaque mask, bit 7 = leftmost pixel. Identical -// pixel walk to stSpriteDrawByteAligned: hi(b0),lo(b0),hi(b1),lo(b1), -// hi(b2),lo(b2),hi(b3),lo(b3) at bit positions 7..0. -static void c2pColumn(const uint8_t *trp, uint8_t plane[4], uint8_t *opaqueOut) { - uint8_t opaque; - uint8_t i; - - plane[0] = 0u; - plane[1] = 0u; - plane[2] = 0u; - plane[3] = 0u; - opaque = 0u; - for (i = 0u; i < 8u; i++) { - uint8_t nibble; - uint8_t bit; - - nibble = (i & 1u) ? (uint8_t)(trp[i >> 1] & 0x0Fu) - : (uint8_t)(trp[i >> 1] >> 4); - if (nibble != 0u) { - bit = (uint8_t)(0x80u >> i); - opaque = (uint8_t)(opaque | bit); - if (nibble & 1u) { plane[0] = (uint8_t)(plane[0] | bit); } - if (nibble & 2u) { plane[1] = (uint8_t)(plane[1] | bit); } - if (nibble & 4u) { plane[2] = (uint8_t)(plane[2] | bit); } - if (nibble & 8u) { plane[3] = (uint8_t)(plane[3] | bit); } - } - } - *opaqueOut = opaque; -} - - -// Append a big-endian 16-bit word at cursor; returns the new cursor. -static uint32_t putWord(uint8_t *out, uint32_t cursor, uint16_t w) { - out[cursor] = (uint8_t)(w >> 8); - out[cursor + 1u] = (uint8_t)(w & 0xFFu); - return cursor + 2u; -} +// c2pColumn (identical pixel walk to stSpriteDrawByteAligned: +// hi(b0),lo(b0),...,hi(b3),lo(b3) at bit positions 7..0) and putWord +// are shared with the Amiga emitter via spriteEmitC2p.h. // ----- Emit API ----- @@ -130,7 +90,7 @@ uint32_t spriteEmitDrawInterleaved68k(uint8_t *out, uint32_t cap, const jlSprite return 0u; } wTiles = sp->widthTiles; - srcH = (uint16_t)(sp->heightTiles * TILE_PIXELS); + srcH = (uint16_t)(sp->heightTiles * TILE_PIXELS_PER_SIDE); xMod16 = (uint16_t)(shift * 8u); // shift 0 -> 0, shift 1 -> 8 cursor = 0u; diff --git a/src/m68k/spriteEmitPlanar68k.c b/src/m68k/spriteEmitPlanar68k.c index 60bed94..497288f 100644 --- a/src/m68k/spriteEmitPlanar68k.c +++ b/src/m68k/spriteEmitPlanar68k.c @@ -31,15 +31,16 @@ #include "joey/sprite.h" #include "joey/surface.h" +#include "joey/tile.h" +#include "spriteEmitC2p.h" #include "spriteEmitter.h" #include "spriteInternal.h" // ----- Constants ----- -#define TILE_BYTES 32u // bytes per 8x8 4bpp tile -#define TILE_BYTES_PER_ROW 4u // 8 pixels * 4 bits / 8 -#define TILE_PIXELS 8u +// Tile geometry (TILE_BYTES / TILE_BYTES_PER_ROW / TILE_PIXELS_PER_SIDE) +// comes from joey/tile.h. #define AMIGA_PLANE_STRIDE (SURFACE_WIDTH / 8) // 40 bytes/plane-row #define AMIGA_PLANES 4u @@ -75,51 +76,10 @@ static const uint16_t OP_MOVE_B_IMM_AN[AMIGA_PLANES] = { 0x117Cu, 0x137Cu, 0x157 #define EPILOGUE_BYTES 6u // movem(4) + rts(2) -// ----- Prototypes ----- - -static void c2pColumn(const uint8_t *trp, uint8_t plane[4], uint8_t *opaqueOut); -static uint32_t putWord(uint8_t *out, uint32_t cursor, uint16_t w); - - // ----- Emit helpers ----- -// Convert one 8-pixel sprite tile column (4 chunky 4bpp bytes) into 4 -// plane bytes plus an opaque mask, bit 7 = leftmost pixel. Identical -// pixel walk to jlpSpriteDrawPlanes' c2p loop. -static void c2pColumn(const uint8_t *trp, uint8_t plane[4], uint8_t *opaqueOut) { - uint8_t opaque; - uint8_t i; - - plane[0] = 0u; - plane[1] = 0u; - plane[2] = 0u; - plane[3] = 0u; - opaque = 0u; - for (i = 0u; i < 8u; i++) { - uint8_t nibble; - uint8_t bit; - - nibble = (i & 1u) ? (uint8_t)(trp[i >> 1] & 0x0Fu) - : (uint8_t)(trp[i >> 1] >> 4); - if (nibble != 0u) { - bit = (uint8_t)(0x80u >> i); - opaque = (uint8_t)(opaque | bit); - if (nibble & 1u) { plane[0] = (uint8_t)(plane[0] | bit); } - if (nibble & 2u) { plane[1] = (uint8_t)(plane[1] | bit); } - if (nibble & 4u) { plane[2] = (uint8_t)(plane[2] | bit); } - if (nibble & 8u) { plane[3] = (uint8_t)(plane[3] | bit); } - } - } - *opaqueOut = opaque; -} - - -// Append a big-endian 16-bit word at cursor; returns the new cursor. -static uint32_t putWord(uint8_t *out, uint32_t cursor, uint16_t w) { - out[cursor] = (uint8_t)(w >> 8); - out[cursor + 1u] = (uint8_t)(w & 0xFFu); - return cursor + 2u; -} +// c2pColumn (identical pixel walk to jlpSpriteDrawPlanes' c2p loop) +// and putWord are shared with the ST emitter via spriteEmitC2p.h. // ----- Emit API ----- @@ -136,7 +96,7 @@ uint32_t spriteEmitDrawPlanar68k(uint8_t *out, uint32_t cap, const jlSpriteT *sp return 0u; } wTiles = sp->widthTiles; - srcH = (uint16_t)(sp->heightTiles * TILE_PIXELS); + srcH = (uint16_t)(sp->heightTiles * TILE_PIXELS_PER_SIDE); cursor = 0u; diff --git a/tests/goldens/sfxmix-baseline.txt b/tests/goldens/sfxmix-baseline.txt new file mode 100644 index 0000000..c72ca36 --- /dev/null +++ b/tests/goldens/sfxmix-baseline.txt @@ -0,0 +1,37 @@ +sfxMixHost: audioSfxOverlayMix scenarios (LCG-seeded, deterministic) +name=fixedUp8363to11025 slots=1 len=1024 hash=0x4136218D +first=12 52 3F 4B CF B2 3B 1B 47 47 1E 1B 00 59 62 51 +raw=12523F4BCFB23B1B47471E1B0059625100A89E0D6B00D37FEDDD814DAD8FFF5BB3FFFF2C77E186607840DA883A6CEED100578AAA25C21B7255123E153B0045FED2E6E95D0061D8A07740001F007483E94F4A02D7373010FFFFC135B000E2B19586FF5E4AB9AC7FB637CB0EFFFA4CF5E30054ABAA825D00F49980AD4A2296546A519AF665295CCBDAFFFF1F0DFEA7FFCCB137AB6EFBDD33B6AA26F3FF62AD74B3D5FF7149000070FFD6FF84C0FBEC0B4841FFEBFF84613B000027B93F6600002B0000AC86250FF5DA500F95CBFFF100FF50C4367AABA424B99E594FFF6F9FE26EFF5A3DFF7600747F005579000286FFFFDE63B57E00687ABCCE770F5AFF43AD1C4BD500466C6FB007441EB38410C1001961FF0442DAC67755ADC7685C5BFFE1F5A39343D0FF6A95D8D2FF5CCFFD80758594FFBECA000564617ABC73FF9EC7EF0000009400CB1717001600B92800E3CD0D715DB566C05DC88900DFB5039FF1A9CB2C3CC286A200EAFFC8A559FFB1FF7D9C7E87D7B602281E60A1F39661989A00D970F9729F0000553CE7F743A57800499981FFF2C7B983CAAD620000B1C8C726FF7ECEFFDC0884F7387564C75A567854E760973BE25E0883727553D3B170BB5C7BB97DFFEF5E5E63A4C7AC479CFFDF152700C0FFF9B7168067962894290098FFA14A6245F18E169C060000109C24CBCD97AE7F6B594700919FB20525DEAB009DA02B8F118C2E2E00B9FF1E0048B713C9C26F5F7B7B0044AB97A822A0EFD34D00B0FFFFDB4D00CDDE9AEE71748D6FC0FF0066E099FFD75350788F36C8FFDEB56972EC88FF6B3526E0704C333850FF3D0D7B59E569FFAAFFD30087ABB4C7002491294E0B99B60DFF17A3B776BC7229241C0044008CEC6B2860ED9C00F100823C27BCD5667F74511C4DFFFFA460FF4D493EFB189F75825F9BFF3AB4C57C656CFC08619A5E8500798CC900154BBB74DFE79895CA848316817A92FFDD7208966B0037A9AE67CCDB0B0068594D9D118700FFE8EE85533B1E652259C0002CF83A7F00A33DFF79E7C52A58370047E618A4C5B6469CC944D20D346C0093D5B5FFFF603226307FAC58940001B1937D6B77008C6C560000C51FB64B49A21F4C91E20058919800B716BBABBEFFA000420287C3FFAE0B2F4E8E3A3846FFD489473BFF64DA9962006B79615B2323394D46819E2FFFCE3D5AFF6382A79491AB04A6794AF4AADBD464C59091C977EDAE4A8184DA51599269E5FFF527BA6EB388101007E1B15A45BE5B6CDD5062B62A00892B91213730328C39B100557FFF480984DDB9D015B21070211ADC00556B648285365C1A8E187A59CADF79558A653077FF64E1A200D42B001730FF0015E4E342692CF373BCB9C0CDFF8A535BC27FD2FFEDA00023AF314754FF44A70012B2FFB856FFB6CAFF6240A1F3FD63095FC7596A84DF200000903700D2 +name=fixedDown22050to11025 slots=1 len=1024 hash=0x54317F3C +first=12 BE 00 77 D4 78 B3 FF 75 4A BF FF 1E 70 AE 6D +raw=12BE0077D478B3FF754ABFFF1E70AE6D16FF00AADB41387C00FF2649794E456C8CB20E005E3C8E050000CC086845565057C2F06C69E98EA49AFFA60000F73BF7FF98C6FFF1614458DCFF67B2DA81CFD5001059BCC3046E68DF6EFFFF71B707ADC1A3B8007A00FF001C49CB00C74D409D0063FF8D8C661500EBFF7772FF00A0FF56FF00DFF0C77F166099000B0009ED0000CF67992340182E55F7CF99130063C700A90140B3FF38DF0CEECDEBFF77FF6D693DC4A20F15FFED571E9CC5BAFF00FFFF2F3995689336A05AB14C357B3F803C4015FF9D23265165008F8D877A6FAC52CAF3C0798A003997D9FF93BEB0D706F8000573A8CB3A00C1AA58AEED396BB626770011B732AEB2389727FFB5F27FFF6E36FF002B006875B100FF55C3FFBA16BE73D2D500870D8546DF86E9FF41BC89B059EFA31180EEFF4F9D71001A1855006EB319278CF629470025FF32FFBE8258A3E9100054FFF01EFFFF7858E2004174EEC32D595D52E1FFD41690FF62A361C24C229E4B1EBE847114ECAD93A383FF00DE33FF2994443BFC18FF30DAFCF3EA006900DC7D34007F00247BBFFF4FF38BFFFFB79C1AA0BBE9626AB30228D03E5F420068000EC48F02FFFF60FF46367A42A139D58C5B227C00A176FF0CE569C560135735F0CC5F798108CCB300191BBF009A190086811DEBACD1719D501F00FBFF67FF841963FD01D0FFA36C00AEE2A5FF534F8688EB3879D682DF5DFF31B13997FFCF84DA44FFC1A5883460FF7900B700E8E546FF9E426BEEB9E98BA060A4334179D1493D003D6471DFAF5832D9D6829AA59A6E649F00FF222A984E90A928FF001557686A82056B67FF3C1FC7597EB0910088A86F93B6AAED8EFFABFF002B1340FF085A30FF00BC3D2A1EAAC853A27683F56633002369008AF52E2802AE00799D004314B0FFACEF2FA30399F00011F454246154B5F3A0B1EFFF2B6A4661B33FFF593341126287D4C9BDB19A00748345BCAE007C33BD94E1655DF6008E1A5DFD474AB9C4FEA489B000FC23EA553291A272DFD5FFAD92EF37B5EC13FFB79A26D6C2FF00E69AF48200FF006A6400FFC19C70724A336562294368FF3400B995400000F235F1FFFF6FA37600FF7058061E85B3FF4D6C70187F35C8D65A89FD6D39F8E100E58F2E19ECFF97AFF94FAD67896478F473BD6610FFBC6A27004DFF420FA9FF0025C6D2B436A959DDA1004867DD3A001DB1631F008C00A1001FCE7FFF1C0DD73DCEA240C87CACFF450052C2646CFF00022184003DCD9F0F008F8235B86163665E4D0201BEFC3C00A238A140FFA60B34005D8700FF714E4C6F1A00A959FF7F3D1635AD490022450059A400E4CEA9802A9472316B631F89FF70C7005EFF45147200FF002DFD50BBD7FF634800FF1FFFFF2BB6DA5333576188C8B5FF5F7DDB6B94BFFFD26FD069E0FF2600C07FAE00B4CA51DC +name=fixedUnity11025 slots=1 len=1024 hash=0x7B6DBEC4 +first=13 9D DB 9F 73 82 47 FC 00 19 4A 8A 1E 6C F9 2B +raw=139DDB9F738247FC00194A8A1E6CF92B59EC0DEA97003D5329A5FF469AFF669D8F0A8A129D6D00A0A47C22B0D727ED99C76C00372647B75C237FF9E3FF57694C33FF1495FBD3FFDA3468AE75043D394DFFBC0D431D0BCA9D340AA8F13B3EC48B9BB28966BE9474542702A06F00FFFF544A00E36EA5E1F0924600454CBE6F51A054061FB231FF80CC0000FFACC59D6EDCAAB968649E5F0090456D532CED9FA4B53F33F59200FF645DACB1579F0045761D5B3208FC682F06C1C8FF0648FF45D67BF10073AF4BB91AD1B3745A965D002F342969762020CC0084C400F0FFFDBD00CDB6B669C3001E6EED891E63DE87D87962004C512700E581D376E179EE63C552C9FFBCEFB2A58400FFFFFF3C0374AE55FFFF75F9978E3A8CFFDEFF42C0C3F96C00AA3366B1FF1F60BEA25F4AFF1896235CF9478162EBBF739D00C86400FF204777B59E000003D4862C7D788D8BD5AE880D9F911B8CC09B63372B12B2B5BE7A9B488C8858BDBF1935003D7AB91FB600804800CB87853705B69B476995D884B142405CA600DB90FF26A83A143BFF55219270DB37FF4F95FF79EF4996B333FF6F355E3038540DE5AA49F50F00A0C337A0FE8E2AC597D540446951FF7A45B2B69D9345406A1E11434300FF5DFFC3BB320000B65C0000525405745700923E0078CC93A500123331344008170027DED7A2A5D69D6F0E2F561030FF2B4E00AD3E374B946F41AE4E881739AC51FFB54199337DC8C4775AA3360AFFA3B01B97565C9E5271BD2EEAD87F4C7200D157FF965F5C49484C2366531E5848FF7E0060005C001369AEFF00BACC6F00F272FF2F3EC4FFF737000035753F6C58FFE036BBFFFFE50059499E7D0D1817FF60E69918B4C7D63B5C9A6A000003941478FF84A8CFF179005D5C78BE6F34384B46153EA9CC403647FF6881F6AA7B1B1AB9FA4DFF3C6B975835FF62D0FF620400F520A303AEA5AC54E42B6E429B48FF7F435F6F5F8C4DE994015800B44616A86E0B9930233F106E5AFFFF2183DCBD9A826575C8AF75B9015F5F9DF972C2AECDB3FFB02C9DDDFF7ED518727FCE7300B93D1FDEDBA1FF2744DD11CD60BE1DD9FF2A6EC409427C004CFF96F224862B855C3967D0D45775951C3800D5CC37BFF4704C6D51B8BD2899B569D8A300B47A22FDCF7FB59E9573FB8578AE0B4CA4FFC851269FC40E14A73D3F24C886E6F15FD773309AB2A95242F366AB1EA7E8FFC800FFFF7BA5EC66D700C2BD8EFE3E7CFF9E0E5A00004DC300B3009FC46BF7986EFF7B91002E2E6F3132DD9BB53BEFFF6AC01F20B8CEA55B0FADC7A6C500FF44B46C519945931AE442FF3F4AA94B963697126EFFB6FFB0825D97B374A56E3B936EDC8C4500FF7AF3B3B062D894A9CA38A5B7E6EEC999315F7885FF5FA52F292100B717BE94935D6700FF6DB3C23734969FC5275758C7D02F73C57CC34B9C +name=fixedOddStep5000 slots=1 len=8192 hash=0x5EA13285 +first=13 43 22 CD 96 53 C7 78 5C E1 DD 45 FF 50 C7 FF +raw=134322CD9653C7785CE1DD45FF50C7FFE6FFFFFF98FFFF751CDF773530FFFFFFBC77FFFA8FFF8EE4F889AF7EA367B03A2B4DD085C4AA84EC36C811BE22F3A96DBA08B3AFFF451365D8FFFFFFFF50B05DFD000000066F331D2DC966A2FFCA030D52532E8325968A42E4001D83FFFFB2FF7EEB6473881CAE80002A9C8BF3AF88D963AA73C8E8FF7268C14621004980C2F4C0D594736996A0FFFF76B96824FF6FFFD8008FFFCE69D4CE0F6B000000C78392ACFD67FF253D27E344FFA7B12FBDFFFFA4FFFFFF230036002EFFAD00AECB88578CEEE5EFEEAA4DD6F1AFBD39B7A76552BDCFB5005257C3FFFF0000C4441A77DE83FFCD4C7FC79061F92F00938B3E55A100008A008A2D0DA35D98FF94DD91FFECAB6EB5224168AFE00E2DD734B7540F215B6AA98800279DB3FFE4FF35C9DDAA6E899FCC4E004D0007754653BF714D6600614FB900006E7680A4F2CA713EECFFFFFFCFFFFFFFCCFAEBA755998600001D516D46EEC12E00ADD48EDD8A002D920068FF80FAFF84FF9AA0ACFF3A555A7B476C9CABFCE7D4001961CBE8B6BBFFFFC1008B4C4149FFCDFF92F6FFF4665F847B2D8A000054FF65FF9DD3A944CCFFB7FF718C2D2271D77F0000AD5CD1D8001D0C3115C5CCFFFF43A5C500578752985515FFC5574020BF3E8C920C83638602A69E5600302635CD81F4B7FF3E5FA0D3DE80FF8F901394989C84FFEBF951FFFFFFD2FF52D2CDB5575052ED26E48EFFB6006E8700009788FF84FF873392FF5D841F0628B0440029646F62FF6DE1D53CE0A768FCAAE8FF8CB200150800C9F5FFFF8EDC6E430059E9FF6C9064847AFFB449D2915DA737AD0049DDB8918DCD800000A068FFFF7FFFFFFFFDFFCA7E92A6476F6EFFD93C3C0F0711002F542A564E00754281C0A400002068FFE7679883F8FFEC6B3CCF7984CD739124920084310073B8FFFFB45C407D626E1C87A5E2572B3A769EFFFFD55AC2FFFFDBC1A931B5D299205831079C32A91F600087000061546CAD75EC5C044FC7B6D7C4C90F007E50296E1D3C7A94FFEE716C6FB5FFFF349EC3E59EA8433A6EFF6C31F8F6BAFFA9CCFFFFFF8C2B588C601E63FFFFFFCC6300C9929107B1FFFFB322772C480052354A4B7926006F000000935133EBD3FFB575967686F1FFAC12340765E2FF7A5839FFC78200937AA91D94BBCBDF774879FFFF7A6DFFFF4A48868051909F480000F89CD807E2FFFF2380C9ABE06DC98F0073A2A5FF93B600496C3FCA57D30E73009884FFA5FFFFCCE4FB9AFF848EFFE40A8294A93674918F0800765D0046125869255F0025036B000002311CA7004E55429A000200EFAD128962FFFFD1D84006D77B008A9B73FFC477DA54240498F5CB00505436BC17943279CE3369C50000386A4E500062A6002EC59A443075DF9F0A0B9F24FFB48574260020D510007C85DB0E635AC5E04A57F27C5B19B6004238F1FFFF13A2FF80B56D93FF0E1C4E005093AEE56B86E898E3372FEFFF528400A0768283FF856442296EFFFFFFA6150000393757467D0090C0BCECFF57CB23444B00E8B73DB56AD051FFE73067194D005452FFFF835A5A460003FFA3A9247866890000403BFFAB9FD6786AC9E9DAC245FF782A9C03AA77A40D7CCE91FFCF23399ED49AF5FF5E6D8D51FF2A2B6EFFEDFF718BA05CF0FFFF90C5CA9FCDFF920F8E95523D2B0C7C0600CFA66BB200B88779A32E66E59AB0ED93FFFFA5CBC069E811320E11DDFF72CCFFFFCED9769EAAB007007650551FE7700600C845B3FFBDFF62E1B8880002FF9D63FFFF45E2BF2E812463B61CE58CFF604E0D5600BB85FF59B09A540162007E752900B5AC95552672012F07543D7D1703000000F27A65FFFEA9278B95342216D5B100A5000007B6A6007C7C1F62BDFAFFFF4C001A9F602DA66AA8503D0079A0BFFFE5FFDA8155A70F009E7AD88146FF7D93DF175C1C00593CFFDB193C5E00263186A3FF58FFFFFBA8000070FFA4AD5700950040008265780021DAC74EFF448DE03FA800153D83792FB3AC7BFF5182FFCE74A4D38F29FF4D5100C2007E20000091B10009002B00AAF9C8FF55755BA9D5760099993782C497D07CAC3A55FF832136004CF4E65A2B17FFA94A9141BA1E4E0000126841216EFE51AB258C6E4D0167193C000065005E1D8400001F3D38CA769A92A8B299207488E10200000036CD8DBCAFFFB9E602B38B00C7D836FB29C7282FD8895463AF4072ED6DA5E97F7EFF5F82FFBB0000CA1E8BFF3C3E028300007E1A6F75240000B5D953FFFFCB6F7B3A710067D77FFFA999FFFFDAFFFFFF3B9105B7002CAFB44177C961E341ADA00000C64FB350A6658C0074D214762550BCD7ABFFFFA78E5066761351B9732324DFBB8FFFC1D04CFF040782D4D1B8D3FF97ADA5DFE66B7003B292B958C280FF5DDDB400250DE4CB6BC87EC8FFFFFFFFFF4F15A069E3CAFF9DFF18573E779E00005AC758974C8F2CABFF0200C2001E74006F8CC099F94F9900B7C4FF649A000A1441C5308A91D800FFABFF8DFFFF71D4D560B10700BA0175529991FF7DBB006F0017DBCBB4FFE4E31B3B00AC528E8511796E41D9FA153DFFF8CA70619B5BA4F8DFB8FFBBFF5A408AD1A522D878E18C968212A8FF9EFF00007FFFF1FF1E7D60001B667227000B3EEE62778951E26C91C8A16DFFD7FF46D9DEFAEBFFFBFFA5FFED6246E6148719AA2899B897E5F6673C4046125C82E9303D292F1DBAA376BC1A63A3AA47B0714D478FCB2C9CA0EA71BCB29FB1E8AAFF995E2F476AA97F91928B004300512D002D00707C5F2066CEAFA0AC6E91F768A728971CC6F27FADBE530F982DC549790D51FF08180AFFECB4236DFFD09AA660F388E2CCE0A8FFE2006E86A69C49006BFFD3F5FAAF4D6D31AB6085007F8E11AB612E08D59E2400ABD194858AFF6AFF669F0000CFE29AFFE2D6649D95958D5F8BB32D4F5E009A8785B7B4F16D09D6988F4C2BEAB545A3FF87FF86FFA77C8C0083A79820A24F41E8872400720033C30CDC99002E002483FFA700B3D9FF65FF745000436FF2FF71707A2B59A66E54D6E9E694309E450084004D0000D868D9845CFF98966C768BFFFFE9B149707D3D8C72625E0514DED42569A43E53183D6E56E0AA64DC79FFB0B9B000002B00359273215E6F8855826000000B009C73FF68FFAEE4B935D32A93B0315C8F00C39F57FF5679FF78FF00730042FDB3C9FFFF1589A0FFCC83AF6BAD6EE70000001D00CAB4803071FF5730FFFF9E5482915BFB0078B7C9CEFFFF6B791BD24FC32039C1FB499F5DD181A74C0059FF89FFFF1EEFFFFF4F00C51B0042D3FF620A002F5FC4FF7AD1F49E78406A99F5B75A4F5DFF5970D1147AE9AF00009CC27C5A78EEF2415A311129099C275913B7D18E00310004910955BB39FF6913C99DDAC976001F4C99115B007717053199A5FF56F8700F2E57D336C4964B8A5B000047EEFFA8D91608857CFFFFE777F14E55333D7F924BA92D001A9EE8DBFFC76E00919DE052FFB2FF5464CEAB648855474041D0FF8AC4A2D151141AB4F87AC3FF63A1FF8C4D8E709BA28AD22D00005BE9686B36CA9BA100007B2B99AE9B836A99A7D70C57DBFEB8A3E0FF03951FFFCA39FF7F97009B40D52385E8AC009215007C23C1D6B00330224E7E9D757381FF000DF8FF465C47FFFF41A917000BBA930B00D89FC50D6CFF2407A176F192227FAFFF5B41997E0070FF80FF36AECB7447C6A0FF7DFFFFAFC000564BFF6A75A14256AADCFFDC5F102B1F4A9C6F57BDACA9FFA23F6CFFFF8B340EB61A498B1EBC3D71D4FF413332E831C2B0C69ED7BF79F854001F000000712C4F10984BFFE20079236387EC7D00000023004EB336A4EFA12C8E7C6F88CD5786059766E6026C69439072FFCA00000000DFA1CB2D542B38A2A099740060099234384B70FFFFA32DC158DFAF2BA4749E671A34B2D63694366786A3592D8933126356A5FFD5FFEDF828E4411F0419C1562DA752AD6009007F97FF8033D4BBB68000A4984D8F6B00AB00747200001112008E7AC64A44D10DDB3693FF7B75B783008F29D4FF71D5CDFF4FE24F86D760477900F5F7501E0028000416E475C91600BF9CBEF8944CC47F61003F8000565F9691E4284D13278C006C3C5DFF908D73618E002C37F0F628A18545EF75FFF799AD006800A617065A807337BF0060FF9580FF65273FBCAF2793002CFFA8517B90FF7E92712F21359387FF665F00321E0005AB4BFF7C5DE3FFBF864557FFF710BC284B225995329AB8693C8E73615EFFCEFF5919BDFFFF2963FFFFFF0028CBCB3FBD6C8E15E1F8FF76FEE3FFC1326C76622CAFCDD64D7646FF8049BE00007FAD2F00004100F1BDA4594B526B68D8A3697CFF4A00367D7B9D7D3677D4B1448BB47F000D0061FFFFFF97FF995F89EEEDDDFF453DEB4542130000C1B9004600B5EE231A26FF7AD7E18DE9FF96FDD54A320500B5FFF964002900450D9309D4A984994000C1D51FA3B3FFDA8632E9E9C1EE007CB4B87365C5071C0008C6B1D17091A175ECFF8F32559EFFCEFF6F00F379584F60FFB0B61D02100000248D25CF9D0000433F4C00DA79F09BC099FF46D5FFFFE11186012EB68F00846E5C5A050000004CD12F88666DC1676437758000DD9E1EAAF3FFF9A0A4FFCBA20055419FFEC3004701138F9F005E8D0B3A77B4E4B2A6298EE68AC6B06AF174B11D32003A2C375CFFFF69AABF8157D6FF65FF57B89700A3745800A0FFDBEFFFFFFFCD008CB48848D17E6C989D004BA0D3D9FF18001732EC820007FFC6E7FF5911274838DE6EFFFF58FFC3AE83A951800077040389D2B2FFFF8B123B700B006091083A65FCFFE100E2CDD7AD90B5FFB15EFFE1FF94636C61C7B9003A5EFF63C09B2B417E9961FF5D61F8056087B291A42C0205C184D2FF369BC3980800A6FF9CA150FF268DDCF7E682008162004E4E817E7A0000734D4A007EA14874713B9B000BAC5FF5FF7F27A7B1ACEEFF8667CB54D28E9B7CEA77F38B04AC376B2323FEC165009486858E4A01C2FFBC00819DFFDAF65FFFFFB80165CC0367118F2C0200006744ED44D200001A00766FFFDBC04A00E2A586CDFC005B87FF85E004AE68CBB73F91FFE6E1FF2D89367E52390000001AF7AF1F42BA350A000092AAB3D1C1386056A47700A4D9FF72DE2F8BFFFFED0000172112FFFEC20027AA150290FF5ABBD4BC4E946A9BBDD07D4D97F3FF89FFA6004241000000A9FD5491FFFF9C7BB56B00920000B08F9DFF2753D4B987C113DDAB6F6271FF705F001B3945CD7EFFCB9A529D56B8FFFFB64D10C01600004DC6FFE8D9344F00163B775FFBB1C9FFFF7CD5B390000000009A00B55C5146A691FF9EFF6AB714BECE0008B0FAF46D77BA00B8000EB9993EA415B6A294FFDDFFD80086FFD755ED5D3F34868037C5007DFFA1FF5FC7B078C58DC3399320ADC4200000E953951776FFFBAE6B00721E2846B6250C00003E588A00000096004793CEC80041FF64FFD31B02394E7405A6FFD9FF4526954AE27F2C0021C85F83E8FFFFCCFF48000000002593CA8329467F5500117D9ED11500E44BCB5400006E6AA3EFD5C8FFFF2C6D98C4FF456C00B76CFF549A0000530D673E000049DA59FF955F6C111F0000992AA90094001157C3CEE08BA9FFFF87D481204E783DFFA440E7E111426886002B2580303CD6C9FFDEC64727188570EABE4E00582F00002B28FFFA00005752FF1FAEE877383EC2881EBDD1E4A6F0C9FFD92DB9012E6E76F35C777A0010B4E982FFE7DC02005BFF8A097A6A000300768384C0EB8D70FF9DFFFFFCFFFFB33FD43ECC75BDE199FF87C5A6623A0067D3210A6198FDFFC67A71B180F33962F4FFA8C875E9FAFFE018A117FF2649B233A76E5ABE4F81AD90798499A66E64EBFFD99D220000FF956CD3A729F866ECFDE3C1EA6C4551651D872142E5C3FF6E9F80FF54966FA884FFF3719E002EA2BE85FF7EC5FFFFFF6EC7FF71C01D004D00763C2B12FFDEFF8D89500ACBFF2D0364003B00008F69FFFFD9BD2C3500FCE633003C0029000000C1AC7E2F003C1E35AB293400797EBFFFA19F72FFFFBC4C0400ACDFD6C936784BDB5697C15EFFFFFFFFA10E63BAF6DB2DBB5317153E2941BC003A690051E1FFEACCFB6E1386F64F16FAFFA472FF3A55DECAFF4000009D69001F53938C82DEFFFFFFFFFF865350AFBBCDFFA8864927A6FF664116DA303D00E04972887BFCC0FFFFFAFF812B6B28A3FFFFF9142B8500754300ABD0B18C49001EA0914B26003BFC6AADB91400A2866BAD879BD2A56CDCF8FF5144327B0D93D855CED641652339E9FF195794B5FF460632DD6EFFAB94B97A5CD56CFFFF80A6410100049200AE4216B2D6F5CD001B004D654DCEA8DE3669998A65CB9257E028007F9A40C1004554389C39E2FF24340044410022FF833F70001E000000004E3B25FF6E5D5C6EC9A3008FFFBA73509D3E7B81AE67FBF53C4E000000008510110A77D4B5FF43D83B9F3F847F8EF7A52F0C39874D1FBE0CB608006A58F8EADCDAFF7E1E530338EF41B36500B48A0976FF09C01985FF957BE8CB1AAD35C451FC7E33F7D386FFF0A9544A1F001489F2702A55032C444DB8D83749A8FFCBFFD886FF9860B1720077005E843CD000CBFF78F8AB8A00002E943BFFAF320E1DFFFCB5C0359D6C56000800AFC18DBAA9B53C68571D00008C23002958088AD43F70FC9CF0FFFFFF51405A53FC02A8CE166F435D1200992B53944DA7FFF1AA0076C487B554998E22A600508D00846DFF850A87FFBCAF3BA6FFA56A6F000035CBCC00A21D720000520000234600C010247800076A005800AE500012A9DA8EE5106AD49B67C2C3A05D458F49C62F00C3A4D36BAE00027DF4BFF4FF69310090323A8D391200775C0000004A986C5BF791059867FFE7D2FF36ACB952FFEF3EFF7BE58A00B239D0D2DEA0D7FFF7578A43DD62FDBC6B53003772D1FFD8FFA4DEE2FF881000FF558B58127FFF9B942B0767FF3C398727C203C392D5AB72BC00D9F4B5080E8AB07AD9AE61B488932DF31DB381871BC9CC2489D24600823C60FF0002B678E37634C43EFF524930001AD383FFFF5C2B8BB5FF0A0000004649AA2415FF609C6FB6DA605FAA0088492A36454DFFFF7DCBFF5AFFC533000026A5FFFF773D498D363D95B4910003937E00E2B1FFFDF428370047696949A046D8B2FCDB50649F00017A5FF5E5F0FFFFBE4A00B4098783BCA0D0EA0AB4B9EFBA8C0E6F3C15B6884D8561997EC0E96EFF5368110004D99D80CE7EB1FFFFFFB3FF00855D868B1600879CF0F2D8FF7A816887EF9DCBF8B2E1FFC95385D347FFCD887C45B3929CA110FFFFC9305CA6EA51C7EBADCA2DFF5C6A90918D00948D93A9EED9627FC8170000000ABF32000CBA9FBA2D55F4229CC52B10A89590946100478D10FFFF84DA81378332328E378BAD50D6FF86C0A3909FBE704BC2F474772D0B007A6652B63932768E12CB5CFFD0AC6FD90093B38CCBAF5674FF7C80B1460F0000008500306EFFD2E9ACD7CAC383E1603CA5929E00369F8AA54FF26A67FF8C99E842944C758DD751F97A4AA29FB87770007971B5F97EFFE90E3F43169857FC40431E001784FFECFF92CCB8FFFFD6440047DFFF845460F5003200005769A4FFFFBA27BD14C73B0000CEBBFF44A100654E96E600861873F832D37A0056A39C3302000004B167F0987FB1BF40000020500090456339B72F00004A58DF3CC8C5545E00911B802FD0FFFFD4B896B3A9E7FE80227B1DEEC99E555AB1006900622E385B2EAF3DB54E0C0000733FA0D7A1E5FF0C64AB2307FFFFEDC9007B5B8EAECC62292D7100A4A2256D924710AE4C004200731ABC77406B5E320D177569898AEC05632EBFCCFFDB9F562CBF8FC800D1C1DF2669C2FF42260000009BBA967972BD007877A5AB2F2352CA3A93FFFF88FFF4A4009E6DE7515276BDFFD38FFF7200009A184AE3C14568FFFF95A21BDAFFD68A0000007B1D9F916AEAFFFBFFFFC9F23240FF906D73581D006C303832000000247C95DDFF6658BB216BEF565E6C70A74B60EF8DCCA4960000ABB083C5F5992C2C393E3EBA000000A280780072FFFFEF002A9DBAEDFFB2CAE0CFD9DDC7008A8600260000008B06530218FFC2581CA16E82458A0008D935343330008427757028FFFF90E5D69691FF83009C69CCDB62842045FFFFC9AD3108F1FF5B00D0EE0009063F000000DD340000670760840313248E61FF86D483A1D989781964003E0E074F00218683FFFFD4D96164D447EF86CCFFFF61FFFF747876726AB500308C7940F6A64B9D9B62E4FE4200330000E1FFFF641E80FFFFFFD6E1C2AC411A2C54003E53AEA1C8A900001827420002000022E349E9007D9F9DF3FFFF38B90033003F1F00E1E100194AA5AD53715EDBA4A5FF7E7CA1120005294500A22F2D9C2A00943614617978BF8FF943AED0C8E680B0165BBD10C5274D48A543B151780B0095429EE164AF9300DEFC1ECDA9FF8D46CE70B892CF5974FFA6FF9E7DFFFFEA9B024C00B737D7D062A1FFCD31AAFFCDEFBAFFFFFF6AC1FFFFFFF5FFD1548400C6C775004E7BB20B56473D2998363E25DBFF86F1C1F3B35E92351404CF5792A05B004460451511C6001EB9608100020340AC3D88504D8780FFA0FFF5FC308D1EFFE8B1AD0013C1813301411E6C00022C4AD7C28FABD2B1BD7E9AAFB1FFEDA1628DF18DA9AD455B1E854F7F842C8758FF1D073B0000FDD3C986C41F4C0000266BFFE7FF1DBF6C98B0FFE7FFEF9BFFF5FFFFF3FF7F62B281C1FF4CB8BA4AC2AE7EA5FFFFFFA0831B544C1C687CFF8EFFFF5AC9D94E94A8A0AA993D000040C94AFFCC67EFA07EFFC18A44D7C92ADEFAF72E71B281A10000072E3864683828365838868FCEE1A1A7009F001F6CBEFFFFD246B4FF3DF1D55FE46B3B003757C735FBFA0000FFFFB8C9009212AF7730962B3E337C90002F6C00206F00006535D307002A00532B00003C7D2E8D37331500BA98C3558217BA5059B8653906FF92E0B5FFFF44DCCC77000C47B2384C4E00CB51DB2DB7B0E877007094368E06F2A80D297FFFC540AEDA61BF575AFFCE00003300385DBC2AFF87FFFCFFF238B07900A35C0000394D43C014A45DEFDB13000083564BEDFFB1FFD06C74E6FFFFB12D034C715AFF263F6F003BE7FF0B00001411377BFFFF99B87E142EA3497E9D7487A9ECFFB5FFFFFFF73CB4F12B995790BEC5562286FF6500002743F080957F6061773E880715973CFE6CFF355F62BF26B2749187FFF3F77DD5A7EE80FFABF3001846B3E45FE2D350FFE90965BB605D9919839C00783438C8FBB9542C76D2CED1DD795B335AB33820FFC696BA5EB5FFBA558A96F1804B2600DDBBA244E7918159C1730022ABDEA03B7500E2B1D832CB2794FF43732E8E001A13FF9AFF28B1EA23A07ECA77DC210D8C280B369CA3FF61D8FFFFDECA54E3570022697AFFFFD2DB4550B8C99DDA8BBAFFA7FF2979AB00D6D14B87FB78FFFFE8F393BFA951FF34FFB40000A91CF4500000E2A4B6901F678B1D007EF634836DB542464063344E005800A53D5D005F3985C6D74023628E004C234800008D00C2B20000003AE5008FC5BD3892E0FFFF918A0000006363AEB933E8723000478715021AB100AF7C000097B2FF430041304BFA450000239436BB4ED3A482746E6E7BB102877E9830A1EAEC40979169C437BDE5FF69408B9300B4FFFF6197FF9F0073BEFF4F57417AFFFFA28E5A5CA9BA597BC7612192EB8E975EB70086928784628DA1D8A6617C20234688A4CBFFFFF40721035EFF52D4D70200000000AF4D85C1976500000036344B93CB02000000FF54D500AF9BDC6A96FF5A69A5D1C209005670004C20713CCDC786003CFF6A8D71FF92FFDD551864A597BC5149E361507FFF845E62FF8B440081BE6298FFCC876B000BA842FF7FFFFF15534E15FFFF8E0075E1BF005334543DB57B6EFFFFA947950E8E4DFF340000C325750C7001FFFF6579687ECA252DC80BAC81FAA54155B7CED297055AC5DB00B5339D1D0BDAD735605FBAFFFFFFADF7ECFFE4001500424D0C224697B0853A8F8E0C382E00708DFFCBFFDBFDDF37D211641543009AA13E001905005800725F65943356B5D3FF58C11C004AED8F53F1F87A9E4B44D1BB057256B0F2008C6F79209F9D821F778948E2A742D3ECF03E230055006ABB342D8821E7FFA2008F4D710000FF5101006968EE3C81390097004A000FD156D9E0A6162900D0FF7DD36782AEF1CE8D7C43B24AF4FFFF627DA48E004E5CFF87E5FFC1FF616E9AAC615B0A9FFFA4FDE1FFD7BBEE3A66E894AFFF543940C03ABE4B5A00F3FF5B41945CD1FF6C4C7300CFB4844E88E299190084617D76B650932FBD3C484B3D717C31FFD277F793542E370000011B4BD7FBC8FFFF68D4CBB8000031A99300CFE458D65F68B26D1E1FC3FF8E4891FFBD4E5C66A746009EA68EB1237900BCEA921C812F595727325D2491BC77C896FBFF25FFD4ED87004600B000803F7C360F87552332C5FFF126B7649AA999FF5E05944E1B320000415E65A69D1284D931D9CB0074C28E3F75FFFFF511A1002A4AB04F62FF00880015EF4D8F47A68AFFF7402C5063710A60FEFFFC517363CA6F5C111EB5F0A4260042A5FF210093A0FF3401958D389F77BFB5FFC224476E1996FEA09BB096C4E996BBFFFFB3FFA3304D40EAFFFFA96100CBFF33E700002D7E727600099D00683A9E2B65FFFF494800821F4B637F559F9D90442C00513B008D690049C4FFBF82FFB4FF29FFC59EE900000029FF4EA4302355C36258EEA6FFBD561F55D91C076B0C00128BD55213B7003D922E00003FB98590009FF85700A84F6B8261DCE992167C8795A924005451C9581DECCE0000746900A59C96B976FC6E376173008300841F3FCAB02397EAFF2086CCE9FFFF23602E0400B52C322E95923586CF00288C64A0DD009144E78DCB2F50FFC6574051FFB6C3E14200005C967131592E0069007E951E6FFF54D6E23C2791001E2F5087FF6A00CCD6D15BF5BF984679E4B40048AB002D0047BCD82C6875F793FFBAEF008290F90044BE000027D64D5BCAD69A0E09C3E8BE5C0075FF7CFFF5432B4A6E619E388C25AEC9851F2B22D6E2FF0668FFF3447CAE4EDC00003C8DB190555614EE53CFFFDDAC57CBAD0036AA6E1300001D75580E448B3C73C512707902744B8DD7FFE6D8DBC296C6597C8FFF98FFB8470000685F2A4E6EFF78003E24006FA243EC00019FFFFFDCFF8AC01885C3E39E67A1FFC94588F0C4671C8FA57500491171DFCFC3E3FFFF98CE5B1F83281894FFF6007BC3FFF6FF9896CCB215AF758E2DD2FFB875DB7BFF6D61A021A6ECC0C0AAFFFFFF82F4A0E4006F00CDFF9722B500005AA225069BB779BD24880000003CFFFFFF98D23DAB2C00DAE7739F92AF94D7BB1D0B367866637686C63151CF5B6A706D06FFFF17393BECA8006FFFFFFF4682522B2EB7ACC015415C64008E02917004DEF922351C9F007F5169745E9682FFF32F44A2FF250079093271001B61A38D9F853A30FF777243329EBF9946963EAEB79D5E5A401D61FF5D0522000030F4D4B632E8A6A38823000012068DC2BB758B93C35BF5979811766E8B34667AFFED11BFEFA241BFB70000 +name=fixedAmpZero slots=1 len=1024 hash=0x1FB1A6AA +first=16 C6 9D B8 F2 00 95 D2 8C 38 7E 57 21 91 00 81 +raw=16C69DB8F20095D28C387E572191008177DAA4AF57B3D7B040E17CFA6C97BCBF9AD116162E1302A1A01E9A754A721F2C08AB4C554715C3BF6308214A8ADC6C346C8DE61A7667961F6A41EFE78A297966A08E61A9A33E531ECCC903F9412146C2BC88A42BDC25C2B2EACA0FB854C987082DEA8FFC62D029BA7C69053B2436360CBC838D01B9E8DC50A397ADC6E05B2600A55922F0BBE0CC16F8DF2E1990DC8CC8A2737FA9A6C01A869C39A3C22BDB974C00AB0C7D2CF8A5464AD8A86EA2CAFBC2A980FF85832F997362F8ED34F33A81033BE2E4ED761402F5FFB3C2EA3BAB9AD810073449742D5ACB03CCAC74BF5CEC4D5536E2E09EA717602984EF12021CE4FD189AEBFFDE2641D6136424B2D41945665510E549EE95FA585E13C63E61B1B83709FC93056DF8156EAB21C0EF403F5E9F41116F6FF139A444B6DC019A86EF5BA12C283F0A11F88404641A0B02D38C6C5D280BA4ED795FF41DD00F802763517369D24AA815FBED1E9B5F18B69520B70819180550B39C49AC71CD9044B7AE460BD24BC22A7EA41257CC3F9A96257F66305E283ADF03B1AD736153F676F4E12F9334EF27C4F3C61686C12CD0A3060E3745CE6F18647557B3D3A38A8E5F5A3A64DEF818401975601EE93AD2A2FC5CAABA0C1E094395F46EF93A8122586E38BC2E239D240B7359B5BFA08C60A8DF23F974AE151892CABF1C91FAD84A0637B32BCDFEB677B8BC474D07AF9D8831B32963DCB992C00F0269C800491AB90370C412726DEA76AC843EF174FFE98440FF0F145F1E832AF9E0D92140434DA968F335C043D5F38D81FF8FB3F95A810F88714CFB5E33EF81C4A74B17BDE50AD607C0A8465DFDA0290405DEE3FE73076776DA2ACD2DB0EDF716434DE059127F8262FA917ACB0ED3BE3412251A5FDDB351262EC80214B2ABC0CA41C4F46A8F6D73B3E637F7929A83C24746B0326B1051146A8616D650BC6915EFD2E510B9072BF3E4F1B819A794BBAEA763224BE569417BC81EDC4811B27B32C8CE3C368409A2CE97A80205F6519A03EFD26FC7042876D77B58B41E7CDE4B57CCACA9AA7B064F571DBEDA9E5B8F5242F1A0E04E747178FA70B90BDEA9ED54CAA58C5EF6C4554563810BD990AA6B29F2B17A13CA554D633D49E825D5A4F128D2332FE3E7F931284B7D886B48AA6B7B9E6AB7F86B3AA8E57A5CB416136E5D6CBC89FA128D28B733EE8156CFF91473B6D4864CD6A42A514412B20CCA6CFB0DD7DAEDF81C3DD0E933ABB1B6199996D15D26A8D6442BA1FF56E70B06FE78343C377EDAD68292E9AF8B6AE60F33E0D433062BA59530FC8D478862D7DA4493F58D6C03FFE71D67DAD22E94ABEDD84F64AC5C7E846CDC5553A8D86D3CE503DD0F056BC4401F17DE8301A81540FD9826D0B41EBD84E3DCBACA81DB7915A7763B450D3C21B318F05B33CD15609814D86C97271E +name=fixedAmpMax slots=1 len=1024 hash=0xB4308D46 +first=12 E8 75 EE 92 6E 00 B0 1C 40 D0 00 25 CA 77 FF +raw=12E875EE926E00B01C40D00025CA77FF797EA67F379DBCE8764947CA2EA931E4D39D240000819B69324C0800B00332EC6807D50017F3BC676287E4B0036E2A0A8825B097580000B8BFFFA2AE0000240061BA6E03000000FF7977BA7A28FFFFA3E2004C6C265B00A2F05C16977363007B9BC65AAAFFFFE8688BE7FFA0FFFF72AECBFFFF774C08E7FC6A0D6AFFAECAFF9AFF58FFFF33C3D4797D43FFF14903DCFFF47A5CB436FF00807B37FAFF93C92D0D591751E3009000368AF071894577EC0015B2604C44C1428C7C74D1FFCC406875277F350ACFA4DEFE5E79FFFFBEAA09E2007BD3026C474BBC98A4FAFFA85C8B3CCC9C4A6B3DB5A509C343633D90363600003B2D13004F67CB87F14E623C64C4CDE2FFEB2644FF255D00A3F08301A100264BEDFFB2F0CCFF0D66986FFF8755A3819B61824300A400704A93BB8A328E595B5F1D29FF00284CD3B8FF9640FF5987B69D52E92E6C3F885F8CC51BE2A3273F00FFA1FF350CC16C2825D2D4F91DA495C900007000088749045F9814E80D00BC574AF52544B06BB500526900288FF469415F7C723122C0B1A009515B9AFFFFFF24320000F1FF5C53D918A0FF9FAF00AB32A9E7AB0EBAE50800282E16E37DFF59FFC5924648C1BCA78D381905DF00784FA3031B0083F8FF2BF868E1AC5E3A5C5D6137C98D9A110440FFFF10E8A568B4A2BD82B40500002700385AFFFF3A3EC8AC0099C1047D0062FFFF1BACFFE52F0052CB7BD4B4682B4E79671DB4176D1E679806274246008D0050004C7A086EFF44B830269759EF7A36A7000F4F9A91C298A42244EF7A8315FFC21904A9BBCA3D2C29C6FF9F2955C100F464000E9392FFACA0FFD570FFE9C945860005C1025D32717A5A9380D4D970C0FF3945708D2E47CC888C25CFA53231682573BCF48B39FFA40746FFFDC6006440378254691300AAFFFF669A004C4369B00000008B00090075BE12F2291CD78EF9BD94FF6F5B3E305CD6001BC3C8F9002058A0888B58EF5EFF9A468D00FFE7FFB1D4FFFF6DF3A3AB5067BC5792B1003C447757034BB49EEF006BCF0000D6618ABFF385BABB8C31BC6AFFCFC5240000A09BA449AA54FF6D315BCAFFB192D0A9610083FFEAD9E241F82BBC3027B2008A9A7B4033FF47BB7986D8FF9FDFDF0851FFFF11088D3D80FF819871EA6047924CC20E79BEF6BC000000EE35047036E792DCFFFEFF774776F8E06EA9A4C073FFE5954CA70000B400CC6D3012BEA5DA7DBC2C0A8415099BBBE8FF9D351E70FF00B41283FF00B3FFD2AF00000C2B00BE861C7BA9C95DA3BA24CA9990630063FF7200D00300002FFF9B99FFFFFFC35FFF45B8A6FF8C3D0EE2F3165E003705678F435D9D8C36D151FF85965859656CF8A37E2D0000008F962E71C62D00A7AA1F9888005D3A5E047DFF49D86AFF8992E6A4690049B63C590000D07D00892C79 +name=fixedShortEnd slots=1 len=1024 hash=0x78939871 +first=13 FF FF BF 4C B7 5F 8F FF 4F 87 EF 1F 46 49 4E +raw=13FFFFBF4CB75F8FFF4F87EF1F46494E9976154C525A27FFCCA19FAA57F3C123B900E5FFFFFFFFFF076D71333158928A8B36EB001A7F6977636DCE3C5AC1E49630FF95BD17DDFF2B47ECD0007BFFCB480B9096CB7A4BFF9E2F230C39B1D66DE5CD792D0CDB93BA00A7004D00CBBDBB7C9166081C5ECDE96589FFEA603C73FF808B423C734191FFE7FF7B0000580DE4FF3D9FA5B06E3C81AE2FFFFFE66B92CDFF6290FFFFEF74A006F5FF61FF7C04714E1BA000BF73CC00003BFFEB83DBBAFFFFAC8AA477E4BDB86F56C6F35B3715777F362E00004D3EFC00FF532024DCFFEC00251893FF656922B388B8DAA0FFFFE7CB4900A4699B000E4CFF9D004377341D748BD7812B00C9D5E2BCA45300A7C7A05D52A5DB93EC0EF600775F8EFFFFCF0B4995FF00E8C3EF338DCAB0ADEC2300F07EDA2F00BC7381A54B6AFFE8F5EC5F8E00AAFF94CA4889DD937278CD007ACE707A9513452F8CFFFF8FFB97BA7861000000083A0E1E0000008EF1BBAE4F90AA21FF4D9D420E61D5AEFFE337FFFFB9D9FF597676000B0055F896E2993A00572236A437B7FEF0590BD07C298BF8E601B05D72F76D724C1F5AE671D43C1F14BCFAF98C921363300229C29F0E393A5EC08242CA3E572FC86E3A8AB4BE076671CE1C0FA5FBADB7771AC50B69E09DD01CBC3CE90B229E73A89ECECC5511ACBC190548182CFA187D9DFF711AB520F29FE45DE6B44C3FC1A43F42341D479F8E27893C50B592BB407BEDF756E31172475071729E06D8D1946B51D01E743460E57134B31B8E7C0E60BE282A2EDEBDA07C4D6C2B93482EBA3EB418A2D149766EBE27810CA44CC3660A937FA44A06B19649C8781E0CE6018039B241F7269B1D08F99BC94FF99F73DC248E9A558CDA966538B528445F51374D56D8EEF28CEAEF914BE55BE83E38CE2BE58696126E5EC942A6CA06FBFAFEECEEB8E0B497043D61903ADD7AA4A8CF49B3DC950192FA1A5E85C6858C035D656FD5D4C79DD2241DA2AF24225CB9821E8C78E91CDF73D0191AAB9D252C811E03421978CE257115978FBD38162CBD2BE2742142BDA63524EA765607AE4B01E4581C73C17B3F26963DBDAD7BDF07AA16E013FB705F443BBDA2A049B2E93F6D6DE8E84023955088F7247595C56701E2C9DEADF4502C16CFF7D77C6E1E615719893DC683652A30101EE6B1B2C25C1F26DFA5BBA11395F01BC2A69ED1A367D2B81DE308E0A38B2F1D86A12360F22F5C9F0705EA3A3F4C1B31A2AD50A46B0EE565216934A34A4E0CF23BF62D24AA18A6D703CF1B9BAC485006AD11903A742FB89CA26607B9204AFDB4DF0AE8A31010E1BEA9DC634287C5C0709078100E9065673FC1716EB33A9EAD6DA9881FAA23D5E1A7E6040A5553400E2D8B0F37BA65110493B3268CC804AA370D619C5325B1E97249B6278E9AE37E51DC70851D016E569142BF28BA +name=multiSlot3 slots=3 len=1024 hash=0x91080E39 +first=10 7C FF D3 3D 1E FF 3F A5 6B C6 FF 00 C9 F1 5C +raw=107CFFD33D1EFF3FA56BC6FF00C9F15C97C2950BD8FFA9DF440021005500FF0061C300FFB6FFC82FFF7D42B18D0013422573BFDD920000D07B344D0000686CED9F007978FF5C2BB66B5DFFE4FFF869FFFFF0D3097A76240A6D6BFF70FFBCF0B900000010FF21DAB34E82045C918E7761470C707C0CFFC736AB009CC65BFFC0FF375F35D2003B00813E9B00CDFFFFFABA00A436FF7732030F26570046A67F6359E02FD2006050FF5A2CF4DEFFFF09004EBC3288FF71FFC200AC2E7B5296965B00A8FF005DD23824003BFF80FF1F67FF00777380B6A0CAF9003ECE3800FAFF0700FF4A009965000CFFEE1588FF5A203F60FF74417855706AFFFFFFEF882201FFD7AE43FF890094EF42FFFF68FF5DAA00AFFF19854D0081671C00D30000AC59257E18431740FF791D00B4C4FF00FF5115FFB0000091E273FFAAFFC490ADFF00CE9D8E96CCF582D3FFEA8991315FC12A0E11ADBFDEB1380C3200FF78153000B07800B200A9825A5A79CC9B859021A02AD2B74F005ACA256A008D6662FF90FFD276B832B4070000BF0062AB003B45647BFCEBDFA3064F978DFF2F82FFB427009BFFACA8FF31AF42CC6E5209A21B2BFFC65DFFA16A9919D5A1FE45FF007D951692A04FFE8030FF56F900B4B29CB4C385FF00FFE86B006053FFFC96FF3B3812025162FF660000476A00C1C7C43031342C00E3C17780FF43B8FF020000AA522A3BD12791E5C542AD640A800300171C000076E0B92DF85216737EF1A09B7A2B97FFADB813C738884FFF914D00A32A7064EDFB58B700001883EF8C00C716BA80C1FF4B1DBBC1FF185F00FFFF73DA9B11448E00D0FFFFF8326166C75700FFA97C18E08020223CFF98CDC4008E009E6789502CFF5967103DFF6076FBFF4A00CE339F9700C48ECAD470C5FFFE3AE536FF4A73BC59AAB18BBABDE9D47E00323BAD34FF4016007D002F000073630B2DE3FF9C7F54DEFF5D2BA2FF792F32969988A7FFFF6A413BF241FFFFAA67FF004FFFFF3E00881176193386009577D1864A127BB2FF0197FE000095250872DCE82B752C00BFF5008B4E146392605F4BA2657FAB6C002C74803B1A419068FD7228F58F6CFF00047F000B358DF87AAD00B60ABBFF002EFFF02E766F2C7E25FC6B1AC700C000A0DB3F32FFFFFAFDD39DFF96B5D4FF5AFF38AF00FFFFD3DA904009007DFFFCC253530053349D7CC4FFADF21E4B00FF71D598A026A5FF7B99E3FFB80069AAB75B182D00BD96C4B5FF1C0000AC25001FFA3F71067D17D6D45DDEFF00FF6CFF886C5386FF972F007200943CCE127100297EFFCCD2FF00394200E3D0B8FF85FDDAF582E7534A29A88719BB77070059756F00DBF68329AAD00089B3E8C0FF2DF064BFA9003494C15B7DD0777200209BB8B864676A004C2F94AD8D51FFB0A68894A7F1CA1864419F89FF527C0100FFC1D3AEA60000D500BD +name=streamRefillMid slots=1 len=1024 hash=0xE26AC28B +first=00 23 64 C1 49 FF 7B 88 95 8A 39 00 00 8B FF 6D +raw=002364C149FF7B88958A3900008BFF6D0BFFDE0C00E344E6DE628D008A000172AD83D7FFE88B861D676CF5DAD996F02A291738E49A4BC371583A00C900001EB7DA5FCC2B95033B27968EB94A8CC1C2FFB3BABE172200684C66C0A3608F477A8C36FF2D008014124F0D1C3C73000092F500163FF3CCE3BCFF4EA36A6C9D30BE1BAC996BDA7593007BFF9DFFA018766F896F2D3E2DC4A6BEB4F68A67FFB135AAC7B24B5A15A95AA3000338FFFFC4D0FF8BDE4800AB55A115FBFE421B7637FEB675004A9F2AD801C5471D00143BB695223579DC411C7800678AB795480712FFFF57CD25B5B0DA6FFFD100C4FF983A007381CAE2A8FFB039CCC3C08A3408D3815EFF642E02BD88DEFF82FF408858FFFFFF0000FFBF3A5B7B52990072A9635E945FF5D2FFAC7BA5CC258E93351841BDC593FF9F935A2846FF13E47C0079E1693B0034805F4D713BFF87FB7DCCFF87B9FF61582F00B7FF1039652ACE00ABADBBF2ABFF8077E7FFE59DAAFEF6905C73DD0046FDFFA44DFFF9CAFC39FAB54F00996DC39A8BB13545010052D552A6BEE6058810EE560000B8226364C0E90026DD55000095FFC61BC3ADB50C0056C4AA628D9FFD295B008FFFEACDFFD4ADFF744E3AA074A8866F021D57090052D48AD12F9C47021FDD05DCFFFFADAD0F4DB429FFD871D37D000689FF82E7FF59FFB49C44444F984AFFAB0C5EFF1D31A4B0FF110BBD2291F600FF8F0002935C9C71A043A40092EB965F1BCD420091611E60A14C5AFFBDBBFF7EBEAF60B5FFB6341D9BC22F51ECC329B69A6CA92600363DD8AD5862BAFFB559FFFF90F9A57700B0A1ACCAFF37D6365C2C172A7073D3004E3959C38EFFDFCC388736348E3656D1427297806BAFAD77EBC5009BDBFFFFC098060032A142FFB794C4CAE9C4AADDFF56FA1F6EBD8A1A88C562007E421F66641800425CCEEE245341113DC087BE97BBC85A2400FBA3FE8B0F71F87540537F55700BCE657EEB001476FF324B77959AFF35F77000B955FF9B60E80D81CE94022EA9635C7858EF5CC1F26600C5FF2C7767DD7BB79E7431E7FFBBA66B8C9FFFFFF1810FCA7794311A17C25E656F7BBFB10004806C00005CACA7CF1000000042A2FFFD26F0D592150060E3D7B06A845AC7FFAAFF7DFF8BE5FFFF0012650000B3FF95FFCDFF7243E04800001C0F677AEC0008C1FF7263754A37EF3D95B94FF7FF2A9955C5B6A0FF9F4A887AD0E5C6657AADDF008E0205D1FC28E4BD4E6B7600014D46224900A9B517C8002979FF98BEB8CDCE307826468259D34FFFB84BD50088C2C4C800001C39284FBE0000732ACC4823F181FF64134AB33D1B41B724A097C47A119E8B69CDF70002326CC2B1A4767FFF4222FF43F5C01DD40093FF72680A6D48009D69C5106248E5D3F7A2B17DA795FF9C7C7816002E0F0C6EFC0000B6CF89B4882C6F005E0000FF5E6F +name=streamEndMid slots=1 len=1024 hash=0xDBBAA972 +first=00 00 15 FF 7A 8F A0 75 2B 30 F7 FF 57 21 36 FF +raw=000015FF7A8FA0752B30F7FF572136FF688C0037BE4C4606FF3B6F9B12FFCB5BA2DF485FC8003F050B4F18011B6444FF7E308DA145FEFFFF048DDAC7FF52B3AD06FF1A004E87FF4825F67077A8025895B2FF00ACB0FF50C1FF00AC7A89330B2DB838E200BBAA63FF4AFF64E958989DC430FF9EF77D007D7346FF6E0015A37C5484FFD258E24E8C0090A8A9BCF9501900DA2D73E16C9999A7DB0EC9F06EB42C7980E53F36988862FFFF09FF00EF4D939930AE0CDA36D37447006DE7C598E4FF00FFFFFF00E57E9A001E9F00ADB37B6A634D86D1FF87436A6CEC00508EBCF449B6A4F21C696C2DECFF9B44D5FD23659100FFFFFA95856800BDB1FBFF49F0FA173579E95DEB95A5F70DFB9AA90A7EB793A375A8A33BBBF6EE28B4E67CA106445DEC057827F4FE8F1C6CD413A017AFE0C4EF9E510338717184B2350E70870DD534A353DBC47E3E3330B07B7D1FAF738A09A22B7E3B2DF310028C61CE8F7E3F9260C16C734B22D4F684690D481BA52FBBE197EED2BC462A99B5FA5F1A6829AB2298146BA4081D9480DD0E3D6B3AEF1A3DBE0ECEF32B5355185C25EA6A3C5131F08BCE83D87851A5BF76FA505ED8A9419D07A9C68C61C803DB291953B802E4882FD884F7794E4482E4FA6B20200711842E156FE44B6BBB1B77C5C67E8562F13CD0132F20FA731FFA648D821B318989970637CE4CE289EAD4C24B02E1D4BBAEA88CC52C6BCE00B231F8C2474198D69902FEB092330A30B4BDD64A83882D1F7401E06A3D586F456C9D9DA4A32ADD1AEC21B5B6F0E47A0B1DB713C6E6149B55BF4B0B73877D58C4650994B065FD0F364F258D7380BEF4F4CCF61D36ACB7ADD43062124493810A34569662D7407ABFBDB712AC063C3438FE0E05D986383A78CD8CF3BC364E220A819CB5CF7FC9F202F839C0FC8183CC0E6DD1BF7414D4F79220BB8095940A30E0C83A2EEA817A4A73E111DA2BEB19213FBAAB565BB5ABE01863CB60EEA07C90195ED41D032AB2FC2D299DE2AB3E25E19B92D25043B0CD77A8E0ECBDDB8EBE3A45C8B6F0AF3DB415D433602BB26833D0F5D6228521C030D46696739432864C3BF9C3CD6BC7EEA5388C1901DE3A4D32C4219B1B233214AAA2FB36D9B992A408B4CD3CAAA469E7909A00A54BF601C174A7FC29E2AAF550F9229126BAD159F937D95E578407F44464055C582C448990B27964FE3DF12B8CBD27D780D856AB8A82F44D6A34530F99F1D65264B76C1878074B3ACB991FDC537A1902121697724EF7603D01379CA2E9EFFF18B423E6B82CF145677405B354A398F3C95E7FB55832088ED8DAB147DBAFF80667E1CBFE385154A1D312357E22F2644468993EA7CD178804CAE965112AD88B047D3D2ED5288378AE6FF281A317250D329DAEA43707D1B259C454C00944C409A71BCBEE5EE04F08CE2B96E49E4C98A973AD3B2704FC2923 +name=mixedFixedStream slots=2 len=1024 hash=0x2B1919FE +first=00 6A 3E 7D AB 6F 00 68 4F A2 1A B8 B5 4F 5A 91 +raw=006A3E7DAB6F00684FA21AB8B54F5A9182FD86004F01D857FF4901F431A04AD18D0099DA69007C9F0027E9C0999D7B83AADAA0596FDA3B530055D81FA852002363AA90A4003BCE04586215481A1C3E000E000078FFFFF677A3BD001659AC4126721BFF3A5D2BA58C6CABFF00800D8D4F2DFF0654C08485750016883400005400092D004277D79CE000B3B2FFFF2DC20340FD9D7536BE29580054912E6B9D49867CFFFF72FF82B5FF8FAC00372E387A7FD74C007D0AAAFF6BA75A22166A2AD189FF3C1500005C00B8327DA8C732B85B4969001573FF00041C5FC6698BAFA5005AEC4657A7284987FFE76A004E8A642D483076005BF1FFFF6C20A86CB553307CA1FB636536732F2DFF3752FFFF417B2863E14E52125DB3699E573F618C7B00990666CDA0111F6FFF28B9B56972BE9D00B9B400C609E200DB4EB5006E2789DCC9C7FF73FF4A41DDC4AEF051ED4A1F1AEBE2FFFF594FFFD5B7FCCC9E00276668175E86CBD699FFB97DFF29A0FFCE943E848EFFCBA3CDAA59CEFFFF8800004CD88BE0866D2047D1EA1D1750BCAC000051687D8D8F3F4530FCFFA1BD82FF6304FFFFFE1CB3BDEAF5FF7AFF4C003F0000AF5100FF0050AD5300ABFF5BC9FD1B4C900ED77DAA1284806FFFB9A8D600DA28FF0099D776F1332122D059D00055B8B6293BAF0055728771C900FF4DC32A0B16FFD00DAA007873FF705538CEDA791DB700363E84AC2F0075C8FF597B784E35ABA0FFCAC59A68CA520000FFFFCB00D000FF96CD630F007B8F9221FF615654C5CC8411178601CFE3721700B0B47E5CAE1D0000A410A1000861C0649C2576DCD0423099D35BCB98FFC1A400D200F9FF76FF8BCCCA0C9D0000002ACE26003C00696E8B5B496F13410000F097997ED00033F9C1D82D994C56C4AAFF0F3000461765C2F1230000001C003AA1FF55FF88B46B36B5ADBC50953CB18D00764E7BB1CD65600AFA07D6450000CAA43AB2C83D64445EAD4485000A8839AB7EB79B2FDE6796AE45D7A44E00766CD94A4A4F3F7D00D286579F80002DFF2F9338779557B67F49C784E8A99B45A38D1735A2F0658CDA7EFFFFC1B6FFC02AC2B1A4FF7719DE3CFF1598FF8FA6001C3564BD312A0000660916E5D162FF10CBAE3670D74D50E2A8EDD900C2ED686D14CDB5FF003E8673003FE007C1926D8F64C095BAC34F5AFFCF00158F140D641BBF7262FE55665EC7FDB34C09B071CC7600FB0E0067FFD5A75A550000D649CF9FCD00861C00DE9CF7EC30FF80CF7DA07B0694CDCEB8EDDCD21CE858E2BFB4F5B97EC3FF8DC4E0D97195D43979FF006D009697645FBAD253F2B90B04437D37E1E4B745C2F6A4D54E28954121B7EBCF5097003BFF3B53D266FF9BCE4C756F9C6F32788592A7009C0095268DC66D0095E73941B3FF4BC1357BC0528D9249082C970F0600E3D9E2269C82FF19F3266599 +name=fixedLongPos64k slots=1 len=8192 hash=0x5EE54B61 +first=2D 98 5A 00 99 1D 3B 6A C7 C4 A0 E5 7F 88 9E FF +raw=2D985A00991D3B6AC7C4A0E57F889EFF0088B0789E67EBADFF64D792A520A1FD338D47850057B88949378882892C020000A8D300FF428C75A37935E1CBBD50A91F8800FF1445C5D100E1FC76C36A6E0695553900B65EFFFFB34F6600B8A6FF8678A00F807883255800FF7351CF6CA28C9665B498497800000044A9EDBE53D44862E2973B00EAB67752258308BA49064642FF009C43FF3AEF00EA3A1317D77611295B25C4279AFF7EE66A8E673AFF00FF1E251463002DCC2100D2FE11BF0650F6E25EF030009A28D3A56C90BE1D628079B824008E9A60A10041E38392E31DF9FF7DFFF670D8FFF15061A94C0000AE54B9BA0BFF8878004A3F0487C3282CFF0076D65D9B259E957167CEDD47645155D1004157FF8B8C3DA79184A6A92924168EC6901B007F5373394EDDFF989B1754968EC64505FF80A40C6AFFDFB4112F455DA800FF54C4F1FA8A9CAAB9FFFFEB5B5BFF548CD29EEC1680A074FFE98AA1B3DB74149F0D00BBCB41FFB30022004B97A0987A559D18C2A409D1001CD10093E75C007AFD3C31FF3216105ECBA2FF8315FFE7D3FF846350FF7EC8FF13D70E5188FF6BC4FF9281B76F5D67BE056BFFE4CEB100DF7184746032AC9B476DCB13FF333400B0C88B0E0D19DC5700B6529B6367FF8AAE3A6F95DBA06EDFFE8A641392C39D342E87A1495100FF64C9C679FFCDC7F700EED7A63D00FF00BD7A3971FFDF75C534C747FF39BF609361A47A00552400BF9535489CEDFF502F30E29A9CFF00157642FF9C001A2E0BB0476D8ADD2B38FF03DD2CA400AA67AC7090439937577DE8131BCD224EE16C1EE9651800B42762FDC8000045CCAC4836ED40D5B7A9DD5FA7C481DE539673E48D0070B7610012F5FDAAFC82A300008C7EC1DD4F16C40AEFB6005BEF00702A00919426FF560854FF5755D536004059EB195BEE1506D0EBFFA5C7416D9F616BDAA86FB200ADB5C152A9E2D000809D796FF44E68ACACF140DE19C84A9B00ED459106FFB3A4FF68ED9AC949D5DA5534DC376AFF944D2F38FF749FB85CEC4FD50B3DC7C2B0DC95FFE050DB9BB7F72DB05DFF7817449800F22600FFBFFF00713838DDDB4E91BC007800B7197B3EFFEF1746D213FEA65A9152547880A62B0A769DFFE2FF00648EB1327584BDA8A387732EDEF400005C753C4BFFC5C921690042C56391AA870BD49E6E6D6809A15FA6E33959AE3A7381298FC247E5EE51FE009A888E641E72CFDF1AA4F3A8BEFF0668C782509436402CFF32119500FF9334B050A16BE8D1FF00D90000004FCC7AA14440FFE273D8FF00D9C654A500414F40003ABD21592F20FFB22D93256FB715A04ED43E7EFF40A12F6F23443EC8364C00A5DF2B6F123EDD0000C63F19B3FFCAFF0C1CA787FFB34A0638FFD064F04392450C9DB966A851D145CC8DAFFF2F000B7C688CFF00FF4056FBBFB1F220FF4D68E10EE3DF00AB289C5D6AD431DC435ABA8F46C73237002C4AFFFFD400003DF678FE886DDCBFF4006375B9071E71223BC2D36A82A89117C12AFFB1D4487A00002D61A325FF3C005B078E7E91005300B590F6A58A05CE2A62E75D4DFF4B0CF3803A1353E5B98B1800FC9F7DC7F0E8AC862233CD55B65BF5496C4D007126F85B9FB6D0D5DC72CD514681A122D7225F3F3AE7936AB42E0CA7B30000FF2238FFD51BB90076C6EA34874FA03F929ED89B93AAC6CEC100319829FFD5B8A9D41760CB958A176891902B0B10AF8BE8A3461200666B70001161B1FFE842D98645CB19FF255A1EEAA5659A0084F91C51004172FF6ADBAF0825305100697900440482CB95E8B8B0B164003F1A9B6400C2009AFADB0EA8C9FFC1799C3E5AE70002E0DD22CD04CA24FF9071000000FFFFFF8778FF4EE34D6B718C5800FF353D1BA0A14B6D0B4E347E9F007F49FF14004AF7446A92E5DAD20042FF2900CD0000C09DB5420000978B6F96722D37B4BB1F73C600338DCF8A2E4AA77EAB480012E97CFF5B70474B8195B19AE2552B1E4EA2B4793127355540C10E50A4CBFFD46C7D1BD934A9AE65008366570DEF4DFF63EB98C9D9DD4FC3C051A68C07B600CB5119590228EDCF3DC06D4566A8CA0700A4E1725D4DAB34D28CE4FF9B79008222892B76686E750D181C07006B0D9AE18EB549FFFFD8DE412DCD86C756091F4E005E1F5D7F002D62D7D2FFBF1A13AC95D2A0C9FF74664F1E0014E7B869FF97CD7E299CEB004CB175627F227B1DD6004E4818B0E9A904FFFF00ECB54A8054A56700A79A7D1E7CFFA200CBA500235057D438C178005400D4E11F8DFFB5DAEF2EED371CFF9CE8EEC4FFC3FF9FA390209531A6BA7158B48D1B79A7B0E6FF37FF33619B92716C00FF1AD88D53BBFF559D310000A600BD4A0081AEF56E258A6B00FFB310534462C57956BB19FF4BFFAE08D10000162A90896847B1CA7750D8D400712F80BFA760921845AE0F007985974FE27685DA1F4AB8670AD69655004BFCC19B82113D4397AB008170303AEC15FF6CCDFF002DB1D416B438D422727288B1CDFFBE961242FF2DC5A9C53AEEFC8D9CF5FFEC00A0286E8D00F5351F30EED085D2E4718EE7000014E0C04BCB5247F200D6DFEA609C42895F1CDADB6FCDACB900010094D12DACA6E2FF00457AA6FFD99767557DC8C2641AC4CF9C26BC467871A96C0077FF241800C399FF62A62C885B3BFF062B0A534BEFFF115373125B8E122E259AF513790E3D840E91986CDD000A07D0ECD8FF57BAFFA136CBAFFF805493FF5BEED77F8DF63ECDB776CD7400AD1194AE7CFF15FA0077781C5584B12D03CE5C71A738FE886FE68A61B99BFFDA1700FFA55630B5C0561E174D9DA33442005BDFB83A00FF004702679C2D6D00A90317BE9759891D34B177858D834F04EC337F2C57E47609C6D34868FF88C1CA9564595DB5B64206B65ECDB2AF7A1E0048E2AC607691FFFA8C00D91337CCAA154DD20F7996007CDEFF00BC965A84FF6F40FEDBF476007A007741006FA8AA3C907E63EBB3948F9BCBEEB1F4B9007BC17580329B01751CB4BB587A40FF3FED00FE000269C475FF76A3B150F5000000CD1A437E5A9438A38E89A8B2A5FFA07E0016305049BB15FFA2F6348F75FD5F2D5DCC4B66FFAE41FB00A02B0350A0C4976FB57D6200A0687F3D3143B34B5982C186A766EDFF4354647200FF301CFAD8EE8E0713409B241B80567BA772F3FFD486C2F4E36E1100BC2BF4A4527399FFFF678000582066ED006A6E5EFF7454A49757FFEAFF7A850032A93B993548D2FFBC00E0FF4128FE00DA99F239BD5FFF0BC5B4881FC300546293FF8079D8025CFF0000AB8011643B1D52F8789A319BA2CE48D9C3735E538984C4130000B2FF2949F2214CC50040A95B3ED36DEF6AFF85FF32E3FFF7C3FF1AFC574B5884AA5AC5005E009C9859C7002332D87C00003231AFEEEDDBFF00838E9AFF27687000A8DD5CDE97624C274E5E938357006BABB2BB7485E8613DC00662B498BA5BCC4A58B287CD4317ADFFA599FF3300FF00AB004F8EB3E10058B773FF7CD7970A50BE767FFF237E3D472062FFFF97936E221130AFFF00F5B7AD4BB815ECA4B4FF745500E985B5CB349E9877E69BFF32AF06000500875DA39BE69C3D58CD5FB300FFFF00947552B86630417BB6DB43E1FF8F9B4027BFF888DEFF88D26DD6B4E93207AE613BD8EDBDC61F3BFFFF0E66FF7E4CFF60FFD32A1B51CCA5B7A55FEE4A9E67B40A296A51D170A9FF9C3AB7F67A368CEDE500741B65524678FFE000BDA4FFABD1D6C359139AC45100CAEE2DCE293FC5BB0D3270ACA4BA7EE858199FFF9F6BC4CD73D50004AA9D50FF76AB8B1032002F8A8D3C6B963E01DCDC0D34F7212E18CC7A881DFF4BE07CCE001FDFDD00FF872068A046A1D8D3FF51FF23728F659470BFE24C262A9A2964CBB5A48D383A14FF6EACD446FF595A6C13FFEBEC3975FFACECA1E60058006E00F9E88DFFD7B3A2A1009D00A2AC7E785EF445ED009AC3E90F6B9F00627376D849906050C7051F3CFF5EEF92CBA311FF054DEB2707007722000B5400A367B70E867F59B335221ACB00CE67FF3400FFB9002930641AB37D3100ED3F98A868673DC752F1CD8A0630FFA32A947A150017D943E49EFFD956B2B6003C3A90FFB78EF8CF4CFF6E4F0D66FFE2D5BBC2FA12415643FFAFE54ADECEA93C67FFFF42EECA9C65E2A88C472A1900749F25FFDE00FF1ACA7ED81231A33D77C200750000DA76B237ED346D9EC59666EE2077005AFF64A6C8D34863FFB5510036B1824D243F52AFA877B7226D2FBF4B5982FF8C2C0045003480D17773CF818FE921BB7296FFA18ED2520036FF54D52DF6A9D0C7E4AB3504CB15AF578FB78B7E965EAEDDFF5A9F52BCBFC27A7F26FF394FFFA332A9009D0FF8221A812660DEFFA5AA0AB7D579571296C63A7E636B40648BAC9100B657FF4B650D83409FDAFF80805A57FF50B4D800E78C5E46A200BB4B6DC7B4A67D1CF99F7427409F62FFEA3047143B00FFA11B6500CEA7E700A15F9E84FF303163D4FB1749030059FF7DEC8E25D3BD10FFB4FF001BD38CA8FF00D213004B41D6CA697E51A35339347700E38DC824B1EEB15265C49A8EF5BD0905A20025A940BD33FFDE5EFF7F5D871ED9608ABC00F2B3646D7B00E1FFD69A7D259F000076E3E289F32E00926FE56CFF0157BB97FFF745322A8D9D322C82BD501489BAD645BA877F19001A48CA83BB4B230000FF3E117C72710FB7DA32851D009E7B81B25A25B0E6470F9915CD5BBF7BDE43DE2BF6DDDBFFBC8436AB4E004D40567A09FF00009613FF6597D2797D8F8093C7F19B834F7F0023F915DF7CFF9700A3A9F3031361C102FFE25039AF4BCA028DFF809BA3B9FF2A6F02C24D00176DFF2C3F15743CC1638D0065B1FF2CA12E68AFF4BB000B0050DCED08239FE31F2EB3D2A1403A86000038983E16AD1E00AD531908FFC25CB4B321AC00CC7985C711D7441AD34F91FF582436006B845662F619E000C5EE55AF0026621C79A8EA73C2FFDCE1B1E07A5D2CF6A7CF4239F4B0FF79B29700E2EE3C1840B63FDF234E2BFFA46F9899A6A78DE567C8006F4B227BFFCC66B0C8BCEA353D5D6D00AF8F4BFF75183245166EAC0A8BA7B458C4B0DC95FFC375D9499FA57C9DBBDC6C4FCB6848F280727EDA5E21A8EAE13A984BCFEB6687135CBB45A80D6827B7FF7332C9560082474785005FFFCB15005D8C8FFF19FF03DF5F3F2600ACF40E33AA3CF77BED9C741E995E96AA29774270320022566880F05CFF9FB97600BD5EA2006DFFB47FA4FF7DD1921DFFFFFD0F00532F408A9A49FFC8001B187D4C6629E07D00098D5C64743EDE78680029FF8E6ED538C200A7E23F9194FFF202FFCE8AA03146C75B13FF7207AD9D9D904CFFB7FF39F72A46FB80AD8725FFE4DE4E9A88055241FFB8C56C989EFF008C84FF0A51EF000AE34ECD2A5CA6A5FFFB8D53D7DF3B1578FF64DD00982AA07EBE19FFF0863E425EFF009B83D359BF88B80087851584FAD49ED1B0B9AE42B38A43150013B2A5AEA446C52B16B129594F19BC9F008EAA067BFF025943DFA132609CC24062C60034376072CCBF6F5F6B14D94496D50BA937B9FF784D1BFF7A05006C60E4C480EA0037AB001B7CB33781B8F6EA4C79EE5D37C200932C3CC40FC2779DBFC2CFBAC29FF5A98588B8992900FF1E3F907F723CD900C8245FFF0036A3FF89FF0000AA0600967000DE2051AF88526AFACE0700FFC1285A00BC46001B315F210094B1008B7B008159004100009DC2292A422E388D8FFF4E3FC86D6990FF286B0871FFA639AD02A969564457EE7C7100B2A7314D64008AD293FB8B6E000DDFED409367BD5FE7FFA2B43054ABFA5D8942FF1EBCB6074DD7FD3DC077147E3B00B7014B4D400F619EEAFFFF20C200996BC6AA0060BF0038514F4B18DC53FFB200DCE7373163E99B50FFF8B404759CBEADCE9EBE1105B34DBECB12FEFB0000B4C1A22E1B72C955EA8A9C6E6C00DF6A6EEB57966ABAC634EE00FF2F711268805EC9479CF341974DAE55CCC0709700782EB81278828B6961FFBE44001D00722C12639248A3217DC79BA393DAFFDB8F0E84DA4B3705C73168C55C9D0D633F63CF10009E2F526A25F67880EA000090C453141E5600FC86FFDDD5419BACA19C58A20865B3C6C6AA8D125F835DFF8C5E00FF47003488D7CE0694390030938E3A762DFA3CF700AD108F9F7A64E6F3000A615C5FFFA037192F0D68413BF869C7D9E98A7662300097FF3186FF0000FF1637B4D9F6FFE44F7800C3E7CBFFFF3C60BFBB6E562388002CF16068BF4B933BCFBCCF03962ACCB3D7553B2D64B04C72D901B2907E476CFF4A17985D8E6C7F415082A48DA70519498658559C10771A3397AD0B8F9F9F8DF8BF896B8600FFB7CF00BB1EA493F70059B6EFFFD8B41F8BEBE883A641F5BAFF2130BB450017D7675A087300DFD7E46EA1FF262D8D9DC1AD560033E5FE009DD4D809090057FF6E54C95654FC6DA9DAB05A00FFFFC14F2F0022A7B993776FA2B130C5701207007B5CFF964AB219A6C923B1E05D7F623E000010008C00B6C249FCFFE492B08AF227A932C7EBC786FFDD61A38FAB1E8C631A842EDA64BEFFEA68F72400B88203A300BD778E5D00A83462F000939EEAC2BD43FFD7331D008CFFA5841268E1C400FF89005B2F714BAC00E3570C262D00E03C6FE7A36DB9CD3B64FF4D9504CF62B021476A67D94374AC62C6FF813FFF4CC5FF004700277A7600DD79D0B1FFB1B4D8B9BEB880B7E47A15004D74ECDA3861BED468BC2BFF30A21ED1B5795A7700E4AA00542600001C3E762C7C309F626A44D2A18CE9FFA4E0648877D1966FAA45007B2D7D63EF913D1BFF00B7006CD67AA3850B00301A336F4588E670DF8469755AC3985189F78F03C05C7D074900FF00E0E7C10CFFFFE30024496FCF002C971327620C6CB712833012AD1A74B2C6AF51C32420A703FD2E38F4C072AA988AA70095C8942B3640A21C9795DC8A0744C80909EDDE20BAAE9D2AAF00F25D90FF7588D5FF00D975C794FF2B0023D0A1E9FF66003EFFE2A16F93FF3D8B56C32D136700407A3B77B3E3CDDEC52500495A6700CDE22CFEF9FED6B800004DBA6BD63F99C9BCA07097375DFF6A704CE8FFF741C22576787BFFC0884AC396A46F658EC5E4C8C7B30778EC23B400B8A558FFC39CFFBBFF009881FF2A57FFF3000D7689003D534AA791B6FFA03F738BC545B694BF9F6F601EB0DC7D6DFF3AE921855A239F2669AC008AFF49FF31EF28E255BEA3C1FFA18EC0B18979809791D0F0760C931F33432A0000FF53800C2EE100C4BCACD1E75E22008777930C957EFFA434C11B10812B9722BD8FAEC3B2003CC16967791C00FF7C6C9E68A8329E875E1AC586AD1B54F0FF73FF00009D00FFB72F6EFF9C2B976DB551C7B2485518A0FFFF46715D0098150DBC7AE000E5F3D24C48BD67AF942B77AE6EEE84B65F3AC253BB5F755B9908B5812E6E80BE2AC248FF00718432609900A985372BB6880047821C9EA28EAFC14790FF38B8EFA9FFC245063F359E823CAA77140965ED831DD67C17A9B6FAFF8900FF7EB996FF84BBFF174153FFFF80E9C1FC806B45FB5FD74AFF7B9AF72C8016FF629440B3746F5413ED9DB7E8A6CB597062A634CFC67C289D6FB200F9886CA738E289A6D6EA7FC7FF2AA6DC82C325B5A67C68A8B59370AFFF49AF8FCA81A616F9008FBFFF83FF3E657DC643FF9AF1419A00DC99D9DFFFC0AAE14D4871FFF80400386FFF2A1CD2E19C986C45D48A4B4E235D8EB9C5DCF34CFF829295A6F5077CEE828E43099C53A3F2B6509E59BDB21A024E55FCC74A95BEC10027FFE840FFFF6FB76B276B55B500E9DCA8A66B0FFFFF1C104C43CEBD55AC4CC44C5B6759CDA61A9D97E88DCAB1E44BD05CCF4A947F9132FF091794A3AD1FAF0198D4D275FF00EAFA3C9826735000009EF9CA3891008DA5CB7A16A81E046A3F9C3F026778483783AF7730004F8875002CD32C173E72A2043DDE3BF7FFFF8E0028E5E200956C8245FF09BEF854CDDA2FFFD26F006D15A9FF3F9C69EA31C0B8674458FD6DEDB9F05E25475C25ED1B8400C96E40280BFE007FFA59FF5FC7D0FF4DA38396AFDE85FFA56D0000D47051272503B24DAFE73400A642FF505DFF918D16008719BBDC0F83FF65CD94ABB895FF63C01697FAE4E071003A54FF638600057454D06DFDD1C190B23852AD0000B0BE98949AA3862E37AA1FEE067A54005E4CADB6ABFF28BD009FB399A678FF00ADA50C3B720EDBC1726B5FD9A5D4CB4DAC4F4FA88B7F88EA518F1B7311334205DA0D00ABB931252FDD61B986CD2B16888523C74F6457C8DE7B9720A4B671FFC06A59FF2EF24EB71DB2D549E22A00B8BA9600CA86FF2D1A3D2B7F15228B0014250076B0F92EF15D27FC05BEA0C3F80076399B634349FFE6248CFAADB597552EFFDF28625CFFAE8F00CE7D442A36484CA4002B72B848670F32DB0A863DFF5CFFB3A2FE3888E517F29571009E001001DADD7620403191754E6CC35700FFBA7DCA846B677C205CADE700FF004AD17C69BB4C1500BD9B3F7AFFFF7E1EDDFFCBA71D917B824D2F2B33F3D72FF356E0215A95BDC8F2D1204F925800849E3E795442D691D6FFC5FF919C7732D2FFCC89AE9418ECB1BD6EB339550E5C4DEC48E0FECEFF9036FC0057AB469A7B6A9100004D3C0050A200B9FF64A19D4479AA82C1CB3EBDEF3F2D99830012A9643F3458DA73855905206BB270BE35687344E7FFAD00F600AF385947FF83C9CFB999B7FFDB007AFF00E646ED9DD11023A812440DFF2E7A70FF003F445EACB800FF6372F905CB628CFE9D391157450010A712093EEB0B77FF8DC864B1A45879FFFEFF001F894B2E69FF6600A069B42AF8006700489EB5AAAA78F3FFE5FFFF79696372CCB3837A9EC4B629BD460043AF67D5BEFBFFC4D0483247690008B6B4E05ADB4ACFC046A660DCB6A6AB9D9000FE2B5EAB70373D77B333B4FD4F560014CDD6AA431CBEA7B0520234A8DA084F88AA04639BCAD78C2D5400A055D200000003B4490400A3DB64A7B61CBF00A17C792E009B627AE31F56FF4D9ED3A1D4EC8E65BC6C49D526EB80174A39C191E1FFDE38A1616AB2250043B92F94451B9B7AFFA8364044E6E6D1E4C59B408F69DD4087FF003FDCDBA1FFE3269D65FF783ACBC2B7FFA1D475AD44B758F7FFD0770D40DE92FF11C34054FF2AFFA75E0E86FF80660765BD518E9230734A625BD6E7D4849DC2A2146773C58A1C9E3A7096BE650000B52C7CA17D66FF936213FF8E3861E8DCC7971BFF00FD00951B3CD99C45001EBBAD342E2D72183E133AEB52E9FFFF394CFFB3CBB6E90EFF60A76AFF51975CB55725A0E48DC57051C9B87C7C3E4D842BA788FFA413427484D2EDDBFFFFA7B4FC657478FFE0B47FAC8B11826DC4FDFF1DD954007995F20B35B9FB639681AA680022952EF3B77C6765812AFF8DC598B080009A27DBFE94ED00C2128878B1F265E678E8CBFF098E37FF65000700E1008700FC207B8E84AD373B4759FD20CB050338AA01C4BB0CA06E44FF75D200FF5A663E982D3819966B99C60625D67DDB98941B3DF70C84A3D5B89668D3DFA97330A4A3BAC347AFDF0DFF904684FF45005C5D3A8A2AC9FF00FF07D509CCFE18C27C00FF6D8B81A6AA9DFF98FFBCE58F2FCC507010B3A1E16B5564CAC2A737A369CCFF43B700BF5FB1FF6ADF51A882A099A163709EE81E5EFFFFB67EAC859590DAB3738A705BAEE7BC51FFCB9A75A4003900FFC529E3E885FF2B00FBFF0000661FFF3AC4474FFFFF003AE0FFE9E4B70E7A70F4FF2A44B23E3579DBBB90873EAF8C294DFB50ABAAD8CD3F4BF182E20110CA00C61983994496B4B300A7A29132004C0AFF5BE700FF8E7E18CAE180F0FF86008706850000FF0022F900E26398FFAD81001A867C9A408D00953A448CA0E27FAEFD6FAE8135714EFF033517DBFFFFFE6D00FF79EB49A83085F783A440DBFFE96A00E0FF69616F1B4B59A351A9E83821F593D85FBDA97930E14525AE80C63174E16B003B0690B99643DC4BE1278F73EA0F8E7FFF13FF7F5063BAFF4C006DFF250000D600AFFF7BC46F22CF4300024136FF288A711FC533B5FF4E612ADF8B72FF89078E8B8EFF7BFFBCB64D198F1C12F2E85DFF1AAB6AC2FFA76CB500C3F5543EC27F6F7614CAE33F937F0022045800DC0A96DAADCB0E68D072A18782156B0043B84D07431E77BFFFAB98B6734BB87785AB3570FFF714A4002700DFCFA3635C5C6AB546FF547A3B3B4663573E420D44555021D5638E680055AEE623738CD89B525D46A2C7D90000E2CA890019C6FFD2AA482900CBCC3C78000E360B49355B3495350DCA55A5DA007D4D22CBFF30E02C470000A7FF29FF6D00026EFFBB42C6DF008D605B58C500ECAA473A8CE545DF4C2335CCDD850D006055FF18FF429D3C16FFFF3200AD394CE4A7AE1E9756D4F6FFAC3375941D008451A1ABF69B4BA9568298C093194DFF7948FFD834FFB5E2CCA110598583049F87ECA900BAEB49C012C0B4867C58281F9EFF854610D04C00BE39FFFFDDEECA00BA0AB48DFE00147822FFFFAEB4724C933B1F9116140071405DF312F166A4FFCB09AB141B86DEA19015C2E2EDB746FFD2CCBE8C1E5C00CE75AB009300622971DF29477ED100C66A230078AED1A771718CDFFFC8C3746773A9D73E036DC2D5BB62CDD1615CCC058CE77234407CFF008AFF37317842206CF447FFB39AEB904CCEB38A9959C7143D3E2DA8860B00FF8D73EF633B7B8BD8C83ACD50892047E9C7A73031C4F025EE03F7A8C3504DC0AAD3209F1615A243FF7BD7588EA925FFA6FF3597B0C1A60D30E26CB341E1275730C8FFA694CD46977073AF32C53875EF49464CC022FF80E92AAD0B260300A630502CA1E14166545FBEDEA6F4FFFF4A005F773A7ABA7034319D725DFF7E76614182C9B3967B6000BA0010BDFF99DB2C001AFFFF2F754C8F31E5C1CB4E00075D5DD7462300E37875080A49636BA290F20055922FA4B6C17759CD6E0E0346B2005F7D2604B03CDFED4BA6D2158F31D3D9007BA146C37ED693C5E0309F9A720063985C539E788BD61D100041DFC7CC672EC6329C6B08E1006973A8286540F752FF7F8C8BFFFFB0A8B4015C59EB968D002039642505034EC0423085C3FF82FFE23EEF3382605F8ECE6F704F49FDFF40FF985EFF07BF80B49E10E135A928A853932036D9967E8E8545607EFF617A060045FFFFB5C8FF410757A52C330C634F47AF3BA80C5A8EC151D300685A4D8D7000BCAB30377016867E3F07FF865F9B00A33913BCAC2B7DF01D00008E2273FF0C76276A4BB75E8DF431008466C87E3BBA95D4A5746673756994AD000080FCB531A590BA00008D49B164009D23B6436181991D596CDE8500210046FF5D97114B723BF3FF4CC15EE53B8D52FB7FCE5A1E50D6F7FF0096C17360003791FF31D376431498BA00B462CDE3C1B67F827B006E6D06FFD9FF0E0000003337002272DC00351625FF62E754CA4D09FFFF71A58753B7A0BDA6BC71A007D40584130E00A739AC9F3E00309B2DAD7168D8E3FF199AC140DCFF3F1CF0FA00889C8F6163F82644C7610079FF90AD295AFF0AFF970403309FF55E637C6074A08F77389C5CABADD1B08766593F35A8C530 diff --git a/tests/goldens/uber/amiga.txt b/tests/goldens/uber/amiga.txt new file mode 100644 index 0000000..ffc77dc --- /dev/null +++ b/tests/goldens/uber/amiga.txt @@ -0,0 +1,120 @@ +amiga: shadow plane 0 in CHIP (blitter, addr=$00015280) +amiga: shadow plane 1 in CHIP (blitter, addr=$000171C0) +amiga: shadow plane 2 in CHIP (blitter, addr=$00019100) +amiga: shadow plane 3 in CHIP (blitter, addr=$0001B040) +UBER: jlSpriteCompile: 1 call in <= 1 frame + +UBER: audioInit OK + +UBER: showcase cells: 0=pixels 1=lineH 2=lineV 3=diag 4=rect 5=fillRect 6=circle 7=fillCircle 8=tiles 9=sprite 10=flood 11=scene + +UBER: ----- begin ----- + +UBER: jlSurfaceClear: 33 iters / 22 frames = 75 ops/sec | hash=428CEE42 + +UBER: jlPaletteSet: 2145 iters / 16 frames = 6703 ops/sec | hash=5267FFB1 + +UBER: jlScbSetRange: 1233 iters / 16 frames = 3853 ops/sec | hash=5267FFB1 + +UBER: jlDrawPixel: 1361 iters / 16 frames = 4253 ops/sec | hash=75D75161 + +UBER: jlDrawLine H: 513 iters / 16 frames = 1603 ops/sec | hash=1E97E361 + +UBER: jlDrawLine V: 49 iters / 16 frames = 153 ops/sec | hash=A0474D91 + +UBER: jlDrawLine diag: 13 iters / 16 frames = 40 ops/sec | hash=69471D91 + +UBER: jlDrawRect 100x100: 49 iters / 19 frames = 128 ops/sec | hash=5CAFF471 + +UBER: jlDrawCircle r=16: 97 iters / 17 frames = 285 ops/sec | hash=7F1F5531 + +UBER: jlDrawCircle r=80: 33 iters / 26 frames = 63 ops/sec | hash=B249C9AB + +UBER: jlFillRect 16x16: 193 iters / 17 frames = 567 ops/sec | hash=F4BD4B0B + +UBER: jlFillRect 80x80: 33 iters / 23 frames = 71 ops/sec | hash=7C952E5F + +UBER: jlFillRect 320x200: 33 iters / 24 frames = 68 ops/sec | hash=6467AFB1 + +UBER: jlFillCircle r=40: 10 iters / 16 frames = 31 ops/sec | hash=6467AFB1 + +UBER: jlSamplePixel: 2321 iters / 16 frames = 7253 ops/sec | hash=6467AFB1 + +UBER: jlTileFill: 721 iters / 16 frames = 2253 ops/sec | hash=6467AFB1 + +UBER: jlTileCopy: 593 iters / 16 frames = 1853 ops/sec | hash=6467AFB1 + +UBER: jlTileCopyMasked: 305 iters / 16 frames = 953 ops/sec | hash=6467AFB1 + +UBER: jlTilePaste: 737 iters / 16 frames = 2303 ops/sec | hash=6467AFB1 + +UBER: jlTileSnap: 1137 iters / 16 frames = 3553 ops/sec | hash=6467AFB1 + +UBER: jlSpriteSaveUnder: 177 iters / 16 frames = 553 ops/sec | hash=CA673FB1 + +UBER: jlSpriteDraw: 305 iters / 16 frames = 953 ops/sec | hash=82B9646B + +UBER: jlSpriteRestoreUnder: 177 iters / 16 frames = 553 ops/sec | hash=CA673FB1 + +UBER: jlSpriteSaveAndDraw: 113 iters / 16 frames = 353 ops/sec | hash=82B9646B + +UBER: jlStagePresent full: 105 iters / 16 frames = 328 ops/sec | hash=82B9646B + +UBER: jlInputPoll: 1281 iters / 16 frames = 4003 ops/sec | hash=82B9646B + +UBER: jlKeyDown: 12737 iters / 16 frames = 39803 ops/sec | hash=82B9646B + +UBER: jlKeyPressed: 12161 iters / 16 frames = 38003 ops/sec | hash=82B9646B + +UBER: jlMouseX: 31265 iters / 16 frames = 97703 ops/sec | hash=82B9646B + +UBER: joeyJoyConnected: 12465 iters / 16 frames = 38953 ops/sec | hash=82B9646B + +UBER: jlAudioFrameTick: 18241 iters / 16 frames = 57003 ops/sec | hash=82B9646B + +UBER: jlAudioIsPlayingMod: 17857 iters / 16 frames = 55803 ops/sec | hash=82B9646B + +UBER: surfaceMarkDirtyRect (via jlFillRect 32x32): 113 iters / 16 frames = 353 ops/sec | hash=D2B9E46B + +UBER: ----- end ----- + +UBER-CHK: ----- begin ----- + +UBER-CHK: edge-pixels: hash=5267FFB1 + +UBER-CHK: edge-drawRect: hash=BC75D707 + +UBER-CHK: edge-circles: hash=20674FB1 + +UBER-CHK: edge-lines: hash=D4F5BCA7 + +UBER-CHK: sprite-clipped: hash=D922E4B4 + +UBER-CHK: sprite-clip-roundtrip: PASS + +UBER-CHK: tilePasteMono: hash=B0829554 + +UBER-CHK: floodFill: hash=780DC48B + +UBER-CHK: drawText: hash=80B5264B + +UBER-CHK: drawText-offgrid: hash=9435DA4B + +UBER-CHK: palette-roundtrip: PASS + +UBER-CHK: random-golden: PASS + +UBER-CHK: arena-churn: PASS + +UBER-CHK: sprite-from-surface: PASS + +UBER-CHK: sprite-from-surface-draw: hash=5F4394C9 + +UBER-CHK: tilePasteMonoAll: hash=30C3A2C9 + +UBER-CHK: floodFillBounded: hash=EF6B6F51 + +UBER-CHK: ----- end ----- + +UBER: total wall time: 145820 ms (7291 frames @ 50 Hz) + diff --git a/tests/goldens/uber/atarist.txt b/tests/goldens/uber/atarist.txt new file mode 100644 index 0000000..cd6d43f --- /dev/null +++ b/tests/goldens/uber/atarist.txt @@ -0,0 +1,118 @@ +UBER: jlSpriteCompile: 1 call in <= 1 frame + +UBER: audioInit OK + +UBER: showcase cells: 0=pixels 1=lineH 2=lineV 3=diag 4=rect 5=fillRect 6=circle 7=fillCircle 8=tiles 9=sprite 10=flood 11=scene + +UBER: ----- begin ----- + +UBER: jlSurfaceClear: 12 iters / 16 frames = 37 ops/sec | hash=428CEE42 + +UBER: jlPaletteSet: 1009 iters / 16 frames = 3153 ops/sec | hash=5267FFB1 + +UBER: jlScbSetRange: 641 iters / 16 frames = 2003 ops/sec | hash=5267FFB1 + +UBER: jlDrawPixel: 721 iters / 16 frames = 2253 ops/sec | hash=75D75161 + +UBER: jlDrawLine H: 369 iters / 16 frames = 1153 ops/sec | hash=1E97E361 + +UBER: jlDrawLine V: 33 iters / 16 frames = 103 ops/sec | hash=A0474D91 + +UBER: jlDrawLine diag: 12 iters / 16 frames = 37 ops/sec | hash=69471D91 + +UBER: jlDrawRect 100x100: 33 iters / 19 frames = 86 ops/sec | hash=5CAFF471 + +UBER: jlDrawCircle r=16: 49 iters / 16 frames = 153 ops/sec | hash=7F1F5531 + +UBER: jlDrawCircle r=80: 11 iters / 16 frames = 34 ops/sec | hash=B249C9AB + +UBER: jlFillRect 16x16: 177 iters / 17 frames = 520 ops/sec | hash=F4BD4B0B + +UBER: jlFillRect 80x80: 33 iters / 16 frames = 103 ops/sec | hash=7C952E5F + +UBER: jlFillRect 320x200: 11 iters / 17 frames = 32 ops/sec | hash=6467AFB1 + +UBER: jlFillCircle r=40: 33 iters / 26 frames = 63 ops/sec | hash=6467AFB1 + +UBER: jlSamplePixel: 1313 iters / 16 frames = 4103 ops/sec | hash=6467AFB1 + +UBER: jlTileFill: 417 iters / 16 frames = 1303 ops/sec | hash=6467AFB1 + +UBER: jlTileCopy: 353 iters / 16 frames = 1103 ops/sec | hash=6467AFB1 + +UBER: jlTileCopyMasked: 193 iters / 17 frames = 567 ops/sec | hash=6467AFB1 + +UBER: jlTilePaste: 417 iters / 16 frames = 1303 ops/sec | hash=6467AFB1 + +UBER: jlTileSnap: 785 iters / 16 frames = 2453 ops/sec | hash=6467AFB1 + +UBER: jlSpriteSaveUnder: 225 iters / 16 frames = 703 ops/sec | hash=CA673FB1 + +UBER: jlSpriteDraw: 161 iters / 16 frames = 503 ops/sec | hash=82B9646B + +UBER: jlSpriteRestoreUnder: 177 iters / 16 frames = 553 ops/sec | hash=CA673FB1 + +UBER: jlSpriteSaveAndDraw: 97 iters / 18 frames = 269 ops/sec | hash=82B9646B + +UBER: jlStagePresent full: 15 iters / 16 frames = 46 ops/sec | hash=82B9646B + +UBER: jlInputPoll: 385 iters / 16 frames = 1203 ops/sec | hash=82B9646B + +UBER: jlKeyDown: 6049 iters / 16 frames = 18903 ops/sec | hash=82B9646B + +UBER: jlKeyPressed: 6049 iters / 16 frames = 18903 ops/sec | hash=82B9646B + +UBER: jlMouseX: 15745 iters / 16 frames = 49203 ops/sec | hash=82B9646B + +UBER: joeyJoyConnected: 5921 iters / 16 frames = 18503 ops/sec | hash=82B9646B + +UBER: jlAudioFrameTick: 3152 iters / 16 frames = 9850 ops/sec | hash=82B9646B + +UBER: jlAudioIsPlayingMod: 8065 iters / 16 frames = 25203 ops/sec | hash=82B9646B + +UBER: surfaceMarkDirtyRect (via jlFillRect 32x32): 113 iters / 17 frames = 332 ops/sec | hash=D2B9E46B + +UBER: ----- end ----- + +UBER-CHK: ----- begin ----- + +UBER-CHK: edge-pixels: hash=5267FFB1 + +UBER-CHK: edge-drawRect: hash=BC75D707 + +UBER-CHK: edge-circles: hash=20674FB1 + +UBER-CHK: edge-lines: hash=D4F5BCA7 + +UBER-CHK: sprite-clipped: hash=D922E4B4 + +UBER-CHK: sprite-clip-roundtrip: PASS + +UBER-CHK: tilePasteMono: hash=B0829554 + +UBER-CHK: floodFill: hash=780DC48B + +UBER-CHK: drawText: hash=80B5264B + +UBER-CHK: drawText-offgrid: hash=9435DA4B + +UBER-CHK: palette-roundtrip: PASS + +UBER-CHK: random-golden: PASS + +UBER-CHK: arena-churn: PASS + +UBER-CHK: sprite-from-surface: PASS + +UBER-CHK: sprite-from-surface-draw: hash=5F4394C9 + +UBER-CHK: tilePasteMonoAll: hash=30C3A2C9 + +UBER-CHK: floodFillBounded: hash=EF6B6F51 + +UBER-CHK: ----- end ----- + +UBER: total wall time: 302060 ms (15103 frames @ 50 Hz) + +UBER: press any key to exit + diff --git a/tests/goldens/uber/dos.txt b/tests/goldens/uber/dos.txt new file mode 100644 index 0000000..c0d0349 --- /dev/null +++ b/tests/goldens/uber/dos.txt @@ -0,0 +1,118 @@ +UBER: jlSpriteCompile: 1 call in <= 1 frame + +UBER: audioInit OK + +UBER: showcase cells: 0=pixels 1=lineH 2=lineV 3=diag 4=rect 5=fillRect 6=circle 7=fillCircle 8=tiles 9=sprite 10=flood 11=scene + +UBER: ----- begin ----- + +UBER: jlSurfaceClear: 16 iters / 17 frames = 65 ops/sec | hash=428CEE42 + +UBER: jlPaletteSet: 3505 iters / 16 frames = 15334 ops/sec | hash=5267FFB1 + +UBER: jlScbSetRange: 1889 iters / 16 frames = 8264 ops/sec | hash=5267FFB1 + +UBER: jlDrawPixel: 3985 iters / 16 frames = 17434 ops/sec | hash=75D75161 + +UBER: jlDrawLine H: 1329 iters / 16 frames = 5814 ops/sec | hash=1E97E361 + +UBER: jlDrawLine V: 81 iters / 16 frames = 354 ops/sec | hash=A0474D91 + +UBER: jlDrawLine diag: 33 iters / 24 frames = 96 ops/sec | hash=69471D91 + +UBER: jlDrawRect 100x100: 81 iters / 16 frames = 354 ops/sec | hash=5CAFF471 + +UBER: jlDrawCircle r=16: 85 iters / 16 frames = 371 ops/sec | hash=7F1F5531 + +UBER: jlDrawCircle r=80: 19 iters / 16 frames = 83 ops/sec | hash=B249C9AB + +UBER: jlFillRect 16x16: 609 iters / 16 frames = 2664 ops/sec | hash=F4BD4B0B + +UBER: jlFillRect 80x80: 81 iters / 16 frames = 354 ops/sec | hash=7C952E5F + +UBER: jlFillRect 320x200: 13 iters / 16 frames = 56 ops/sec | hash=6467AFB1 + +UBER: jlFillCircle r=40: 29 iters / 16 frames = 126 ops/sec | hash=6467AFB1 + +UBER: jlSamplePixel: 8321 iters / 16 frames = 36404 ops/sec | hash=6467AFB1 + +UBER: jlTileFill: 2081 iters / 16 frames = 9104 ops/sec | hash=6467AFB1 + +UBER: jlTileCopy: 1569 iters / 16 frames = 6864 ops/sec | hash=6467AFB1 + +UBER: jlTileCopyMasked: 545 iters / 16 frames = 2384 ops/sec | hash=6467AFB1 + +UBER: jlTilePaste: 1825 iters / 16 frames = 7984 ops/sec | hash=6467AFB1 + +UBER: jlTileSnap: 2993 iters / 16 frames = 13094 ops/sec | hash=6467AFB1 + +UBER: jlSpriteSaveUnder: 1937 iters / 16 frames = 8474 ops/sec | hash=CA673FB1 + +UBER: jlSpriteDraw: 1393 iters / 16 frames = 6094 ops/sec | hash=82B9646B + +UBER: jlSpriteRestoreUnder: 1297 iters / 16 frames = 5674 ops/sec | hash=CA673FB1 + +UBER: jlSpriteSaveAndDraw: 961 iters / 16 frames = 4204 ops/sec | hash=82B9646B + +UBER: jlStagePresent full: 81 iters / 16 frames = 354 ops/sec | hash=82B9646B + +UBER: jlInputPoll: 705 iters / 16 frames = 3084 ops/sec | hash=82B9646B + +UBER: jlKeyDown: 17441 iters / 16 frames = 76304 ops/sec | hash=82B9646B + +UBER: jlKeyPressed: 16897 iters / 16 frames = 73924 ops/sec | hash=82B9646B + +UBER: jlMouseX: 35105 iters / 16 frames = 153584 ops/sec | hash=82B9646B + +UBER: joeyJoyConnected: 17601 iters / 16 frames = 77004 ops/sec | hash=82B9646B + +UBER: jlAudioFrameTick: 13377 iters / 16 frames = 58524 ops/sec | hash=82B9646B + +UBER: jlAudioIsPlayingMod: 29041 iters / 16 frames = 127054 ops/sec | hash=82B9646B + +UBER: surfaceMarkDirtyRect (via jlFillRect 32x32): 305 iters / 16 frames = 1334 ops/sec | hash=D2B9E46B + +UBER: ----- end ----- + +UBER-CHK: ----- begin ----- + +UBER-CHK: edge-pixels: hash=5267FFB1 + +UBER-CHK: edge-drawRect: hash=BC75D707 + +UBER-CHK: edge-circles: hash=20674FB1 + +UBER-CHK: edge-lines: hash=D4F5BCA7 + +UBER-CHK: sprite-clipped: hash=D922E4B4 + +UBER-CHK: sprite-clip-roundtrip: PASS + +UBER-CHK: tilePasteMono: hash=B0829554 + +UBER-CHK: floodFill: hash=780DC48B + +UBER-CHK: drawText: hash=80B5264B + +UBER-CHK: drawText-offgrid: hash=9435DA4B + +UBER-CHK: palette-roundtrip: PASS + +UBER-CHK: random-golden: PASS + +UBER-CHK: arena-churn: PASS + +UBER-CHK: sprite-from-surface: PASS + +UBER-CHK: sprite-from-surface-draw: hash=5F4394C9 + +UBER-CHK: tilePasteMonoAll: hash=30C3A2C9 + +UBER-CHK: floodFillBounded: hash=EF6B6F51 + +UBER-CHK: ----- end ----- + +UBER: total wall time: 20928 ms (1465 frames @ 70 Hz) + +UBER: press any key to exit + diff --git a/tests/goldens/uber/iigs.txt b/tests/goldens/uber/iigs.txt new file mode 100644 index 0000000..e2b9744 --- /dev/null +++ b/tests/goldens/uber/iigs.txt @@ -0,0 +1,62 @@ +UBER: jlSpriteCompile failed +UBER: jlSpriteCompile: 1 call in <= 1 frame +UBER: audioInit OK +UBER: showcase cells: 0=pixels 1=lineH 2=lineV 3=diag 4=rect 5=fillRect 6=circle 7=fillCircle 8=tiles 9=sprite 10=flood 11=scene +UBER: ----- begin ----- +UBER: jlSurfaceClear: 10 iters / 17 frames = 35 ops/sec | hash=428CEE42 +UBER: jlPaletteSet: 385 iters / 16 frames = 1443 ops/sec | hash=5267FFB1 +UBER: jlScbSetRange: 49 iters / 18 frames = 163 ops/sec | hash=5267FFB1 +UBER: jlDrawPixel: 865 iters / 16 frames = 3243 ops/sec | hash=75D75161 +UBER: jlDrawLine H: 273 iters / 16 frames = 1023 ops/sec | hash=1E97E361 +UBER: jlDrawLine V: 33 iters / 20 frames = 99 ops/sec | hash=A0474D91 +UBER: jlDrawLine diag: 12 iters / 17 frames = 42 ops/sec | hash=69471D91 +UBER: jlDrawRect 100x100: 33 iters / 23 frames = 86 ops/sec | hash=5CAFF471 +UBER: jlDrawCircle r=16: 81 iters / 16 frames = 303 ops/sec | hash=7F1F5531 +UBER: jlDrawCircle r=80: 33 iters / 28 frames = 70 ops/sec | hash=B249C9AB +UBER: jlFillRect 16x16: 145 iters / 17 frames = 511 ops/sec | hash=F4BD4B0B +UBER: jlFillRect 80x80: 33 iters / 18 frames = 110 ops/sec | hash=7C952E5F +UBER: jlFillRect 320x200: 6 iters / 17 frames = 21 ops/sec | hash=6467AFB1 +UBER: jlFillCircle r=40: 12 iters / 17 frames = 42 ops/sec | hash=6467AFB1 +UBER: jlSamplePixel: 1313 iters / 16 frames = 4923 ops/sec | hash=6467AFB1 +UBER: jlTileFill: 433 iters / 16 frames = 1623 ops/sec | hash=6467AFB1 +UBER: jlTileCopy: 337 iters / 16 frames = 1263 ops/sec | hash=6467AFB1 +UBER: jlTileCopyMasked: 161 iters / 16 frames = 603 ops/sec | hash=6467AFB1 +UBER: jlTilePaste: 417 iters / 16 frames = 1563 ops/sec | hash=6467AFB1 +UBER: jlTileSnap: 705 iters / 16 frames = 2643 ops/sec | hash=6467AFB1 +UBER: jlSpriteSaveUnder: 33 iters / 19 frames = 104 ops/sec | hash=CA673FB1 +UBER: jlSpriteDraw: 5 iters / 16 frames = 18 ops/sec | hash=82B9646B +UBER: jlSpriteRestoreUnder: 33 iters / 18 frames = 110 ops/sec | hash=CA673FB1 +UBER: jlSpriteSaveAndDraw: 5 iters / 19 frames = 15 ops/sec | hash=82B9646B +UBER: jlStagePresent full: 33 iters / 28 frames = 70 ops/sec | hash=82B9646B +UBER: jlInputPoll: 65 iters / 16 frames = 243 ops/sec | hash=82B9646B +UBER: jlKeyDown: 3281 iters / 16 frames = 12303 ops/sec | hash=82B9646B +UBER: jlKeyPressed: 3745 iters / 16 frames = 14043 ops/sec | hash=82B9646B +UBER: jlMouseX: 8033 iters / 16 frames = 30123 ops/sec | hash=82B9646B +UBER: joeyJoyConnected: 3281 iters / 16 frames = 12303 ops/sec | hash=82B9646B +UBER: jlAudioFrameTick: 13393 iters / 16 frames = 50223 ops/sec | hash=82B9646B +UBER: jlAudioIsPlayingMod: 3601 iters / 16 frames = 13503 ops/sec | hash=82B9646B +UBER: surfaceMarkDirtyRect (via jlFillRect 32x32): 81 iters / 16 frames = 303 ops/sec | hash=D2B9E46B +UBER: ----- end ----- +UBER-CHK: ----- begin ----- +UBER-CHK: edge-pixels: hash=5267FFB1 +UBER-CHK: edge-drawRect: hash=BC75D707 +UBER-CHK: edge-circles: hash=20674FB1 +UBER-CHK: edge-lines: hash=D4F5BCA7 +UBER-CHK: sprite-clipped: hash=D922E4B4 +UBER-CHK: sprite-clip-roundtrip: PASS +UBER-CHK: tilePasteMono: hash=B0829554 +UBER-CHK: floodFill: hash=780DC48B +UBER-CHK: drawText: hash=80B5264B +UBER-CHK: drawText-offgrid: hash=9435DA4B +UBER-CHK: palette-roundtrip: PASS +UBER-CHK: random-golden: PASS +UBER-CHK: arena-churn: PASS +UBER-CHK: sprite-from-surface: PASS +UBER-CHK: sprite-from-surface-draw: hash=5F4394C9 +UBER-CHK: tilePasteMonoAll: hash=30C3A2C9 + +UBER-CHK: floodFillBounded: hash=EF6B6F51 + +UBER-CHK: ----- end ----- +UBER: total wall time: 367016 ms (22021 frames @ 60 Hz) +UBER: press any key to exit diff --git a/tests/host/millisHost.c b/tests/host/millisHost.c new file mode 100644 index 0000000..343df78 --- /dev/null +++ b/tests/host/millisHost.c @@ -0,0 +1,101 @@ +// millisHost.c - Host-cc harness for jlpGenericMillisElapsed (the +// IIgs/Amiga millisecond clock). Drives simulated frame-count sequences, +// including the 16-bit wrap at 65536 frames, and asserts the millisecond +// clock never runs backward. +// +// Built by scripts/check-millis.sh with -DJL_HAS_FRAME_COUNT and +// -DJL_HAS_FRAME_HZ so port.h routes jlpFrameCount/jlpFrameHz to the mock +// implementations below instead of the generic stubs. +// +// TODAY this harness FAILS: jlpGenericMillisElapsed is a pure function of +// the wrapping uint16_t frame count (PERF-AUDIT.md finding #23), so the +// clock jumps backward every 65536 frames (~18 min at 60Hz). The Phase 1 +// fix makes it accumulate deltas; this harness must then PASS and becomes +// the regression gate for it. + +#include +#include +#include + +#include "port.h" + +static uint16_t gMockFrame = 0; +static uint16_t gMockHz = 60; + +// Prototypes (harness-local, alphabetical). +static bool runScenario(const char *name, uint16_t hz, const uint32_t *frames, int count); + + +uint16_t jlpFrameCount(void) { + return gMockFrame; +} + + +uint16_t jlpFrameHz(void) { + return gMockHz; +} + + +// Walk an absolute-frame sequence (values may exceed 65535 to express +// real elapsed frames; the mock truncates to uint16_t exactly like real +// hardware counters wrap). Asserts millis is non-decreasing and lands +// within 1 frame-period of the true elapsed time at the end. +static bool runScenario(const char *name, uint16_t hz, const uint32_t *frames, int count) { + uint32_t prevMs; + uint32_t ms; + uint32_t expectMs; + uint32_t tolerance; + bool ok; + int i; + + gMockHz = hz; + ok = true; + prevMs = 0; + ms = 0; + for (i = 0; i < count; i++) { + gMockFrame = (uint16_t)frames[i]; + ms = jlpGenericMillisElapsed(); + if (ms < prevMs) { + printf(" %s: BACKWARD at step %d (frame %lu): %lu ms -> %lu ms\n", + name, i, (unsigned long)frames[i], + (unsigned long)prevMs, (unsigned long)ms); + ok = false; + } + prevMs = ms; + } + expectMs = (uint32_t)(((uint64_t)frames[count - 1] * 1000u) / hz); + tolerance = (uint32_t)(1000u / hz + 1u); + if (ok && (ms + tolerance < expectMs || ms > expectMs + tolerance)) { + printf(" %s: DRIFT: got %lu ms, expected ~%lu ms (+/- %lu)\n", + name, (unsigned long)ms, (unsigned long)expectMs, + (unsigned long)tolerance); + ok = false; + } + printf(" %s: %s\n", name, ok ? "PASS" : "FAIL"); + return ok; +} + + +int main(void) { + // Sequences are ABSOLUTE frame counts; steps stay under 65535 frames + // apart (the documented poll-at-least-once-per-wrap requirement). + static const uint32_t simple60[] = { 0, 60, 600, 3600, 36000 }; + static const uint32_t wrap60[] = { 0, 30000, 65000, 70000, 131000, 190000, 200000 }; + static const uint32_t wrap50[] = { 0, 40000, 65500, 66000, 131000 }; + bool ok = true; + + printf("millisHost: jlpGenericMillisElapsed scenarios\n"); + // Scenario order alternates Hz deliberately: the accumulator's + // statics persist across scenarios, and a frame-rate change is the + // documented re-base point -- alternating gives each scenario a + // fresh epoch instead of inheriting the previous one's baseline. + ok &= runScenario("simple-60hz", 60, simple60, (int)(sizeof(simple60) / sizeof(simple60[0]))); + ok &= runScenario("wrap-50hz", 50, wrap50, (int)(sizeof(wrap50) / sizeof(wrap50[0]))); + ok &= runScenario("wrap-60hz", 60, wrap60, (int)(sizeof(wrap60) / sizeof(wrap60[0]))); + if (!ok) { + printf("millisHost: FAIL (finding #23 wrap bug present -- expected until Phase 1 lands)\n"); + return 1; + } + printf("millisHost: PASS\n"); + return 0; +} diff --git a/tests/host/sfxMixHost.c b/tests/host/sfxMixHost.c new file mode 100644 index 0000000..b615c47 --- /dev/null +++ b/tests/host/sfxMixHost.c @@ -0,0 +1,302 @@ +// sfxMixHost.c - Host-cc differential harness for the shared SFX +// overlay mixer (src/core/audioSfxMix.c). Built by +// scripts/check-sfxmix.sh on the millisHost.c / check-millis.sh +// pattern. The mixer TU has no jlp* platform hooks; its only external +// input is the stream fill callback, mocked below (streamFill) the way +// millisHost.c mocks jlpFrameCount/jlpFrameHz. +// +// Every scenario is fully deterministic: all sample data, background +// (dst) data, and stream chunks come from a fixed-seed LCG -- no time, +// no rand(), no host entropy. Each scenario prints: +// +// name= slots= len= hash=0x +// first= +// raw= +// +// check-sfxmix.sh captures the first run as +// tests/goldens/sfxmix-baseline.txt and later diffs against it: +// * exact mode (default): hashes must match -- the gate for +// PERF-AUDIT findings #7 and #33 (bit-identical rewrites). +// * JL_SFX_TOLERANCE=1 mode: per-sample compare of the raw lines; +// finding #6 (interp 16x16 multiply) may deviate by at most 1 LSB +// PER SLOT CONTRIBUTION, so the allowed per-sample delta is +// tolerance * slots (deviations from simultaneous slots stack +// before the clamp). That is why slots= is part of the output. +// +// The mixer has no per-slot volume field (volume is applied upstream +// when the sample data is produced), so the "volume 0 / volume max" +// scenarios use amplitude-scaled sample data: amp 0 = silent samples +// (output must equal the untouched background), amp 127 = full-scale +// samples on a full-range background (exercises both clamp branches). + +#include +#include +#include +#include + +#include "audioSfxMixInternal.h" + +// Output samples per single mix call. +#define MIX_LEN 1024u +// Debug prefix bytes echoed on the first= line. +#define FIRST_BYTES 16u +// Mixer output rate used by most scenarios (DOS SB-ish rate). +#define OUT_RATE 11025u +// Largest fixed-buffer sample used by the single-mix scenarios. +#define SAMPLE_MAX 4096u +// fixedLongPos64k: enough sequential mix blocks at ~8.19 step to push +// the read index past 65535 input samples (the uint64 posFp headroom +// that the finding #7 rewrite must preserve): 8 * 1024 * 8.19 ~ 67k. +#define LONG_BLOCKS 8u +#define LONG_SAMPLE_LEN 68000u +#define LONG_RATE_HZ 65535u +#define LONG_OUT_RATE 8000u + +// Mock stream source: hands out LCG-generated signed samples in +// chunks of at most chunkCap until remaining is exhausted, then +// returns 0 (end-of-stream). +typedef struct { + uint32_t lcgState; + uint32_t remaining; + uint32_t chunkCap; + int32_t amp; +} StreamCtxT; + +// Prototypes (harness-local, alphabetical). +static void dstFill(uint8_t *dst, uint32_t len, uint32_t seed); +static uint32_t fnv1a(const uint8_t *data, uint32_t len); +static void genSignedSamples(uint32_t *state, uint8_t *dst, uint32_t count, int32_t amp); +static uint32_t lcgNext(uint32_t *state); +static void reportScenario(const char *name, uint8_t slots, const uint8_t *dst, uint32_t len); +static void runFixedScenario(const char *name, uint16_t rateHz, uint32_t outputRate, int32_t amp, uint32_t sampleCount, uint16_t mixLen, uint32_t dstSeed, uint32_t sampleSeed); +static uint32_t streamFill(void *ctx, int8_t *dst, uint32_t count); + +static uint8_t gDst[MIX_LEN]; +static uint8_t gLongOut[LONG_BLOCKS * MIX_LEN]; +static uint8_t gLongSample[LONG_SAMPLE_LEN]; +static uint8_t gSampleA[SAMPLE_MAX]; +static uint8_t gSampleB[SAMPLE_MAX]; +static uint8_t gSampleC[SAMPLE_MAX]; + + +// Fill a mix destination with full-range unsigned LCG bytes -- a +// deterministic stand-in for already-rendered MOD output. Full range +// (not centered) so loud SFX hit both clamp branches. +static void dstFill(uint8_t *dst, uint32_t len, uint32_t seed) { + uint32_t state; + uint32_t i; + + state = seed; + for (i = 0; i < len; i++) { + dst[i] = (uint8_t)(lcgNext(&state) >> 24); + } +} + + +// FNV-1a 32-bit. +static uint32_t fnv1a(const uint8_t *data, uint32_t len) { + uint32_t hash; + uint32_t i; + + hash = 2166136261u; + for (i = 0; i < len; i++) { + hash ^= (uint32_t)data[i]; + hash *= 16777619u; + } + return hash; +} + + +// Fill dst with signed 8-bit samples (stored as their uint8_t bit +// pattern, matching AudioSfxSlotT.sample) scaled to amplitude `amp` +// (0 = silence, 127 = full scale). Truncation toward zero is +// C99-defined, so the scaling is portable and deterministic. +static void genSignedSamples(uint32_t *state, uint8_t *dst, uint32_t count, int32_t amp) { + uint32_t i; + int32_t raw; + int32_t scaled; + + for (i = 0; i < count; i++) { + raw = (int32_t)(lcgNext(state) >> 24) - 128; + scaled = (raw * amp) / 127; + dst[i] = (uint8_t)(int8_t)scaled; + } +} + + +// Numerical Recipes LCG; only the high byte is consumed (the low bits +// of an LCG are weak). +static uint32_t lcgNext(uint32_t *state) { + *state = (*state * 1664525u) + 1013904223u; + return *state; +} + + +// Emit the three per-scenario output lines (see file header). +static void reportScenario(const char *name, uint8_t slots, const uint8_t *dst, uint32_t len) { + uint32_t i; + uint32_t show; + + printf("name=%s slots=%u len=%lu hash=0x%08lX\n", name, (unsigned)slots, (unsigned long)len, (unsigned long)fnv1a(dst, len)); + show = (len < FIRST_BYTES) ? len : FIRST_BYTES; + printf("first="); + for (i = 0; i < show; i++) { + if (i > 0) { + putchar(' '); + } + printf("%02X", dst[i]); + } + printf("\n"); + printf("raw="); + for (i = 0; i < len; i++) { + printf("%02X", dst[i]); + } + printf("\n"); +} + + +// One fixed-buffer slot mixed once into a fresh background. mixLen +// may exceed MIX_LEN up to sizeof(gLongOut). +static void runFixedScenario(const char *name, uint16_t rateHz, uint32_t outputRate, int32_t amp, uint32_t sampleCount, uint16_t mixLen, uint32_t dstSeed, uint32_t sampleSeed) { + AudioSfxSlotT slot; + uint32_t state; + + state = sampleSeed; + genSignedSamples(&state, gSampleA, sampleCount, amp); + dstFill(gLongOut, mixLen, dstSeed); + memset(&slot, 0, sizeof(slot)); + audioSfxSlotArm(&slot, gSampleA, sampleCount, rateHz, outputRate); + audioSfxOverlayMix(gLongOut, mixLen, &slot, 1u); + reportScenario(name, 1u, gLongOut, mixLen); +} + + +// Mock jlAudioStreamFillT (see StreamCtxT). +static uint32_t streamFill(void *ctx, int8_t *dst, uint32_t count) { + StreamCtxT *sc; + uint32_t give; + + sc = (StreamCtxT *)ctx; + give = count; + if (give > sc->chunkCap) { + give = sc->chunkCap; + } + if (give > sc->remaining) { + give = sc->remaining; + } + genSignedSamples(&sc->lcgState, (uint8_t *)dst, give, sc->amp); + sc->remaining -= give; + return give; +} + + +int main(void) { + AudioSfxSlotT slot; + AudioSfxSlotT slots[3]; + StreamCtxT ctx; + uint32_t state; + uint32_t block; + + printf("sfxMixHost: audioSfxOverlayMix scenarios (LCG-seeded, deterministic)\n"); + + // --- Fixed-buffer slots, one slot per mix. --- + // Rate conversion up: 8363 Hz source into an 11025 Hz mix + // (step ~0.7585, every fractional interp path exercised). + runFixedScenario("fixedUp8363to11025", 8363u, OUT_RATE, 100, 2048u, (uint16_t)MIX_LEN, 0xD57B0001u, 0x5EED0001u); + // Rate conversion down: 22050 -> 11025 (step 2.0, input skipping). + runFixedScenario("fixedDown22050to11025", 22050u, OUT_RATE, 100, 2200u, (uint16_t)MIX_LEN, 0xD57B0002u, 0x5EED0002u); + // Unity rate: step exactly 1.0, frac stays 0 (interp identity). + runFixedScenario("fixedUnity11025", 11025u, OUT_RATE, 80, 1100u, (uint16_t)MIX_LEN, 0xD57B0003u, 0x5EED0003u); + // Odd stepFp ((5000 << 16) / 11025 = 29721): frac takes odd + // values, the case where the finding #6 interp reformulation + // deviates. Single slot, so tolerance mode checks the strict + // 1-LSB-per-sample bound here (the even-step scenarios above are + // insensitive to the frac low bit and must stay bit-identical). + // The deviation only fires when the dropped d * 1 crosses a 65536 + // boundary (~|delta| / 65536 odds per odd-frac sample), so this + // one mixes 8x longer at full amplitude to make hits certain. + runFixedScenario("fixedOddStep5000", 5000u, OUT_RATE, 127, 4096u, (uint16_t)(LONG_BLOCKS * MIX_LEN), 0xD57B000Bu, 0x5EED000Cu); + // "Volume" 0: silent samples; output must equal the background. + runFixedScenario("fixedAmpZero", 8363u, OUT_RATE, 0, 2048u, (uint16_t)MIX_LEN, 0xD57B0004u, 0x5EED0004u); + // "Volume" max: full-scale samples on a full-range background -- + // both clamp branches (+127 / -128) fire. + runFixedScenario("fixedAmpMax", 8363u, OUT_RATE, 127, 2048u, (uint16_t)MIX_LEN, 0xD57B0005u, 0x5EED0005u); + // Short sample: slot runs out and deactivates mid-mix (~output + // 395 of 1024); also hits the s1 == s0 tail at idx + 1 == length. + runFixedScenario("fixedShortEnd", 8363u, OUT_RATE, 127, 300u, (uint16_t)MIX_LEN, 0xD57B0006u, 0x5EED0006u); + + // --- Multiple simultaneous fixed slots. --- + // Three rates (down / unity / up), three amplitudes; slot B is + // short and deactivates mid-mix while A and C keep going. + state = 0x5EED0007u; + genSignedSamples(&state, gSampleA, 2048u, 60); + state = 0x5EED0008u; + genSignedSamples(&state, gSampleB, 900u, 90); + state = 0x5EED0009u; + genSignedSamples(&state, gSampleC, 2048u, 127); + memset(slots, 0, sizeof(slots)); + audioSfxSlotArm(&slots[0], gSampleA, 2048u, 5000u, OUT_RATE); + audioSfxSlotArm(&slots[1], gSampleB, 900u, 11025u, OUT_RATE); + audioSfxSlotArm(&slots[2], gSampleC, 2048u, 18000u, OUT_RATE); + dstFill(gDst, MIX_LEN, 0xD57B0007u); + audioSfxOverlayMix(gDst, (uint16_t)MIX_LEN, slots, 3u); + reportScenario("multiSlot3", 3u, gDst, MIX_LEN); + + // --- Streaming slots. --- + // Rate-up stream whose 200-sample chunks force several + // streamRefill calls inside a single mix (prime + ~3 mid-mix). + memset(&slot, 0, sizeof(slot)); + ctx.lcgState = 0x5712EA41u; + ctx.remaining = 4000u; + ctx.chunkCap = 200u; + ctx.amp = 100; + audioSfxSlotArmStream(&slot, streamFill, &ctx, 8363u, OUT_RATE); + dstFill(gDst, MIX_LEN, 0xD57B0008u); + audioSfxOverlayMix(gDst, (uint16_t)MIX_LEN, &slot, 1u); + reportScenario("streamRefillMid", 1u, gDst, MIX_LEN); + + // Rate-down stream (step 2.0) that ends mid-mix: 500 source + // samples cover only ~250 outputs, then fill returns 0 and the + // slot deactivates with the background left untouched after it. + memset(&slot, 0, sizeof(slot)); + ctx.lcgState = 0x5712EA42u; + ctx.remaining = 500u; + ctx.chunkCap = 255u; + ctx.amp = 110; + audioSfxSlotArmStream(&slot, streamFill, &ctx, 22050u, OUT_RATE); + dstFill(gDst, MIX_LEN, 0xD57B0009u); + audioSfxOverlayMix(gDst, (uint16_t)MIX_LEN, &slot, 1u); + reportScenario("streamEndMid", 1u, gDst, MIX_LEN); + + // Fixed + streaming slot in the same mix call (the isStream + // branch takes both arms across one dst). + state = 0x5EED000Au; + genSignedSamples(&state, gSampleA, 2048u, 70); + memset(slots, 0, sizeof(slots)); + audioSfxSlotArm(&slots[0], gSampleA, 2048u, 6000u, OUT_RATE); + ctx.lcgState = 0x5712EA43u; + ctx.remaining = 3000u; + ctx.chunkCap = 180u; + ctx.amp = 95; + audioSfxSlotArmStream(&slots[1], streamFill, &ctx, 16000u, OUT_RATE); + dstFill(gDst, MIX_LEN, 0xD57B000Au); + audioSfxOverlayMix(gDst, (uint16_t)MIX_LEN, slots, 2u); + reportScenario("mixedFixedStream", 2u, gDst, MIX_LEN); + + // --- Long-position fixed slot (finding #7 headroom). --- + // Sequential mix blocks walk the read index past 65535 input + // samples; a posFp rewrite that truncates the index (or drops the + // frac carry) diverges here. Output is the concatenation of all + // blocks so every block is part of the hash and raw compare. + state = 0x5EED000Bu; + genSignedSamples(&state, gLongSample, LONG_SAMPLE_LEN, 90); + memset(&slot, 0, sizeof(slot)); + audioSfxSlotArm(&slot, gLongSample, LONG_SAMPLE_LEN, LONG_RATE_HZ, LONG_OUT_RATE); + for (block = 0; block < LONG_BLOCKS; block++) { + dstFill(gLongOut + (block * MIX_LEN), MIX_LEN, 0xD57B0100u + block); + audioSfxOverlayMix(gLongOut + (block * MIX_LEN), (uint16_t)MIX_LEN, &slot, 1u); + } + reportScenario("fixedLongPos64k", 1u, gLongOut, LONG_BLOCKS * MIX_LEN); + + return 0; +} diff --git a/tools/diff-uber-hashes b/tools/diff-uber-hashes index 6b36814..3c77611 100755 --- a/tools/diff-uber-hashes +++ b/tools/diff-uber-hashes @@ -25,13 +25,21 @@ LINE_RE = re.compile( r"UBER:\s+(?P[^:]+):\s+\d+\s+iters\s+/\s+\d+\s+frames\s+=\s+\d+\s+ops/sec\s+\|\s+hash=(?P[0-9A-Fa-f]+)" ) +# Non-timed correctness checks (Phase 0 harness). Match e.g.: +# UBER-CHK: edge-drawRect: hash=A1B2C3D4 +CHK_RE = re.compile( + r"UBER-CHK:\s+(?P[^:]+):\s+hash=(?P[0-9A-Fa-f]+)" +) + def parse_log(path): """Return ordered dict {op_name: hash} from a UBER log file. - Multiple runs may be concatenated in the same log (joeyLog appends) - -- in that case the LAST hash for each op wins, matching the most - recent run. + Timed-op hashes and UBER-CHK correctness hashes both count; the + check names get a "chk:" prefix so they can never collide with a + timed op name. Multiple runs may be concatenated in the same log + (joeyLog appends) -- in that case the LAST hash for each op wins, + matching the most recent run. """ hashes = {} with open(path) as f: @@ -39,6 +47,10 @@ def parse_log(path): m = LINE_RE.search(line) if m: hashes[m.group("op").strip()] = m.group("hash").upper() + continue + m = CHK_RE.search(line) + if m: + hashes["chk:" + m.group("op").strip()] = m.group("hash").upper() return hashes diff --git a/tools/uber-perf-table b/tools/uber-perf-table index 6561c9c..4abfd97 100755 --- a/tools/uber-perf-table +++ b/tools/uber-perf-table @@ -74,14 +74,21 @@ def parse_log(path): return perf -def format_ratio(port_ops, ref_ops): - if port_ops is None or ref_ops is None or ref_ops == 0: +def format_cell(port_ops, ref_ops): + """Non-reference cell: absolute ops/sec plus percentage of the + reference (the IIgs floor), e.g. "6703 (464%)". When the reference + value for the op is missing (IIgs column pending), the absolute + number still prints -- data is never dropped just because the floor + is unavailable. Sub-100% cells are bolded: the reference port is + the perf floor, so below-floor cells are the ones worth attention. + """ + if port_ops is None: return "-" - ratio = port_ops / ref_ops - cell = f"{ratio:.2f}x" - # Bold sub-parity ratios. DESIGN.md treats the reference port as - # the perf floor; ratios below 1.00 are the cells worth attention. - if ratio < 1.0: + if ref_ops is None or ref_ops == 0: + return str(port_ops) + pct = port_ops / ref_ops * 100.0 + cell = f"{port_ops} ({pct:.0f}%)" + if pct < 100.0: cell = f"**{cell}**" return cell @@ -94,7 +101,7 @@ def render_table(port_data, ops_in_order, ref_key): if key == ref_key: headers.append(f"{label} (ops/sec)") else: - headers.append(f"{label} (vs {ref_key.upper()})") + headers.append(f"{label} (ops/sec, % of {ref_key.upper()})") rows = [headers, ["---"] * len(headers)] for op in ops_in_order: @@ -103,9 +110,9 @@ def render_table(port_data, ops_in_order, ref_key): for key, _, _ in PORTS: ops = port_data[key]["perf"].get(op) if key == ref_key: - row.append(str(ops) if ops is not None else "-") + row.append(str(ops) if ops is not None else "pending") else: - row.append(format_ratio(ops, ref_ops)) + row.append(format_cell(ops, ref_ops)) rows.append(row) return "\n".join("| " + " | ".join(r) + " |" for r in rows)