// Sprite system: create, destroy, draw, save/restore-under.
//
// This file is the interpreted fallback path. It is correct on every
// platform and serves as the reference implementation for the
// runtime-compiled emitters that will land per-CPU later.

#include <stdlib.h>
#include <string.h>

#include "joey/sprite.h"
#include "joey/tile.h"
#include "codegenArenaInternal.h"
#include "port.h"
#include "spriteInternal.h"
#include "spriteDispatch.h"
#include "surfaceInternal.h"



// 8x8 tile geometry constants come from joey/tile.h -- one source of
// truth for the 4bpp packed layout (32 bytes/tile, 4 bytes/row, 8 px/side).

// Color 0 is always transparent for sprites (DESIGN.md contract);
// TRANSPARENT_NIBBLE comes from spriteInternal.h, shared with every
// per-CPU emitter.

// On Amiga (post-Phase 9 / Phase 6 redux) the compiled sprite emitter
// writes directly to the bitplanes, so the jlpSprite*Planes hooks are
// pure duplicate work after a compiled call. On other ports the
// hooks are either no-op stubs (chunky-only IIgs/DOS) or the only
// thing writing planes (ST: chunky-shadow + planes). Slow / interpreter
// paths still need the hooks unconditionally on every platform -- the
// chunky interpreter is a no-op on Amiga (s->pixels NULL) so the hook
// is the only draw.
/* ST also runs pure planar post-Phase-9 (s->pixels NULL); the JIT
 * routine writes plane bytes directly, so the chunky interpreter
 * is a no-op and the jlpSpriteDrawPlanes hook would be a redundant
 * second draw. Same rationale as Amiga. */
/* X68000 is pure planar too (s->pixels NULL) and shares the Amiga's compiled
 * planar routines via the one spriteDispatch.h arm, so its compiled body writes
 * the planes as well. Omitting it here made jlSpriteSaveAndDraw run the
 * interpreted jlpSpriteSavePlanes AFTER the compiled draw had already painted
 * the sprite -- the backup captured the SPRITE instead of the background, so
 * the paired restore repainted it and left residue. Draw-only rows hid this
 * because a double draw is idempotent. */
#if defined(JOEYLIB_PLATFORM_AMIGA) || defined(JOEYLIB_PLATFORM_ATARIST) || defined(JOEYLIB_PLATFORM_X68000)
#define COMPILED_SPRITE_WRITES_PLANES 1
#else
#define COMPILED_SPRITE_WRITES_PLANES 0
#endif


// ----- Sprite-window dirty marks (NATIVE-PERF Phase 6 W1) -----
//
// Every compiled sprite draw/restore used to pay the generic
// surfaceMarkDirtyRect macro -> out-of-line surfaceMarkDirtyRows loop
// (~800 cyc for 8 rows measured on the 68000 ports; ~1.3-1.6k for the
// 16-row sprite window) -- the same per-call tax tile.c's Step-B mark
// removed for tiles. spriteMarkDirty derives the word band ONCE with
// surfaceMarkDirtyRect's EXACT arithmetic and widens the common
// 16-row window unrolled (Step-B indexed-displacement shape, NOT the
// pointer-pair unroll that regressed the ST in Phase 2 Step C).
//
// Two extra spellings mark the fusion seam (tileMarkDirtyFused
// precedent), kept separate so draw fusion may land before restore
// fusion:
//   spriteMarkDirtyDraw    -- after a compiled draw (jlSpriteDraw and
//                             jlSpriteSaveAndDraw).
//   spriteMarkDirtyRestore -- after a compiled restore.
// On the IIgs all three spellings expand to surfaceMarkDirtyRect,
// token-identical to today's path (band derive inline, one JSL into
// iigsMarkDirtyRowsInner; a C unroll of 16 long-abs RMW pairs would
// REGRESS the 65816). The W1 fused IIgs sprite entries take ownership
// DISPATCH-side, not here: an s == gStage arm in the compiled path
// calls the fused asm entry (which widens the band in-section) and
// returns BEFORE these C marks, so the spellings stay live only for
// the non-stage compiled paths, where surfaceMarkDirtyRect's stage
// gate makes them no-ops. Slow / interpreted paths always mark
// through plain spriteMarkDirty -- never fused on any port.
#ifdef JOEYLIB_PLATFORM_IIGS
#define spriteMarkDirty(_s, _x, _y, _w, _h)        surfaceMarkDirtyRect((_s), (_x), (_y), (_w), (_h))
#define spriteMarkDirtyDraw(_s, _x, _y, _w, _h)    surfaceMarkDirtyRect((_s), (_x), (_y), (_w), (_h))
#define spriteMarkDirtyRestore(_s, _x, _y, _w, _h) surfaceMarkDirtyRect((_s), (_x), (_y), (_w), (_h))
#else
#define spriteMarkDirtyDraw(_s, _x, _y, _w, _h)    spriteMarkDirty((_s), (_x), (_y), (_w), (_h))
#define spriteMarkDirtyRestore(_s, _x, _y, _w, _h) spriteMarkDirty((_s), (_x), (_y), (_w), (_h))
#endif


// ----- Sprite registry (PERF-AUDIT.md #34) -----
//
// jlShutdown frees the codegen arena (codegenArenaShutdown) while
// user-owned sprites may still hold ArenaSlotT pointers. If such a
// sprite survives a shutdown -> re-init cycle, jlSpriteDestroy would
// free a stale slot (heap corruption) and jlSpriteDraw would execute
// stale offsets inside the NEW arena. The registry tracks live
// sprites so spriteSystemShutdown (called from jlShutdown BEFORE
// codegenArenaShutdown) can reset each one's compiled-path state; the
// arena bytes themselves are released by codegenArenaShutdown.
//
// Fixed cap; registration is best-effort. Sprites beyond the cap are
// simply not registered and MUST be destroyed before jlShutdown --
// which matches the implicit contract every sprite had before the
// registry existed.
#define SPRITE_REGISTRY_CAP 64

static jlSpriteT *gSpriteRegistry[SPRITE_REGISTRY_CAP];


// ----- Prototypes -----

static bool    clipRect(int16_t *dstX, int16_t *dstY, int16_t *srcX, int16_t *srcY, int16_t *w, int16_t *h);
static bool    isFullyOnSurface(int16_t x, int16_t y, uint16_t widthPx, uint16_t heightPx);
static void    spriteDrawInterpreted(jlSurfaceT *s, jlSpriteT *sp, int16_t x, int16_t y);
#ifndef JOEYLIB_PLATFORM_IIGS
static void    spriteMarkDirty(jlSurfaceT *s, int16_t x, int16_t y, int16_t w, int16_t h);
#endif
static void    spriteRegistryRemove(jlSpriteT *sp);
static void    spriteResetCompiledState(jlSpriteT *sp);


// ----- Internal helpers (alphabetical) -----

// Clip a draw at (dstX,dstY) of size (w,h) against the destination
// surface. Updates srcX/srcY to the offset into the sprite that the
// remaining visible region starts at. Returns false if entirely
// off-surface.
static bool clipRect(int16_t *dstX, int16_t *dstY, int16_t *srcX, int16_t *srcY, int16_t *w, int16_t *h) {
    *srcX = 0;
    *srcY = 0;
    if (*w <= 0 || *h <= 0) {
        return false;
    }
    if (*dstX < 0) {
        *srcX  = -(*dstX);
        *w    -= *srcX;
        *dstX  = 0;
    }
    if (*dstY < 0) {
        *srcY  = -(*dstY);
        *h    -= *srcY;
        *dstY  = 0;
    }
    if (*dstX >= SURFACE_WIDTH || *dstY >= SURFACE_HEIGHT) {
        return false;
    }
    if (*dstX + *w > SURFACE_WIDTH) {
        *w = SURFACE_WIDTH - *dstX;
    }
    if (*dstY + *h > SURFACE_HEIGHT) {
        *h = SURFACE_HEIGHT - *dstY;
    }
    return (*w > 0 && *h > 0);
}


