Working on performance. Still.

This commit is contained in:
Scott Duensing 2026-07-07 01:11:34 -05:00
parent 24b88c3ba2
commit 813bf595bc
27 changed files with 1530 additions and 546 deletions

View file

@ -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

216
PERF.md
View file

@ -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).

View file

@ -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;

View file

@ -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

View file

@ -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

View file

@ -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);

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

44
src/amiga/amigaPlanar.h Normal file
View file

@ -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 <stdint.h>
#include <stdbool.h>
#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

View file

@ -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

View file

@ -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.
@ -81,6 +83,13 @@ 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;
// SFX overlay shared with the DOS HAL (see src/core/audioSfxMix.c).
@ -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();
}
}

View file

@ -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) {

51
src/atarist/spriteWalk.s Normal file
View file

@ -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

42
src/atarist/stPlanar.h Normal file
View file

@ -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 <stdint.h>
#include <stdbool.h>
#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

View file

@ -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;
uint32_t written;
uint8_t shift;
uint8_t op;
} 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;
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;
if (written == SPRITE_EMIT_OVERFLOW || written > SPRITE_EMIT_SCRATCH_BYTES) {
return 0;
}
total += written;
#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;
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++) {
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;
SpritePlanT plan;
ArenaSlotT *slot;
uint8_t *dst;
uint32_t written;
uint32_t offset;
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,50 +344,12 @@ 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. */
if (!spritePlanEmit(scratch, sp, &plan, slot)) {
memset(sp->routineOffsets, 0xFF, sizeof(sp->routineOffsets));
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;
}
}
}
sp->slot = slot;
// Registry (#34): every creation path (including jlSpriteBankLoad,
// #32) already registered sp via spriteInitFields; this idempotent
@ -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);
AmigaPlanarT *pd;
uint16_t byteOff;
uint8_t *p0;
uint8_t *p1;
uint8_t *p2;
uint8_t *p3;
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);
byteOff = (uint16_t)((uint16_t)y * AMIGA_BYTES_PER_ROW + ((uint16_t)x >> 3));
fn = (DrawFn)(codegenArenaBase() + sp->slot->offset + routeOffset);
fn(p0 + byteOff, p1 + byteOff, p2 + byteOff, p3 + byteOff);
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;
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

View file

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

View file

@ -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

View file

@ -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);

View file

@ -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;
// 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).

View file

@ -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)
// 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))
#elif defined(JOEYLIB_PLATFORM_ATARIST)
#define SPRITE_SHIFT_INDEX(x) ((uint8_t)(((x) & 7) ? 2u : (uint8_t)(((x) >> 3) & 1u)))
#define SPRITE_DEGRADE_DRAW_MASK 0x0001u // shift 0
#else
#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

View file

@ -235,15 +235,59 @@ 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];
#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 ((rows & 1u) != 0u) {
if (minWord < *minPtr) {
*minPtr = minWord;
}
if (maxWord > *maxPtr) {
*maxPtr = maxWord;
}
}
}
#else
{
uint8_t *minEnd;
minEnd = &gStageMinWord[yEnd];
while (minPtr < minEnd) {
if (minWord < *minPtr) {
@ -255,6 +299,8 @@ void surfaceMarkDirtyRows(uint16_t y, uint16_t yEnd, uint8_t minWord, uint8_t ma
minPtr++;
maxPtr++;
}
}
#endif
}
#endif

View file

@ -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);
}
}

View file

@ -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;
}

View file

@ -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.
uint32_t spriteEmitSaveInterleaved68k(uint8_t *out, uint32_t cap, const jlSpriteT *sp, uint8_t shift) {
(void)out; (void)cap; (void)sp; (void)shift;
// 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) {
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);
}

View file

@ -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.
uint32_t spriteEmitSavePlanar68k(uint8_t *out, uint32_t cap, const jlSpriteT *sp, uint8_t shift) {
(void)out; (void)cap; (void)sp; (void)shift;
// 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) {
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);
}