From 813bf595bc2ae6bece1786b799e608299a8f5e99 Mon Sep 17 00:00:00 2001 From: Scott Duensing Date: Tue, 7 Jul 2026 01:11:34 -0500 Subject: [PATCH] Working on performance. Still. --- NATIVE-PERF-PLAN.md | 89 +++++- PERF.md | 216 ++++++++----- examples/adventure/adventure.c | 8 +- examples/spacetaxi/stRender.c | 9 +- examples/sprite/sprite.c | 6 +- examples/uber/uber.c | 11 +- include/joey/platform.h | 2 - include/joey/sprite.h | 47 +-- make/atarist.mk | 2 +- scripts/bench-amiga.sh | 7 +- src/amiga/amigaPlanar.h | 44 +++ src/amiga/hal.c | 48 +-- src/atarist/audio.c | 84 +++++- src/atarist/hal.c | 119 ++++---- src/atarist/spriteWalk.s | 51 ++++ src/atarist/stPlanar.h | 42 +++ src/codegen/spriteCompile.c | 451 ++++++++++++++++++---------- src/core/draw.c | 17 +- src/core/init.c | 18 +- src/core/port.h | 12 +- src/core/sprite.c | 66 ++-- src/core/spriteInternal.h | 87 +++++- src/core/surface.c | 72 ++++- src/core/tile.c | 104 ++++--- src/generic/genericSurface.c | 6 - src/m68k/spriteEmitInterleaved68k.c | 250 ++++++++++++++- src/m68k/spriteEmitPlanar68k.c | 208 +++++++++++-- 27 files changed, 1530 insertions(+), 546 deletions(-) create mode 100644 src/amiga/amigaPlanar.h create mode 100644 src/atarist/spriteWalk.s create mode 100644 src/atarist/stPlanar.h diff --git a/NATIVE-PERF-PLAN.md b/NATIVE-PERF-PLAN.md index 1a617e3..71c53e4 100644 --- a/NATIVE-PERF-PLAN.md +++ b/NATIVE-PERF-PLAN.md @@ -64,6 +64,21 @@ Sprite ceilings are 2.5-4x above current aligned numbers (ST via movem long-RMW + all 16 phases; Amiga via the BLITTER, which also frees the CPU -- its compiled-CPU path caps at ~1,350). +**ERRATUM 2026-07-07: every ST "meas" number above (and every ST +number anywhere dated before 2026-07-07) was captured with ~49.5% of +the CPU consumed by the idle 12.3 kHz Timer-A audio ISR that +jlpAudioInit started unconditionally (fixed in src/atarist/audio.c: +demand-driven pump; PERF.md artifact #4). Honest ST values are ~2x the +table above: tile paste 3,203/s (gap to ceiling 4.0-4.8x, not ~10x), +aligned sprite draw 1,188/s (gap 1.9-2.3x), gameFrame 19 fps. The +probe also measured the true ST layer split for tile paste: port op +~1,094 cyc + dirty mark ~804 + wrapper/call ~590 = 2,484 cyc/call, +within ~11% of instruction-count models (the remainder is ST 4-cycle +bus rounding) -- the cycle-model methodology is validated. Amiga +layer split from the same probe: full call ~3,120 cyc, port op alone +~1,719 (chip-RAM contention), steady-state mark ~970. Amiga/DOS/IIgs +audited for the same bug class: clean (~1.8% / ~0.5% / 0% idle).** + ### Item 2 RESULT -- alignment-cliff code truth (supersedes all memory) SPRITE_SHIFT_INDEX (spriteInternal.h:24-30): Amiga x&7, ST @@ -181,9 +196,77 @@ perf-directive memory with the outcome. the bottom-line number; the program ends when this is at/near the port's refresh-limited ceiling or a documented structural bound. -- [ ] Phase 1 (68k all-shift emitters + save/restore emitters; - constraints (a)-(e) in the Phase-0 item-2 section above) -- [ ] Phase 2 +- [~] Phase 1 DRAW SIDE COMPLETE 2026-07-06 (steps 1-4 of the + adversary-reconciled order; save/restore = step 5 still open): + step 1 fused per-port shift ladder (ST 16/x&15, Amiga 8, chunky 2; + IIgs cache fields #ifdef'd) -- gated 49/49 x4 bit-identical; + step 2 plan-based jlSpriteCompile (measure-all, uint16-cap greedy, + draws tier 1 / save+restore atomic pairs tier 2, arena-full degrade + to SPRITE_DEGRADE_DRAW_MASK, 68k-only even-size guard); step 3 Amiga + all-shift JIT (accumulator merge, shift-0 byte-identical, host- + verified); step 4 ST 16-phase draw (spriteWalk.s masked-movem walker + + per-phase 22B thunk + 12B/group/row pre-shifted tables). GATE: + 49/49 x4; unaligned ST 40->500 (12.5x), Amiga 75->803 (10.7x); + sweep16 ST 2->32, Amiga 5->50; unaligned now ~82-84% of aligned on + both. BUGS FOUND BY THE GATES: even-size guard demoted odd-sized + IIgs/DOS routines to the interpreter (fixed: 68k-only); Amiga bench + task stack was the shell 4KB default -- the deeper compile path + overflowed it nondeterministically (fixed: stack 32768 in + bench-amiga.sh + emitter accumulators to file statics). + Steps 5+6 COMPLETE 2026-07-06 -- **PHASE 1 DONE**, final gate 49/49 + x4. Step 5: group-window save/restore emitters both ports (2 width + classes/sprite, IIgs-MVN-style; ST raw-span move.l chains, Amiga + plane-run move.l/move.w), format-law gates (SPRITE_SAVE_CLASS / + SPRITE_RESTORE_CLASS / SPRITE_BACKUP_PTR_OK in spriteInternal.h; + restore derives class from RECORDED geometry, (bx&15)==0 term is + correctness-critical), ST HAL raw-span interpreted branch (save + + restore switched atomically incl. the 16x16 asm reroute), planar + metadata-probe widening, JOEY_SPRITE_BACKUP_BYTES capacity macro + + all 4 example owners migrated and 2-aligned. SaveAndDraw's combined + path fires on 68k at every x for the first time. Step 6: + DEFAULT_CODEGEN_BYTES 64KB Amiga/ST; doc sweep done. RESULTS + (floor-relative): Amiga Save 276% / Restore 236% / SaveAndDraw 160%, + ST 203% / 163% / 107%; Amiga gameFrame 18 -> 23 fps. Host-verified + emitted streams (objdump); every intermediate gate 49/49 x4. +- [x] INTERLUDE 2026-07-07 (pre-Phase-2, found by the Phase-2 V1 + probe): ST idle-audio ISR tax discovered and fixed -- see the + Phase-0 ERRATUM above and PERF.md artifact #4. All four ports + re-gated 49/49 after a power-failure clean-rebuild recovery AND + after the audio fix (pixels untouched). The Phase-2+ gate baselines + are now (millis clock, post-fix): jlSpriteDraw unaligned ST 992 / + Amiga 803 / IIgs 845 / DOS 5118; sweep16 ST 64 / Amiga 50; jlTilePaste + single ST 3203 / Amiga 2253 / IIgs 1741 / DOS 9806; map 10x5 (x50 = + tiles/s) ST 3100 / Amiga 2300 / IIgs 1750 / DOS 10000; gameFrame + ST 19 / Amiga 23 / IIgs 10 / DOS 18 fps. Floor parity: the ST has + ZERO below-floor cells for the first time; remaining below-floor is + Amiga-only (circle family + unaligned 95% / sweep16 89%) + DOS + audioFrameTick (gates nothing). +- [~] Phase 2 IN PROGRESS 2026-07-07. Recon: 6-agent workflow + + merged synthesis (12 ranked items R1-R12) + calibrated-probe layer + split (see ERRATUM above). Step A LANDED (gate 49/49 x4): + plane-pointer inlining via new src/amiga/amigaPlanar.h + + src/atarist/stPlanar.h internal headers (jlpSurfacePlanePtr stanza + deleted -- its only callers were spriteCompile.c's 68k dispatchers), + dead-validation cleanup in both 68k HALs. Amiga sprite ops +10-23% + (SaveUnder 1503->1853), ST +3.5-5.2%; Amiga unaligned draw now ABOVE + floor (903 vs IIgs 845). Step B LANDED (gate 49/49 x4): whole-tile + dirty mark specialized in tile.c (band = [bx*2, bx*2+1] with + compile-time proof, 8-row widen unrolled ~480 cyc vs 804 measured; + IIgs keeps its asm marker). Tile rows: ST +9-15%, Amiga +13-18%, + DOS +24-29%, map row ST 72 / Amiga 53 / DOS 258; tileSnap (no mark) + flat everywhere = clean control. Step C LANDED (gate 49/49 x4): + jlDrawRect raw-edge fills + ONE mark (band-interval proof in + draw.c) -- ST +25% / Amiga +40% / DOS +22% on the drawRect row; the + x2 widen unroll in surfaceMarkDirtyRows is PER-PORT by measurement: + it lifted Amiga sprite rows +4-6% but cost the ST -5% in BOTH the + pointer-walking and counted forms (gcc-mint materializes 4 live + pointers either way; simple loop re-measured exactly at ST + baseline; DOS flat) -- so Amiga unrolls, everyone else keeps the + simple loop (documented at the #if in surface.c). + REMAINING: R4a 68k tile call-chain flatten + R8 Amiga unroll check; + Step D IIgs glue (R2/R5/R6/R10 fused asm entries -- biggest IIgs + upside, ~2x its tile rows; own session recommended); R9 palette/SCB + memcmp->flags on 68k/DOS presents (hygiene). - [ ] Phase 3 - [ ] Phase 4 - [ ] Phase 5 diff --git a/PERF.md b/PERF.md index c552cd7..bc58b20 100644 --- a/PERF.md +++ b/PERF.md @@ -40,6 +40,68 @@ walkers as pixel-perfect at all 14 previously-untested x phases. -- e.g. jlAudioFrameTick 50223 -> 27612 -- run-to-run MAME variance on cheap ops; hashes are unaffected and those rows gate nothing.) +**2026-07-06, NATIVE-PERF Phase 1 (draw side) LANDED:** all-shift +compiled sprite DRAW on both 68k ports. Amiga emits pre-shifted JIT +variants for all 8 bit phases; the ST emits per-phase thunk + data +tables driven by a shared masked-movem walker (spriteWalk.s) for all +16 intra-group phases, with the dispatcher's ST shift contract changed +to x & 15. jlSpriteCompile is now plan-based: it measures every +(shift, op) variant, greedily fits them under the uint16 offset cap +(draws first, save/restore as atomic pairs), and degrades to the +byte-aligned phases when the arena is too small instead of losing the +sprite. RESULT -- the alignment cliff is CLOSED: `jlSpriteDraw +unaligned` ST 40 -> 500 ops/sec (12.5x), Amiga 75 -> 803 (10.7x); +sweep16 ST 2 -> 32 sweeps/sec (512 draws/sec), Amiga 5 -> 50 (800 +draws/sec). Unaligned now performs like aligned on both ports (ST 82% / +Amiga 84% of their aligned rows), and every one of the 49 golden +hashes still matches -- the compiled paths are bit-identical to the +interpreted walkers they replace, on all four ports. (Found along the +way: the Amiga bench task stack was ~4 KB -- one deep call chain from +overflow -- now 32 KB in bench-amiga.sh; the emitters' accumulators are +file statics.) + +**Phase 1 step 5 (save/restore) ALSO LANDED, completing Phase 1:** +compiled save/restore on both 68k ports operate on the 16-px-group +window (two width classes per sprite, like the IIgs MVN pair), the +backup format is a pure function of the recorded geometry (identical +for compiled and interpreted producers/consumers -- the ST HAL gained a +raw-span interpreted branch, switched atomically with restore), and the +gates route by SPRITE_SAVE_CLASS / SPRITE_RESTORE_CLASS with an odd- +buffer-pointer demotion (68000 word ops need even addresses). The +combined jlSpriteSaveAndDraw path now fires on 68k at EVERY x for the +first time. RESULTS (floor-relative): Amiga SaveUnder 104% -> 276%, +RestoreUnder 98% -> 236%, SaveAndDraw 94% -> 160%; ST SaveUnder +158% -> 203%, RestoreUnder 122% -> 163%, SaveAndDraw 87% -> 107%; +Amiga gameFrame 18 -> 23 fps. Backup capacity contract: +JOEY_SPRITE_BACKUP_BYTES in sprite.h (window worst case; all in-repo +examples migrated + 2-aligned). DEFAULT_CODEGEN_BYTES is 64 KB on +Amiga/ST (full variant sets). All 49 golden hashes held throughout. + +**2026-07-07, THE ST COLUMN DOUBLED -- idle-audio ISR tax found and +fixed:** every previous ST capture ran with ~49.5% of the CPU silently +consumed by the 12.3 kHz Timer-A PWM ISR that jlpAudioInit started +unconditionally -- it streamed buffer silence to the YM even with +nothing playing, and UBER inits audio before its timed run. Found by +a calibrated probe (known-cycle dbra spin + per-layer direct-call rows +under the bench Hatari config): every probe row slowed by exactly +1.98x once audio was initialized, and the probe's un-taxed tilePaste +cycles/call matched the instruction-count model that UBER's number +mysteriously doubled. Fix: src/atarist/audio.c Timer A is now +demand-driven -- installed on the first PlayMod/PlaySfx, parked again +once the mix buffer drains back to silence with nothing live +(drain-to-silence handshake in jlpAudioFrameTick; StopMod parks +immediately when no SFX is riding the pump). Probe-verified: idle rows +match the never-initialized rows exactly, the tax correctly returns +while an SFX drains, and it vanishes after the drain. Full ST +re-bench: 49/49 hashes bit-identical (pixels untouched) and EVERY row +~2x: gameFrame 9 -> 19 fps, tilePaste 1,574 -> 3,203/s, sprite draw +607 -> 1,188/s, unaligned draw 508 -> 992/s. The other three ports +were audited for the same bug class and are clean: Amiga ~1.8% idle +tax (PTPlayer rides Paula DMA), DOS ~0.5% (SB auto-init DMA, ~11 Hz +half-buffer IRQ), IIgs 0% (no handler installed until PlayMod). ALL +pre-2026-07-07 ST comparisons -- including the Phase-0 ceiling gaps +and the Phase-1 relative results -- understated the ST by ~2x. + Correctness statement backing this table: all four ports produce BYTE-IDENTICAL pixels on every measured op and every correctness check -- **49/49** surface hashes match the frozen goldens in @@ -51,8 +113,8 @@ 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: +Four measurement artifacts have shaped this table's history. Three 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 @@ -86,55 +148,66 @@ one is inherent -- know them before comparing rows: per-op `UBER-CLK:` log line reports frame-vs-millis as a standing cross-check (they now agree). ST cells rose ~17% (e.g. jlSpriteDraw 503->585, jlFillRect 80x80 97->115); DOS/IIgs/Amiga unchanged. +4. **ST idle-audio ISR tax -- FOUND and FIXED 2026-07-07; the ST + column below is the post-fix capture.** jlpAudioInit started the + 12.3 kHz Timer-A PWM ISR at init and left it running forever, even + silent (~320 cycles/tick = ~49.5% of the CPU), and UBER inits audio + before timing anything. Every ST capture before 2026-07-07 is ~2x + pessimistic. Timer A is demand-driven now (runs only while a mod or + SFX is audible), so the benches measure the op and an idle game + gets the whole CPU. Amiga/DOS/IIgs audited clean for the same bug + class (~1.8% / ~0.5% / 0% idle cost). -## Baseline (2026-07-05): absolute ops/sec per port +## Baseline (2026-07-07): 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). +default cuts off the final checks). Capture provenance: ST 2026-07-07 +(post-audio-fix); IIgs/Amiga/DOS 2026-07-06 (recovery-gate rerun, +unaffected by the ST fix). | Op | IIgs (ops/sec) | Amiga (ops/sec, % of IIGS) | Atari ST (ops/sec, % of IIGS) | DOS (ops/sec, % of IIGS) | | --- | --- | --- | --- | --- | -| jlSurfaceClear | 33 | 75 (227%) | 44 (133%) | 66 (200%) | -| jlPaletteSet | 1561 | 11503 (737%) | 5256 (337%) | 29809 (1910%) | -| jlScbSetRange | 163 | 3953 (2425%) | 2418 (1483%) | 8495 (5212%) | -| jlDrawPixel | 3179 | 5853 (184%) | 3384 (106%) | 23757 (747%) | -| jlDrawLine H | 1142 | 1703 (149%) | 1341 (117%) | 5708 (500%) | -| jlDrawLine V | 99 | 269 (272%) | 132 (133%) | 470 (475%) | -| jlDrawLine diag | 42 | **40 (95%)** | 43 (102%) | 109 (260%) | -| jlDrawRect 100x100 | 89 | 203 (228%) | 108 (121%) | 377 (424%) | -| jlDrawCircle r=16 | 303 | **285 (94%)** | **184 (61%)** | 449 (148%) | -| jlDrawCircle r=80 | 70 | **63 (90%)** | **42 (60%)** | 98 (140%) | -| jlFillRect 16x16 | 605 | 1153 (191%) | 610 (101%) | 3013 (498%) | -| jlFillRect 80x80 | 110 | 269 (245%) | 120 (109%) | 414 (376%) | -| jlFillRect 320x200 | 21 | 66 (314%) | 40 (190%) | 58 (276%) | -| jlFillCircle r=40 | 42 | **31 (74%)** | 77 (183%) | 156 (371%) | -| jlSamplePixel | 4815 | 7253 (151%) | 4862 (101%) | 46397 (964%) | -| jlTileFill | 1981 | 2303 (116%) | **1603 (81%)** | 9313 (470%) | -| jlTileCopy | 1441 | 1853 (129%) | **1341 (93%)** | 8764 (608%) | -| jlTileCopyMasked | 623 | 953 (153%) | 689 (111%) | 3147 (505%) | -| jlTilePaste | 1741 | 2303 (132%) | **1574 (90%)** | 9806 (563%) | -| jlTileSnap | 2520 | 3503 (139%) | 2907 (115%) | 19247 (764%) | -| jlSpriteSaveUnder | 545 | 567 (104%) | 849 (156%) | 9229 (1693%) | -| jlSpriteDraw | 902 | 953 (106%) | **607 (67%)** | 6218 (689%) | -| jlSpriteRestoreUnder | 566 | **553 (98%)** | 689 (122%) | 5933 (1048%) | -| jlSpriteSaveAndDraw | 376 | **332 (88%)** | **328 (87%)** | 4271 (1136%) | -| jlStagePresent full | 67 | 359 (536%) | **55 (82%)** | 360 (537%) | -| jlInputPoll | 229 | 4003 (1748%) | 1400 (611%) | 3134 (1369%) | -| jlKeyDown | 10191 | 39753 (390%) | 22403 (220%) | 77800 (763%) | -| jlKeyPressed | 11432 | 38003 (332%) | 22344 (195%) | 74977 (656%) | -| jlMouseX | 20138 | 97703 (485%) | 58314 (290%) | 155140 (770%) | -| joeyJoyConnected | 10191 | 38903 (382%) | 21870 (215%) | 77819 (764%) | -| jlAudioFrameTick | 27612 | 56453 (204%) | **11732 (42%)** | 59162 (214%) | -| jlAudioIsPlayingMod | 11029 | 56503 (512%) | 29870 (271%) | 127794 (1159%) | -| surfaceMarkDirtyRect (via jlFillRect 32x32) | 323 | 853 (264%) | 389 (120%) | 1478 (458%) | -| jlSpriteDraw unaligned | 842 | **75 (9%)** | **40 (5%)** | 5115 (607%) | -| jlSpriteDraw sweep16 | 56 | **5 (9%)** | **2 (4%)** | 353 (630%) | -| jlTilePaste map 10x5 | 35 | 46 (131%) | **32 (91%)** | 200 (571%) | -| gameFrame composite | 10 | 18 (180%) | **9 (90%)** | 18 (180%) | +| jlSurfaceClear | 33 | 75 (227%) | 89 (270%) | 67 (203%) | +| jlPaletteSet | 1561 | 11453 (734%) | 10255 (657%) | 29828 (1911%) | +| jlScbSetRange | 163 | 4003 (2456%) | 4744 (2910%) | 8491 (5209%) | +| jlDrawPixel | 3179 | 5853 (184%) | 6645 (209%) | 23689 (745%) | +| jlDrawLine H | 1142 | 1703 (149%) | 2660 (233%) | 5708 (500%) | +| jlDrawLine V | 99 | 269 (272%) | 265 (268%) | 456 (461%) | +| jlDrawLine diag | 42 | **40 (95%)** | 86 (205%) | 108 (257%) | +| jlDrawRect 100x100 | 89 | 203 (228%) | 216 (243%) | 378 (425%) | +| jlDrawCircle r=16 | 303 | **285 (94%)** | 359 (118%) | 461 (152%) | +| jlDrawCircle r=80 | 70 | **61 (87%)** | 83 (119%) | 98 (140%) | +| jlFillRect 16x16 | 605 | 1153 (191%) | 1211 (200%) | 3017 (499%) | +| jlFillRect 80x80 | 110 | 269 (245%) | 236 (215%) | 412 (375%) | +| jlFillRect 320x200 | 21 | 66 (314%) | 78 (371%) | 59 (281%) | +| jlFillCircle r=40 | 42 | **31 (74%)** | 155 (369%) | 156 (371%) | +| jlSamplePixel | 4815 | 7253 (151%) | 9603 (199%) | 46266 (961%) | +| jlTileFill | 1981 | 2253 (114%) | 3144 (159%) | 9301 (470%) | +| jlTileCopy | 1441 | 1853 (129%) | 2720 (189%) | 8764 (608%) | +| jlTileCopyMasked | 623 | 953 (153%) | 1366 (219%) | 3147 (505%) | +| jlTilePaste | 1741 | 2253 (129%) | 3203 (184%) | 9834 (565%) | +| jlTileSnap | 2520 | 3503 (139%) | 5751 (228%) | 19290 (765%) | +| jlSpriteSaveUnder | 545 | 1503 (276%) | 2196 (403%) | 9248 (1697%) | +| jlSpriteDraw | 902 | 953 (106%) | 1188 (132%) | 6234 (691%) | +| jlSpriteRestoreUnder | 510 | 1203 (236%) | 1662 (326%) | 5925 (1162%) | +| jlSpriteSaveAndDraw | 376 | 603 (160%) | 803 (214%) | 4271 (1136%) | +| jlStagePresent full | 67 | 359 (536%) | 125 (187%) | 360 (537%) | +| jlInputPoll | 228 | 4003 (1756%) | 2788 (1223%) | 3148 (1381%) | +| jlKeyDown | 10191 | 39303 (386%) | 45286 (444%) | 77548 (761%) | +| jlKeyPressed | 11389 | 38053 (334%) | 45286 (398%) | 75092 (659%) | +| jlMouseX | 20214 | 96703 (478%) | 117981 (584%) | 155372 (769%) | +| joeyJoyConnected | 10191 | 39303 (386%) | 43440 (426%) | 77548 (761%) | +| jlAudioFrameTick | 27509 | 55853 (203%) | 40637 (148%) | **11527 (42%)** | +| jlAudioIsPlayingMod | 17627 | 56453 (320%) | 60441 (343%) | 127708 (725%) | +| surfaceMarkDirtyRect (via jlFillRect 32x32) | 323 | 853 (264%) | 774 (240%) | 1472 (456%) | +| jlSpriteDraw unaligned | 845 | **803 (95%)** | 992 (117%) | 5118 (606%) | +| jlSpriteDraw sweep16 | 56 | **50 (89%)** | 64 (114%) | 363 (648%) | +| jlTilePaste map 10x5 | 35 | 46 (131%) | 62 (177%) | 200 (571%) | +| gameFrame composite | 10 | 23 (230%) | 19 (190%) | 18 (180%) | > **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 @@ -148,42 +221,25 @@ default cuts off the final checks). ## 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 67%, ST jlSpriteSaveAndDraw - 87%, ST jlTileFill 77%, and Amiga jlSpriteRestoreUnder 97% + - jlSpriteSaveAndDraw 88% remain below the IIgs floor (they were masked - while the IIgs interpreted; ST jlSpriteRestoreUnder is now 119%, above - floor, after the clock fix). A 2026-07-06 shared fix -- inlining the - per-row widen in surfaceMarkDirtyRows (removing an 8x function call in - every multi-row stage op's dirty mark) -- lifted all ST multi-row ops - ~2-5% (tileFill 1516->1574, spriteDraw 585->607, tileCopy 1307->1366, - fillRect16 to 100%), the ST/Amiga counterpart to the IIgs asm marker. 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 +* **Sprite rows measure COMPILED code on all four ports** since + NATIVE-PERF Phase 1 (2026-07-06): the 68k ports JIT all-shift draw + variants plus group-window save/restore (previously stubs that fell + to interpreters), IIgs codegen re-enabled via finding #77, DOS via + finding #74. The interpreted walkers remain only as arena-full + degrade paths -- and are proven bit-identical to the compiled code + by the 49-hash gate. +* **The below-floor cells are the standing punch list**, and after the + 2026-07-07 audio-tax fix the ST has NONE -- every remaining + below-floor graphics cell is the Amiga's: the circle family (diag + line 95%, circle r=16 94%, r=80 87%, fillCircle 74% -- planar 4-plane + RMW tax) and the two sprite workload rows a hair under floor + (unaligned 95%, sweep16 89%; chip-RAM display-DMA contention that the + fast-RAM-code ST does not pay). DOS jlAudioFrameTick (42%) is the SB + half-buffer silence refill on an otherwise-trivial call -- it gates + nothing. Floor parity is effectively DONE; the open work is + ceiling-relative (NATIVE-PERF-PLAN.md Phase 2+), where every port + still runs 2-5x below hand-coded technique on tile/sprite glue. + Amiga jlFillCircle r=40 (74%) was probed 2026-07-06 with a full whole-circle asm routine (Bresenham + inline 4-plane long-fill span): it held 45/45 hashes but did NOT move the number (34 vs 31, within the coarse 11-iter sample), so the per-span @@ -192,9 +248,9 @@ default cuts off the final checks). 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. + 50 Hz frame counter, a coarse measurement. (The old note here blaming + a "4-5x model-vs-measured gap" partly on measurement was vindicated + 2026-07-07: on the ST that gap was the idle-audio tax, artifact #4.) * **jlRandom has no timed row.** Phase 8 (#43) rewrote its xorshift32 update in 16-bit halves on the IIgs only (~2-3x fewer cycles per call on the 65816; 32-bit shifts there are per-bit helper loops). diff --git a/examples/adventure/adventure.c b/examples/adventure/adventure.c index 4e4c618..5cc4b10 100644 --- a/examples/adventure/adventure.c +++ b/examples/adventure/adventure.c @@ -61,13 +61,13 @@ /* SaveUnder rounds x down to platform alignment: 8 px on Amiga * planar, 2 px on chunky. Add 4 bytes/row of slack to cover the * worst case on either. */ -#define EGO_BACKUP_BYTES (((EGO_W >> 1) + 4) * EGO_H) +#define EGO_BACKUP_BYTES JOEY_SPRITE_BACKUP_BYTES(EGO_W / 8, EGO_H / 8) #define CURSOR_W 12 #define CURSOR_H 12 #define CURSOR_TILES_X 2 #define CURSOR_TILES_Y 2 -#define CURSOR_BACKUP_BYTES (((CURSOR_TILES_X * 8) >> 1) + 4) * (CURSOR_TILES_Y * 8) +#define CURSOR_BACKUP_BYTES JOEY_SPRITE_BACKUP_BYTES(CURSOR_TILES_X, CURSOR_TILES_Y) #define ITEM_ICON_W 16 #define ITEM_ICON_H 16 @@ -204,7 +204,7 @@ static RoomT gRooms[ROOM_COUNT]; static jlSpriteT *gEgo[DIR_COUNT][EGO_FRAMES]; static uint8_t gEgoTiles[DIR_COUNT][EGO_FRAMES][EGO_TILE_BYTES]; -static uint8_t gEgoBackup[EGO_BACKUP_BYTES]; +static uint8_t gEgoBackup[EGO_BACKUP_BYTES] __attribute__((aligned(2))); static jlSpriteBackupT gEgoBak; static bool gEgoBakValid; @@ -213,7 +213,7 @@ static jlSpriteT *gCursor[VERB_COUNT]; static jlSpriteT *gItemSprite[ITEM_COUNT]; /* index 0 = unused */ static jlSpriteT *gLampLitSprite; /* lit-state inventory icon */ static uint8_t gLampLitTiles[2 * 2 * TILE_BYTES]; -static uint8_t gCursorBackup[CURSOR_BACKUP_BYTES]; +static uint8_t gCursorBackup[CURSOR_BACKUP_BYTES] __attribute__((aligned(2))); static jlSpriteBackupT gCursorBak; static bool gCursorBakValid; diff --git a/examples/spacetaxi/stRender.c b/examples/spacetaxi/stRender.c index bdab0cf..250f080 100644 --- a/examples/spacetaxi/stRender.c +++ b/examples/spacetaxi/stRender.c @@ -107,7 +107,8 @@ #define ST_PASSENGER_CEL_BOARD0 2 /* maps to C64 ptr $C6 */ #define ST_PASSENGER_CEL_SPARKLE 8 /* C64 ptr $D9 -- 3rd sparkle cel */ -#define ST_SPRITE_BACKUP_BYTES (((ST_TAXI_W_PX >> 1) + 4) * ST_TAXI_H_PX) +// Library-wide worst case (16-px-group window on planar ports). +#define ST_SPRITE_BACKUP_BYTES JOEY_SPRITE_BACKUP_BYTES(ST_TAXI_W_PX / 8, ST_TAXI_H_PX / 8) typedef struct { @@ -119,16 +120,16 @@ typedef struct { jlSpriteT *warpCels[ST_WARP_CEL_COUNT]; jlSpriteT *flameCels[ST_FLAME_CEL_COUNT]; jlSpriteBackupT flameBackup; - uint8_t flameBackupMem[ST_SPRITE_BACKUP_BYTES]; + uint8_t flameBackupMem[ST_SPRITE_BACKUP_BYTES] __attribute__((aligned(2))); bool flameHasBackup; jlSpriteT *passengerCels[ST_PASSENGER_CEL_COUNT]; jlSurfaceT *fontSurface; // glyph surface, built from font.tbk at load uint16_t asciiMap[128]; bool fontReady; jlSpriteBackupT taxiBackup; - uint8_t taxiBackupMem[ST_SPRITE_BACKUP_BYTES]; + uint8_t taxiBackupMem[ST_SPRITE_BACKUP_BYTES] __attribute__((aligned(2))); jlSpriteBackupT passengerBackup[ST_MAX_PASSENGERS]; - uint8_t passengerBackupMem[ST_MAX_PASSENGERS][ST_SPRITE_BACKUP_BYTES]; + uint8_t passengerBackupMem[ST_MAX_PASSENGERS][ST_SPRITE_BACKUP_BYTES] __attribute__((aligned(2))); bool taxiHasBackup; bool passengerHasBackup[ST_MAX_PASSENGERS]; // Tilemap repaint gating: the static playfield art only needs to diff --git a/examples/sprite/sprite.c b/examples/sprite/sprite.c index a1dada9..2c816fc 100644 --- a/examples/sprite/sprite.c +++ b/examples/sprite/sprite.c @@ -19,7 +19,9 @@ // for chunky 4bpp (1 extra byte/row worst case), 8 px for planar // 4-plane (4 extra bytes/row worst case -- one per plane). The +4 // covers the planar case and is a no-op overhead on chunky. -#define BALL_BACKUP_BYTES (((BALL_W >> 1) + 4) * BALL_H) +// Sized by the library-wide worst case (16-px-group window on the +// planar ports); see JOEY_SPRITE_BACKUP_BYTES in joey/sprite.h. +#define BALL_BACKUP_BYTES JOEY_SPRITE_BACKUP_BYTES(BALL_W / 8, BALL_H / 8) #define BALL_PALETTE_IDX 0 @@ -50,7 +52,7 @@ static const uint8_t gBallAuthored[BALL_H * (BALL_W / 2)] = { }; static uint8_t gBallTiles[BALL_TILE_BYTES]; -static uint8_t gBallBackup[BALL_BACKUP_BYTES]; +static uint8_t gBallBackup[BALL_BACKUP_BYTES] __attribute__((aligned(2))); // Repack from authored "wide row" layout to tile-major jlSpriteT diff --git a/examples/uber/uber.c b/examples/uber/uber.c index 56880c6..8741c5d 100644 --- a/examples/uber/uber.c +++ b/examples/uber/uber.c @@ -113,7 +113,7 @@ static const char *gCurName = "(none)"; static jlSurfaceT *gStage = NULL; static jlSpriteT *gSprite = NULL; static jlSpriteBackupT gBackup; -static unsigned char gBackupBytes[256]; +static unsigned char gBackupBytes[JOEY_SPRITE_BACKUP_BYTES(2, 2)] __attribute__((aligned(2))); static jlTileT gTileScratch; @@ -127,7 +127,7 @@ static jlTileT gTileMapB; #define UBER_FRAME_SPRITES 3 static jlSpriteBackupT gFrameBackups[UBER_FRAME_SPRITES]; -static unsigned char gFrameBackupBytes[UBER_FRAME_SPRITES][256]; +static unsigned char gFrameBackupBytes[UBER_FRAME_SPRITES][JOEY_SPRITE_BACKUP_BYTES(2, 2)] __attribute__((aligned(2))); // Current timed-op number (1-based); mirrored into the mailbox. Lives @@ -1106,9 +1106,10 @@ static bool __attribute__((noinline)) setupSprite(void) { static bool __attribute__((noinline)) initJoeyLib(void) { jlConfigT config; - /* 32 KB fits the 8 pre-shifted DRAW variants the Amiga planar - * compiled sprite emitter generates. UL on the multiply because - * a 16-bit int overflows on 32 * 1024. */ + /* 32 KB fits one 16x16 sprite's full compiled set on either 68k + * port (Amiga: 8 pre-shifted DRAW variants ~15-18 KB; ST: 16-phase + * thunk+table DRAWs ~12 KB; plus ~1-2 KB of save/restore classes). + * UL on the multiply because a 16-bit int overflows on 32 * 1024. */ config.codegenBytes = 32UL * 1024; config.audioBytes = 64UL * 1024; return jlInit(&config); diff --git a/include/joey/platform.h b/include/joey/platform.h index 69a68ab..b93cd25 100644 --- a/include/joey/platform.h +++ b/include/joey/platform.h @@ -160,7 +160,6 @@ #define JL_HAS_SURFACE_ALLOC_PIXELS // amiga: NULL (planar storage), function #define JL_HAS_SURFACE_ALLOC_PORT_DATA // amiga AmigaPlanarT + chip planes, function #define JL_HAS_SURFACE_FREE_PORT_DATA // amiga FreeMem planes, function - #define JL_HAS_SURFACE_PLANE_PTR // amiga plane base pointer, function #define JL_HAS_FRAME_HZ // PAL 50, function #define JL_HAS_FRAME_COUNT // VBL-server counter, function #define JL_HAS_WAIT_VBL // WaitTOF, function @@ -215,7 +214,6 @@ #define JL_HAS_SURFACE_ALLOC_PIXELS // st: NULL (planar storage), function #define JL_HAS_SURFACE_ALLOC_PORT_DATA // st StPlanarT + interleaved buffer, function #define JL_HAS_SURFACE_FREE_PORT_DATA // st free buffer, function - #define JL_HAS_SURFACE_PLANE_PTR // st interleaved buffer base, function #define JL_HAS_FRAME_HZ // PAL 50, function #define JL_HAS_FRAME_COUNT // VBL-ISR counter, function #define JL_HAS_WAIT_VBL // frame-counter wait, function diff --git a/include/joey/sprite.h b/include/joey/sprite.h index 417a3d8..c5c55a1 100644 --- a/include/joey/sprite.h +++ b/include/joey/sprite.h @@ -29,16 +29,14 @@ #include "surface.h" #include "types.h" -// Sprite codegen emits per-shift variants. Chunky 4bpp ports (DOS, -// IIgs, Atari ST) only need 2 shifts -- pixel offset 0 (sprite/dest -// byte boundaries align) and offset 1 (every dest byte combines two -// sprite bytes' nibbles). Planar ports (Amiga -- 8 px per plane byte) -// need 8 shifts: one for each x % 8 alignment, so smooth horizontal -// motion at any pixel position uses pre-shifted source bytes without -// runtime bit-shifting. Allocate the max so routineOffsets[] has -// slots for every variant; chunky ports leave shifts 2..7 as -// SPRITE_NOT_COMPILED, planar ports use all 8. -#define JOEY_SPRITE_SHIFT_COUNT 8 +// Sprite codegen emits per-shift variants so smooth horizontal motion +// at any pixel position uses pre-shifted routines without runtime +// bit-shifting. The variant count is per-platform (chunky 4bpp ports +// need only x & 1; Amiga planar needs x & 7; ST word-interleaved +// planar needs the full x & 15 intra-group phase) and lives with the +// dispatcher's index macro in src/core/spriteInternal.h so count and +// mask cannot drift. jlSpriteT is opaque to applications, so the +// count is not part of the public ABI. typedef struct jlSpriteT jlSpriteT; @@ -111,18 +109,27 @@ void jlSpritePrewarm(jlSpriteT *sp); // Off-surface portions are clipped. void jlSpriteDraw(jlSurfaceT *s, jlSpriteT *sp, int16_t x, int16_t y); +// Worst-case backup capacity for a widthTiles x heightTiles sprite at +// ANY x, on every port. The planar 68k compiled save/restore operates +// on the 16-pixel-group-aligned window covering the sprite (x rounded +// DOWN to a 16-px boundary, width UP), so the worst case is one extra +// 16-px group: (ceil(w/16) + 1) * 8 bytes per row. This also covers the +// chunky ports' (w/2 + 1) * h trivially. Give the backing array 2-byte +// alignment (e.g. uint16_t backing or __attribute__((aligned(2)))) -- +// odd buffer pointers route save/restore to the slower interpreted +// paths on planar ports (68000 word ops require even addresses). +#define JOEY_SPRITE_BACKUP_BYTES(_wT, _hT) \ + ((uint16_t)((((((uint16_t)(_wT) * 8u) + 15u) >> 4) + 1u) * 8u * ((uint16_t)(_hT) * 8u))) + // Capture the destination region a subsequent jlSpriteDraw at the same // (x,y) would write to. backup->bytes must have at least -// (widthTiles*4) * (heightTiles*8) bytes of capacity for fully -// in-bounds draws on chunky ports (DOS, IIgs); for clipped draws only -// the visible bytes are stored. The captured region's exact size is -// reported in backup->sizeBytes. -// -// Planar ports (Amiga, Atari ST) round x DOWN to an 8-pixel plane -// boundary and width UP, so a sprite drawn at an odd-of-8 x captures -// one extra plane-byte column per row. Size backup->bytes for the -// planar worst case ((widthTiles*4) + 1) * (heightTiles*8) bytes so an -// odd-x save cannot overrun the buffer. +// JOEY_SPRITE_BACKUP_BYTES(widthTiles, heightTiles) bytes of capacity +// for fully in-bounds draws; for clipped draws only the visible bytes +// are stored. The captured region's exact geometry is reported in +// backup->x/width/height/sizeBytes -- on planar ports (Amiga, ST) it is +// the 16-px-aligned window containing the sprite, so the region a +// caller must not invalidate before restoring is the RECORDED window, +// not just the sprite rect. void jlSpriteSaveUnder(const jlSurfaceT *s, jlSpriteT *sp, int16_t x, int16_t y, jlSpriteBackupT *backup); // Repaint the destination region from a jlSpriteBackupT captured by a diff --git a/make/atarist.mk b/make/atarist.mk index 021f899..fbff0e7 100644 --- a/make/atarist.mk +++ b/make/atarist.mk @@ -7,7 +7,7 @@ BUILD := $(REPO_DIR)/build/$(PLATFORM) LIBDIR := $(BUILD)/lib BINDIR := $(BUILD)/bin -CFLAGS := $(COMMON_CFLAGS) -m68000 -fomit-frame-pointer -I$(REPO_DIR)/toolchains/audio/libxmp-lite/include -I$(REPO_DIR)/toolchains/atarist/include-shim -I$(SRC_68K) -MMD -MP +CFLAGS := $(COMMON_CFLAGS) -m68000 -fomit-frame-pointer -I$(REPO_DIR)/toolchains/audio/libxmp-lite/include -I$(REPO_DIR)/toolchains/atarist/include-shim -I$(SRC_DIR)/atarist -I$(SRC_68K) -MMD -MP LDFLAGS := # libxmp-lite shared with the DOS port. Built as a static archive that diff --git a/scripts/bench-amiga.sh b/scripts/bench-amiga.sh index 3c89e02..56f6dc8 100755 --- a/scripts/bench-amiga.sh +++ b/scripts/bench-amiga.sh @@ -37,8 +37,11 @@ find "$bin_dir" -maxdepth 1 -type f -executable -exec cp -t "$work/" {} + if [[ -d "$bin_dir/DATA" ]]; then cp -r "$bin_dir/DATA" "$work/" fi -# ':' anchors to the boot volume root. -echo ":Uber" > "$work/s/startup-sequence" +# ':' anchors to the boot volume root. 32 KB task stack: the shell +# default (~4 KB) left UBER one deep call chain from overflow -- the +# Phase-1 sprite-compile path found it (emitter accumulator frame + +# vsnprintf underneath = nondeterministic wandering crashes). +printf 'stack 32768\n:Uber\n' > "$work/s/startup-sequence" log=$work/joeylog.txt diff --git a/src/amiga/amigaPlanar.h b/src/amiga/amigaPlanar.h new file mode 100644 index 0000000..a42ca10 --- /dev/null +++ b/src/amiga/amigaPlanar.h @@ -0,0 +1,44 @@ +// Amiga per-surface planar storage, shared between the HAL +// (src/amiga/hal.c) and the cross-platform sprite dispatchers +// (src/codegen/spriteCompile.c) so plane-pointer access inlines +// instead of paying a cross-TU function call per plane (NATIVE-PERF +// Phase 2). Plane pointers are lifetime-immutable: portData is written +// only at surface create/destroy, so reading them needs no cache and +// no invalidation. +#ifndef JOEY_AMIGA_PLANAR_H +#define JOEY_AMIGA_PLANAR_H + +#include +#include + +#include "surfaceInternal.h" + +#define AMIGA_BITPLANES 4 +#define AMIGA_BYTES_PER_ROW (SURFACE_WIDTH / 8) +#define AMIGA_PLANE_SIZE (AMIGA_BYTES_PER_ROW * SURFACE_HEIGHT) + +// jlSurfaceT.portData points to this on Amiga: the 4 plane base +// pointers + stride. Every surface (including the stage, which gets +// its OWN shadow planes, NOT aliased to the displayed gPlanes) owns +// AllocMem(MEMF_CHIP) planes so the blitter can reach them; present +// copies shadow -> display planes atomically. +// +// Direct fields rather than a union because the consumer is asm / +// inline C that wants minimal indirection in the inner loop. +typedef struct { + uint8_t *planes[AMIGA_BITPLANES]; + uint16_t bytesPerRow; // = AMIGA_BYTES_PER_ROW (40) + uint16_t bytesPerPlane; // = AMIGA_PLANE_SIZE (8000) + bool ownsPlanes; // true = AllocMem'd (free at destroy); + // false = aliased (don't free) +} AmigaPlanarT; + + +// NULL means "surface has no planar storage" -- callers use that as +// their route-to-generic / no-op gate, exactly the contract the old +// jlpSurfacePlanePtr cross-TU function provided. +static inline AmigaPlanarT *amigaSurfacePlanar(const jlSurfaceT *s) { + return (AmigaPlanarT *)s->portData; +} + +#endif diff --git a/src/amiga/hal.c b/src/amiga/hal.c index f848b54..7be3626 100644 --- a/src/amiga/hal.c +++ b/src/amiga/hal.c @@ -60,30 +60,13 @@ extern struct Custom custom; static void installVblServer(void); static void removeVblServer(void); -// ----- Constants ----- - -#define AMIGA_BITPLANES 4 -#define AMIGA_BYTES_PER_ROW 40 -#define AMIGA_PLANE_SIZE (AMIGA_BYTES_PER_ROW * SURFACE_HEIGHT) - - -// ----- Per-surface planar storage (project_planar_68k_plan) ----- +// ----- Constants + per-surface planar storage ----- // -// Phase 1 carved out the jlSurfaceT.portData hook. This struct is what -// it points to on Amiga: the 4 plane base pointers + stride. For the -// stage, planes[i] aliases gPlanes[i] (Intuition-allocated, already -// in chip RAM, already on display). For non-stage surfaces planes[i] -// gets its own AllocMem(MEMF_CHIP) so the blitter can reach it. -// -// Direct fields rather than a union because the consumer is asm / -// inline C that wants minimal indirection in the inner loop. -typedef struct { - uint8_t *planes[AMIGA_BITPLANES]; - uint16_t bytesPerRow; // = AMIGA_BYTES_PER_ROW (40) - uint16_t bytesPerPlane; // = AMIGA_PLANE_SIZE (8000) - bool ownsPlanes; // true = AllocMem'd (free at destroy); - // false = aliased to gPlanes (don't free) -} AmigaPlanarT; +// AmigaPlanarT, AMIGA_BITPLANES/BYTES_PER_ROW/PLANE_SIZE, and the +// inline portData accessor moved to amigaPlanar.h (NATIVE-PERF Phase +// 2) so spriteCompile.c's dispatchers can read plane pointers without +// a cross-TU call. +#include "amigaPlanar.h" // ----- Prototypes ----- @@ -1092,9 +1075,9 @@ void jlpFillRect(jlSurfaceT *s, int16_t x, int16_t y, int16_t w, int16_t h, uint uint8_t bitVal; uint8_t *dst; - if (s == NULL || w == 0u || h == 0u) { - return; - } + // Callers guarantee s != NULL and positive w/h: the jlFillRect + // wrapper validates + clips before fillRectOnSurface, and the + // fill-circle span loop emits spanWidth = 2x+1 >= 1 with h = 1. pd = (AmigaPlanarT *)s->portData; if (pd == NULL) { jlpGenericFillRect(s, x, y, w, h, colorIndex); @@ -2266,19 +2249,6 @@ uint8_t *jlpSurfaceAllocPixels(void) { } -uint8_t *jlpSurfacePlanePtr(const jlSurfaceT *s, uint8_t planeIdx) { - AmigaPlanarT *pd; - if (planeIdx >= AMIGA_BITPLANES) { - return NULL; - } - pd = (AmigaPlanarT *)s->portData; - if (pd == NULL) { - return NULL; - } - return pd->planes[planeIdx]; -} - - // Allocate the per-surface planar storage: an AmigaPlanarT plus 4 // off-screen chip-RAM plane buffers. The stage gets its OWN shadow // planes (NOT aliased to gPlanes / Intuition's BitMap) so drawing diff --git a/src/atarist/audio.c b/src/atarist/audio.c index ea5a8b5..3ca980b 100644 --- a/src/atarist/audio.c +++ b/src/atarist/audio.c @@ -2,9 +2,11 @@ // running PWM through YM2149 channel-A volume register. // // Pipeline: -// * jlpAudioInit creates an xmp_context, allocates a 2 KB mix buffer -// (in BSS), parks the Timer A vector at our 68k ISR (audio_isr.s), -// and starts Timer A at ~12.3 kHz (MFP prescaler 200, data 1). +// * jlpAudioInit creates an xmp_context. Timer A is NOT started here: +// the ISR costs ~half the CPU at 12.3 kHz, so the pump is demand- +// driven -- installed on the first PlayMod/PlaySfx (MFP prescaler +// 200, data 1) and uninstalled once the mix buffer has drained back +// to pure silence with nothing playing. // * jlpAudioPlayMod loads a .MOD via xmp_load_module_from_memory and // calls xmp_start_player at the same 12288 Hz mono unsigned-8-bit // output that the ISR consumes one byte at a time. @@ -75,11 +77,18 @@ volatile uint8_t gNeedRefill[2] = { 0, 0 }; extern void mfpTimerAIsr(void); -static xmp_context gXmpCtx = NULL; -static bool gReady = false; -static bool gXmpStarted = false; -static bool gXmpLoaded = false; -static int gXmpLoopCount = 0; +static xmp_context gXmpCtx = NULL; +static bool gReady = false; +static bool gXmpStarted = false; +static bool gXmpLoaded = false; +static int gXmpLoopCount = 0; + +// Demand-driven Timer A: the 12.3 kHz ISR eats ~half the CPU, so it +// only runs while there is (or may be) audible output. gHalfSilent[i] +// records that half i was last refilled with pure silence; once both +// halves are silent and nothing is playing, frameTick stops the pump. +static bool gPumpRunning = false; +static bool gHalfSilent[2] = { false, false }; static void (*gOldTimerAVec)(void) = NULL; @@ -178,6 +187,37 @@ static void silenceMixBuffer(void) { } +static bool anySfxActive(void) { + uint8_t i; + for (i = 0; i < JOEY_AUDIO_SFX_SLOTS; i++) { + if (gSfxSlots[i].active) { + return true; + } + } + return false; +} + + +static void pumpStart(void) { + if (gPumpRunning) { + return; + } + Supexec(installTimerA); + gPumpRunning = true; + gHalfSilent[0] = false; + gHalfSilent[1] = false; +} + + +static void pumpStop(void) { + if (!gPumpRunning) { + return; + } + Supexec(uninstallTimerA); + gPumpRunning = false; +} + + // ----- HAL API (alphabetical) ----- bool jlpAudioInit(void) { @@ -188,7 +228,6 @@ bool jlpAudioInit(void) { if (gXmpCtx == NULL) { return false; } - Supexec(installTimerA); gReady = true; return true; } @@ -198,7 +237,7 @@ void jlpAudioShutdown(void) { if (!gReady) { return; } - Supexec(uninstallTimerA); + pumpStop(); silenceMixBuffer(); if (gXmpCtx != NULL) { @@ -246,6 +285,7 @@ void jlpAudioPlayMod(const uint8_t *data, uint32_t length, bool loop) { } gXmpLoopCount = loop ? -1 : 0; gXmpStarted = true; + pumpStart(); } @@ -254,6 +294,7 @@ void jlpAudioPlaySfx(uint8_t slot, const uint8_t *sample, uint32_t length, uint1 return; } audioSfxSlotArm(&gSfxSlots[slot], sample, length, rateHz, MIX_RATE); + pumpStart(); } @@ -262,6 +303,7 @@ void jlpAudioPlaySfxStream(uint8_t slot, jlAudioStreamFillT fill, void *ctx, uin return; } audioSfxSlotArmStream(&gSfxSlots[slot], fill, ctx, rateHz, MIX_RATE); + pumpStart(); } @@ -391,6 +433,12 @@ void jlpAudioStopMod(void) { gXmpLoaded = false; } silenceMixBuffer(); + // StopMod discards the mixed tail by design (buffer just went + // silent), so the pump can stop immediately -- unless an SFX is + // still live, in which case frameTick's drain path owns the stop. + if (!anySfxActive()) { + pumpStop(); + } } @@ -413,7 +461,9 @@ void jlpAudioStopSfx(uint8_t slot) { // audioSfxOverlayMix can approach that budget under load, so callers // should run the game loop comfortably faster than ~12 Hz. void jlpAudioFrameTick(void) { - if (!gReady) { + bool silentFill; + + if (!gReady || !gPumpRunning) { return; } // Underrun guard: both flags set means the producer fell a full @@ -423,22 +473,34 @@ void jlpAudioFrameTick(void) { if (gNeedRefill[0] && gNeedRefill[1]) { silenceMixBuffer(); } + // silentFill is sampled BEFORE the overlay: a slot that exhausts + // during this overlay still wrote its tail into the half, so the + // half only counts as silent when nothing was live going in. if (gNeedRefill[0]) { + silentFill = !gXmpStarted && !anySfxActive(); if (gXmpStarted) { xmp_play_buffer(gXmpCtx, (void *)gMixBuf, MIX_HALF, gXmpLoopCount); } else { memset((void *)gMixBuf, 0x80, MIX_HALF); } audioSfxOverlayMix(gMixBuf, MIX_HALF, gSfxSlots, JOEY_AUDIO_SFX_SLOTS); + gHalfSilent[0] = silentFill; gNeedRefill[0] = 0; } if (gNeedRefill[1]) { + silentFill = !gXmpStarted && !anySfxActive(); if (gXmpStarted) { xmp_play_buffer(gXmpCtx, (void *)(gMixBuf + MIX_HALF), MIX_HALF, gXmpLoopCount); } else { memset((void *)(gMixBuf + MIX_HALF), 0x80, MIX_HALF); } audioSfxOverlayMix(gMixBuf + MIX_HALF, MIX_HALF, gSfxSlots, JOEY_AUDIO_SFX_SLOTS); + gHalfSilent[1] = silentFill; gNeedRefill[1] = 0; } + // Drain-to-silence stop: both halves hold pure silence and nothing + // is playing, so the ISR would stream silence forever -- park it. + if (!gXmpStarted && gHalfSilent[0] && gHalfSilent[1] && !anySfxActive()) { + pumpStop(); + } } diff --git a/src/atarist/hal.c b/src/atarist/hal.c index 442c165..380e427 100644 --- a/src/atarist/hal.c +++ b/src/atarist/hal.c @@ -55,24 +55,10 @@ // ----- Per-surface planar storage (project_planar_68k_plan, ST Phase 2) ----- // -// ST has word-interleaved planar: ONE 32000-byte buffer per surface -// holds all 4 planes packed 8 bytes per 16-pixel group. Per-scanline -// layout (160 bytes) is 20 groups of 8 bytes, where each group holds -// p0_word, p1_word, p2_word, p3_word back-to-back. Compare with -// Amiga's 4 separate plane buffers -- same total bytes, very different -// access pattern. -// -// The stage gets its own SHADOW planar buffer (NOT aliased to a -// displayed page) so drawing primitives don't appear until -// jlStagePresent copies the union(prev,current) dirty rows from the -// shadow into the off-screen page and flips the shifter to it. Same -// rationale as Amiga's per-stage shadow planes feeding its 2-buffer -// page flip. -typedef struct { - uint8_t *base; // 32000-byte interleaved planar buffer - uint8_t *raw; // unaligned malloc result for free() - bool ownsBuffer; // true = we malloc'd, false = aliased -} StPlanarT; +// StPlanarT and the inline portData accessor moved to stPlanar.h +// (NATIVE-PERF Phase 2) so spriteCompile.c's dispatchers can read the +// buffer base without a cross-TU call. +#include "stPlanar.h" // Shifter palette registers: 16 words at $FFFF8240..$FFFF825F. #define ST_PALETTE_REGS ((volatile uint16_t *)0xFFFF8240L) @@ -1281,9 +1267,9 @@ void jlpFillRect(jlSurfaceT *s, int16_t x, int16_t y, int16_t w, int16_t h, uint jlpGenericFillRect(s, x, y, w, h, colorIndex); return; } - if (s == NULL || w == 0u || h == 0u) { - return; - } + // Callers guarantee s != NULL and positive w/h: the jlFillRect + // wrapper validates + clips before fillRectOnSurface, and the + // fill-circle span loop emits spanWidth = 2x+1 >= 1 with h = 1. pd = (StPlanarT *)s->portData; if (pd == NULL) { return; @@ -1399,13 +1385,7 @@ void jlpTilePaste(jlSurfaceT *dst, uint8_t bx, uint8_t by, const uint8_t *chunky jlpGenericTilePaste(dst, bx, by, chunkyTile); return; } - if (dst == NULL || chunkyTile == NULL) { - return; - } pd = (StPlanarT *)dst->portData; - if (pd == NULL) { - return; - } /* Phase 10.5: jlTileT.pixels holds plane-major bytes (4 plane bytes * per row * 8 rows = 32 bytes). Direct byte copy to the planar * buffer; no chunky <-> planar conversion. Mirrors the sibling @@ -1557,14 +1537,8 @@ void jlpTileCopy(jlSurfaceT *dst, uint8_t dstBx, uint8_t dstBy, const jlSurfaceT jlpGenericTileCopy(dst, dstBx, dstBy, src, srcBx, srcBy); return; } - if (dst == NULL || src == NULL) { - return; - } dstPd = (StPlanarT *)dst->portData; srcPd = (const StPlanarT *)src->portData; - if (dstPd == NULL || srcPd == NULL) { - return; - } srcGroup = (uint16_t)((uint16_t)srcBx >> 1); dstGroup = (uint16_t)((uint16_t)dstBx >> 1); srcAddr = srcPd->base @@ -1624,13 +1598,7 @@ void jlpTileCopyMasked(jlSurfaceT *dst, uint8_t dstBx, uint8_t dstBy, const jlSu jlpGenericTileCopyMasked(dst, dstBx, dstBy, src, srcBx, srcBy, transparent); return; } - if (dst == NULL || src == NULL) { - return; - } dstPd = (StPlanarT *)dst->portData; - if (dstPd == NULL) { - return; - } /* Phase 10.5: bulk-plane fast path. scratch holds plane-major bytes * (4 plane bytes per row * 8 rows). For each row, build a "non- * transparent" mask = OR of (plane_byte XOR replicated transparent @@ -1932,9 +1900,36 @@ void jlpSpriteSavePlanes(const jlSurfaceT *s, int16_t x, int16_t y, uint16_t w, if (pd == NULL) { return; } + /* NATIVE-PERF Phase 1: group-aligned raw-span format. When x AND w + * are both 16-px multiples (the compiled save/restore window + * shape) each row is a CONTIGUOUS span of interleaved-group bytes, + * so the save is a straight per-row copy -- and, critically, the + * SAME byte format the compiled emitters read and write (backup + * format is a pure function of the recorded geometry; see + * spriteInternal.h). This branch must run BEFORE the byte-aligned + * branch: a group-aligned window previously took the byte-col asm, + * whose byte ORDER differs -- save and restore switch formats + * together, atomically, on the same predicate. */ + if (((x | (int16_t)w) & 15) == 0 + && x >= 0 && (x + (int16_t)w) <= SURFACE_WIDTH + && y >= 0 && (y + (int16_t)h) <= SURFACE_HEIGHT) { + uint16_t spanBytes = (uint16_t)(w >> 1); + const uint8_t *src = pd->base + (uint16_t)y * ST_BYTES_PER_ROW + + (uint16_t)((uint16_t)x >> 4) * ST_BYTES_PER_GROUP; + uint8_t *dst = dstPlaneBytes; + + for (row = 0; row < (int16_t)h; row++) { + memcpy(dst, src, (size_t)spanBytes); + src += ST_BYTES_PER_ROW; + dst += spanBytes; + } + return; + } /* Phase 10.5 fast path: byte-aligned, fully on-surface. * Specialized 16x16 (the UBER ball-sprite size) skips the asm - * walker's per-row col-init + col-loop-check overhead. */ + * walker's per-row col-init + col-loop-check overhead. Group- + * aligned windows never reach here (raw-span above owns them); + * this serves the x % 16 == 8 byte-aligned case. */ if ((x & 7) == 0 && (w & 7) == 0 && x >= 0 && (x + (int16_t)w) <= SURFACE_WIDTH && y >= 0 && (y + (int16_t)h) <= SURFACE_HEIGHT) { @@ -2001,8 +1996,29 @@ void jlpSpriteRestorePlanes(jlSurfaceT *s, int16_t x, int16_t y, uint16_t w, uin if (pd == NULL) { return; } + /* Group-aligned raw-span restore: MUST mirror the save-side branch + * above exactly (same predicate, same byte order) -- see the save + * comment. A raw-span backup restored through the byte-col asm (or + * vice versa) silently scrambles planes. */ + if (((x | (int16_t)w) & 15) == 0 + && x >= 0 && (x + (int16_t)w) <= SURFACE_WIDTH + && y >= 0 && (y + (int16_t)h) <= SURFACE_HEIGHT) { + uint16_t spanBytes = (uint16_t)(w >> 1); + uint8_t *dst = pd->base + (uint16_t)y * ST_BYTES_PER_ROW + + (uint16_t)((uint16_t)x >> 4) * ST_BYTES_PER_GROUP; + const uint8_t *src = srcPlaneBytes; + + for (row = 0; row < (int16_t)h; row++) { + memcpy(dst, src, (size_t)spanBytes); + dst += ST_BYTES_PER_ROW; + src += spanBytes; + } + return; + } /* Phase 10.5 fast path: byte-aligned, fully on-surface. - * Specialized 16x16 (UBER ball-sprite) skips walker overhead. */ + * Specialized 16x16 (UBER ball-sprite) skips walker overhead. + * Serves only the x % 16 == 8 case (raw-span above owns group- + * aligned windows). */ if ((x & 7) == 0 && (w & 7) == 0 && x >= 0 && (x + (int16_t)w) <= SURFACE_WIDTH && y >= 0 && (y + (int16_t)h) <= SURFACE_HEIGHT) { @@ -2328,27 +2344,6 @@ uint8_t *jlpSurfaceAllocPixels(void) { } -// ST is word-interleaved: one buffer holds all 4 planes per group -// back-to-back. There's no per-plane base, but we overload planeIdx -// 0 to return the single buffer base so the cross-platform sprite -// dispatcher (spriteCompiledDraw) can hand it to the ST JIT -// routine, which computes plane offsets internally via d16(a0) -// chains. planeIdx >= 1 returns NULL since they don't make sense -// in interleaved layout. -uint8_t *jlpSurfacePlanePtr(const jlSurfaceT *s, uint8_t planeIdx) { - StPlanarT *pd; - - if (s == NULL || planeIdx != 0u) { - return NULL; - } - pd = (StPlanarT *)s->portData; - if (pd == NULL) { - return NULL; - } - return pd->base; -} - - // Phase 9: stage has no chunky shadow either. Cross-platform stageAlloc // stores NULL in s->pixels and skips the chunky memset. uint8_t *jlpStageAllocPixels(void) { diff --git a/src/atarist/spriteWalk.s b/src/atarist/spriteWalk.s new file mode 100644 index 0000000..239e7a3 --- /dev/null +++ b/src/atarist/spriteWalk.s @@ -0,0 +1,51 @@ +| ST shifted compiled-sprite walker (NATIVE-PERF Phase 1). +| +| One shared routine serves all 14 sub-byte sprite phases: the emitted +| per-phase thunk (spriteEmitInterleaved68k.c) loads groupBase into a0, +| points a1 at its pre-shifted data table, loads the row / group loop +| counts into d0/d1, and jmp's here. The data table holds, per 16-px +| group per row, three big-endian longs: +| notMask = ~M16 : ~M16 (same mask for all four plane words) +| bits01 = bp0 : bp1 (pre-shifted OR bits, planes 0..1) +| bits23 = bp2 : bp3 (planes 2..3) +| The walker RMWs each group's four interleaved plane words with two +| movem.l transfers -- addressing is linear (stride 8) at group +| granularity, which is what lets one routine serve every phase where +| byte-granular displacement patterns could not. +| +| Register ABI (private -- reached only from emitted thunks): +| a0 = groupBase (base + y*160 + (x>>4)*8; even -- movem-safe) +| a1 = data table +| d0.w = rows - 1 +| d1.w = groupsPerRow - 1 +| The thunk jmp's (not jsr's), so sp still holds the dispatcher's +| return address and the final rts returns straight to C. d0/d1/a0/a1 +| are cdecl scratch; d3-d6/a2 are callee-save and preserved here. +| +| GAS m68k syntax, assembled by m68k-atari-mint-as. ST-only: this file +| must stay in src/atarist/ (src/m68k/*.s is wildcarded into the Amiga +| build too). + + .text + + .globl _stSpriteDrawShiftWalker + +_stSpriteDrawShiftWalker: + movem.l %d3-%d6/%a2,-(%sp) +.LswRow: + movea.l %a0,%a2 | a2 = this row's first group + move.w %d1,%d3 | d3 = groupsPerRow - 1 +.LswGrp: + movem.l (%a2),%d4-%d5 | d4 = p0w:p1w, d5 = p2w:p3w + move.l (%a1)+,%d6 | notMask long + and.l %d6,%d4 + and.l %d6,%d5 + or.l (%a1)+,%d4 | bits01 + or.l (%a1)+,%d5 | bits23 + movem.l %d4-%d5,(%a2) + addq.l #8,%a2 | next group + dbra %d3,.LswGrp + lea 160(%a0),%a0 | ST_BYTES_PER_ROW + dbra %d0,.LswRow + movem.l (%sp)+,%d3-%d6/%a2 + rts diff --git a/src/atarist/stPlanar.h b/src/atarist/stPlanar.h new file mode 100644 index 0000000..fd04afe --- /dev/null +++ b/src/atarist/stPlanar.h @@ -0,0 +1,42 @@ +// Atari ST per-surface planar storage, shared between the HAL +// (src/atarist/hal.c) and the cross-platform sprite dispatchers +// (src/codegen/spriteCompile.c) so buffer-base access inlines instead +// of paying a cross-TU function call (NATIVE-PERF Phase 2). The base +// pointer is lifetime-immutable: portData is written only at surface +// create/destroy, so reading it needs no cache and no invalidation. +#ifndef JOEY_ST_PLANAR_H +#define JOEY_ST_PLANAR_H + +#include +#include + +#include "surfaceInternal.h" + +// jlSurfaceT.portData points to this on the ST. ST has word-interleaved +// planar: ONE 32000-byte buffer per surface holds all 4 planes packed +// 8 bytes per 16-pixel group. Per-scanline layout (160 bytes) is 20 +// groups of 8 bytes, where each group holds p0_word, p1_word, p2_word, +// p3_word back-to-back. Compare with Amiga's 4 separate plane buffers +// -- same total bytes, very different access pattern. +// +// The stage gets its own SHADOW planar buffer (NOT aliased to a +// displayed page) so drawing primitives don't appear until +// jlStagePresent copies the union(prev,current) dirty rows from the +// shadow into the off-screen page and flips the shifter to it. Same +// rationale as Amiga's per-stage shadow planes feeding its 2-buffer +// page flip. +typedef struct { + uint8_t *base; // 32000-byte interleaved planar buffer + uint8_t *raw; // unaligned malloc result for free() + bool ownsBuffer; // true = we malloc'd, false = aliased +} StPlanarT; + + +// NULL means "surface has no planar storage" -- callers use that as +// their route-to-generic / no-op gate, exactly the contract the old +// jlpSurfacePlanePtr cross-TU function provided. +static inline StPlanarT *stSurfacePlanar(const jlSurfaceT *s) { + return (StPlanarT *)s->portData; +} + +#endif diff --git a/src/codegen/spriteCompile.c b/src/codegen/spriteCompile.c index f98a704..cbb5471 100644 --- a/src/codegen/spriteCompile.c +++ b/src/codegen/spriteCompile.c @@ -101,33 +101,93 @@ static uint32_t emitRestoreForTarget(uint8_t *out, uint32_t cap, const jlSpriteT } -// Sizing pass: returns total bytes the emitters will produce for -// this sprite's DRAW (per shift) + SAVE (per shift) + RESTORE (per -// shift). Emitters that aren't implemented for the current platform -// return 0 here, so totalSize tracks only the ops that will actually -// land in the arena. -static uint32_t emitTotalSize(uint8_t *scratch, const jlSpriteT *sp) { +// Per-compile plan: which (shift, op) variants land in the slot and +// their exact sizes. Pass A (spritePlanBuild) measures every variant +// and greedily includes them under the uint16 routineOffsets cap; the +// emit pass replays the SAME emitter arguments in the SAME order so +// the byte streams are identical by construction (PERF-AUDIT #74). +typedef struct SpritePlanT { + uint16_t sizes[JOEY_SPRITE_SHIFT_COUNT][SPRITE_OP_COUNT]; + uint16_t drawMask; // bit s set = DRAW shift s included + uint16_t pairMask; // bit s set = SAVE+RESTORE pair for shift s included uint32_t total; +} SpritePlanT; + + +// Measure one (shift, op) variant. A variant that overflows the emit +// scratch (or is odd-sized, which would odd-align every later 68k call +// target in the slot -- address error) is recorded as 0 ("unavailable"), +// exactly like an emitter's not-implemented return: that shift falls to +// the interpreter instead of rejecting the whole sprite. +static uint16_t spritePlanMeasure(uint8_t *scratch, const jlSpriteT *sp, uint8_t shift, uint8_t op) { uint32_t written; + + switch (op) { + 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 || written > SPRITE_EMIT_SCRATCH_BYTES) { + return 0; + } +#if defined(JOEYLIB_PLATFORM_AMIGA) || defined(JOEYLIB_PLATFORM_ATARIST) + if ((written & 1u) != 0u) { + // 68k call targets must stay word-aligned: one odd routine + // would misalign every subsequent routine in the slot (address + // error on the fetch). The 68k emitters only produce even + // sizes, so this is a defensive skip. 65816/x86 code has NO + // even-length invariant -- the guard must not run there (it + // silently demoted odd-sized IIgs/DOS routines to the + // interpreter when it did). + return 0; + } +#endif + return (uint16_t)written; +} + + +// Pass A: measure every variant and greedily include them under the +// uint16 cap. Tier 1 = DRAW shifts ascending (they are the Phase-1 +// parity-gate metric); tier 2 = SAVE+RESTORE as ATOMIC pairs per shift +// (a compiled save whose restore is absent would strand a compiled- +// format backup with an interpreted restore expecting the other format +// -- silent plane scramble; both-or-neither, always). When alignedOnly +// is set (the arena-full degrade tier) tier 1 keeps only the cheap +// byte-aligned phases in SPRITE_DEGRADE_DRAW_MASK. +static void spritePlanBuild(uint8_t *scratch, const jlSpriteT *sp, bool alignedOnly, SpritePlanT *plan) { uint8_t shift; uint8_t op; + uint16_t drawSize; + uint16_t saveSize; + uint16_t restSize; - total = 0; + plan->drawMask = 0; + plan->pairMask = 0; + plan->total = 0; for (shift = 0; shift < JOEY_SPRITE_SHIFT_COUNT; shift++) { for (op = 0; op < SPRITE_OP_COUNT; op++) { - switch (op) { - 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) { - return SPRITE_EMIT_OVERFLOW; - } - total += written; + plan->sizes[shift][op] = spritePlanMeasure(scratch, sp, shift, op); + } + } + for (shift = 0; shift < JOEY_SPRITE_SHIFT_COUNT; shift++) { + if (alignedOnly && ((SPRITE_DEGRADE_DRAW_MASK >> shift) & 1u) == 0u) { + continue; + } + drawSize = plan->sizes[shift][SPRITE_OP_DRAW]; + if (drawSize != 0 && plan->total + drawSize <= 0xFFFFu) { + plan->drawMask |= (uint16_t)(1u << shift); + plan->total += drawSize; + } + } + for (shift = 0; shift < JOEY_SPRITE_SHIFT_COUNT; shift++) { + saveSize = plan->sizes[shift][SPRITE_OP_SAVE]; + restSize = plan->sizes[shift][SPRITE_OP_RESTORE]; + if (saveSize != 0 && restSize != 0 && plan->total + saveSize + restSize <= 0xFFFFu) { + plan->pairMask |= (uint16_t)(1u << shift); + plan->total += (uint32_t)saveSize + restSize; } } - return total; } @@ -160,15 +220,64 @@ static uint32_t emitTotalSize(uint8_t *scratch, const jlSpriteT *sp) { #endif +// Emit pass: replay the plan's included variants in the SAME order the +// plan accumulated them (tier-1 draws ascending, then tier-2 save+ +// restore pairs ascending) with the SAME emitter arguments, so every +// byte stream is identical to the sizing pass by construction. The +// emitters' bound checks are deliberately conservative (they reserve a +// worst case per row/iteration), so the scratch always gets the full +// SPRITE_EMIT_SCRATCH_BYTES cap -- an exact-fit capLeft made the last +// routine in every slot spuriously overflow (PERF-AUDIT.md #74). +// Returns false on a pass disagreement (must not happen with identical +// args, but never write past the slot). +static bool spritePlanEmit(uint8_t *scratch, jlSpriteT *sp, const SpritePlanT *plan, ArenaSlotT *slot) { + uint8_t *dst; + uint32_t offset; + uint32_t written; + uint8_t shift; + + memset(sp->routineOffsets, 0xFF, sizeof(sp->routineOffsets)); + dst = codegenArenaBase() + slot->offset; + offset = 0; + for (shift = 0; shift < JOEY_SPRITE_SHIFT_COUNT; shift++) { + if (((plan->drawMask >> shift) & 1u) == 0u) { + continue; + } + written = emitDrawForTarget(scratch, SPRITE_EMIT_SCRATCH_BYTES, sp, shift); + if (written != plan->sizes[shift][SPRITE_OP_DRAW] || offset >= SPRITE_NOT_COMPILED) { + return false; + } + memcpy(dst + offset, scratch, (size_t)written); + sp->routineOffsets[shift][SPRITE_OP_DRAW] = (uint16_t)offset; + offset += written; + } + for (shift = 0; shift < JOEY_SPRITE_SHIFT_COUNT; shift++) { + if (((plan->pairMask >> shift) & 1u) == 0u) { + continue; + } + written = emitSaveForTarget(scratch, SPRITE_EMIT_SCRATCH_BYTES, sp, shift); + if (written != plan->sizes[shift][SPRITE_OP_SAVE] || offset >= SPRITE_NOT_COMPILED) { + return false; + } + memcpy(dst + offset, scratch, (size_t)written); + sp->routineOffsets[shift][SPRITE_OP_SAVE] = (uint16_t)offset; + offset += written; + written = emitRestoreForTarget(scratch, SPRITE_EMIT_SCRATCH_BYTES, sp, shift); + if (written != plan->sizes[shift][SPRITE_OP_RESTORE] || offset >= SPRITE_NOT_COMPILED) { + return false; + } + memcpy(dst + offset, scratch, (size_t)written); + sp->routineOffsets[shift][SPRITE_OP_RESTORE] = (uint16_t)offset; + offset += written; + } + return true; +} + + bool jlSpriteCompile(jlSpriteT *sp) { - uint8_t *scratch; - uint32_t totalSize; - uint8_t shift; - uint8_t op; - ArenaSlotT *slot; - uint8_t *dst; - uint32_t written; - uint32_t offset; + uint8_t *scratch; + SpritePlanT plan; + ArenaSlotT *slot; if (sp == NULL) { return false; @@ -183,34 +292,24 @@ 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; 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) { return false; } - totalSize = emitTotalSize(scratch, sp); - /* SPRITE_EMIT_OVERFLOW (0xFFFFFFFF) is > 0xFFFF too, so a single - * routine that did not fit the scratch is rejected here as well. */ - if (totalSize > 0xFFFFu) { - jlpBigFree(scratch); - return false; - } - if (totalSize == 0) { + spritePlanBuild(scratch, sp, false, &plan); + if (plan.total == 0) { /* Platforms whose emitter returns 0 for every (shift, op) have * no compiled bytes -- spriteCompiledDraw / SaveUnder / * RestoreUnder would dereference a degenerate slot or chunky * shadow. Bail so sp->slot stays NULL and the dispatcher - * routes through the interpreted the jlpSprite*Planes ops path. */ + * routes through the interpreted jlpSprite*Planes ops path. */ jlpBigFree(scratch); return false; } - slot = codegenArenaAlloc(totalSize); + slot = codegenArenaAlloc(plan.total); if (slot == NULL) { // Fragmented arena: total free space may suffice even though no // single hole does (sprite create/destroy churn across levels). @@ -218,7 +317,24 @@ bool jlSpriteCompile(jlSpriteT *sp) { // (codegenArenaInternal.h) once before giving up -- failure-path // only, zero hot-path cost (#5). codegenArenaCompact(); - slot = codegenArenaAlloc(totalSize); + slot = codegenArenaAlloc(plan.total); + } + if (slot == NULL) { + // Degrade tier: rebuild the plan with only the cheap byte- + // aligned DRAW phases (SPRITE_DEGRADE_DRAW_MASK) + the save/ + // restore pairs, so an arena too small for the full variant set + // still compiles what pre-Phase-1 builds compiled instead of + // losing the whole sprite to the interpreter. Deterministic: + // the reduced plan depends only on the sprite and the mask, + // never on fragmentation state. + spritePlanBuild(scratch, sp, true, &plan); + if (plan.total != 0) { + slot = codegenArenaAlloc(plan.total); + if (slot == NULL) { + codegenArenaCompact(); + slot = codegenArenaAlloc(plan.total); + } + } } if (slot == NULL) { // Specific message so a swallowed jlSpritePrewarm/jlSpriteCompile @@ -228,49 +344,11 @@ bool jlSpriteCompile(jlSpriteT *sp) { return false; } - dst = codegenArenaBase() + slot->offset; - offset = 0; - for (shift = 0; shift < JOEY_SPRITE_SHIFT_COUNT; shift++) { - for (op = 0; op < SPRITE_OP_COUNT; op++) { - // 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 (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 || (written != 0 && written > (uint32_t)(totalSize - offset))) { - /* The second pass disagreed with the sizing pass (must - * 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 - * explicitly so a future change can't store an offset that - * collides with the sentinel. */ - if (written == 0 || offset >= SPRITE_NOT_COMPILED) { - sp->routineOffsets[shift][op] = SPRITE_NOT_COMPILED; - } else { - sp->routineOffsets[shift][op] = (uint16_t)offset; - offset = offset + written; - } - } + if (!spritePlanEmit(scratch, sp, &plan, slot)) { + memset(sp->routineOffsets, 0xFF, sizeof(sp->routineOffsets)); + codegenArenaFree(slot); + jlpBigFree(scratch); + return false; } sp->slot = slot; // Registry (#34): every creation path (including jlSpriteBankLoad, @@ -501,71 +579,87 @@ void spriteCompiledRestoreUnder(jlSurfaceT *dst, const jlSpriteBackupT *backup, // 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 +// bitplanes, one pre-shifted variant per x % 8 phase (all 8 compiled +// since NATIVE-PERF Phase 1). Compute byteOff = y*40 + x/8 and pass +// plane[i]+byteOff as the 4 plane args. 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 +// SPRITE_NOT_COMPILED before calling here (#29); anything uncompiled +// (clip, degrade-tier truncation) falls back to the interpreted // jlpSpriteDrawPlanes / jlpSpriteSavePlanes / jlpSpriteRestorePlanes. +// +// Plane pointers come straight from portData via amigaPlanar.h's +// inline accessor (NATIVE-PERF Phase 2) -- the old jlpSurfacePlanePtr +// cross-TU call cost ~190 cycles PER PLANE, x4 per dispatch. The +// pd == NULL gate preserves the "not a planar surface -> no-op" +// contract those calls provided. -// 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) +#include "amigaPlanar.h" 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); - uint16_t byteOff; - uint8_t *p0; - uint8_t *p1; - uint8_t *p2; - uint8_t *p3; - DrawFn fn; + AmigaPlanarT *pd; + uint16_t byteOff; + DrawFn fn; - byteOff = (uint16_t)((uint16_t)y * AMIGA_PLANE_BYTES_PER_ROW + ((uint16_t)x >> 3)); - p0 = jlpSurfacePlanePtr(dst, 0); - if (p0 == NULL) { + pd = amigaSurfacePlanar(dst); + if (pd == NULL) { return; } - 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); + byteOff = (uint16_t)((uint16_t)y * AMIGA_BYTES_PER_ROW + ((uint16_t)x >> 3)); + fn = (DrawFn)(codegenArenaBase() + sp->slot->offset + routeOffset); + fn(pd->planes[0] + byteOff, pd->planes[1] + byteOff, pd->planes[2] + byteOff, pd->planes[3] + byteOff); } -// 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. +// Compiled save/restore (NATIVE-PERF Phase 1): the emitted routines are +// cdecl(p0, p1, p2, p3, buf) plane-run copiers over the 16-px-group +// window (bx = x & ~15, nGroups = SPRITE_GROUPS0 + class). shift here +// is the width CLASS from SPRITE_SAVE_CLASS / SPRITE_RESTORE_CLASS, +// not the draw bit-phase. Row displacements are baked into the +// routine; the window's x phase enters only through the plane base +// pointers (byteOff is even by construction -- bx % 16 == 0 -- so the +// emitted move.w/move.l are 68000-legal). 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; + typedef void (*CopyFn)(uint8_t *p0, uint8_t *p1, uint8_t *p2, uint8_t *p3, uint8_t *buf); + AmigaPlanarT *pd; + int16_t bx; + uint16_t nGroups; + uint16_t byteOff; + CopyFn fn; + + pd = amigaSurfacePlanar(src); + if (pd == NULL) { + return; + } + bx = (int16_t)(x & ~15); + nGroups = (uint16_t)(SPRITE_GROUPS0(sp->widthPx) + shift); + byteOff = (uint16_t)((uint16_t)y * AMIGA_BYTES_PER_ROW + ((uint16_t)bx >> 3)); + + backup->x = bx; + backup->y = y; + backup->width = (uint16_t)(nGroups << 4); + backup->height = sp->heightPx; + backup->sizeBytes = (uint16_t)((uint16_t)(nGroups << 3) * sp->heightPx); + + fn = (CopyFn)(codegenArenaBase() + sp->slot->offset + routeOffset); + fn(pd->planes[0] + byteOff, pd->planes[1] + byteOff, pd->planes[2] + byteOff, pd->planes[3] + byteOff, backup->bytes); } void spriteCompiledRestoreUnder(jlSurfaceT *dst, const jlSpriteBackupT *backup, uint8_t shift, uint16_t routeOffset) { - (void)dst; - (void)backup; + typedef void (*CopyFn)(uint8_t *p0, uint8_t *p1, uint8_t *p2, uint8_t *p3, uint8_t *buf); + AmigaPlanarT *pd; + uint16_t byteOff; + CopyFn fn; + (void)shift; - (void)routeOffset; + pd = amigaSurfacePlanar(dst); + if (pd == NULL) { + return; + } + byteOff = (uint16_t)((uint16_t)backup->y * AMIGA_BYTES_PER_ROW + ((uint16_t)backup->x >> 3)); + fn = (CopyFn)(codegenArenaBase() + backup->sprite->slot->offset + routeOffset); + fn(pd->planes[0] + byteOff, pd->planes[1] + byteOff, pd->planes[2] + byteOff, pd->planes[3] + byteOff, backup->bytes); } #elif defined(JOEYLIB_PLATFORM_ATARIST) @@ -577,13 +671,12 @@ void spriteCompiledRestoreUnder(jlSurfaceT *dst, const jlSpriteBackupT *backup, // 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. +// shift selection (in spriteInternal.h SPRITE_SHIFT_INDEX): the full +// intra-group phase x & 15. Phases 0/8 are the unrolled byte-aligned +// JIT; the 14 sub-byte phases are thunk + pre-shifted data tables +// driven by stSpriteDrawShiftWalker (all compiled since NATIVE-PERF +// Phase 1). Anything uncompiled falls back to jlpSpriteDrawPlanes via +// the caller gates (#29). // ST word-interleaved row stride: 320 px * 4 planes / 8 bits = 160 // bytes/row. Numerically equal to SURFACE_BYTES_PER_ROW but a distinct @@ -592,17 +685,24 @@ void spriteCompiledRestoreUnder(jlSurfaceT *dst, const jlSpriteBackupT *backup, #define ST_GROUP_BYTES_PER_ROW (SURFACE_WIDTH * 4 / 8) #define ST_BYTES_PER_PLANE_GROUP 8u +// Buffer base comes straight from portData via stPlanar.h's inline +// accessor (NATIVE-PERF Phase 2); the pd == NULL gate preserves the +// "not a planar surface -> no-op" contract the old jlpSurfacePlanePtr +// cross-TU call provided. + +#include "stPlanar.h" + void spriteCompiledDraw(jlSurfaceT *dst, const jlSpriteT *sp, int16_t x, int16_t y, uint16_t routeOffset) { typedef void (*DrawFn)(uint8_t *groupBase); - uint8_t *base; - uint8_t *groupBase; - DrawFn fn; + StPlanarT *pd; + uint8_t *groupBase; + DrawFn fn; - base = jlpSurfacePlanePtr(dst, 0); - if (base == NULL) { + pd = stSurfacePlanar(dst); + if (pd == NULL) { return; } - groupBase = base + groupBase = pd->base + (uint16_t)y * ST_GROUP_BYTES_PER_ROW + (uint16_t)((uint16_t)x >> 4) * ST_BYTES_PER_PLANE_GROUP; fn = (DrawFn)(codegenArenaBase() + sp->slot->offset + routeOffset); @@ -610,28 +710,59 @@ void spriteCompiledDraw(jlSurfaceT *dst, const jlSpriteT *sp, int16_t x, int16_t } -// 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. +// Compiled save/restore (NATIVE-PERF Phase 1): the emitted routines are +// cdecl(src, dst) raw-span copiers over the 16-px-group window +// (bx = x & ~15, nGroups = SPRITE_GROUPS0 + class); a group window's +// rows are CONTIGUOUS byte spans in the interleaved layout, so the +// routine is a pure move.l chain with the inter-row stride baked in. +// shift here is the width CLASS, not the draw bit-phase. rowStart is +// always even (bx % 16 == 0, base is long-aligned), so move.l is +// 68000-legal. 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; + typedef void (*CopyFn)(const uint8_t *src, uint8_t *dst); + StPlanarT *pd; + int16_t bx; + uint16_t nGroups; + uint8_t *rowStart; + CopyFn fn; + + pd = stSurfacePlanar(src); + if (pd == NULL) { + return; + } + bx = (int16_t)(x & ~15); + nGroups = (uint16_t)(SPRITE_GROUPS0(sp->widthPx) + shift); + rowStart = pd->base + + (uint16_t)y * ST_GROUP_BYTES_PER_ROW + + (uint16_t)((uint16_t)bx >> 4) * ST_BYTES_PER_PLANE_GROUP; + + backup->x = bx; + backup->y = y; + backup->width = (uint16_t)(nGroups << 4); + backup->height = sp->heightPx; + backup->sizeBytes = (uint16_t)((uint16_t)(nGroups << 3) * sp->heightPx); + + fn = (CopyFn)(codegenArenaBase() + sp->slot->offset + routeOffset); + fn(rowStart, backup->bytes); } void spriteCompiledRestoreUnder(jlSurfaceT *dst, const jlSpriteBackupT *backup, uint8_t shift, uint16_t routeOffset) { - (void)dst; - (void)backup; + typedef void (*CopyFn)(const uint8_t *src, uint8_t *dst); + StPlanarT *pd; + uint8_t *rowStart; + CopyFn fn; + (void)shift; - (void)routeOffset; + pd = stSurfacePlanar(dst); + if (pd == NULL) { + return; + } + rowStart = pd->base + + (uint16_t)backup->y * ST_GROUP_BYTES_PER_ROW + + (uint16_t)((uint16_t)backup->x >> 4) * ST_BYTES_PER_PLANE_GROUP; + fn = (CopyFn)(codegenArenaBase() + backup->sprite->slot->offset + routeOffset); + fn(backup->bytes, rowStart); } #else diff --git a/src/core/draw.c b/src/core/draw.c index ac67a6d..0726a0b 100644 --- a/src/core/draw.c +++ b/src/core/draw.c @@ -663,20 +663,27 @@ void jlDrawRect(jlSurfaceT *s, int16_t x, int16_t y, uint16_t w, uint16_t h, uin // (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)) { + // Raw edge fills + ONE dirty mark for the whole outline + // (NATIVE-PERF Phase 2 R11, the fill-circle span pattern). + // Band state is provably identical to marking each edge: + // bands are [min, max] intervals per row, and the left+right + // edge words widen an interior row to exactly the same + // [wordof(x), wordof(x+w-1)] the full-width mark produces. // Top edge. - fillRectOnSurface(s, x, y, (int16_t)w, 1, colorIndex); + jlpFillRect(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); + jlpFillRect(s, x, (int16_t)(y + (int16_t)h - 1), (int16_t)w, 1, 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 + // == 0 rects out of jlpFillRect, 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); + jlpFillRect(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); + jlpFillRect(s, (int16_t)(x + (int16_t)w - 1), (int16_t)(y + 1), 1, (int16_t)(h - 2), colorIndex); } + surfaceMarkDirtyRect(s, x, y, (int16_t)w, (int16_t)h); return; } // General (possibly clipped) case: let jlFillRect clip each edge. diff --git a/src/core/init.c b/src/core/init.c index 246bf52..8e47e03 100644 --- a/src/core/init.c +++ b/src/core/init.c @@ -24,14 +24,20 @@ // 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. +// Amiga/ST: the all-shift compiled sprites (NATIVE-PERF Phase 1) +// emit one DRAW variant per x phase (8 on Amiga, 16 on ST) plus two +// save/restore classes -- a 16x16 sprite's full set is ~13-19 KB and +// a mixed 24x24 can reach ~25-50 KB, so 32 KB starved multi-sprite +// apps into the degrade tier (aligned-only draws). 64 KB holds a +// couple of full-variant sprites; jlSpriteCompile degrades per +// sprite rather than failing when it still runs out. These ports +// have real heaps. +// +// DOS: two chunky shifts, ~1-6 KB per sprite; 32 KB is generous. #if defined(JOEYLIB_PLATFORM_IIGS) #define DEFAULT_CODEGEN_BYTES (8UL * 1024UL) +#elif defined(JOEYLIB_PLATFORM_AMIGA) || defined(JOEYLIB_PLATFORM_ATARIST) +#define DEFAULT_CODEGEN_BYTES (64UL * 1024UL) #else #define DEFAULT_CODEGEN_BYTES (32UL * 1024UL) #endif diff --git a/src/core/port.h b/src/core/port.h index b356d9e..4733ebf 100644 --- a/src/core/port.h +++ b/src/core/port.h @@ -604,14 +604,10 @@ void jlpGenericSurfaceFreePortData(jlSurfaceT *s, bool isStage, void *portData); #endif #endif -uint8_t *jlpGenericSurfacePlanePtr(const jlSurfaceT *s, uint8_t planeIdx); -#if !defined(jlpSurfacePlanePtr) - #if defined(JL_HAS_SURFACE_PLANE_PTR) - uint8_t *jlpSurfacePlanePtr(const jlSurfaceT *s, uint8_t planeIdx); - #else - #define jlpSurfacePlanePtr(_s, _i) jlpGenericSurfacePlanePtr((_s), (_i)) - #endif -#endif +// (jlpSurfacePlanePtr was retired in NATIVE-PERF Phase 2: its only +// callers were spriteCompile.c's 68k dispatchers, which now read +// portData directly via the inline accessors in amigaPlanar.h / +// stPlanar.h instead of paying a cross-TU call per plane.) // --- Timing services (platform-only stub generics; millisElapsed has a real default) --- uint16_t jlpGenericFrameHz(void); diff --git a/src/core/sprite.c b/src/core/sprite.c index f2c0931..5b176df 100644 --- a/src/core/sprite.c +++ b/src/core/sprite.c @@ -343,9 +343,11 @@ static void spriteRegistryRemove(jlSpriteT *sp) { static void spriteResetCompiledState(jlSpriteT *sp) { sp->slot = NULL; memset(sp->routineOffsets, 0xFF, sizeof(sp->routineOffsets)); +#if defined(JOEYLIB_PLATFORM_IIGS) memset(sp->cachedDstBank, 0xFF, sizeof(sp->cachedDstBank)); memset(sp->cachedSrcBank, 0xFF, sizeof(sp->cachedSrcBank)); memset(sp->cachedSizeBytes, 0, sizeof(sp->cachedSizeBytes)); +#endif } @@ -565,12 +567,19 @@ void jlSpriteSaveAndDraw(jlSurfaceT *s, jlSpriteT *sp, int16_t x, int16_t y, jlS // mark. One mark instead of two (save doesn't dirty -- it's a // 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)) { + if (slot != NULL && backup->bytes != NULL && SPRITE_BACKUP_PTR_OK(backup->bytes) + && isFullyOnSurface(x, y, widthPx, heightPx)) { + uint8_t saveClass; + + // DRAW routes by the x bit-phase; SAVE/RESTORE route by the + // 16-px window width class (planar) or nibble parity (chunky). + // The two indices differ on planar ports -- derive both (#29). shift = SPRITE_SHIFT_INDEX(x); - saveOffset = SPRITE_ROUTE_OFFSET(sp, shift, SPRITE_OP_SAVE); + saveClass = SPRITE_SAVE_CLASS(sp, x); + saveOffset = SPRITE_ROUTE_OFFSET(sp, saveClass, 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); + spriteCompiledSaveUnder(s, sp, x, y, backup, saveClass, saveOffset); spriteCompiledDraw (s, sp, x, y, drawOffset); if (!COMPILED_SPRITE_WRITES_PLANES) { jlpSpriteSavePlanes(s, backup->x, backup->y, backup->width, backup->height, backup->bytes); @@ -613,7 +622,6 @@ void jlSpriteRestoreUnder(jlSurfaceT *s, const jlSpriteBackupT *backup) { uint16_t bw; uint16_t bh; jlSpriteT *sp; - uint16_t spriteBytesPerRow; int16_t copyBytes; uint8_t shift; @@ -640,20 +648,22 @@ void jlSpriteRestoreUnder(jlSurfaceT *s, const jlSpriteBackupT *backup) { } sp = backup->sprite; - if (sp != NULL && sp->slot != NULL && bh == sp->heightPx) { + if (sp != NULL && sp->slot != NULL && bh == sp->heightPx + && SPRITE_BACKUP_PTR_OK(backup->bytes)) { uint16_t routeOffset; - 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 - // shift 0, spriteBytesPerRow+1 for shift 1). A horizontally - // clipped save records a narrower backup->width / copyBytes that - // matches neither, so only take the fast path when copyBytes is - // exactly one of those two compiled widths; any other width must - // 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; + copyBytes = (int16_t)(bw >> 1); + // The compiled restore routine walks a FIXED per-row byte count, + // so the class must be derived from the backup's RECORDED + // geometry -- never from x. SPRITE_RESTORE_CLASS returns 0/1 + // only for a backup whose geometry proves the compiled format + // (chunky: copyBytes == bytesPerRow or +1, exactly the historic + // check; planar: 16-px-aligned bx AND bw plus a class width -- + // the bx term is correctness-critical, see spriteInternal.h), + // and 0xFF for everything else (horizontal clips, walker-format + // backups), which falls through to the interpreted paths that + // honor copyBytes. + shift = SPRITE_RESTORE_CLASS(sp, bx, bw, (uint16_t)copyBytes); + if (shift != 0xFFu) { routeOffset = SPRITE_ROUTE_OFFSET(sp, shift, SPRITE_OP_RESTORE); if (routeOffset != SPRITE_NOT_COMPILED) { spriteCompiledRestoreUnder(s, backup, shift, routeOffset); @@ -715,9 +725,13 @@ void jlSpriteSaveUnder(const jlSurfaceT *s, jlSpriteT *sp, int16_t x, int16_t y, // 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. - if (backup->bytes != NULL && slot != NULL && isFullyOnSurface(x, y, widthPx, heightPx)) { + if (backup->bytes != NULL && slot != NULL && SPRITE_BACKUP_PTR_OK(backup->bytes) + && isFullyOnSurface(x, y, widthPx, heightPx)) { uint16_t routeOffset; - shift = SPRITE_SHIFT_INDEX(x); + // Save routes by the width class (planar 16-px window / chunky + // nibble parity), NOT by the DRAW shift index -- see + // SPRITE_SAVE_CLASS in spriteInternal.h. + shift = SPRITE_SAVE_CLASS(sp, x); routeOffset = SPRITE_ROUTE_OFFSET(sp, shift, SPRITE_OP_SAVE); if (routeOffset != SPRITE_NOT_COMPILED) { spriteCompiledSaveUnder(s, sp, x, y, backup, shift, routeOffset); @@ -777,6 +791,20 @@ void jlSpriteSaveUnder(const jlSurfaceT *s, jlSpriteT *sp, int16_t x, int16_t y, // metadata so they can size their buffer for the next // SaveUnder call. RestoreUnder will refuse to operate on a // backup with bytes==NULL. +#if defined(JOEYLIB_PLATFORM_AMIGA) || defined(JOEYLIB_PLATFORM_ATARIST) + // Planar metadata widening: a real (buffered) unclipped save on + // these ports goes through the compiled 16-px-window path, so a + // probe-then-allocate caller must be told the WINDOW size, not + // the chunky-rounded size -- otherwise the next buffered save + // could overrun the allocation by up to one group per row. + if (dx == x && w == (int16_t)widthPx) { + uint16_t nGroups = (uint16_t)(SPRITE_GROUPS0(widthPx) + SPRITE_SAVE_CLASS(sp, x)); + + backup->x = (int16_t)(x & ~15); + backup->width = (uint16_t)(nGroups << 4); + backup->sizeBytes = (uint16_t)((uint16_t)(nGroups << 3) * (uint16_t)h); + } +#endif return; } /* Chunky save path: skip on planar ports (s->pixels NULL). diff --git a/src/core/spriteInternal.h b/src/core/spriteInternal.h index 552e293..0d63fb5 100644 --- a/src/core/spriteInternal.h +++ b/src/core/spriteInternal.h @@ -13,20 +13,32 @@ #define SPRITE_OP_RESTORE 2 #define SPRITE_OP_COUNT 3 -// Per-platform shift index used by the dispatcher. Chunky 4bpp ports -// store one nibble per pixel pair so the only sub-byte alignment is -// x % 2. Amiga planar packs 8 pixels per plane byte so all 8 -// alignments matter. ST word-interleaved planar groups 16 pixels -// per word; for byte-aligned x (x mod 8 == 0) the only meaningful -// distinction is high vs low byte of the plane word, which is bit -// 3 of x (== (x >> 3) & 1). Other shifts (x mod 8 != 0) emit 0 -// from the JIT and route to the interpreter. -#if defined(JOEYLIB_PLATFORM_AMIGA) -#define SPRITE_SHIFT_INDEX(x) ((uint8_t)((x) & 7)) -#elif defined(JOEYLIB_PLATFORM_ATARIST) -#define SPRITE_SHIFT_INDEX(x) ((uint8_t)(((x) & 7) ? 2u : (uint8_t)(((x) >> 3) & 1u))) +// Per-platform shift-variant count + dispatcher index. Count and mask +// live in ONE ladder so they cannot drift: count == mask + 1 always. +// Chunky 4bpp ports (IIgs/DOS/generic) store one nibble per pixel pair +// so the only sub-byte alignment is x & 1 -- shifts 0+1 cover EVERY x. +// Amiga planar packs 8 pixels per plane byte so all 8 bit-phases +// matter. ST word-interleaved planar groups 16 pixels per plane word +// AND the displacement pattern differs between a sprite starting in +// the word's high byte vs low byte (+1 within a group vs +7 crossing +// to the next group's high half), so the full intra-group phase x & 15 +// keys the variant: 16 slots, with today's byte-aligned phases at +// slots 0 (x%16==0) and 8 (x%16==8). +// SPRITE_DEGRADE_DRAW_MASK: the DRAW shifts kept by jlSpriteCompile's +// degrade tier when the arena cannot hold the full plan -- the cheap +// byte-aligned phases every port compiled before NATIVE-PERF Phase 1. +#if defined(JOEYLIB_PLATFORM_ATARIST) +#define JOEY_SPRITE_SHIFT_COUNT 16 +#define SPRITE_SHIFT_INDEX(x) ((uint8_t)((x) & 15)) +#define SPRITE_DEGRADE_DRAW_MASK 0x0101u // phases 0 and 8 +#elif defined(JOEYLIB_PLATFORM_AMIGA) +#define JOEY_SPRITE_SHIFT_COUNT 8 +#define SPRITE_SHIFT_INDEX(x) ((uint8_t)((x) & 7)) +#define SPRITE_DEGRADE_DRAW_MASK 0x0001u // shift 0 #else -#define SPRITE_SHIFT_INDEX(x) ((uint8_t)((x) & 1)) +#define JOEY_SPRITE_SHIFT_COUNT 2 +#define SPRITE_SHIFT_INDEX(x) ((uint8_t)((x) & 1)) +#define SPRITE_DEGRADE_DRAW_MASK 0x0003u // both nibble phases #endif // Sentinel stored in routineOffsets[shift][op] when that op's emitter @@ -35,6 +47,50 @@ // is valid for the first emitted op (typically DRAW shift 0). #define SPRITE_NOT_COMPILED 0xFFFFu +// ----- Save/restore width classes (NATIVE-PERF Phase 1) ----- +// +// Compiled save/restore on the planar 68k ports operates on the +// 16-px-group-aligned window covering the sprite (bx = x & ~15, +// bw = nGroups * 16), which makes every row a contiguous raw span (ST) +// or an even-offset plane run (Amiga) -- pure move.l/move.w copies. +// Because save/restore bake no masks, the emitted body depends only on +// the group count, so ALL x phases funnel into exactly TWO class +// variants per op: class 0 = ceil(w/16) groups, class 1 = one more +// (when (x & 15) pushes the sprite across a group boundary). Save and +// restore route by these macros, NOT by SPRITE_SHIFT_INDEX -- their +// table entries live at shifts 0/1 regardless of the DRAW shift count. +// +// SPRITE_RESTORE_CLASS derives the class from the backup's RECORDED +// geometry (never from x): returns 0/1 for a compiled-format backup, +// 0xFF to route to the interpreted paths. The (bx & 15) == 0 term is +// CORRECTNESS-CRITICAL on both ports, not an optimization: a backup +// written by the byte-aligned interpreted walker at x % 16 == 8 can +// record copyBytes numerically equal to a class width while holding a +// DIFFERENT byte format (ST byte-col vs raw-span) or run length +// (Amiga), and on the ST its odd byte offset would address-error the +// compiled move.l reader. Chunky expansions are token-identical to the +// pre-Phase-1 code so IIgs/DOS dispatch is provably unchanged. +// +// SPRITE_BACKUP_PTR_OK: the compiled 68k routines move.l/move.w through +// backup->bytes, so an odd caller buffer must route BOTH ops to the +// byte-safe interpreted paths (pointer parity is stable between save +// and restore, so provenance never splits). +#if defined(JOEYLIB_PLATFORM_AMIGA) || defined(JOEYLIB_PLATFORM_ATARIST) +#define SPRITE_GROUPS0(_wPx) ((uint16_t)(((_wPx) + 15u) >> 4)) +#define SPRITE_SAVE_CLASS(_sp, _x) ((uint8_t)((uint16_t)((((uint16_t)(_x) & 15u) + (_sp)->widthPx + 15u) >> 4) > SPRITE_GROUPS0((_sp)->widthPx) ? 1u : 0u)) +#define SPRITE_RESTORE_CLASS(_sp, _bx, _bw, _copyBytes) \ + ((uint8_t)((((uint16_t)(_bx) | (uint16_t)(_bw)) & 15u) != 0u ? 0xFFu : \ + ((_copyBytes) == (uint16_t)(SPRITE_GROUPS0((_sp)->widthPx) << 3) ? 0u : \ + ((_copyBytes) == (uint16_t)((SPRITE_GROUPS0((_sp)->widthPx) + 1u) << 3) ? 1u : 0xFFu)))) +#define SPRITE_BACKUP_PTR_OK(_p) ((((uintptr_t)(_p)) & 1u) == 0u) +#else +#define SPRITE_SAVE_CLASS(_sp, _x) ((uint8_t)((_x) & 1)) +#define SPRITE_RESTORE_CLASS(_sp, _bx, _bw, _copyBytes) \ + ((uint8_t)((_copyBytes) == (uint16_t)((_sp)->widthPx >> 1) ? 0u : \ + ((_copyBytes) == (uint16_t)(((_sp)->widthPx >> 1) + 1u) ? 1u : 0xFFu))) +#define SPRITE_BACKUP_PTR_OK(_p) (1) +#endif + // 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 @@ -87,13 +143,15 @@ struct jlSpriteT { ArenaSlotT *slot; uint16_t routineOffsets[JOEY_SPRITE_SHIFT_COUNT][SPRITE_OP_COUNT]; +#if defined(JOEYLIB_PLATFORM_IIGS) // Per-shift, per-op MVN bank-patch cache for IIgs save/restore. // patchMvnBanks rewrites 16+ MVN bank operands every call, but the // banks themselves rarely change frame-to-frame (screen surface // is fixed; backup buffer is allocated once). After the first // patch, subsequent calls compare requested banks to the cache // and skip the re-stamp loop. 0xFF means "never patched yet". - // 12 bytes per sprite. Unused on non-IIgs. + // IIgs-only: no other port bank-patches, so the fields (and their + // resets in spriteResetCompiledState) compile away elsewhere. uint8_t cachedDstBank[JOEY_SPRITE_SHIFT_COUNT][SPRITE_OP_COUNT]; uint8_t cachedSrcBank[JOEY_SPRITE_SHIFT_COUNT][SPRITE_OP_COUNT]; @@ -102,6 +160,7 @@ struct jlSpriteT { // software multiply helper (~30-50 cyc); cache hit dodges it. Filled // lazily on first call (0 sentinel = uncached). uint16_t cachedSizeBytes[JOEY_SPRITE_SHIFT_COUNT]; +#endif }; // Compiled entry points. Implemented alongside jlSpriteCompile in diff --git a/src/core/surface.c b/src/core/surface.c index acfc98d..24f3139 100644 --- a/src/core/surface.c +++ b/src/core/surface.c @@ -235,26 +235,72 @@ void surfaceMarkDirtyAll(const jlSurfaceT *s) { void surfaceMarkDirtyRows(uint16_t y, uint16_t yEnd, uint8_t minWord, uint8_t maxWord) { uint8_t *minPtr; uint8_t *maxPtr; - uint8_t *minEnd; // Straight-line inlined widen (no per-row helper call) with walking - // pointers, so a multi-row stage op -- tile fill/paste/copy, sprite - // draw, fillRect -- pays one tight loop instead of yEnd-y function - // calls. This is the ST/Amiga counterpart to the IIgs asm marker; - // the 8-row tile mark was ~a third of jlTileFill's cost. + // pointers, so a multi-row stage op -- sprite draw, fillRect -- + // pays one tight loop instead of yEnd-y function calls. This is + // the ST/Amiga counterpart to the IIgs asm marker. (Whole-tile + // marks bypass this entirely since NATIVE-PERF Phase 2 R1 -- + // tile.c's tileMarkDirty unrolls its constant 8 rows inline.) + // + // Loop shape is PER-PORT, decided by measurement (Phase 2 Step C, + // 2026-07-07): the x2 unroll lifted Amiga sprite gate rows +4-6% + // but cost the ST -5% in BOTH the pointer-walking and counted + // forms (gcc-mint materializes four live pointers either way), + // and the simple loop re-measured exactly at the ST baseline. + // DOS was flat either way, so only the Amiga takes the unroll. minPtr = &gStageMinWord[y]; maxPtr = &gStageMaxWord[y]; - minEnd = &gStageMinWord[yEnd]; - while (minPtr < minEnd) { - if (minWord < *minPtr) { - *minPtr = minWord; +#if defined(JOEYLIB_PLATFORM_AMIGA) + { + uint16_t rows; + uint16_t pairs; + + rows = (uint16_t)(yEnd - y); + pairs = (uint16_t)(rows >> 1); + while (pairs != 0u) { + if (minWord < minPtr[0]) { + minPtr[0] = minWord; + } + if (maxWord > maxPtr[0]) { + maxPtr[0] = maxWord; + } + if (minWord < minPtr[1]) { + minPtr[1] = minWord; + } + if (maxWord > maxPtr[1]) { + maxPtr[1] = maxWord; + } + minPtr += 2; + maxPtr += 2; + pairs--; } - if (maxWord > *maxPtr) { - *maxPtr = maxWord; + if ((rows & 1u) != 0u) { + if (minWord < *minPtr) { + *minPtr = minWord; + } + if (maxWord > *maxPtr) { + *maxPtr = maxWord; + } } - minPtr++; - maxPtr++; } +#else + { + uint8_t *minEnd; + + minEnd = &gStageMinWord[yEnd]; + while (minPtr < minEnd) { + if (minWord < *minPtr) { + *minPtr = minWord; + } + if (maxWord > *maxPtr) { + *maxPtr = maxWord; + } + minPtr++; + maxPtr++; + } + } +#endif } #endif diff --git a/src/core/tile.c b/src/core/tile.c index 3cc7537..9fdf330 100644 --- a/src/core/tile.c +++ b/src/core/tile.c @@ -29,7 +29,59 @@ #endif #define TILE_BLOCK_TO_PIXEL(_b) ((uint16_t)((uint16_t)(_b) << 3)) -// Prototype (helper below is the only static in this TU). +// Whole-tile dirty mark (NATIVE-PERF Phase 2 R1). Every whole-tile op +// marks the compile-time-constant rect h = TILE_PIXELS_PER_SIDE with +// word band exactly [bx*2, bx*2 + 1]: 8 px per tile over 4 px per +// dirty word means SURFACE_WORD_INDEX(bx*8) == bx*2 and +// SURFACE_WORD_INDEX(bx*8 + 7) == bx*2 + 1. Deriving the band from bx +// and widening the 8 rows inline replaces the out-of-line +// surfaceMarkDirtyRows call (~800 cycles measured steady-state on the +// 68000 ports). Callers own the `s == gStage` gate (non-stage +// surfaces carry no dirty state), exactly like surfaceMarkDirtyRect. +// The IIgs keeps its single-JSL asm marker -- already the cheap path +// there. +// +// Compile-time proof of the band identity (breaks the build if either +// the tile side or the 4-px-per-word grain ever changes): +typedef char tileMarkBandProof[(SURFACE_WORD_INDEX(TILE_PIXELS_PER_SIDE) == 2) ? 1 : -1]; + +#ifdef JOEYLIB_PLATFORM_IIGS +#define tileMarkDirty(_pixelY, _bx) iigsMarkDirtyRowsInner((uint16_t)(_pixelY), (uint16_t)((_pixelY) + TILE_PIXELS_PER_SIDE), (uint16_t)((uint16_t)(_bx) << 1), (uint16_t)(((uint16_t)(_bx) << 1) + 1u)) +#else +// Explicit 8-row unroll: gcc-6.5 -O2 keeps the equivalent loop rolled +// (8 iterations of compare + spill-reload, ~670 cycles); the d16(An) +// unroll is ~450. Same widen semantics, row by row, as the loop. +#define TILE_MARK_ROW(_i) do { \ + if (minWord < minPtr[_i]) { \ + minPtr[_i] = minWord; \ + } \ + if (maxWord > maxPtr[_i]) { \ + maxPtr[_i] = maxWord; \ + } \ +} while (0) + +static void tileMarkDirty(uint16_t pixelY, uint8_t bx) { + uint8_t *minPtr; + uint8_t *maxPtr; + uint8_t minWord; + uint8_t maxWord; + + minWord = (uint8_t)(bx << 1); + maxWord = (uint8_t)(minWord + 1u); + minPtr = &gStageMinWord[pixelY]; + maxPtr = &gStageMaxWord[pixelY]; + TILE_MARK_ROW(0); + TILE_MARK_ROW(1); + TILE_MARK_ROW(2); + TILE_MARK_ROW(3); + TILE_MARK_ROW(4); + TILE_MARK_ROW(5); + TILE_MARK_ROW(6); + TILE_MARK_ROW(7); +} +#endif + +// Prototype (drawTextUnion is the only other static in this TU). static void drawTextUnion(uint8_t cx, uint8_t cy, uint8_t *bounds); @@ -136,9 +188,6 @@ void jlDrawText(jlSurfaceT *dst, uint8_t bx, uint8_t by, const jlSurfaceT *fontS void jlTileCopy(jlSurfaceT *dst, uint8_t dstBx, uint8_t dstBy, const jlSurfaceT *src, uint8_t srcBx, uint8_t srcBy) { - uint16_t dstPixelX; - uint16_t dstPixelY; - if (dst == NULL || src == NULL) { return; } @@ -146,19 +195,15 @@ void jlTileCopy(jlSurfaceT *dst, uint8_t dstBx, uint8_t dstBy, const jlSurfaceT srcBx >= TILE_BLOCKS_PER_ROW || srcBy >= TILE_BLOCKS_PER_COL) { return; } - 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, - TILE_PIXELS_PER_SIDE, TILE_PIXELS_PER_SIDE); + if (dst == gStage) { + tileMarkDirty(TILE_BLOCK_TO_PIXEL(dstBy), dstBx); + } } void jlTileCopyMasked(jlSurfaceT *dst, uint8_t dstBx, uint8_t dstBy, const jlSurfaceT *src, uint8_t srcBx, uint8_t srcBy, uint8_t transparentIndex) { - uint16_t dstPixelX; - uint16_t dstPixelY; - if (dst == NULL || src == NULL) { return; } @@ -166,57 +211,45 @@ void jlTileCopyMasked(jlSurfaceT *dst, uint8_t dstBx, uint8_t dstBy, const jlSur srcBx >= TILE_BLOCKS_PER_ROW || srcBy >= TILE_BLOCKS_PER_COL) { return; } - 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); + if (dst == gStage) { + tileMarkDirty(TILE_BLOCK_TO_PIXEL(dstBy), dstBx); + } } void jlTileFill(jlSurfaceT *s, uint8_t bx, uint8_t by, uint8_t colorIndex) { - uint16_t pixelX; - uint16_t pixelY; - if (s == NULL) { return; } if (bx >= TILE_BLOCKS_PER_ROW || by >= TILE_BLOCKS_PER_COL) { return; } - 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); - surfaceMarkDirtyRect(s, (int16_t)pixelX, (int16_t)pixelY, - TILE_PIXELS_PER_SIDE, TILE_PIXELS_PER_SIDE); + if (s == gStage) { + tileMarkDirty(TILE_BLOCK_TO_PIXEL(by), bx); + } } void jlTilePaste(jlSurfaceT *dst, uint8_t bx, uint8_t by, const jlTileT *in) { - uint16_t pixelX; - uint16_t pixelY; - if (dst == NULL || in == NULL) { return; } if (bx >= TILE_BLOCKS_PER_ROW || by >= TILE_BLOCKS_PER_COL) { return; } - 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, - TILE_PIXELS_PER_SIDE, TILE_PIXELS_PER_SIDE); + if (dst == gStage) { + tileMarkDirty(TILE_BLOCK_TO_PIXEL(by), bx); + } } 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; } @@ -225,14 +258,13 @@ void jlTilePasteMono(jlSurfaceT *dst, uint8_t bx, uint8_t by, const jlTileT *in, } 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); + if (dst == gStage) { + tileMarkDirty(TILE_BLOCK_TO_PIXEL(by), bx); + } } diff --git a/src/generic/genericSurface.c b/src/generic/genericSurface.c index 08f0a7b..036e715 100644 --- a/src/generic/genericSurface.c +++ b/src/generic/genericSurface.c @@ -160,9 +160,3 @@ void jlpGenericSurfaceFreePortData(jlSurfaceT *s, bool isStage, void *portData) } -// Bitplane base pointer: NULL on chunky ports (no planes); planar ports override. -uint8_t *jlpGenericSurfacePlanePtr(const jlSurfaceT *s, uint8_t planeIdx) { - (void)s; - (void)planeIdx; - return NULL; -} diff --git a/src/m68k/spriteEmitInterleaved68k.c b/src/m68k/spriteEmitInterleaved68k.c index 5308141..0fc9d7e 100644 --- a/src/m68k/spriteEmitInterleaved68k.c +++ b/src/m68k/spriteEmitInterleaved68k.c @@ -27,10 +27,12 @@ // move.b d0,disp(a0) // matching stSpriteDrawByteAligned in src/atarist/hal.c bit-for-bit. // -// Only shifts 0 (x mod 16 == 0) and 1 (x mod 16 == 8) are byte-aligned -// and compiled; other shifts return 0 so jlSpriteCompile records -// SPRITE_NOT_COMPILED and the dispatcher routes non-aligned x through -// the interpreted jlpSpriteDrawPlanes walker. +// The shift argument is the full intra-group phase x & 15 (16 slots on +// the ST; see spriteInternal.h). Only the byte-aligned phases 0 +// (x mod 16 == 0) and 8 (x mod 16 == 8) are compiled today; the other +// 14 phases return 0 so jlSpriteCompile records SPRITE_NOT_COMPILED +// and the dispatcher routes those x through the interpreted +// jlpSpriteDrawPlanes walker (until NATIVE-PERF Phase 1 fills them in). #include "joey/sprite.h" #include "joey/surface.h" @@ -68,6 +70,35 @@ // Emitted bytes for the trailing rts. #define EPILOGUE_BYTES 2u +// Shifted-phase thunk opcodes (see emitShiftedDraw): +// movea.l 4(sp),a0 : 206F 0004 (groupBase -- unchanged dispatcher ABI) +// lea 16(pc),a1 : 43FA 0010 (data table at thunk+22; ext word at +6) +// move.w #imm,d0 : 303C rrrr (rows - 1) +// move.w #imm,d1 : 323C gggg (groupsPerRow - 1) +// jmp abs.l : 4EF9 hhhh llll (stSpriteDrawShiftWalker) +#define OP_LEA_16PC_A1 0x43FAu +#define LEA_PC_DISP 0x0010u +#define OP_MOVE_W_IMM_D0 0x303Cu +#define OP_MOVE_W_IMM_D1 0x323Cu +#define OP_JMP_ABSL 0x4EF9u + +#define SHIFT_THUNK_BYTES 22u +#define SHIFT_GROUP_DATA_BYTES 12u // notMask long + bits01 long + bits23 long + +// The shared shifted-phase walker (src/atarist/spriteWalk.s). Its +// absolute address is baked into each thunk's jmp at emit time -- +// emission happens at runtime (post-TOS-relocation), and arena moves +// via codegenArenaCompact relocate only the thunk+data, whose one +// internal reference (lea 16(pc)) is PC-relative. +extern void stSpriteDrawShiftWalker(void); + +// Per-row widened accumulators for emitShiftedDraw. File statics, NOT +// stack locals: the compile path runs deep and big frames overflowed +// the Amiga shell's default 4 KB task stack (same class on ST/TOS). +// Emit-time only and single-threaded, so statics are safe. +static uint8_t gStM[SURFACE_WIDTH / 8 + 1]; +static uint8_t gStBp[4][SURFACE_WIDTH / 8 + 1]; + // ----- Emit helpers ----- @@ -76,6 +107,123 @@ // are shared with the Amiga emitter via spriteEmitC2p.h. +// ----- Emit helpers (shifted phases) ----- + +// Emit one sub-byte phase (shift & 7 != 0): a 22-byte thunk that tail- +// jumps into the shared stSpriteDrawShiftWalker, followed by the +// phase's pre-shifted data table (SHIFT_GROUP_DATA_BYTES per group per +// row). The table is built with the SAME c2pColumn walk the aligned +// JIT uses; a source column's bits split across two adjacent widened +// byte columns (>> s into col c, << 8-s into col c+1, disjoint at the +// seam), and byte columns pack into 16-px groups two at a time with +// the intra-group half phase h = shift >> 3 offsetting the mapping. +// The walker's masked movem RMW of a group composes to exactly the +// interpreted walker's per-byte RMWs, so pixels are bit-identical. +static uint32_t emitShiftedDraw(uint8_t *out, uint32_t cap, const jlSpriteT *sp, uint8_t shift) { + uint32_t cursor; + uint32_t walker; + uint16_t wTiles; + uint16_t srcH; + uint16_t groupsPerRow; + uint16_t rowBytes; + uint8_t s; + uint8_t h; + int16_t row; + int16_t col; + int16_t g; + uint8_t p; + + // The minimal on-surface x for this phase is `shift` itself, so a + // phase the gate can never dispatch gets no dead table bytes. + if ((uint16_t)shift + sp->widthPx > SURFACE_WIDTH) { + return 0u; + } + s = (uint8_t)(shift & 7u); + h = (uint8_t)(shift >> 3); + wTiles = sp->widthTiles; + srcH = (uint16_t)(sp->heightTiles * TILE_PIXELS_PER_SIDE); + groupsPerRow = (uint16_t)((uint16_t)((h + wTiles) >> 1) + 1u); + rowBytes = (uint16_t)(groupsPerRow * SHIFT_GROUP_DATA_BYTES); + + cursor = 0u; + if (cursor + SHIFT_THUNK_BYTES > cap) { + return SPRITE_EMIT_OVERFLOW; + } + walker = (uint32_t)stSpriteDrawShiftWalker; + cursor = putWord(out, cursor, OP_MOVEA_4SP_A0); + cursor = putWord(out, cursor, 0x0004u); + cursor = putWord(out, cursor, OP_LEA_16PC_A1); + cursor = putWord(out, cursor, LEA_PC_DISP); + cursor = putWord(out, cursor, OP_MOVE_W_IMM_D0); + cursor = putWord(out, cursor, (uint16_t)(srcH - 1u)); + cursor = putWord(out, cursor, OP_MOVE_W_IMM_D1); + cursor = putWord(out, cursor, (uint16_t)(groupsPerRow - 1u)); + cursor = putWord(out, cursor, OP_JMP_ABSL); + cursor = putWord(out, cursor, (uint16_t)(walker >> 16)); + cursor = putWord(out, cursor, (uint16_t)(walker & 0xFFFFu)); + + for (row = 0; row < (int16_t)srcH; row++) { + uint16_t tileY = (uint16_t)((uint16_t)row >> 3); + uint16_t inTileY = (uint16_t)((uint16_t)row & 7u); + const uint8_t *tileRowBase = sp->tileData + + (uint32_t)tileY * wTiles * TILE_BYTES + + (uint32_t)inTileY * TILE_BYTES_PER_ROW; + + if (cursor + rowBytes > cap) { + return SPRITE_EMIT_OVERFLOW; + } + // Widened per-byte-column mask/data for this row: source column + // col contributes >> s to widened col `col` and << (8-s) to + // widened col `col + 1` (indices relative to the sprite's first + // written byte column, which sits at absolute byte column h). + for (col = 0; col <= (int16_t)wTiles; col++) { + gStM[col] = 0u; + gStBp[0][col] = 0u; + gStBp[1][col] = 0u; + gStBp[2][col] = 0u; + gStBp[3][col] = 0u; + } + for (col = 0; col < (int16_t)wTiles; col++) { + const uint8_t *trp = tileRowBase + (uint32_t)col * TILE_BYTES; + uint8_t plane[4]; + uint8_t opaque; + + c2pColumn(trp, plane, &opaque); + if (opaque == 0u) { + continue; + } + gStM[col] = (uint8_t)(gStM[col] | (uint8_t)(opaque >> s)); + gStM[col + 1] = (uint8_t)(gStM[col + 1] | (uint8_t)(opaque << (8u - s))); + for (p = 0u; p < 4u; p++) { + gStBp[p][col] = (uint8_t)(gStBp[p][col] | (uint8_t)(plane[p] >> s)); + gStBp[p][col + 1] = (uint8_t)(gStBp[p][col + 1] | (uint8_t)(plane[p] << (8u - s))); + } + } + // Pack widened columns into group records. Group g's high byte + // is absolute column 2g, low byte 2g+1; absolute column a maps + // to widened index a - h (outside [0, wTiles] contributes 0). + for (g = 0; g < (int16_t)groupsPerRow; g++) { + int16_t hiJ = (int16_t)(2 * g - (int16_t)h); + int16_t loJ = (int16_t)(hiJ + 1); + uint8_t mHi = (hiJ >= 0 && hiJ <= (int16_t)wTiles) ? gStM[hiJ] : 0u; + uint8_t mLo = (loJ >= 0 && loJ <= (int16_t)wTiles) ? gStM[loJ] : 0u; + uint16_t m16 = (uint16_t)(((uint16_t)mHi << 8) | mLo); + uint16_t nm16 = (uint16_t)~m16; + + cursor = putWord(out, cursor, nm16); + cursor = putWord(out, cursor, nm16); + for (p = 0u; p < 4u; p++) { + uint8_t dHi = (hiJ >= 0 && hiJ <= (int16_t)wTiles) ? gStBp[p][hiJ] : 0u; + uint8_t dLo = (loJ >= 0 && loJ <= (int16_t)wTiles) ? gStBp[p][loJ] : 0u; + + cursor = putWord(out, cursor, (uint16_t)(((uint16_t)dHi << 8) | dLo)); + } + } + } + return cursor; +} + + // ----- Emit API ----- uint32_t spriteEmitDrawInterleaved68k(uint8_t *out, uint32_t cap, const jlSpriteT *sp, uint8_t shift) { @@ -86,12 +234,17 @@ uint32_t spriteEmitDrawInterleaved68k(uint8_t *out, uint32_t cap, const jlSprite int16_t row; int16_t tileCol; - if (shift > 1u) { - return 0u; + // Shift is the full intra-group phase x & 15 (spriteInternal.h). + // The byte-aligned phases 0 (x%16==0, high byte halves) and 8 + // (x%16==8, low byte halves) emit the unrolled byte-RMW JIT below; + // the 14 sub-byte phases emit a thunk + pre-shifted data table + // driven by the shared stSpriteDrawShiftWalker. + if ((shift & 7u) != 0u) { + return emitShiftedDraw(out, cap, sp, shift); } wTiles = sp->widthTiles; srcH = (uint16_t)(sp->heightTiles * TILE_PIXELS_PER_SIDE); - xMod16 = (uint16_t)(shift * 8u); // shift 0 -> 0, shift 1 -> 8 + xMod16 = shift; // phase 0 -> 0, phase 8 -> 8 cursor = 0u; @@ -188,17 +341,84 @@ uint32_t spriteEmitDrawInterleaved68k(uint8_t *out, uint32_t cap, const jlSprite } -// Save/restore are not compiled on the ST yet -- the interpreted -// jlpSpriteSavePlanes / jlpSpriteRestorePlanes path already meets the -// IIgs perf floor (plane byte copies), so returning 0 routes them -// there. The entry points exist only for the dispatcher's uniform API. +// Compiled save/restore (NATIVE-PERF Phase 1): raw-span copiers over +// the 16-px-group window. A group window's rows are CONTIGUOUS spans +// of interleaved bytes, so each row is a pure move.l chain; the shift +// argument is the width CLASS (0 = ceil(w/16) groups, 1 = one more -- +// see SPRITE_SAVE_CLASS in spriteInternal.h), and only classes 0/1 +// emit. cdecl void copy(const uint8_t *src, uint8_t *dst): SAVE gets +// (screenRowStart, buf) -- a0 strided, a1 contiguous; RESTORE gets +// (buf, screenRowStart) -- a0 contiguous, a1 strided. Byte format is +// identical to jlpSpriteSavePlanes' raw-span branch (format law: +// pure function of the recorded geometry). +// movea.l 4(sp),a0 : 206F 0004 movea.l 8(sp),a1 : 226F 0008 +// move.l (a0)+,(a1)+ : 22D8 +// lea d16(a0),a0 : 41E8 dddd lea d16(a1),a1 : 43E9 dddd +#define OP_MOVEA_8SP_A1 0x226Fu +#define OP_MOVE_L_POSTI 0x22D8u +#define OP_LEA_D16_A0 0x41E8u +#define OP_LEA_D16_A1 0x43E9u + +static uint32_t emitGroupSpanCopy(uint8_t *out, uint32_t cap, const jlSpriteT *sp, uint8_t shift, bool strideOnDst) { + uint32_t cursor; + uint16_t nGroups; + uint16_t spanBytes; + uint16_t longsPerRow; + uint16_t strideFix; + uint16_t srcH; + int16_t row; + uint16_t i; + + if (shift > 1u) { + return 0u; + } + nGroups = (uint16_t)(((sp->widthPx + 15u) >> 4) + shift); + if ((uint16_t)(nGroups << 4) > SURFACE_WIDTH) { + // Degenerate: the widened window would exceed the surface, so + // this class is unreachable through the on-surface gate. Both + // save and restore bail identically (pair provenance). + return 0u; + } + spanBytes = (uint16_t)(nGroups << 3); + longsPerRow = (uint16_t)(spanBytes >> 2); + strideFix = (uint16_t)(ST_PLANAR_STRIDE - spanBytes); + srcH = (uint16_t)(sp->heightTiles * TILE_PIXELS_PER_SIDE); + + cursor = 0u; + if (cursor + 8u > cap) { + return SPRITE_EMIT_OVERFLOW; + } + cursor = putWord(out, cursor, OP_MOVEA_4SP_A0); + cursor = putWord(out, cursor, 0x0004u); + cursor = putWord(out, cursor, OP_MOVEA_8SP_A1); + cursor = putWord(out, cursor, 0x0008u); + for (row = 0; row < (int16_t)srcH; row++) { + if (cursor + (uint32_t)(longsPerRow << 1) + 4u + EPILOGUE_BYTES > cap) { + return SPRITE_EMIT_OVERFLOW; + } + for (i = 0u; i < longsPerRow; i++) { + cursor = putWord(out, cursor, OP_MOVE_L_POSTI); + } + if (row < (int16_t)(srcH - 1u)) { + // Advance the strided (screen) side to the next row; the + // buffer side is contiguous and needs no fix. + cursor = putWord(out, cursor, strideOnDst ? OP_LEA_D16_A1 : OP_LEA_D16_A0); + cursor = putWord(out, cursor, strideFix); + } + } + if (cursor + EPILOGUE_BYTES > cap) { + return SPRITE_EMIT_OVERFLOW; + } + cursor = putWord(out, cursor, OP_RTS); + return cursor; +} + + uint32_t spriteEmitSaveInterleaved68k(uint8_t *out, uint32_t cap, const jlSpriteT *sp, uint8_t shift) { - (void)out; (void)cap; (void)sp; (void)shift; - return 0u; + return emitGroupSpanCopy(out, cap, sp, shift, false); } uint32_t spriteEmitRestoreInterleaved68k(uint8_t *out, uint32_t cap, const jlSpriteT *sp, uint8_t shift) { - (void)out; (void)cap; (void)sp; (void)shift; - return 0u; + return emitGroupSpanCopy(out, cap, sp, shift, true); } diff --git a/src/m68k/spriteEmitPlanar68k.c b/src/m68k/spriteEmitPlanar68k.c index 497288f..ac799da 100644 --- a/src/m68k/spriteEmitPlanar68k.c +++ b/src/m68k/spriteEmitPlanar68k.c @@ -18,16 +18,21 @@ // signed 16-bit displacement range for any on-surface sprite). Only // d0/a0/a1 are cdecl scratch; a2/a3 are saved/restored via movem. // -// ONLY shift 0 (x mod 8 == 0, byte-aligned) is compiled. For shift 0 a -// sprite tile column maps to exactly one plane byte (no sub-byte spill), -// so the per-plane work mirrors the ST byte path: -// fully opaque (mask==0xFF): move.b #plane[p],off(a_i) (direct store) -// mixed: move.b off(a_i),d0; andi.b #~mask,d0; ori.b #plane[p],d0; +// ALL 8 shifts (x mod 8) are compiled (NATIVE-PERF Phase 1). At shift 0 +// a sprite tile column maps to exactly one plane byte; at shift s > 0 a +// column's bits split across two adjacent dest bytes (low 8-s bits of +// the left byte, high s bits of the right byte), and the seam byte +// between two source columns receives disjoint contributions from both. +// The emitter pre-shifts and OR-merges those contributions per dest +// byte at emit time (destMask/destData accumulators), then emits one +// write group per non-empty dest byte -- the same opcode inventory the +// proven shift-0 path used: +// fully opaque (mask==0xFF): move.b #data,off(a_i) (direct store) +// mixed: move.b off(a_i),d0; andi.b #~mask,d0; [ori.b #data,d0;] // move.b d0,off(a_i) -// matching jlpSpriteDrawPlanes in src/amiga/hal.c for the shift-0 case. -// Shifts 1..7 return 0 so jlSpriteCompile records SPRITE_NOT_COMPILED -// and the dispatcher routes non-aligned x through the interpreted -// jlpSpriteDrawPlanes walker (which handles the two-byte sub-byte spill). +// This is algebraically identical to jlpSpriteDrawPlanes' two sequential +// seam RMWs (disjoint masks compose), so pixels are bit-for-bit the +// walker's -- the frozen unaligned/sweep16 golden hashes gate it. #include "joey/sprite.h" #include "joey/surface.h" @@ -84,19 +89,39 @@ static const uint16_t OP_MOVE_B_IMM_AN[AMIGA_PLANES] = { 0x117Cu, 0x137Cu, 0x157 // ----- Emit API ----- +// Per-row accumulators. File statics, NOT stack locals: the compile +// path runs deep (jlSpriteCompile -> planBuild -> measure -> emitter, +// with vsnprintf-depth logging possible underneath) and a ~200-byte +// stack frame here overflowed the Amiga shell's default 4 KB task +// stack nondeterministically. Emit-time only and single-threaded, so +// statics are safe. +static uint8_t gDestMask[SURFACE_WIDTH / 8 + 1]; +static uint8_t gDestData[SURFACE_WIDTH / 8 + 1][AMIGA_PLANES]; + + uint32_t spriteEmitDrawPlanar68k(uint8_t *out, uint32_t cap, const jlSpriteT *sp, uint8_t shift) { uint32_t cursor; uint16_t wTiles; uint16_t srcH; + uint16_t lastDest; uint8_t i; int16_t row; int16_t byteCol; + int16_t d; - if (shift != 0u) { + if (shift >= 8u) { + return 0u; // Amiga shift space is x & 7; defensive. + } + if (shift != 0u && sp->widthPx >= SURFACE_WIDTH) { + // A full-width sprite can never draw unaligned on-surface (the + // dispatcher gate requires x + w <= 320 with x > 0), so a + // shifted variant would be dead bytes in the arena. return 0u; } wTiles = sp->widthTiles; srcH = (uint16_t)(sp->heightTiles * TILE_PIXELS_PER_SIDE); + // Shift 0 writes wTiles dest bytes per row; shifted rows spill one. + lastDest = (shift == 0u) ? (uint16_t)(wTiles - 1u) : wTiles; cursor = 0u; @@ -119,33 +144,68 @@ uint32_t spriteEmitDrawPlanar68k(uint8_t *out, uint32_t cap, const jlSpriteT *sp + (uint32_t)inTileY * TILE_BYTES_PER_ROW; uint16_t rowOff = (uint16_t)((uint16_t)row * AMIGA_PLANE_STRIDE); + // Phase 1: accumulate this row's per-dest-byte mask/data. A + // source column at shift s contributes its low 8-s bits to dest + // byte c (>> s) and its high s bits to dest byte c+1 (<< 8-s); + // the seam byte's two contributions occupy disjoint bit ranges, + // so OR-accumulation reproduces the walker's two sequential + // RMWs exactly. + for (d = 0; d <= (int16_t)lastDest; d++) { + gDestMask[d] = 0u; + gDestData[d][0] = 0u; + gDestData[d][1] = 0u; + gDestData[d][2] = 0u; + gDestData[d][3] = 0u; + } for (byteCol = 0; byteCol < (int16_t)wTiles; byteCol++) { const uint8_t *trp = tileRowBase + (uint32_t)byteCol * TILE_BYTES; uint8_t plane[4]; uint8_t opaque; - uint16_t off; c2pColumn(trp, plane, &opaque); if (opaque == 0u) { continue; } - off = (uint16_t)(rowOff + (uint16_t)byteCol); + if (shift == 0u) { + gDestMask[byteCol] = (uint8_t)(gDestMask[byteCol] | opaque); + for (i = 0u; i < AMIGA_PLANES; i++) { + gDestData[byteCol][i] = (uint8_t)(gDestData[byteCol][i] | plane[i]); + } + } else { + gDestMask[byteCol] = (uint8_t)(gDestMask[byteCol] | (uint8_t)(opaque >> shift)); + gDestMask[byteCol + 1] = (uint8_t)(gDestMask[byteCol + 1] | (uint8_t)(opaque << (8u - shift))); + for (i = 0u; i < AMIGA_PLANES; i++) { + gDestData[byteCol][i] = (uint8_t)(gDestData[byteCol][i] | (uint8_t)(plane[i] >> shift)); + gDestData[byteCol + 1][i] = (uint8_t)(gDestData[byteCol + 1][i] | (uint8_t)(plane[i] << (8u - shift))); + } + } + } + + // Phase 2: emit one write group per non-empty dest byte. + for (d = 0; d <= (int16_t)lastDest; d++) { + uint8_t m = gDestMask[d]; + uint16_t off; + + if (m == 0u) { + continue; + } + off = (uint16_t)(rowOff + (uint16_t)d); for (i = 0u; i < AMIGA_PLANES; i++) { - if (opaque == 0xFFu) { - // Fully opaque column: store the plane byte directly. - // move.b #plane[i],off(a_i) (6 bytes) + if (m == 0xFFu) { + // Fully covered dest byte: store the data directly. + // move.b #data,off(a_i) (6 bytes) if (cursor + 6u > cap) { return SPRITE_EMIT_OVERFLOW; } cursor = putWord(out, cursor, OP_MOVE_B_IMM_AN[i]); - cursor = putWord(out, cursor, (uint16_t)plane[i]); + cursor = putWord(out, cursor, (uint16_t)gDestData[d][i]); cursor = putWord(out, cursor, off); } else { // Exact reservation (see ST emitter): a worst-case reserve // would falsely overflow the emit pass on trailing shorter // writes. +ori.b only when this plane sets bits. - uint32_t planeBytes = (plane[i] != 0u) ? 16u : 12u; + uint32_t planeBytes = (gDestData[d][i] != 0u) ? 16u : 12u; if (cursor + planeBytes > cap) { return SPRITE_EMIT_OVERFLOW; @@ -153,13 +213,13 @@ uint32_t spriteEmitDrawPlanar68k(uint8_t *out, uint32_t cap, const jlSpriteT *sp // move.b off(a_i),d0 cursor = putWord(out, cursor, OP_MOVE_B_AN_D0[i]); cursor = putWord(out, cursor, off); - // andi.b #~opaque,d0 + // andi.b #~m,d0 cursor = putWord(out, cursor, OP_ANDI_B_D0); - cursor = putWord(out, cursor, (uint16_t)(uint8_t)(~opaque)); - // ori.b #plane[i],d0 (skip if no bits) - if (plane[i] != 0u) { + cursor = putWord(out, cursor, (uint16_t)(uint8_t)(~m)); + // ori.b #data,d0 (skip if no bits) + if (gDestData[d][i] != 0u) { cursor = putWord(out, cursor, OP_ORI_B_D0); - cursor = putWord(out, cursor, (uint16_t)plane[i]); + cursor = putWord(out, cursor, (uint16_t)gDestData[d][i]); } // move.b d0,off(a_i) cursor = putWord(out, cursor, OP_MOVE_B_D0_AN[i]); @@ -180,16 +240,106 @@ uint32_t spriteEmitDrawPlanar68k(uint8_t *out, uint32_t cap, const jlSpriteT *sp } -// Save/restore are not compiled on the Amiga yet -- the interpreted -// jlpSpriteSavePlanes / jlpSpriteRestorePlanes path handles them. The -// entry points exist only for the dispatcher's uniform API. +// Compiled save/restore (NATIVE-PERF Phase 1): plane-run copiers over +// the 16-px-group window. shift is the width CLASS (0 = ceil(w/16) +// groups, 1 = one more; see SPRITE_SAVE_CLASS), so only classes 0/1 +// emit. cdecl void copy(p0, p1, p2, p3, buf): the dispatcher passes +// each plane base already advanced to the window's first byte +// (y*40 + bx/8, even because bx % 16 == 0 and planes are AllocMem'd), +// and the routine copies run = nGroups*2 bytes per plane per row in +// [p0 run][p1 run][p2 run][p3 run] order -- byte-identical to the +// interpreted aligned walker's plane-run format (format law). Row +// displacements are baked (max 199*40 + 4 < d16 range); the buffer +// side walks contiguously via (a1)+. +// movem.l a2-a4,-(sp) : 48E7 0038 (predec mask bit-reversed) +// movem.l (sp)+,a2-a4 : 4CDF 1C00 +// movea.l d16(sp),aN : a0=206F a2=246F a3=266F a4=286F a1=226F +// SAVE move.l d16(aP),(a1)+ : 22E8/22EA/22EB/22EC dddd +// SAVE move.w d16(aP),(a1)+ : 32E8/32EA/32EB/32EC dddd +// REST move.l (a1)+,d16(aP) : 2159/2559/2759/2959 dddd +// REST move.w (a1)+,d16(aP) : 3159/3559/3759/3959 dddd +#define OP_MOVEM_SAVE_A24 0x0038u +#define OP_MOVEM_REST_A24 0x1C00u + +static const uint16_t OP_COPY_ARG_AN[5] = { 0x206Fu, 0x246Fu, 0x266Fu, 0x286Fu, 0x226Fu }; +static const uint16_t OP_SAVE_L_AN[AMIGA_PLANES] = { 0x22E8u, 0x22EAu, 0x22EBu, 0x22ECu }; +static const uint16_t OP_SAVE_W_AN[AMIGA_PLANES] = { 0x32E8u, 0x32EAu, 0x32EBu, 0x32ECu }; +static const uint16_t OP_REST_L_AN[AMIGA_PLANES] = { 0x2159u, 0x2559u, 0x2759u, 0x2959u }; +static const uint16_t OP_REST_W_AN[AMIGA_PLANES] = { 0x3159u, 0x3559u, 0x3759u, 0x3959u }; + +// Arg displacements after the 12-byte movem push (+4 return PC): +// p0/p1/p2/p3/buf at entry 4/8/12/16/20 -> 16/20/24/28/32. +#define COPY_ARG_DISP(i) (uint16_t)(16u + (uint16_t)(i) * 4u) + +static uint32_t emitPlaneRunCopy(uint8_t *out, uint32_t cap, const jlSpriteT *sp, uint8_t shift, bool isRestore) { + uint32_t cursor; + uint16_t nGroups; + uint16_t run; + uint16_t srcH; + int16_t row; + uint8_t p; + uint16_t i; + + if (shift > 1u) { + return 0u; + } + nGroups = (uint16_t)(((sp->widthPx + 15u) >> 4) + shift); + if ((uint16_t)(nGroups << 4) > SURFACE_WIDTH) { + // Unreachable class for this sprite width; both save and + // restore bail identically (pair provenance). + return 0u; + } + run = (uint16_t)(nGroups << 1); // bytes per plane per row + srcH = (uint16_t)(sp->heightTiles * TILE_PIXELS_PER_SIDE); + + cursor = 0u; + if (cursor + 24u > cap) { + return SPRITE_EMIT_OVERFLOW; + } + cursor = putWord(out, cursor, OP_MOVEM_SAVE); + cursor = putWord(out, cursor, OP_MOVEM_SAVE_A24); + for (i = 0u; i < 5u; i++) { + cursor = putWord(out, cursor, OP_COPY_ARG_AN[i]); + cursor = putWord(out, cursor, COPY_ARG_DISP(i)); + } + for (row = 0; row < (int16_t)srcH; row++) { + uint16_t rowDisp = (uint16_t)((uint16_t)row * AMIGA_PLANE_STRIDE); + + // Worst case per row: 4 planes x (longs + one word) ops at + // 4 bytes each. + if (cursor + (uint32_t)(4u * ((run >> 2) + 1u) * 4u) + 6u > cap) { + return SPRITE_EMIT_OVERFLOW; + } + for (p = 0u; p < AMIGA_PLANES; p++) { + uint16_t disp = rowDisp; + uint16_t longs = (uint16_t)(run >> 2); + + for (i = 0u; i < longs; i++) { + cursor = putWord(out, cursor, isRestore ? OP_REST_L_AN[p] : OP_SAVE_L_AN[p]); + cursor = putWord(out, cursor, disp); + disp = (uint16_t)(disp + 4u); + } + if ((run & 2u) != 0u) { + cursor = putWord(out, cursor, isRestore ? OP_REST_W_AN[p] : OP_SAVE_W_AN[p]); + cursor = putWord(out, cursor, disp); + } + } + } + if (cursor + 6u > cap) { + return SPRITE_EMIT_OVERFLOW; + } + cursor = putWord(out, cursor, OP_MOVEM_REST); + cursor = putWord(out, cursor, OP_MOVEM_REST_A24); + cursor = putWord(out, cursor, OP_RTS); + return cursor; +} + + uint32_t spriteEmitSavePlanar68k(uint8_t *out, uint32_t cap, const jlSpriteT *sp, uint8_t shift) { - (void)out; (void)cap; (void)sp; (void)shift; - return 0u; + return emitPlaneRunCopy(out, cap, sp, shift, false); } uint32_t spriteEmitRestorePlanar68k(uint8_t *out, uint32_t cap, const jlSpriteT *sp, uint8_t shift) { - (void)out; (void)cap; (void)sp; (void)shift; - return 0u; + return emitPlaneRunCopy(out, cap, sp, shift, true); }