// Delegates to the shared 16-bit on-surface predicate (PERF-AUDIT
// #44/#62) instead of the old 32-bit add+compare pair. Safe within the
// macro's contract: every sprite creation path rejects 0 tiles, so
// widthPx/heightPx are at least 8 (w >= 1 holds), and they are bounded
// by 2040 (255 tiles * 8), so the macro's guarded w <= SURFACE_WIDTH /
// h <= SURFACE_HEIGHT form rejects oversized sprites before the
// 16-bit subtractions could wrap.
static bool isFullyOnSurface(int16_t x, int16_t y, uint16_t widthPx, uint16_t heightPx) {
    return SURFACE_RECT_ON_SURFACE(x, y, widthPx, heightPx);
}


// Pixel-by-pixel draw. Used directly when the sprite has no compiled
// slot, and as the clip-edge fallback when a compiled draw would
// extend off the surface.
//
// The source sprite is tile-major (8x8 tiles, 4 bytes/row, consecutive
// tile columns 32 bytes apart). All row-invariant source math is
// hoisted above the row loop (#45): the tile-band byte offset is
// seeded with the single (sy >> 3) * bandStride multiply, then
// maintained incrementally as inTileY wraps, so no multiply helper
// runs per row. The destination byte pointer and nibble parity are
// carried incrementally per pixel (#2) instead of being rederived
// through a per-pixel function call.
//
// When source and destination share nibble parity (the aligned phase,
// #18) the inner loop runs byte-at-a-time, chunked per tile column
// (up to TILE_BYTES_PER_ROW contiguous source bytes, then
// srcTileRow += TILE_BYTES): a 0x00 source byte skips both pixels, a
// both-nibbles-opaque byte is one whole-byte store, and a single
// opaque nibble merges read-modify-write. The odd leading/trailing
// pixels are handled outside the byte loop. The misaligned phase
// keeps the per-pixel walker.
#if TRANSPARENT_NIBBLE != 0
#error "spriteDrawInterpreted's aligned byte path assumes TRANSPARENT_NIBBLE == 0"
#endif
static void spriteDrawInterpreted(jlSurfaceT *s, jlSpriteT *sp, int16_t x, int16_t y) {
    int16_t dx;
    int16_t dy;
    int16_t sx;
    int16_t sy;
    int16_t w;
    int16_t h;

    dx = x;
    dy = y;
    w  = (int16_t)sp->widthPx;
    h  = (int16_t)sp->heightPx;
    if (!clipRect(&dx, &dy, &sx, &sy, &w, &h)) {
        return;
    }

    // Skip the chunky write loop on planar ports (s->pixels == NULL).
    // jlpSpriteDrawPlanes is called by the jlSpriteDraw caller and does
    // its own clip + plane write, so the dirty mark + planar update
    // happen there. Phase 9 dropped the chunky shadow on Amiga.
    if (s->pixels != NULL) {
        uint16_t bandStride;
        uint16_t bandBase;
        uint16_t inTileY;
        uint16_t inTileXStart;
        uint16_t srcColBase;
        uint16_t dstByteStart;
        uint16_t leadOdd;
        uint16_t trailing;
        int16_t  pairCount;
        int16_t  row;
        bool     aligned;

        // Row-invariant hoists (#45). Shifts dodge multiply helpers
        // (TILE_BYTES == 1<<5, TILE_BYTES_PER_ROW == 1<<2); only the
        // bandBase seed pays a multiply, once per draw.
        bandStride   = (uint16_t)((uint16_t)sp->widthTiles << 5);
        bandBase     = (uint16_t)((uint16_t)((uint16_t)sy >> 3) * bandStride);
        inTileY      = (uint16_t)((uint16_t)sy & 7u);
        inTileXStart = (uint16_t)((uint16_t)sx & 7u);
        srcColBase   = (uint16_t)(((uint16_t)((uint16_t)sx >> 3)) << 5);
        dstByteStart = (uint16_t)((uint16_t)dx >> 1);
        // Aligned phase (#18): src and dst nibble parity match for the
        // whole draw, so source pixel pairs map to whole dst bytes.
        // leadOdd/pairCount/trailing split each row into an odd
        // leading pixel, whole bytes, and an odd trailing pixel.
        aligned      = (((dx ^ sx) & 1) == 0);
        leadOdd      = (uint16_t)((uint16_t)dx & 1u);
        pairCount    = (int16_t)((w - (int16_t)leadOdd) >> 1);
        trailing     = (uint16_t)((uint16_t)(w - (int16_t)leadOdd) & 1u);

        for (row = 0; row < h; row++) {
            const uint8_t *srcTileRow;
            uint8_t       *dst;

            srcTileRow = sp->tileData + bandBase + (inTileY << 2) + srcColBase;
            dst        = &s->pixels[SURFACE_ROW_OFFSET((uint16_t)(dy + row)) + dstByteStart];

            if (aligned) {
                uint16_t inTileX;
                int16_t  pairsLeft;

                inTileX = inTileXStart;
                // Odd leading pixel: low nibble of both src and dst.
                if (leadOdd != 0u) {
                    uint8_t nibble;

                    nibble = (uint8_t)(srcTileRow[inTileX >> 1] & 0x0Fu);
                    if (nibble != TRANSPARENT_NIBBLE) {
                        *dst = (uint8_t)((*dst & 0xF0u) | nibble);
                    }
                    dst++;
                    inTileX++;
                    if (inTileX == TILE_PIXELS_PER_SIDE) {
                        inTileX     = 0;
                        srcTileRow += TILE_BYTES;
                    }
                }
                // Byte loop, chunked per tile column: up to 4
                // contiguous source bytes, then hop to the next tile.
                pairsLeft = pairCount;
                while (pairsLeft > 0) {
                    const uint8_t *src;
                    int16_t        run;
                    int16_t        i;

                    run = (int16_t)((TILE_PIXELS_PER_SIDE - inTileX) >> 1);
                    if (run > pairsLeft) {
                        run = pairsLeft;
                    }
                    src = srcTileRow + (inTileX >> 1);
                    for (i = 0; i < run; i++) {
                        uint8_t b;

                        b = *src++;
                        if (b == 0x00u) {
                            // Two TRANSPARENT_NIBBLEs: skip both.
                            dst++;
                        } else if ((b & 0xF0u) != 0u && (b & 0x0Fu) != 0u) {
                            // Both nibbles opaque: whole-byte store.
                            *dst = b;
                            dst++;
                        } else if ((b & 0x0Fu) != 0u) {
                            // Low nibble opaque (high is 0, so | b works).
                            *dst = (uint8_t)((*dst & 0xF0u) | b);
                            dst++;
                        } else {
                            // High nibble opaque (low is 0).
                            *dst = (uint8_t)((*dst & 0x0Fu) | b);
                            dst++;
                        }
                    }
                    pairsLeft = (int16_t)(pairsLeft - run);
                    inTileX   = (uint16_t)(inTileX + ((uint16_t)run << 1));
                    if (inTileX == TILE_PIXELS_PER_SIDE) {
                        inTileX     = 0;
                        srcTileRow += TILE_BYTES;
                    }
                }
                // Even trailing pixel: high nibble of both src and dst.
                if (trailing != 0u) {
                    uint8_t b;

                    b = (uint8_t)(srcTileRow[inTileX >> 1] & 0xF0u);
                    if (b != 0u) {
                        *dst = (uint8_t)((*dst & 0x0Fu) | b);
                    }
                }
            } else {
                // Misaligned phase: per-pixel walker with the dst byte
                // pointer and parity carried incrementally (#2).
                uint16_t inTileX;
                uint16_t dstOdd;
                int16_t  col;

                inTileX = inTileXStart;
                dstOdd  = leadOdd;
                for (col = 0; col < w; col++) {
                    uint8_t byte;
                    uint8_t nibble;

                    byte   = srcTileRow[inTileX >> 1];
                    nibble = (inTileX & 1u) ? (uint8_t)(byte & 0x0Fu) : (uint8_t)(byte >> 4);
                    if (nibble != TRANSPARENT_NIBBLE) {
                        if (dstOdd != 0u) {
                            *dst = (uint8_t)((*dst & 0xF0u) | nibble);
                        } else {
                            *dst = (uint8_t)((*dst & 0x0Fu) | (uint8_t)(nibble << 4));
                        }
                    }
                    if (dstOdd != 0u) {
                        dst++;
                        dstOdd = 0;
                    } else {
                        dstOdd = 1;
                    }
                    inTileX++;
                    if (inTileX == TILE_PIXELS_PER_SIDE) {
                        inTileX     = 0;
                        srcTileRow += TILE_BYTES;
                    }
                }
            }

            // Advance the tile band incrementally (#45): the bandBase
            // multiply above never reruns.
            inTileY++;
            if (inTileY == TILE_PIXELS_PER_SIDE) {
                inTileY  = 0;
                bandBase = (uint16_t)(bandBase + bandStride);
            }
        }
    }
    spriteMarkDirty(s, dx, dy, w, h);
}


#ifndef JOEYLIB_PLATFORM_IIGS
// Row widen for spriteMarkDirty, indexed-displacement form like
// tile.c's TILE_MARK_ROW: gcc-6.5 -O2 keeps the equivalent loop
// rolled (compare + spill-reload per row); the d16(An) unroll
// measured ~40% cheaper per row on the whole-tile mark (Phase 2
// Step B, ~480 vs 804 cyc for 8 rows).
#define SPRITE_MARK_ROW(_i) do { \
    if (minWord < minPtr[_i]) { \
        minPtr[_i] = minWord; \
    } \
    if (maxWord > maxPtr[_i]) { \
        maxPtr[_i] = maxWord; \
    } \
} while (0)

// Inlined 16-row stage dirty-band widen (NATIVE-PERF Phase 7, P7-1
// lever b). The compiled draw/restore fast paths on the 68k ports call
// this INLINE for the dominant h==16 window instead of the out-of-line
// spriteMarkDirty -- eliminating the call boundary (jsr + 7-register
// movem + h-dispatch), measured as ~26-29% of a 68k sprite restore.
// Two out-of-line variants (a body+mark asm shim; a lean rolled-loop
// asm widen call) both REGRESSED both ports 2026-07-11: each KEPT the
// call boundary and a rolled loop lost to this exact unrolled-indexed
// form. Only inlining wins (ST restore +5%, Amiga +6%). Arithmetic is
// byte-identical to spriteMarkDirty (SURFACE_WORD_INDEX of the first
// and last covered pixel) -- a HASH-INVISIBLE contract; any drift
// under-marks and corrupts presents while the stage hashes stay green.
// Caller must have gated s == gStage and h == 16.
#define SPRITE_MARK_STAGE_ROWS16(_x, _w, _y) do { \
    uint8_t  minWord = SURFACE_WORD_INDEX(_x); \
    uint8_t  maxWord = SURFACE_WORD_INDEX((int16_t)((_x) + (int16_t)(_w) - 1)); \
    uint8_t *minPtr  = &gStageMinWord[(uint16_t)(_y)]; \
    uint8_t *maxPtr  = &gStageMaxWord[(uint16_t)(_y)]; \
    SPRITE_MARK_ROW(0); \
    SPRITE_MARK_ROW(1); \
    SPRITE_MARK_ROW(2); \
    SPRITE_MARK_ROW(3); \
    SPRITE_MARK_ROW(4); \
    SPRITE_MARK_ROW(5); \
    SPRITE_MARK_ROW(6); \
    SPRITE_MARK_ROW(7); \
    SPRITE_MARK_ROW(8); \
    SPRITE_MARK_ROW(9); \
    SPRITE_MARK_ROW(10); \
    SPRITE_MARK_ROW(11); \
    SPRITE_MARK_ROW(12); \
    SPRITE_MARK_ROW(13); \
    SPRITE_MARK_ROW(14); \
    SPRITE_MARK_ROW(15); \
} while (0)

// Specialized sprite-window mark. CONTRACT: identical to
// surfaceMarkDirtyRect's (surfaceInternal.h) -- rect already clipped
// to surface bounds, w and h POSITIVE, caller passes side-effect-free
// arguments. The band arithmetic below must stay IDENTICAL to that
// macro's (SURFACE_WORD_INDEX of the first and last covered pixel):
// any drift under-marks, and an under-mark corrupts presents while
// staying INVISIBLE to the stage hashes.
static void spriteMarkDirty(jlSurfaceT *s, int16_t x, int16_t y, int16_t w, int16_t h) {
    uint8_t *minPtr;
    uint8_t *maxPtr;
    uint8_t  minWord;
    uint8_t  maxWord;

    if (s != gStage) {
        return;
    }
    minWord = SURFACE_WORD_INDEX(x);
    maxWord = SURFACE_WORD_INDEX(x + w - 1);
    minPtr  = &gStageMinWord[(uint16_t)y];
    maxPtr  = &gStageMaxWord[(uint16_t)y];
    if (h == 16) {
        // The dominant window: every 2-tile-high sprite (all UBER
        // sprite rows, the gameFrame sprites).
        SPRITE_MARK_ROW(0);
        SPRITE_MARK_ROW(1);
        SPRITE_MARK_ROW(2);
        SPRITE_MARK_ROW(3);
        SPRITE_MARK_ROW(4);
        SPRITE_MARK_ROW(5);
        SPRITE_MARK_ROW(6);
        SPRITE_MARK_ROW(7);
        SPRITE_MARK_ROW(8);
        SPRITE_MARK_ROW(9);
        SPRITE_MARK_ROW(10);
        SPRITE_MARK_ROW(11);
        SPRITE_MARK_ROW(12);
        SPRITE_MARK_ROW(13);
        SPRITE_MARK_ROW(14);
        SPRITE_MARK_ROW(15);
        return;
    }
    if (h == 8) {
        SPRITE_MARK_ROW(0);
        SPRITE_MARK_ROW(1);
        SPRITE_MARK_ROW(2);
        SPRITE_MARK_ROW(3);
        SPRITE_MARK_ROW(4);
        SPRITE_MARK_ROW(5);
        SPRITE_MARK_ROW(6);
        SPRITE_MARK_ROW(7);
        return;
    }
    // Clipped interpreter marks and unusual sprite heights: the shared
    // multi-row widen (per-port loop shape in surface.c) -- one source
    // of truth, no second rolled loop here. h == 1 also lands here (a
    // sprite clipped to one row); the extra call vs the old macro's
    // inline single-row arm is confined to that edge case.
    surfaceMarkDirtyRows((uint16_t)y, (uint16_t)(y + h), minWord, maxWord);
}
#endif


// Drop a sprite from the registry. No-op if it was never registered
// (registry full at creation time).
static void spriteRegistryRemove(jlSpriteT *sp) {
    uint8_t i;

    for (i = 0; i < SPRITE_REGISTRY_CAP; i++) {
        if (gSpriteRegistry[i] == sp) {
            gSpriteRegistry[i] = NULL;
            return;
        }
    }
}


// Reset a sprite's compiled-path state to "never compiled".
// routineOffsets is filled with 0xFF so every (shift, op) entry reads
// as SPRITE_NOT_COMPILED (0xFFFF); 0 would be a VALID offset and could
// dispatch into stale arena bytes. The IIgs MVN bank caches return to
// 0xFF ("never patched") and cachedSizeBytes to 0 ("uncached") so a
// recompile after arena teardown cannot skip a patch based on stale
// cache state.
static void spriteResetCompiledState(jlSpriteT *sp) {
    sp->slot = NULL;
    memset(sp->routineOffsets,  0xFF, sizeof(sp->routineOffsets));
#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
}


// ----- Public API (alphabetical) -----

jlSpriteT *jlSpriteCreate(const uint8_t *tileData, uint8_t widthTiles, uint8_t heightTiles) {
    jlSpriteT *sp;

    if (tileData == NULL || widthTiles == 0 || heightTiles == 0) {
        return NULL;
    }
    /* jlpBigAlloc, NOT the C heap: the IIgs C heap is a few hundred
     * bytes of unprotected bank-0 leftover; at exhaustion its malloc
     * returns garbage instead of NULL (PERF-AUDIT.md #81). Other ports
     * map jlpBigAlloc to malloc. Applies to every sprite-struct and
     * owned-tileData allocation in this file (finding #31). */
    sp = (jlSpriteT *)jlpBigAlloc((uint32_t)sizeof(jlSpriteT));
    if (sp == NULL) {
        return NULL;
    }
    spriteInitFields(sp, tileData, widthTiles, heightTiles, false);
    return sp;
}


jlSpriteT *jlSpriteCreateFromSurface(const jlSurfaceT *src, int16_t x, int16_t y,
                                 uint8_t widthTiles, uint8_t heightTiles) {
    jlSpriteT       *sp;
    uint8_t       *buf;
    uint16_t       tx;
    uint16_t       ty;
    uint16_t       row;
    uint16_t       widthPx;
    uint16_t       heightPx;
    uint32_t       tileBytes;
    const uint8_t *srcRow;
    uint8_t       *dstTile;

    if (src == NULL || widthTiles == 0 || heightTiles == 0) {
        return NULL;
    }
    // Source x/y must be on a tile boundary so each captured tile lands
    // on whole bytes -- mid-byte snapshots would lose half a pixel at
    // the left edge.
    if ((x & (TILE_PIXELS_PER_SIDE - 1)) != 0 || (y & (TILE_PIXELS_PER_SIDE - 1)) != 0) {
        return NULL;
    }
    widthPx  = (uint16_t)(widthTiles  * TILE_PIXELS_PER_SIDE);
    heightPx = (uint16_t)(heightTiles * TILE_PIXELS_PER_SIDE);
    if (x < 0 || y < 0 ||
        x + (int16_t)widthPx  > SURFACE_WIDTH ||
        y + (int16_t)heightPx > SURFACE_HEIGHT) {
        return NULL;
    }

    tileBytes = (uint32_t)widthTiles * heightTiles * TILE_BYTES;
    buf = (uint8_t *)jlpBigAlloc(tileBytes);
    if (buf == NULL) {
        return NULL;
    }

    if (src->pixels != NULL) {
        // Chunky port: pack src->pixels (full-row 4bpp packed) into
        // tile-major layout. Each tile reads 4 bytes per row from src
        // at column offset (x/2 + tx*4) and writes them contiguously
        // into the tile slot.
        for (ty = 0; ty < heightTiles; ty++) {
            for (tx = 0; tx < widthTiles; tx++) {
                dstTile = &buf[(ty * widthTiles + tx) * TILE_BYTES];
                for (row = 0; row < TILE_PIXELS_PER_SIDE; row++) {
                    srcRow = &src->pixels[((uint16_t)y + ty * TILE_PIXELS_PER_SIDE + row) * SURFACE_BYTES_PER_ROW];
                    memcpy(&dstTile[row * TILE_BYTES_PER_ROW],
                           &srcRow[((uint16_t)x >> 1) + tx * TILE_BYTES_PER_ROW],
                           TILE_BYTES_PER_ROW);
                }
            }
        }
    } else {
        // Planar port (Phase 9: no chunky shadow). Derive chunky 4bpp
        // pixels from plane bits via jlpSamplePixel and pack into the
        // tile-major sprite layout. Slow (per-pixel jlpSamplePixel) but
        // only runs at sprite-capture time, not per frame. Without
        // this branch sprites loaded from a work surface on Amiga/ST
        // all came back NULL -- the symptom was simply no sprites on
        // screen.
        uint16_t px;
        uint16_t py;
        uint8_t  hiNibble;
        uint8_t  loNibble;
        uint16_t col;
        for (ty = 0; ty < heightTiles; ty++) {
            for (tx = 0; tx < widthTiles; tx++) {
                dstTile = &buf[(ty * widthTiles + tx) * TILE_BYTES];
                for (row = 0; row < TILE_PIXELS_PER_SIDE; row++) {
                    py = (uint16_t)((uint16_t)y + ty * TILE_PIXELS_PER_SIDE + row);
                    for (col = 0; col < TILE_PIXELS_PER_SIDE; col += 2) {
                        px = (uint16_t)((uint16_t)x + tx * TILE_PIXELS_PER_SIDE + col);
                        hiNibble = (uint8_t)(jlpSamplePixel(src, (int16_t)px,       (int16_t)py) & 0x0Fu);
                        loNibble = (uint8_t)(jlpSamplePixel(src, (int16_t)(px + 1), (int16_t)py) & 0x0Fu);
                        dstTile[row * TILE_BYTES_PER_ROW + (col >> 1)] =
                            (uint8_t)((hiNibble << 4) | loNibble);
                    }
                }
            }
        }
    }

    sp = (jlSpriteT *)jlpBigAlloc((uint32_t)sizeof(jlSpriteT));
    if (sp == NULL) {
        jlpBigFree(buf);
        return NULL;
    }
    spriteInitFields(sp, buf, widthTiles, heightTiles, true);
    return sp;
}


void jlSpriteDestroy(jlSpriteT *sp) {
    if (sp == NULL) {
        return;
    }
    spriteRegistryRemove(sp);
    if (sp->slot != NULL) {
        codegenArenaFree(sp->slot);
        sp->slot = NULL;
    }
    if (sp->ownsTileData) {
        jlpBigFree((void *)sp->tileData);
    }
    jlpBigFree(sp);
}


void jlSpriteDraw(jlSurfaceT *s, jlSpriteT *sp, int16_t x, int16_t y) {
    uint16_t widthPx;
    uint16_t heightPx;

    if (s == NULL || sp == NULL) {
        return;
    }
    widthPx  = sp->widthPx;
    heightPx = sp->heightPx;

#if defined(JOEYLIB_PLATFORM_IIGS)
    // NATIVE-PERF Phase 6 W1: fused stage draw. ONE JSL runs the
    // compiled body AND the dirty-band widen (iigsSpriteDrawMark),
    // replacing the compiled-call + surfaceMarkDirtyRect ->
    // iigsMarkDirtyRowsInner JSL pair. Gates mirror the shared arm
    // below exactly; non-stage destinations and uncompiled sprites
    // fall through to it (its C mark is a stage-gated no-op there).
    if (s == gStage && sp->slot != NULL && isFullyOnSurface(x, y, widthPx, heightPx)) {
        uint16_t fusedOffset;

        fusedOffset = SPRITE_ROUTE_OFFSET(sp, SPRITE_SHIFT_INDEX(x), SPRITE_OP_DRAW);
        if (fusedOffset != SPRITE_NOT_COMPILED) {
            spriteCompiledDrawMark(sp, x, y, fusedOffset);
            return;
        }
    }
#endif

    // Fast path: compiled bytes + fully on surface. Off-surface draws
    // fall back to the interpreter so the compiled routines never
    // need clip math (they walk fixed offsets). The DRAW offset for
    // this shift is read once and gated against SPRITE_NOT_COMPILED
    // (#46): a slot whose DRAW emitter returned 0 bytes for this shift
    // must never be jumped into (spriteCompiledDraw adds the offset to
    // the slot address unchecked on IIgs/DOS). Mirrors the SaveUnder /
    // RestoreUnder gates. This compiled branch is the production path
    // on every port with a wired emitter; the interpreter is only the
    // uncompiled/clip-edge fallback (#63). Exception, temporary: while
    // JOEY_IIGS_SPRITE_CODEGEN is 0 (toolchain-owned arena-placement
    // bug, see #19 / #77 in spriteCompile.c) jlSpriteCompile leaves
    // sp->slot NULL on IIgs, so that port interprets until the fix
    // lands -- do NOT treat this branch as dead code there.
    if (sp->slot != NULL && isFullyOnSurface(x, y, widthPx, heightPx)) {
        uint8_t  shift;
        uint16_t routeOffset;

        shift       = SPRITE_SHIFT_INDEX(x);
        routeOffset = SPRITE_ROUTE_OFFSET(sp, shift, SPRITE_OP_DRAW);
        if (routeOffset != SPRITE_NOT_COMPILED) {
            spriteCompiledDraw(s, sp, x, y, routeOffset);
            if (!COMPILED_SPRITE_WRITES_PLANES) {
                jlpSpriteDrawPlanes(s, sp, x, y);
            }
#if defined(JOEYLIB_PLATFORM_AMIGA) || defined(JOEYLIB_PLATFORM_ATARIST) || defined(JOEYLIB_PLATFORM_DOS)
            // P7-1 lever b: inline the dominant h==16 stage mark to drop
            // the spriteMarkDirty call boundary (see the restore arm in
            // jlSpriteRestoreUnder). Other heights / non-stage draws keep
            // the out-of-line mark.
            if (s == gStage && heightPx == 16) {
                SPRITE_MARK_STAGE_ROWS16(x, widthPx, y);
            } else {
                spriteMarkDirtyDraw(s, x, y, (int16_t)widthPx, (int16_t)heightPx);
            }
#else
            spriteMarkDirtyDraw(s, x, y, (int16_t)widthPx, (int16_t)heightPx);
#endif
            return;
        }
    }
    spriteDrawInterpreted(s, sp, x, y);
    jlpSpriteDrawPlanes(s, sp, x, y);
}


// TRUSTED draw (perf-hunt-2 rank 9) -- the missing member of the P7-1
// trusted family. Skips the NULL / on-surface gates jlSpriteDraw runs,
// going straight to the compiled draw + mark. Same trust contract as
// jlSpriteRestoreUnderTrusted (see joey/sprite.h); falls back to the
// fully-validated jlSpriteDraw for uncompiled sprites and on DOS /
// generic.
void jlSpriteDrawTrusted(jlSurfaceT *s, jlSpriteT *sp, int16_t x, int16_t y) {
#if defined(JOEYLIB_PLATFORM_AMIGA) || defined(JOEYLIB_PLATFORM_ATARIST) || defined(JOEYLIB_PLATFORM_DOS)
    if (sp->slot != NULL) {
        uint8_t  shift;
        uint16_t drawOffset;

        shift      = SPRITE_SHIFT_INDEX(x);
        drawOffset = SPRITE_ROUTE_OFFSET(sp, shift, SPRITE_OP_DRAW);
        if (drawOffset != SPRITE_NOT_COMPILED) {
            spriteCompiledDraw(s, sp, x, y, drawOffset);
            // Compiled 68k draws write planes directly
            // (COMPILED_SPRITE_WRITES_PLANES) -- no hook, exactly like
            // the trusted save-and-draw arm. P7-1 lever b inline mark.
            if (s == gStage && sp->heightPx == 16) {
                SPRITE_MARK_STAGE_ROWS16(x, sp->widthPx, y);
            } else {
                spriteMarkDirtyDraw(s, x, y, (int16_t)sp->widthPx, (int16_t)sp->heightPx);
            }
            return;
        }
    }
#elif defined(JOEYLIB_PLATFORM_IIGS)
    // Skip validation -> the fused stage draw+mark JSL (the same arm
    // jlSpriteDraw gates behind isFullyOnSurface). s == gStage only
    // (the fused draw derives the pinned stage base).
    if (sp->slot != NULL && s == gStage) {
        uint16_t fusedOffset;

        fusedOffset = SPRITE_ROUTE_OFFSET(sp, SPRITE_SHIFT_INDEX(x), SPRITE_OP_DRAW);
        if (fusedOffset != SPRITE_NOT_COMPILED) {
            spriteCompiledDrawMark(sp, x, y, fusedOffset);
            return;
        }
    }
#endif
    jlSpriteDraw(s, sp, x, y);
}


void jlSpritePrewarm(jlSpriteT *sp) {
    (void)jlSpriteCompile(sp);
}


uint32_t jlSpriteCompiledSize(const jlSpriteT *sp) {
    if (sp == NULL || sp->slot == NULL) {
        return 0u;
    }
    return sp->slot->size;
}


// Combined save-then-draw fast path. Routes both ops through the
// compiled save/draw entry points after a single shared validation
// pass. Falls back to calling the public jlSpriteSaveUnder + jlSpriteDraw
// when the fast path isn't applicable -- semantically identical, just
// pays the dispatcher overhead twice.
void jlSpriteSaveAndDraw(jlSurfaceT *s, jlSpriteT *sp, int16_t x, int16_t y, jlSpriteBackupT *backup) {
    uint16_t    widthPx;
    uint16_t    heightPx;
    ArenaSlotT *slot;

    if (s == NULL || sp == NULL || backup == NULL) {
        return;
    }
    backup->sprite    = sp;
    backup->sizeBytes = 0;

    slot     = sp->slot;
    widthPx  = sp->widthPx;
    heightPx = sp->heightPx;

#if defined(JOEYLIB_PLATFORM_IIGS)
    // Fused pair (W1): compiled save + fused draw. iigsSpriteDrawMark
    // widens the pair's ONE dirty rect (the same rect the shared arm
    // marks at its tail), so there is no separate C mark here.
    // Non-stage destinations and uncompiled sprites fall through to
    // the public-API fallback below (non-stage SaveAndDraw then runs
    // the compiled save + generic draw through those calls).
    if (slot != NULL && backup->bytes != NULL && s == gStage
            && isFullyOnSurface(x, y, widthPx, heightPx)) {
        uint8_t  shift;
        uint8_t  saveClass;
        uint16_t saveOffset;
        uint16_t drawOffset;

        shift      = SPRITE_SHIFT_INDEX(x);
        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) {
            spriteCompiledSaveUnderStage(sp, x, y, backup, saveClass, saveOffset);
            spriteCompiledDrawMark (sp, x, y, drawOffset);
            return;
        }
    }
#else
    // Fast path: compiled bytes available, fully on surface, backup
    // buffer supplied. Save fills out backup->{x,y,width,height,
    // sizeBytes}; draw reuses (x,y,widthPx,heightPx) for the dirty
    // mark. One mark instead of two (save doesn't dirty -- it's a
    // read; only draw dirties). shift and both route offsets are
    // derived once here and handed to the callees (#29).
    if (slot != NULL && backup->bytes != NULL && SPRITE_BACKUP_PTR_OK(backup->bytes)
            && isFullyOnSurface(x, y, widthPx, heightPx)) {
        uint8_t  shift;
        uint8_t  saveClass;
        uint16_t saveOffset;
        uint16_t drawOffset;

        // 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);
        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, 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);
                jlpSpriteDrawPlanes(s, sp, x, y);
            }
#if defined(JOEYLIB_PLATFORM_AMIGA) || defined(JOEYLIB_PLATFORM_ATARIST) || defined(JOEYLIB_PLATFORM_DOS)
            // P7-1 lever b: inline the h==16 stage mark (see jlSpriteDraw).
            if (s == gStage && heightPx == 16) {
                SPRITE_MARK_STAGE_ROWS16(x, widthPx, y);
            } else {
                spriteMarkDirtyDraw(s, x, y, (int16_t)widthPx, (int16_t)heightPx);
            }
#else
            spriteMarkDirtyDraw(s, x, y, (int16_t)widthPx, (int16_t)heightPx);
#endif
            return;
        }
    }
#endif

    // Fall back to the slow paths through the public API. These
    // pay the full dispatcher chain twice but handle every edge
    // case (interpreter, partial clip, no-backup-buffer modes).
    jlSpriteSaveUnder(s, sp, x, y, backup);
    jlSpriteDraw     (s, sp, x, y);
}


// TRUSTED save-and-draw (P7-1). The inner-loop companion to
// jlSpriteRestoreUnderTrusted: skips the NULL / on-surface / backup
// gates that jlSpriteSaveAndDraw runs, going straight to the compiled
// save + draw + mark. Same trust contract as jlSpriteRestoreUnderTrusted
// (see joey/sprite.h). Optimized on Amiga/ST (compiled save + draw +
// inline h==16 mark) and IIgs (compiled save + the fused draw+mark JSL,
// mirroring the safe fused arm, s==gStage only); falls back to the
// fully-validated jlSpriteSaveAndDraw for uncompiled sprites and on
// DOS / generic.
void jlSpriteSaveAndDrawTrusted(jlSurfaceT *s, jlSpriteT *sp, int16_t x, int16_t y, jlSpriteBackupT *backup) {
#if defined(JOEYLIB_PLATFORM_AMIGA) || defined(JOEYLIB_PLATFORM_ATARIST) || defined(JOEYLIB_PLATFORM_DOS)
    ArenaSlotT *slot;

    slot = sp->slot;
    if (slot != NULL) {
        uint8_t  shift;
        uint8_t  saveClass;
        uint16_t saveOffset;
        uint16_t drawOffset;

        shift      = SPRITE_SHIFT_INDEX(x);
        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) {
            backup->sprite = sp;
            spriteCompiledSaveUnder(s, sp, x, y, backup, saveClass, saveOffset);
            spriteCompiledDraw    (s, sp, x, y, drawOffset);
            if (s == gStage && sp->heightPx == 16) {
                SPRITE_MARK_STAGE_ROWS16(x, sp->widthPx, y);
            } else {
                spriteMarkDirtyDraw(s, x, y, (int16_t)sp->widthPx, (int16_t)sp->heightPx);
            }
            return;
        }
    }
#elif defined(JOEYLIB_PLATFORM_IIGS)
    // Skip validation -> the safe fused stage save-and-draw arm
    // (compiled save + spriteCompiledDrawMark's one fused draw+widen JSL).
    // s == gStage only (the fused draw derives the pinned stage base).
    if (sp->slot != NULL && s == gStage) {
        uint8_t  shift;
        uint8_t  saveClass;
        uint16_t saveOffset;
        uint16_t drawOffset;

        shift      = SPRITE_SHIFT_INDEX(x);
        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) {
            spriteCompiledSaveUnderStage(sp, x, y, backup, saveClass, saveOffset);
            spriteCompiledDrawMark (sp, x, y, drawOffset);
            return;
        }
    }
#endif
    jlSpriteSaveAndDraw(s, sp, x, y, backup);
}


// Defragment the codegen arena (compiled sprite slots). The live .spr
// on-disk format is documented beside the loader in core/assetLoad.c.
void jlSpriteCompact(void) {
    codegenArenaCompact();
}


uint32_t jlSpriteCodegenBytesTotal(void) {
    return codegenArenaBytesTotal();
}


uint32_t jlSpriteCodegenBytesUsed(void) {
    return codegenArenaBytesUsed();
}


void jlSpriteRestoreUnder(jlSurfaceT *s, const jlSpriteBackupT *backup) {
    /* Fast-path locals only. Slow-path uses an inner block. */
    int16_t  bx;
    int16_t  by;
    uint16_t bw;
    uint16_t bh;
    jlSpriteT *sp;
    int16_t  copyBytes;
    uint8_t  shift;

    if (s == NULL || backup == NULL) {
        return;
    }
    bx = backup->x;
    by = backup->y;
    bw = backup->width;
    bh = backup->height;

    /* Validate. Note: SURFACE_WIDTH - bx and SURFACE_HEIGHT - by stay
     * in uint16_t range once bx >= 0 / by >= 0 has been checked, so
     * the right-edge / bottom-edge tests don't need 32-bit arithmetic
     * (which would invoke a costly long-compare helper). */
    if (backup->bytes == NULL ||
        bw == 0 || bh == 0 ||
        bx < 0 || by < 0 ||
        bx >= SURFACE_WIDTH || by >= SURFACE_HEIGHT ||
        bw > (uint16_t)(SURFACE_WIDTH  - bx) ||
        bh > (uint16_t)(SURFACE_HEIGHT - by) ||
        (bx & 1) || (bw & 1)) {
        return;
    }

    sp = backup->sprite;
    if (sp != NULL && sp->slot != NULL && bh == sp->heightPx
            && SPRITE_BACKUP_PTR_OK(backup->bytes)) {
        uint16_t routeOffset;
        copyBytes = (int16_t)(bw >> 1);
        // The compiled restore routine walks a FIXED per-row byte count,
        // so the class must be derived from the backup's RECORDED
        // geometry -- never from x. SPRITE_RESTORE_CLASS returns 0/1
        // only for a backup whose geometry proves the compiled format
        // (chunky: copyBytes == bytesPerRow or +1, exactly the historic
        // check; planar: 16-px-aligned bx AND bw plus a class width --
        // the bx term is correctness-critical, see spriteInternal.h),
        // and 0xFF for everything else (horizontal clips, walker-format
        // backups), which falls through to the interpreted paths that
        // honor copyBytes.
        shift = SPRITE_RESTORE_CLASS(sp, bx, bw, (uint16_t)copyBytes);
        if (shift != 0xFFu) {
#if defined(JOEYLIB_PLATFORM_IIGS)
            // Fused stage restore (W1): compiled MVN body + dirty-band
            // widen in one JSL; bank patch stays in C (rarely taken).
            if (s == gStage) {
                routeOffset = SPRITE_ROUTE_OFFSET(sp, shift, SPRITE_OP_RESTORE);
                if (routeOffset != SPRITE_NOT_COMPILED) {
                    spriteCompiledRestoreMark(sp, backup, shift, routeOffset, bx, by, bw, bh);
                    return;
                }
            }
#elif defined(JOEYLIB_PLATFORM_AMIGA) || defined(JOEYLIB_PLATFORM_ATARIST) || defined(JOEYLIB_PLATFORM_DOS)
            // Fused stage restore (P7-1 lever b): the compiled copy body
            // stays INLINED (spriteCompiledRestoreUnder is always-inline,
            // sharing the backup-field loads with the validation above),
            // and for the dominant h==16 window the dirty-band mark is
            // INLINED here too -- eliminating the call boundary to the
            // general spriteMarkDirty (jsr + 7-register movem + h-dispatch,
            // measured ~26-29% of a 68k restore). Two asm variants (a
            // body+mark shim; a lean rolled-loop widen call) both REGRESSED
            // both ports 2026-07-11: each KEPT a call boundary and a rolled
            // loop lost to gcc's unrolled indexed mark. Only eliminating
            // the call (inlining the unrolled 16-row widen) can capture the
            // win. The band arithmetic is SURFACE_WORD_INDEX +
            // SPRITE_MARK_ROW, byte-identical to spriteMarkDirty (a
            // HASH-INVISIBLE contract). Non-h16 windows and non-stage
            // restores keep the split spriteMarkDirty path.
            if (s == gStage && bh == 16) {
                routeOffset = SPRITE_ROUTE_OFFSET(sp, shift, SPRITE_OP_RESTORE);
                if (routeOffset != SPRITE_NOT_COMPILED) {
                    spriteCompiledRestoreUnder(s, backup, shift, routeOffset);
                    SPRITE_MARK_STAGE_ROWS16(bx, bw, by);
                    return;
                }
            }
#endif
            routeOffset = SPRITE_ROUTE_OFFSET(sp, shift, SPRITE_OP_RESTORE);
            if (routeOffset != SPRITE_NOT_COMPILED) {
                spriteCompiledRestoreUnder(s, backup, shift, routeOffset);
                if (!COMPILED_SPRITE_WRITES_PLANES) {
                    jlpSpriteRestorePlanes(s, bx, by, bw, bh, backup->bytes);
                }
                spriteMarkDirtyRestore(s, bx, by, (int16_t)bw, (int16_t)bh);
                return;
            }
        }
    }

    /* Slow / interpreted memcpy fallback. Skip the chunky memcpy if
     * the port has no chunky shadow (Phase 9 Amiga: s->pixels NULL);
     * jlpSpriteRestorePlanes below does the planar restore. */
    if (s->pixels != NULL) {
        int16_t        row;
        uint8_t       *dstRow;
        const uint8_t *srcRow;

        // #19: seed both row cursors once -- SURFACE_ROW_OFFSET is the
        // blessed row-address mechanism (LUT read on IIgs, shift-add
        // elsewhere) -- then walk them with constant-stride adds so no
        // multiply helper runs per row.
        copyBytes = (int16_t)(bw >> 1);
        dstRow    = &s->pixels[SURFACE_ROW_OFFSET((uint16_t)by) + (uint16_t)(bx >> 1)];
        srcRow    = backup->bytes;
        for (row = 0; row < (int16_t)bh; row++) {
            memcpy(dstRow, srcRow, (size_t)copyBytes);
            dstRow += SURFACE_BYTES_PER_ROW;
            srcRow += copyBytes;
        }
    }
    jlpSpriteRestorePlanes(s, bx, by, bw, bh, backup->bytes);
    spriteMarkDirty(s, bx, by, (int16_t)bw, (int16_t)bh);
}


// TRUSTED restore (P7-1). Public contract in joey/sprite.h: the caller
// guarantees a valid, on-surface, compiled-format backup, so the
// geometry-validation chain jlSpriteRestoreUnder runs on EVERY call
// (~11 range/alignment checks + the NULL / sprite-slot gates, measured
// as ~422 cyc ST / ~672 Amiga -- a meaningful slice of a restore whose
// copy body is cheap) is skipped here. Only the class/route derivation
// (needed to pick the compiled routine) and the NOT_COMPILED gate stay:
// the gate both prevents jumping into garbage AND routes anything the
// trusted fast path does not recognise to the fully-validated fallback,
// so a conservative caller degrades safely. Optimized on Amiga/ST (the
// inline h==16 mark) and on IIgs (skip validation -> the fused MVN
// restore+mark JSL, mirroring the safe fused arm); on DOS / generic this
// is just the validated call.
void jlSpriteRestoreUnderTrusted(jlSurfaceT *s, const jlSpriteBackupT *backup) {
#if defined(JOEYLIB_PLATFORM_AMIGA) || defined(JOEYLIB_PLATFORM_ATARIST) || defined(JOEYLIB_PLATFORM_DOS)
    jlSpriteT *sp;
    int16_t    bx;
    int16_t    by;
    uint16_t   bw;
    uint16_t   bh;
    uint8_t    shift;

    sp    = backup->sprite;
    bx    = backup->x;
    by    = backup->y;
    bw    = backup->width;
    bh    = backup->height;
    shift = SPRITE_RESTORE_CLASS(sp, bx, bw, (uint16_t)(bw >> 1));
    if (shift != 0xFFu && s == gStage && bh == 16) {
        uint16_t routeOffset;

        routeOffset = SPRITE_ROUTE_OFFSET(sp, shift, SPRITE_OP_RESTORE);
        if (routeOffset != SPRITE_NOT_COMPILED) {
            spriteCompiledRestoreUnder(s, backup, shift, routeOffset);
            SPRITE_MARK_STAGE_ROWS16(bx, bw, by);
            return;
        }
    }
#elif defined(JOEYLIB_PLATFORM_IIGS)
    // Skip the validation chain -> the safe fused stage-restore arm
    // (spriteCompiledRestoreMark: compiled MVN body + dirty-band widen in
    // one JSL). The class/route derive + NOT_COMPILED gate stay (route to
    // the safe fallback for an uncompiled shift / non-stage window).
    jlSpriteT *sp;
    int16_t    bx;
    int16_t    by;
    uint16_t   bw;
    uint16_t   bh;
    uint8_t    shift;

    sp    = backup->sprite;
    bx    = backup->x;
    by    = backup->y;
    bw    = backup->width;
    bh    = backup->height;
    shift = SPRITE_RESTORE_CLASS(sp, bx, bw, (uint16_t)(bw >> 1));
    if (shift != 0xFFu && s == gStage) {
        uint16_t routeOffset;

        routeOffset = SPRITE_ROUTE_OFFSET(sp, shift, SPRITE_OP_RESTORE);
        if (routeOffset != SPRITE_NOT_COMPILED) {
            spriteCompiledRestoreMark(sp, backup, shift, routeOffset, bx, by, bw, bh);
            return;
        }
    }
#endif
    jlSpriteRestoreUnder(s, backup);
}


void jlSpriteSaveUnder(const jlSurfaceT *s, jlSpriteT *sp, int16_t x, int16_t y, jlSpriteBackupT *backup) {
    /* Only fast-path locals here. Slow-path declarations live inside
     * the slow-path block below so the compiler doesn't reserve
     * stack frame for them on every fast-path call. */
    uint16_t       widthPx;
    uint16_t       heightPx;
    ArenaSlotT    *slot;
    uint8_t        shift;

    if (s == NULL || sp == NULL || backup == NULL) {
        return;
    }
    backup->sprite    = sp;
    backup->sizeBytes = 0;

    slot     = sp->slot;
    widthPx  = sp->widthPx;
    heightPx = sp->heightPx;

    // Compiled fast path: fully on surface and the platform emitted
    // bytes for SAVE at this shift. The compiled routine assumes a
    // full-size, unclipped rectangle, so anything off-edge falls
    // through to the interpreted memcpy loop below.
    if (backup->bytes != NULL && slot != NULL && SPRITE_BACKUP_PTR_OK(backup->bytes)
            && isFullyOnSurface(x, y, widthPx, heightPx)) {
        uint16_t routeOffset;
        // 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 defined(JOEYLIB_PLATFORM_IIGS)
        // Fused stage arm (rank 10): asm-side screen-offset derive +
        // one JSL. Non-stage saves keep the generic dispatch below.
        if (routeOffset != SPRITE_NOT_COMPILED && s == gStage) {
            spriteCompiledSaveUnderStage(sp, x, y, backup, shift, routeOffset);
            return;
        }
#endif
        if (routeOffset != SPRITE_NOT_COMPILED) {
            spriteCompiledSaveUnder(s, sp, x, y, backup, shift, routeOffset);
            if (!COMPILED_SPRITE_WRITES_PLANES) {
                jlpSpriteSavePlanes(s, backup->x, backup->y, backup->width, backup->height, backup->bytes);
            }
            return;
        }
    }

    /* Slow / fallback path: clipping + interpreted memcpy. */
    {
        int16_t        dx;
        int16_t        dy;
        int16_t        sx;
        int16_t        sy;
        int16_t        w;
        int16_t        h;
        int16_t        row;
        int16_t        byteStart;
        int16_t        copyBytes;
        int16_t        clippedX;
        int16_t        clippedW;
        const uint8_t *srcRow;

        dx = x;
        dy = y;
        w  = (int16_t)widthPx;
        h  = (int16_t)heightPx;

    if (!clipRect(&dx, &dy, &sx, &sy, &w, &h)) {
        backup->x      = 0;
        backup->y      = 0;
        backup->width  = 0;
        backup->height = 0;
        return;
    }
    // Round x DOWN and width UP to byte boundaries so we always grab
    // entire 4bpp-packed bytes. RestoreUnder requires byte alignment.
    clippedX = (int16_t)(dx & ~1);
    clippedW = (int16_t)(((dx + w) - clippedX + 1) & ~1);
    if (clippedX + clippedW > SURFACE_WIDTH) {
        clippedW = SURFACE_WIDTH - clippedX;
    }

    backup->x      = clippedX;
    backup->y      = dy;
    backup->width  = (uint16_t)clippedW;
    backup->height = (uint16_t)h;

    byteStart = (int16_t)(clippedX >> 1);
    copyBytes = (int16_t)(clippedW >> 1);
    backup->sizeBytes = (uint16_t)(copyBytes * h);

    if (backup->bytes == NULL) {
        // Caller didn't supply a byte buffer; we just record the
        // 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) || defined(JOEYLIB_PLATFORM_X68000)
        // 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.
        // X68000 takes that same compiled window path (spriteDispatch.h's
        // shared planar arm), so it needs the widening for the same reason.
        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).
     * jlpSpriteSavePlanes below covers the planar case. */
    if (s->pixels != NULL) {
        uint8_t *dstBytes;

        // #19: seed both row cursors once (SURFACE_ROW_OFFSET is the
        // blessed row-address mechanism) and walk them with
        // constant-stride adds; no per-row multiply helpers.
        srcRow   = &s->pixels[SURFACE_ROW_OFFSET((uint16_t)dy) + (uint16_t)byteStart];
        dstBytes = backup->bytes;
        for (row = 0; row < h; row++) {
            memcpy(dstBytes, srcRow, (size_t)copyBytes);
            srcRow   += SURFACE_BYTES_PER_ROW;
            dstBytes += copyBytes;
        }
    }
    jlpSpriteSavePlanes(s, clippedX, dy, (uint16_t)clippedW, (uint16_t)h, backup->bytes);
    }   /* end slow path */
}


// TRUSTED save-under (P7-1). Skips the NULL / on-surface / backup gates
// jlSpriteSaveUnder runs, going straight to the compiled save. Same
// trust contract as jlSpriteRestoreUnderTrusted (see joey/sprite.h);
// keeps backup->sprite (the Amiga/ST compiled save does not set it, and
// jlSpriteRestoreUnder* reads it; the IIgs compiled save sets it too, so
// the write is redundant-but-harmless there). Optimized on Amiga/ST/IIgs
// (identical: the compiled save is a read, so there is no fused entry --
// just skip validation; the IIgs plane hook is a no-op); falls back to
// the fully-validated jlSpriteSaveUnder for uncompiled sprites and on
// DOS / generic.
void jlSpriteSaveUnderTrusted(const jlSurfaceT *s, jlSpriteT *sp, int16_t x, int16_t y, jlSpriteBackupT *backup) {
#if defined(JOEYLIB_PLATFORM_AMIGA) || defined(JOEYLIB_PLATFORM_ATARIST) || defined(JOEYLIB_PLATFORM_IIGS)
    ArenaSlotT *slot;

    slot = sp->slot;
    if (slot != NULL) {
        uint8_t  shift;
        uint16_t routeOffset;

        shift       = SPRITE_SAVE_CLASS(sp, x);
        routeOffset = SPRITE_ROUTE_OFFSET(sp, shift, SPRITE_OP_SAVE);
        if (routeOffset != SPRITE_NOT_COMPILED) {
#if !defined(JOEYLIB_PLATFORM_IIGS)
            // 68k compiled saves do not set backup->sprite; both IIgs
            // arms do (asm fill / generic dispatch), so IIgs skips the
            // redundant far store (P7-2 stage 2).
            backup->sprite = sp;
#endif
#if defined(JOEYLIB_PLATFORM_IIGS)
            // Fused stage arm (rank 10); trusted callers target the
            // stage, but keep the generic dispatch for any other dst.
            if (s == gStage) {
                spriteCompiledSaveUnderStage(sp, x, y, backup, shift, routeOffset);
                return;
            }
#endif
            spriteCompiledSaveUnder(s, sp, x, y, backup, shift, routeOffset);
            return;
        }
    }
#endif
    jlSpriteSaveUnder(s, sp, x, y, backup);
}


// ----- Internal cross-TU entry points (spriteInternal.h) -----

// Initialize a freshly-allocated jlSpriteT's fields to the
// uncompiled/default state and register it for spriteSystemShutdown
// (see the registry comment near the top of this file). The single
// init path for every sprite: jlSpriteCreate, jlSpriteCreateFromSurface,
// and jlSpriteBankLoad (assetLoad.c) all route through here so the
// 0xFF/0 sentinel rules in spriteResetCompiledState have one source
// of truth (PERF-AUDIT.md #32).
void spriteInitFields(jlSpriteT *sp, const uint8_t *tileData, uint8_t widthTiles, uint8_t heightTiles, bool ownsTileData) {
    sp->tileData     = tileData;
    sp->widthTiles   = widthTiles;
    sp->heightTiles  = heightTiles;
    sp->widthPx      = (uint16_t)(widthTiles  * TILE_PIXELS_PER_SIDE);
    sp->heightPx     = (uint16_t)(heightTiles * TILE_PIXELS_PER_SIDE);
    sp->ownsTileData = ownsTileData;
    spriteResetCompiledState(sp);
    spriteRegistryAdd(sp);
}


// Register a sprite for spriteSystemShutdown. Idempotent (re-adding a
// registered sprite is a no-op); every creation path registers via
// spriteInitFields, and jlSpriteCompile re-registers on success as a
// belt-and-braces guard since compile is the moment the sprite first
// holds arena state that jlShutdown must reset. If the registry is
// full the sprite is silently not registered -- see the cap comment
// above.
void spriteRegistryAdd(jlSpriteT *sp) {
    uint8_t i;
    int16_t freeIdx;

    freeIdx = -1;
    for (i = 0; i < SPRITE_REGISTRY_CAP; i++) {
        if (gSpriteRegistry[i] == sp) {
            return;
        }
        if (freeIdx < 0 && gSpriteRegistry[i] == NULL) {
            freeIdx = (int16_t)i;
        }
    }
    if (freeIdx >= 0) {
        gSpriteRegistry[freeIdx] = sp;
    }
}


// Called from jlShutdown BEFORE codegenArenaShutdown (#34). Resets
// every registered sprite's compiled-path state so a sprite surviving
// a shutdown -> re-init cycle can neither free a stale ArenaSlotT
// (heap corruption) nor execute stale offsets inside the new arena.
// Does NOT free anything -- codegenArenaShutdown releases the slot
// structs and arena bytes right after. Sprites stay registered: they
// remain live user objects and may be recompiled after re-init.
void spriteSystemShutdown(void) {
    uint8_t i;

    for (i = 0; i < SPRITE_REGISTRY_CAP; i++) {
        jlSpriteT *sp;

        sp = gSpriteRegistry[i];
        if (sp != NULL) {
            spriteResetCompiledState(sp);
        }
    }
}
