// Sharp X68000 planar graphics HAL.
//
// Adapted from src/amiga/hal.c: a 320px 4bpp surface is 40 bytes per row per
// plane on any planar machine, so the surface layout and every primitive that
// walks it are shared logic. What changed:
//   - AmigaPlanarT -> X68kPlanarT (same fields; see x68kPlanar.h)
//   - Chip RAM AllocMem -> calloc; nothing but the CPU reads these planes
//   - the four blitter helpers -> CPU equivalents below (no blitter here, and
//     unlike the ST there is no STE DMA either -- it is pure 68000)
// The display side (scattering to the text plane at stride 128) lives in
// hal.c's jlpPresent, not here.

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

#include "port.h"
#include "surfaceInternal.h"
#include "joey/debug.h"
#include "spriteInternal.h"
#include "x68kPlanar.h"

// Blitter first/last-word mask "all bits" -- the CPU fill takes the same value.
#define X68K_MASK_ALL 0xFFFFu

// The Amiga picks between blitter and CPU walk at 16 per-plane words because
// the blitter's fixed setup loses below that. Here BOTH paths are the CPU, and
// the memcpy path wins at essentially any size (it is one call per row instead
// of a per-word store loop), so the threshold is 0: always take it.
#define X68K_COPYRECT_MEMCPY_MIN_WORDS 0u



// ----- C stand-ins for the shared 68k asm -----
//
// src/m68k/surface68k.s and src/amiga/circle.s export these, but their symbols
// carry the m68k-amigaos/mint leading underscore (_surface68kFillSpan4Planes)
// and elf2x68k is ELF, which has no such prefix. Rather than fork the asm for
// an ABI difference, the port uses C for now; adopting the asm later is a
// symbol-aliasing change, not a rewrite. These are the obvious optimisation
// targets once correctness is banked.

// Fill one horizontal span across all four planes: a partial left byte, a run
// of whole middle bytes, then a partial right byte. fbN is the fill byte for
// plane N (0x00 or 0xFF), so a colour is applied by writing each plane's bit.
static void surface68kFillSpan4Planes(uint8_t *p0, uint8_t *p1, uint8_t *p2, uint8_t *p3,
                                      uint16_t numMid, uint8_t leftMask, uint8_t rightMask,
                                      uint8_t fb0, uint8_t fb1, uint8_t fb2, uint8_t fb3) {
    uint8_t *pl[X68K_BITPLANES];
    uint8_t  fb[X68K_BITPLANES];
    uint16_t i;
    uint16_t m;

    pl[0] = p0; pl[1] = p1; pl[2] = p2; pl[3] = p3;
    fb[0] = fb0; fb[1] = fb1; fb[2] = fb2; fb[3] = fb3;
    for (i = 0u; i < X68K_BITPLANES; i++) {
        uint8_t *d = pl[i];
        if (leftMask != 0u) {
            *d = (uint8_t)((*d & (uint8_t)~leftMask) | (fb[i] & leftMask));
        }
        d++;
        for (m = 0u; m < numMid; m++) {
            *d++ = fb[i];
        }
        if (rightMask != 0u) {
            *d = (uint8_t)((*d & (uint8_t)~rightMask) | (fb[i] & rightMask));
        }
    }
}


// Bresenham midpoint circle outline, plotted straight into the planes. Caller
// has verified the whole bbox is on-surface, so there is no per-pixel clip.
static void x68kPlotPlanes(uint8_t *pl[X68K_BITPLANES], uint16_t x, uint16_t y, uint8_t color) {
    uint32_t off;
    uint8_t  bit;
    uint16_t i;

    off = ((uint32_t)y * X68K_BYTES_PER_ROW) + (uint32_t)(x >> 3);
    bit = (uint8_t)(0x80u >> (x & 7u));
    for (i = 0u; i < X68K_BITPLANES; i++) {
        if ((color >> i) & 1u) {
            pl[i][off] |= bit;
        } else {
            pl[i][off] = (uint8_t)(pl[i][off] & (uint8_t)~bit);
        }
    }
}


static void surface68kAmigaCircleOutline(uint8_t *p0, uint8_t *p1, uint8_t *p2, uint8_t *p3,
                                         uint16_t cx, uint16_t cy, uint16_t r, uint8_t color) {
    uint8_t *pl[X68K_BITPLANES];
    int16_t  x;
    int16_t  y;
    int16_t  d;

    pl[0] = p0; pl[1] = p1; pl[2] = p2; pl[3] = p3;
    x = 0;
    y = (int16_t)r;
    d = (int16_t)(1 - (int16_t)r);
    while (x <= y) {
        x68kPlotPlanes(pl, (uint16_t)(cx + x), (uint16_t)(cy + y), color);
        x68kPlotPlanes(pl, (uint16_t)(cx - x), (uint16_t)(cy + y), color);
        x68kPlotPlanes(pl, (uint16_t)(cx + x), (uint16_t)(cy - y), color);
        x68kPlotPlanes(pl, (uint16_t)(cx - x), (uint16_t)(cy - y), color);
        x68kPlotPlanes(pl, (uint16_t)(cx + y), (uint16_t)(cy + x), color);
        x68kPlotPlanes(pl, (uint16_t)(cx - y), (uint16_t)(cy + x), color);
        x68kPlotPlanes(pl, (uint16_t)(cx + y), (uint16_t)(cy - x), color);
        x68kPlotPlanes(pl, (uint16_t)(cx - y), (uint16_t)(cy - x), color);
        // The tie d == 0 must take the NO-SHRINK branch. src/core/draw.c:428
        // uses `err <= 0` and src/amiga/circle.s:149 uses tst.w/bgt (shrink
        // only when err > 0); `d < 0` shrinks the major axis on the tie and
        // plots a different pixel set for 114 of the 199 reachable radii.
        if (d <= 0) {
            d = (int16_t)(d + (2 * x) + 3);
        } else {
            d = (int16_t)(d + (2 * (x - y)) + 5);
            y--;
        }
        x++;
    }
}


// ----- CPU stand-ins for the Amiga blitter helpers -----
//
// Signatures match the originals exactly so the adapted call sites are
// untouched. widthWords counts 16-bit words; modBytes is added to the pointer
// after each row (0 for a full-stride operation).

// DEST-only block fill: `height` rows of `widthWords` words set or cleared.
static void x68kCpuFillOne(uint8_t *dst, uint16_t widthWords, uint16_t height,
                           uint16_t dstModBytes, bool setBits) {
    uint16_t  row;
    uint16_t  w;
    uint16_t *p;
    uint16_t  val;

    val = setBits ? 0xFFFFu : 0x0000u;
    for (row = 0u; row < height; row++) {
        p = (uint16_t *)dst;
        for (w = 0u; w < widthWords; w++) {
            *p++ = val;
        }
        dst += (widthWords * 2u) + dstModBytes;
    }
}


// Masked fill: as above, but the first and last words are merged through
// `fwm`/`lwm` so a rect can start and end mid-word. The Amiga does this with
// the blitter's first/last-word masks; here it is an explicit read-modify-write
// on just those two words, with the interior written whole.
static void x68kCpuMaskedFillOne(uint8_t *dst, uint16_t widthWords, uint16_t height,
                                 uint16_t dstModBytes, uint16_t fwm, uint16_t lwm,
                                 bool setBits) {
    uint16_t  row;
    uint16_t  w;
    uint16_t *p;
    uint16_t  val;
    uint16_t  mask;

    val = setBits ? 0xFFFFu : 0x0000u;
    for (row = 0u; row < height; row++) {
        p = (uint16_t *)dst;
        for (w = 0u; w < widthWords; w++) {
            mask = 0xFFFFu;
            if (w == 0u) {
                mask &= fwm;
            }
            if (w == (uint16_t)(widthWords - 1u)) {
                mask &= lwm;
            }
            if (mask == 0xFFFFu) {
                *p = val;
            } else {
                *p = (uint16_t)((*p & (uint16_t)~mask) | (val & mask));
            }
            p++;
        }
        dst += (widthWords * 2u) + dstModBytes;
    }
}


// Copy the same rect out of all four planes.
static void x68kCpuCopyPlanes(uint8_t *dst[X68K_BITPLANES], uint8_t *src[X68K_BITPLANES],
                              uint16_t offsetBytes, uint16_t widthWords, uint16_t height,
                              uint16_t modBytes) {
    uint16_t i;
    uint16_t row;
    uint16_t rowBytes;
    uint8_t *d;
    uint8_t *s;

    rowBytes = (uint16_t)(widthWords * 2u);
    for (i = 0u; i < X68K_BITPLANES; i++) {
        d = dst[i] + offsetBytes;
        s = src[i] + offsetBytes;
        for (row = 0u; row < height; row++) {
            memcpy(d, s, rowBytes);
            d += rowBytes + modBytes;
            s += rowBytes + modBytes;
        }
    }
}


void jlpSurfaceClear(jlSurfaceT *s, uint8_t doubled) {
    X68kPlanarT *pd;
    uint8_t       colorIndex;
    uint16_t      i;
    bool          setBits;

    if (s->portData == NULL) {
        jlpGenericSurfaceClear(s, doubled);
        return;
    }
    colorIndex = (uint8_t)(doubled & 0x0Fu);
    pd = (X68kPlanarT *)s->portData;

    // Blitter DEST-only block fill of every whole plane to $00 (clear)
    // or $FF (set). Each plane is 200 rows x 20 words, contiguous, so
    // BLTSIZE = (200 << 6) | 20 and BLTDMOD = 0. The planes are now
    // CHIP RAM (see jlpSurfaceAllocPortData) so the blitter can reach
    // them. Own the blitter once and fill all four planes; the final
    // CPU fill: no blitter to own or wait on, so the planes are complete
    // written before any CPU read (a subsequent draw or the present
    // copy) touches them.

    for (i = 0; i < X68K_BITPLANES; i++) {
        setBits = ((colorIndex >> i) & 1u) != 0u;
        x68kCpuFillOne(pd->planes[i], (X68K_BYTES_PER_ROW / 2u), SURFACE_HEIGHT, 0u, setBits);
    }

}


// Planar jlFillRect: fills the four off-screen shadow plane buffers via
// the blitter. Caller (cross-platform jlFillRect) has already clipped
// the rect to the surface, so x/y/w/h are on-surface. The shadow planes
// are off-screen, so this is invisible until jlStagePresent.
//
// Layout reminder (see docs/amiga_planar.md): each plane word covers 16
// horizontal pixels; bit 15 = leftmost pixel of that word. The blitter
// fills whole words and cuts the partial-pixel left/right edges with its
// hardware first/last-word masks (fwm/lwm), so a single DEST(+C) blit per
// plane handles any width -- no per-pixel-edge CPU read-modify-write.
// For each plane the bit value at every rect pixel is constant:
// (colorIndex >> plane) & 1; set bits OR the mask into the plane, clear
// bits AND its complement (see the two masked-fill minterms).
void jlpFillRect(jlSurfaceT *s, int16_t x, int16_t y, int16_t w, int16_t h, uint8_t colorIndex) {
    X68kPlanarT *pd;
    uint16_t      wordFirst;
    uint16_t      wordLast;
    uint16_t      widthWords;
    uint16_t      fwm;
    uint16_t      lwm;
    uint16_t      dstMod;
    uint16_t      plane;
    uint8_t       bitVal;
    uint8_t      *dst;

    // 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 = (X68kPlanarT *)s->portData;
    if (pd == NULL) {
        jlpGenericFillRect(s, x, y, w, h, colorIndex);
        return;
    }

    // Word-granular rect geometry. wordFirst/wordLast index the 16-pixel
    // plane words the rect touches; fwm/lwm are the blitter first/last-
    // word masks that cut the partial-pixel edges (pixel 0 is bit 15 of
    // its word, so the left mask drops the high bits of the first word
    // and the right mask drops the low bits of the last). One blit per
    // plane then fills any width, so there is no byte-aligned or
    // per-pixel-edge special case to follow.
    wordFirst  = (uint16_t)((uint16_t)x >> 4);
    wordLast   = (uint16_t)(((uint16_t)x + w - 1u) >> 4);
    widthWords = (uint16_t)(wordLast - wordFirst + 1u);
    fwm        = (uint16_t)(X68K_MASK_ALL >> ((uint16_t)x & 15u));
    lwm        = (uint16_t)(X68K_MASK_ALL << (15u - (((uint16_t)x + w - 1u) & 15u)));
    dstMod     = (uint16_t)(X68K_BYTES_PER_ROW - (widthWords << 1));

    /* Full-width fast path: the rect spans every plane word with both
     * edge masks all-ones, so each plane row is a whole 20-word store
     * with no destination read. jlFillRect 320x200 (full-screen wipes)
     * is the dominant case and lands here. DEST-only block fill --
     * (h << 6) | 20 words, BLTDMOD = 0 because consecutive full-width
     * rows are contiguous; set bits write $FFFF (minterm $FF), clear
     * bits $0000 (minterm $00). One OwnBlitter for all four planes;
     * CPU fill completes synchronously, so the planes are ready before
     * the present copy or any later CPU draw reads them. */
    if (wordFirst == 0u && wordLast == (uint16_t)((X68K_BYTES_PER_ROW / 2u) - 1u)
            && fwm == X68K_MASK_ALL && lwm == X68K_MASK_ALL) {
        uint8_t *rowBase;

        for (plane = 0; plane < X68K_BITPLANES; plane++) {
            bitVal  = (uint8_t)((colorIndex >> plane) & 1u);
            rowBase = pd->planes[plane] + (uint16_t)y * X68K_BYTES_PER_ROW;
            x68kCpuFillOne(rowBase, (X68K_BYTES_PER_ROW / 2u), (uint16_t)h, 0u, bitVal != 0u);
        }

        return;
    }

    /* General masked fill: A is the constant $FFFF cut to the rect by
     * the fwm/lwm word masks, C reads and D writes the destination so
     * bits outside the mask survive. One blit per plane covers any
     * width, partial pixel edges included -- the blitter does the edge
     * masking in hardware, replacing the old per-pixel-edge CPU walkers.
     * UBER jlFillRect 16x16 / 80x80 lands here. (jlFillCircle spans
     * left this path in hunt-2 rank 14: the exact-disk walk feeds
     * x68kFillSpanInline directly, CPU-side.) widthWords == 1 needs no
     * special case: the blitter ANDs both edge masks onto the single
     * word. */

    for (plane = 0; plane < X68K_BITPLANES; plane++) {
        bitVal = (uint8_t)((colorIndex >> plane) & 1u);
        dst    = pd->planes[plane]
                 + (uint16_t)y * X68K_BYTES_PER_ROW
                 + (uint16_t)(wordFirst << 1);
        x68kCpuMaskedFillOne(dst, widthWords, (uint16_t)h, dstMod, fwm, lwm, bitVal != 0u);
    }

}


// Phase 9 sprite save/restore plane-backup hooks.
// Sprite save at (x, y, w, h) records the destination region into the
// caller's backup buffer; restore writes it back. x and w are 2-pixel
// aligned by cross-platform code.
//
// Backup layout matches the CHUNKY-packed format the cross-platform
// contract sizes for ((widthTiles*4)+1)*(heightTiles*8) bytes (see
// include/joey/sprite.h): one byte per pixel-pair, 2 pixels/byte,
// high nibble = even (left) pixel, low nibble = odd (right) pixel,
// (w/2) bytes per row, (w/2)*h bytes total. This is the same format
// the Atari ST port uses and never overruns the contract buffer --
// unlike a plane-major layout, which would round x down to an 8-pixel
// boundary and width up and then write (4*roundedW/8)*h = up to
// (w/2 + 3)*h bytes for an odd-of-8 x, overrunning the buffer. Each
// pixel's 4bpp color is assembled from / written to the 4 plane bits
// at byte x>>3, bit 0x80 >> (x & 7).

void jlpSpriteSavePlanes(const jlSurfaceT *s, int16_t x, int16_t y, uint16_t w, uint16_t h, uint8_t *dstPlaneBytes) {
    const X68kPlanarT *pd;
    uint16_t            pairs;
    uint16_t            row;
    uint16_t            pair;
    uint8_t            *dst;

    pd = (const X68kPlanarT *)s->portData;
    if (pd == NULL || dstPlaneBytes == NULL || w == 0u || h == 0u) {
        return;
    }

    /* Byte-aligned fast path: raw plane-byte copy, no per-pixel c2p.
     * When x and w are both 8-px aligned and the rect is fully on
     * surface, each sprite tile column maps to whole plane bytes, so we
     * save the raw bytes -- ~8x cheaper than the chunky walker below and
     * an exact round-trip on restore (the sprite fills its bytes, so
     * restoring whole bytes touches no neighbor pixels). The backup row
     * holds plane0..plane3, each `wb` bytes, contiguous. The chunky
     * walker below stays as the arbitrary-x fallback. */
    if ((x & 7) == 0 && (w & 7) == 0
            && x >= 0 && (x + (int16_t)w) <= SURFACE_WIDTH
            && y >= 0 && (y + (int16_t)h) <= SURFACE_HEIGHT) {
        uint16_t wb;
        uint16_t wb2;
        uint16_t wb3;
        uint16_t startByte;
        uint16_t rowOff;
        uint8_t *bdst;
        uint16_t col;

        wb        = (uint16_t)(w >> 3);
        wb2       = (uint16_t)(wb << 1);
        wb3       = (uint16_t)(wb2 + wb);
        startByte = (uint16_t)((uint16_t)x >> 3);
        rowOff    = (uint16_t)((uint16_t)y * X68K_BYTES_PER_ROW + startByte);
        bdst      = dstPlaneBytes;
        for (row = 0; row < (uint16_t)h; row++) {
            const uint8_t *s0 = pd->planes[0] + rowOff;
            const uint8_t *s1 = pd->planes[1] + rowOff;
            const uint8_t *s2 = pd->planes[2] + rowOff;
            const uint8_t *s3 = pd->planes[3] + rowOff;
            for (col = 0; col < wb; col++) {
                bdst[col]       = s0[col];
                bdst[wb + col]  = s1[col];
                bdst[wb2 + col] = s2[col];
                bdst[wb3 + col] = s3[col];
            }
            bdst   += (uint16_t)(wb << 2);
            rowOff += X68K_BYTES_PER_ROW;
        }
        return;
    }

    pairs = (uint16_t)(w >> 1);
    for (row = 0; row < (uint16_t)h; row++) {
        const uint8_t *p0 = pd->planes[0] + ((uint16_t)y + row) * X68K_BYTES_PER_ROW;
        const uint8_t *p1 = pd->planes[1] + ((uint16_t)y + row) * X68K_BYTES_PER_ROW;
        const uint8_t *p2 = pd->planes[2] + ((uint16_t)y + row) * X68K_BYTES_PER_ROW;
        const uint8_t *p3 = pd->planes[3] + ((uint16_t)y + row) * X68K_BYTES_PER_ROW;
        dst = dstPlaneBytes + (uint16_t)(row * pairs);
        for (pair = 0; pair < pairs; pair++) {
            uint16_t px      = (uint16_t)(x + (int16_t)(pair << 1));
            uint16_t byteOff = (uint16_t)(px >> 3);
            uint8_t  maskHi  = (uint8_t)(0x80u >> (px & 7u));
            uint8_t  maskLo  = (uint8_t)(0x80u >> ((px + 1u) & 7u));
            uint16_t byteOffLo = (uint16_t)((px + 1u) >> 3);
            uint8_t  hi = 0u;
            uint8_t  lo = 0u;
            if (p0[byteOff]   & maskHi) { hi = (uint8_t)(hi | 0x01u); }
            if (p1[byteOff]   & maskHi) { hi = (uint8_t)(hi | 0x02u); }
            if (p2[byteOff]   & maskHi) { hi = (uint8_t)(hi | 0x04u); }
            if (p3[byteOff]   & maskHi) { hi = (uint8_t)(hi | 0x08u); }
            if (p0[byteOffLo] & maskLo) { lo = (uint8_t)(lo | 0x01u); }
            if (p1[byteOffLo] & maskLo) { lo = (uint8_t)(lo | 0x02u); }
            if (p2[byteOffLo] & maskLo) { lo = (uint8_t)(lo | 0x04u); }
            if (p3[byteOffLo] & maskLo) { lo = (uint8_t)(lo | 0x08u); }
            dst[pair] = (uint8_t)((hi << 4) | lo);
        }
    }
}


void jlpSpriteRestorePlanes(jlSurfaceT *s, int16_t x, int16_t y, uint16_t w, uint16_t h, const uint8_t *srcPlaneBytes) {
    X68kPlanarT  *pd;
    uint16_t       pairs;
    uint16_t       row;
    uint16_t       pair;
    const uint8_t *src;

    pd = (X68kPlanarT *)s->portData;
    if (pd == NULL || srcPlaneBytes == NULL || w == 0u || h == 0u) {
        return;
    }

    /* Byte-aligned fast path mirroring jlpSpriteSavePlanes: writes the
     * raw plane bytes straight back (whole-byte round-trip), skipping
     * the per-pixel chunky->planar masking of the walker below. */
    if ((x & 7) == 0 && (w & 7) == 0
            && x >= 0 && (x + (int16_t)w) <= SURFACE_WIDTH
            && y >= 0 && (y + (int16_t)h) <= SURFACE_HEIGHT) {
        uint16_t       wb;
        uint16_t       wb2;
        uint16_t       wb3;
        uint16_t       startByte;
        uint16_t       rowOff;
        const uint8_t *bsrc;
        uint16_t       col;

        wb        = (uint16_t)(w >> 3);
        wb2       = (uint16_t)(wb << 1);
        wb3       = (uint16_t)(wb2 + wb);
        startByte = (uint16_t)((uint16_t)x >> 3);
        rowOff    = (uint16_t)((uint16_t)y * X68K_BYTES_PER_ROW + startByte);
        bsrc      = srcPlaneBytes;
        for (row = 0; row < (uint16_t)h; row++) {
            uint8_t *d0 = pd->planes[0] + rowOff;
            uint8_t *d1 = pd->planes[1] + rowOff;
            uint8_t *d2 = pd->planes[2] + rowOff;
            uint8_t *d3 = pd->planes[3] + rowOff;
            for (col = 0; col < wb; col++) {
                d0[col] = bsrc[col];
                d1[col] = bsrc[wb + col];
                d2[col] = bsrc[wb2 + col];
                d3[col] = bsrc[wb3 + col];
            }
            bsrc   += (uint16_t)(wb << 2);
            rowOff += X68K_BYTES_PER_ROW;
        }
        return;
    }

    pairs = (uint16_t)(w >> 1);
    for (row = 0; row < (uint16_t)h; row++) {
        uint8_t *p0 = pd->planes[0] + ((uint16_t)y + row) * X68K_BYTES_PER_ROW;
        uint8_t *p1 = pd->planes[1] + ((uint16_t)y + row) * X68K_BYTES_PER_ROW;
        uint8_t *p2 = pd->planes[2] + ((uint16_t)y + row) * X68K_BYTES_PER_ROW;
        uint8_t *p3 = pd->planes[3] + ((uint16_t)y + row) * X68K_BYTES_PER_ROW;
        src = srcPlaneBytes + (uint16_t)(row * pairs);
        for (pair = 0; pair < pairs; pair++) {
            uint8_t  b       = src[pair];
            uint8_t  hi      = (uint8_t)(b >> 4);
            uint8_t  lo      = (uint8_t)(b & 0x0Fu);
            uint16_t px      = (uint16_t)(x + (int16_t)(pair << 1));
            uint16_t byteOff = (uint16_t)(px >> 3);
            uint8_t  maskHi  = (uint8_t)(0x80u >> (px & 7u));
            uint8_t  notHi   = (uint8_t)(~maskHi);
            uint16_t byteOffLo = (uint16_t)((px + 1u) >> 3);
            uint8_t  maskLo  = (uint8_t)(0x80u >> ((px + 1u) & 7u));
            uint8_t  notLo   = (uint8_t)(~maskLo);
            p0[byteOff] = (uint8_t)((p0[byteOff] & notHi) | ((hi & 0x01u) ? maskHi : 0u));
            p1[byteOff] = (uint8_t)((p1[byteOff] & notHi) | ((hi & 0x02u) ? maskHi : 0u));
            p2[byteOff] = (uint8_t)((p2[byteOff] & notHi) | ((hi & 0x04u) ? maskHi : 0u));
            p3[byteOff] = (uint8_t)((p3[byteOff] & notHi) | ((hi & 0x08u) ? maskHi : 0u));
            p0[byteOffLo] = (uint8_t)((p0[byteOffLo] & notLo) | ((lo & 0x01u) ? maskLo : 0u));
            p1[byteOffLo] = (uint8_t)((p1[byteOffLo] & notLo) | ((lo & 0x02u) ? maskLo : 0u));
            p2[byteOffLo] = (uint8_t)((p2[byteOffLo] & notLo) | ((lo & 0x04u) ? maskLo : 0u));
            p3[byteOffLo] = (uint8_t)((p3[byteOffLo] & notLo) | ((lo & 0x08u) ? maskLo : 0u));
        }
    }
}


// Phase 6 planar dual-write for sprite draw. Walks the sprite's
// chunky tile data with the same clipping the cross-platform code
// applies, calling x68kPlanarSetPixel for every non-transparent
// pixel (nibble != 0). Bypasses the compiled fast path entirely --
// the chunky compiled draw already ran by the time we get here, so
// we just mirror its pixel set into the planes.
//
// Sprite tile data layout: tileData = wTiles * hTiles tiles, each
// tile = 8 rows x 4 chunky bytes. Tiles laid out row-major.
// For pixel (px, py) within the sprite:
//   tileX = px / 8, tileY = py / 8
//   inTileX = px % 8, inTileY = py % 8
//   tileBase = tileData + (tileY * wTiles + tileX) * 32
//   byte = tileBase[inTileY * 4 + inTileX/2]
//   nibble = (inTileX & 1) ? byte & 0x0F : byte >> 4
//
// Save/restore have no equivalent planar dual-write yet (the
// jlSpriteBackupT only has chunky storage); workable approach needs
// a parallel plane-data buffer. Apps that depend on PLANAR_PRESENT
// save/restore semantics will see stale planes after restore.
/* Byte-wide sprite walker (replaces the per-pixel x68kPlanarSetPixel
 * loop that was burning ~30+ cycles per pixel). For each 8-pixel
 * column chunk of the sprite this routine:
 *   1. reads 4 chunky bytes (one row of one 8-pixel tile column)
 *   2. converts to 4 plane bytes + a shape mask (1 bit per pixel
 *      where the chunky nibble is non-transparent)
 *   3. shifts the per-plane bytes by (x & 7) so the sprite can land
 *      at any pixel x, not just 8-px boundaries; the shift is derived
 *      from the ORIGINAL signed x so a left-edge clip keeps the true
 *      source-to-dest sub-byte phase
 *   3a. each plane-byte write is guarded by a signed [0,40) dest-byte
 *      range check, which handles left, right, top and bottom clip
 *   4. writes the left byte (and the spilled right byte for shifted
 *      sprites) via AND-mask + OR to all 4 plane buffers
 *
 * This is the "shared walker" referenced in the codegen-rewrite plan:
 * one static routine in lib code handles every sprite cel, so the
 * codegen arena no longer needs the ~17-23 KB of unrolled per-shift
 * code per cel that the old emitter generated. */
void jlpSpriteDrawPlanes(jlSurfaceT *s, const jlSpriteT *sp, int16_t x, int16_t y) {
    X68kPlanarT  *pd;
    int16_t        dy;
    int16_t        sy;
    int16_t        w;
    int16_t        h;
    int16_t        row;
    int16_t        byteCol;
    int16_t        spritePy;
    int16_t        tileY;
    int16_t        inTileY;
    int16_t        startByteX;
    int16_t        dstByteIdx;
    uint16_t       wTiles;
    uint8_t        shift;
    uint8_t        bit;
    uint8_t        p0, p1, p2, p3, mask;
    uint8_t        leftMask, notMask;
    uint8_t        rshift, rightMask;
    uint16_t       dstRowBase;
    const uint8_t *tile;
    const uint8_t *chunky;

    pd = (X68kPlanarT *)s->portData;
    if (pd == NULL) {
        return;
    }

    wTiles = sp->widthTiles;
    w  = (int16_t)(wTiles * 8u);
    h  = (int16_t)(sp->heightTiles * 8u);
    dy = y;
    sy = 0;

    /* Vertical clip only (sy tracks offset INTO sprite); horizontal
     * clip is handled per-byte by the dstByteIdx range guards below so
     * the source sub-byte phase is preserved on a left-edge clip. */
    if (dy < 0) { sy = (int16_t)(-dy); h = (int16_t)(h - sy); dy = 0; }
    if (x >= SURFACE_WIDTH || dy >= SURFACE_HEIGHT || (int16_t)(x + w) <= 0 || h <= 0) {
        return;
    }
    if (dy + h > SURFACE_HEIGHT) { h = (int16_t)(SURFACE_HEIGHT - dy); }

    /* Derive shift / startByteX from the ORIGINAL signed dest x so a
     * partial left clip keeps the true source-to-dest sub-byte phase.
     * shift in 0..7 (floored modulo), startByteX = floor(x / 8). For
     * x = -3: shift = 5, startByteX = -1 -- source column 0 lands
     * shifted by 5 into dest bytes -1 (off-surface, skipped) and 0. */
    shift      = (uint8_t)(((x % 8) + 8) % 8);
    startByteX = (int16_t)((x - (int16_t)shift) / 8);

    for (row = 0; row < h; row++) {
        spritePy   = (int16_t)(sy + row);
        tileY      = (int16_t)(spritePy >> 3);
        inTileY    = (int16_t)(spritePy & 7);
        dstRowBase = (uint16_t)((uint16_t)(dy + row) * X68K_BYTES_PER_ROW);

        for (byteCol = 0; byteCol < (int16_t)wTiles; byteCol++) {
            tile   = sp->tileData + (uint32_t)((tileY * wTiles + byteCol) * 32u);
            chunky = tile + inTileY * 4u;

            /* c2p: 8 pixels (4 chunky bytes) -> 4 plane bytes + mask. */
            p0 = 0u; p1 = 0u; p2 = 0u; p3 = 0u; mask = 0u;
            for (bit = 0; bit < 8u; bit++) {
                uint8_t nibble = (bit & 1u)
                               ? (uint8_t)(chunky[bit >> 1] & 0x0Fu)
                               : (uint8_t)(chunky[bit >> 1] >> 4);
                uint8_t bm = (uint8_t)(0x80u >> bit);
                if (nibble != 0u) { mask |= bm; }
                if (nibble & 1u)  { p0   |= bm; }
                if (nibble & 2u)  { p1   |= bm; }
                if (nibble & 4u)  { p2   |= bm; }
                if (nibble & 8u)  { p3   |= bm; }
            }
            if (mask == 0u) {
                continue;
            }

            /* Signed dest byte index for this source column. Guard each
             * write against [0, X68K_BYTES_PER_ROW) in SIGNED math so
             * a negative startByteX never wraps to a huge unsigned. */
            dstByteIdx = (int16_t)(startByteX + byteCol);
            leftMask   = (uint8_t)(mask >> shift);
            if (leftMask != 0u && dstByteIdx >= 0 && dstByteIdx < (int16_t)X68K_BYTES_PER_ROW) {
                uint16_t dstByteOff = (uint16_t)(dstRowBase + (uint16_t)dstByteIdx);
                notMask = (uint8_t)(~leftMask);
                pd->planes[0][dstByteOff] = (uint8_t)((pd->planes[0][dstByteOff] & notMask) | (uint8_t)(p0 >> shift));
                pd->planes[1][dstByteOff] = (uint8_t)((pd->planes[1][dstByteOff] & notMask) | (uint8_t)(p1 >> shift));
                pd->planes[2][dstByteOff] = (uint8_t)((pd->planes[2][dstByteOff] & notMask) | (uint8_t)(p2 >> shift));
                pd->planes[3][dstByteOff] = (uint8_t)((pd->planes[3][dstByteOff] & notMask) | (uint8_t)(p3 >> shift));
            }

            if (shift != 0u) {
                int16_t rIdx = (int16_t)(dstByteIdx + 1);
                rshift    = (uint8_t)(8u - shift);
                rightMask = (uint8_t)(mask << rshift);
                if (rightMask != 0u && rIdx >= 0 && rIdx < (int16_t)X68K_BYTES_PER_ROW) {
                    uint16_t rOff = (uint16_t)(dstRowBase + (uint16_t)rIdx);
                    notMask = (uint8_t)(~rightMask);
                    pd->planes[0][rOff] = (uint8_t)((pd->planes[0][rOff] & notMask) | (uint8_t)(p0 << rshift));
                    pd->planes[1][rOff] = (uint8_t)((pd->planes[1][rOff] & notMask) | (uint8_t)(p1 << rshift));
                    pd->planes[2][rOff] = (uint8_t)((pd->planes[2][rOff] & notMask) | (uint8_t)(p2 << rshift));
                    pd->planes[3][rOff] = (uint8_t)((pd->planes[3][rOff] & notMask) | (uint8_t)(p3 << rshift));
                }
            }
        }
    }
}


// Phase 3 planar dual-write for jlSurfaceCopy: 4 plane memcpys after
// the cross-platform chunky pixel memcpy. Both src and dst planes
// are off-screen shadow buffers; the displayed gPlanes[] is updated
// only at jlStagePresent.
void jlpSurfaceCopyPlanes(jlSurfaceT *dst, const jlSurfaceT *src) {
    X68kPlanarT *dstPd;
    X68kPlanarT *srcPd;

    dstPd = (X68kPlanarT *)dst->portData;
    srcPd = (X68kPlanarT *)src->portData;
    if (dstPd == NULL || srcPd == NULL) {
        return;
    }
    // Straight blitter A->D copy of every whole plane (200 rows x 20
    // words, contiguous). Both source and dest planes are CHIP RAM now
    // (jlpSurfaceAllocPortData) so the blitter can reach both. The
    // helper copies all four planes synchronously on the CPU and returns
    // before DisownBlitter so the dest planes are complete before any
    // CPU read.
    x68kCpuCopyPlanes(dstPd->planes, srcPd->planes, 0u,
                           (X68K_BYTES_PER_ROW / 2u), SURFACE_HEIGHT, 0u);
}


// Same-position group-snapped rect copy (NATIVE-PERF Phase 4 clean-
// buffer op; Phase 6 W3 blitter upgrade). The core wrapper clipped
// and 16-px-snapped the rect, so x and w are multiples of 16: the
// plane byte offset (x >> 3) is even (68000 word-safe) and each row
// is a whole number of words, so no BLTAFWM/BLTALWM edge masking is
// needed. Both surfaces' planes are CHIP RAM (jlpSurfaceAllocPortData),
// so the copy runs as one modulo-driven A->D blit per plane
// (mod = 40 - rowBytes), replacing 4*h short memcpys whose call
// overhead dominated (a 16x16 window paid 64 memcpy calls to move
// 128 bytes -- ~8-10x the blitter path). Rects below
// X68K_COPYRECT_MEMCPY_MIN_WORDS per-plane words stay on a CPU
// word-store walk instead: the blitter's fixed setup cost loses
// there, and the snap's word alignment makes the walk 68000-safe.
void jlpSurfaceCopyRect(jlSurfaceT *dst, const jlSurfaceT *src, uint16_t x, uint16_t y, uint16_t w, uint16_t h) {
    X68kPlanarT   *dstPd;
    X68kPlanarT   *srcPd;
    uint16_t       *d;
    const uint16_t *s;
    uint16_t        off;
    uint16_t        rowBytes;
    uint16_t        widthWords;
    uint16_t        row;
    uint16_t        word;
    uint8_t         plane;

    dstPd = (X68kPlanarT *)dst->portData;
    srcPd = (X68kPlanarT *)src->portData;
    if (dstPd == NULL || srcPd == NULL) {
        jlpGenericSurfaceCopyRect(dst, src, x, y, w, h);
        return;
    }
    off        = (uint16_t)(y * X68K_BYTES_PER_ROW + (x >> 3));
    rowBytes   = (uint16_t)(w >> 3);
    widthWords = (uint16_t)(rowBytes >> 1);
    if (true) {   // see X68K_COPYRECT_MEMCPY_MIN_WORDS: memcpy always wins here
        x68kCpuCopyPlanes(dstPd->planes, srcPd->planes, off, widthWords, h, (uint16_t)(X68K_BYTES_PER_ROW - rowBytes));
        return;
    }
    for (plane = 0; plane < X68K_BITPLANES; plane++) {
        d = (uint16_t *)(dstPd->planes[plane] + off);
        s = (const uint16_t *)(srcPd->planes[plane] + off);
        for (row = 0; row < h; row++) {
            for (word = 0; word < widthWords; word++) {
                d[word] = s[word];
            }
            d += (X68K_BYTES_PER_ROW / 2u);
            s += (X68K_BYTES_PER_ROW / 2u);
        }
    }
}


// Phase 4 planar dual-write helper: set one pixel's bit in each of
// the four shadow planes. Caller (the per-primitive walker below) has
// already validated (x, y) is on-surface. byteOff is computed once
// and reused across all four planes since stride is the same in each.
/* Per-pixel plane RMW with the color-bit classification hoisted by
 * the caller. set0..set3 are 0xFF if that plane's color bit is 1
 * (OR-in bitMask), 0 if 0 (AND-out bitMask). Each plane updates with
 * `*pn = (*pn & ~bitMask) | (setN & bitMask)` -- branch-free, 4
 * RMWs per pixel. Inlined into shape walkers so the outer loop pays
 * no function-call overhead. */
/* Bit-mask LUT for the per-pixel macro -- replaces a runtime
 * `0x80u >> (x & 7)` (8 cyc per bit shifted on 68000) with a single
 * byte load. Saves ~25 cyc per pixel inside circle / line walkers. */
static const uint8_t kAmigaPlanePutPixelBitLut[8] = {
    0x80u, 0x40u, 0x20u, 0x10u, 0x08u, 0x04u, 0x02u, 0x01u
};

#define AMIGA_PLANE_PUT_PIXEL(pd_, x_, y_, set0_, set1_, set2_, set3_) do { \
    uint16_t byteOff_ = (uint16_t)((uint16_t)(y_) * X68K_BYTES_PER_ROW + ((uint16_t)(x_) >> 3)); \
    uint8_t  bitMask_ = kAmigaPlanePutPixelBitLut[(uint16_t)(x_) & 7u];                           \
    uint8_t  notMask_ = (uint8_t)~bitMask_;                                                       \
    uint8_t *p0_ = &(pd_)->planes[0][byteOff_];                                                   \
    uint8_t *p1_ = &(pd_)->planes[1][byteOff_];                                                   \
    uint8_t *p2_ = &(pd_)->planes[2][byteOff_];                                                   \
    uint8_t *p3_ = &(pd_)->planes[3][byteOff_];                                                   \
    *p0_ = (uint8_t)((*p0_ & notMask_) | ((set0_) & bitMask_));                                   \
    *p1_ = (uint8_t)((*p1_ & notMask_) | ((set1_) & bitMask_));                                   \
    *p2_ = (uint8_t)((*p2_ & notMask_) | ((set2_) & bitMask_));                                   \
    *p3_ = (uint8_t)((*p3_ & notMask_) | ((set3_) & bitMask_));                                   \
} while (0)


static void x68kPlanarSetPixel(X68kPlanarT *pd, int16_t x, int16_t y, uint8_t color) {
    uint16_t byteOff;
    uint8_t  bitMask;
    uint8_t  notMask;
    uint8_t *p0;
    uint8_t *p1;
    uint8_t *p2;
    uint8_t *p3;

    byteOff = (uint16_t)((uint16_t)y * X68K_BYTES_PER_ROW + ((uint16_t)x >> 3));
    bitMask = (uint8_t)(0x80u >> ((uint16_t)x & 7u));
    notMask = (uint8_t)~bitMask;

    /* Unroll the 4-plane loop. Loop counter + array indexing inside
     * the hot per-pixel path is the gating cost on circle outlines:
     * UBER jlDrawCircle r=80 calls this ~640 times per call. */
    p0 = &pd->planes[0][byteOff];
    p1 = &pd->planes[1][byteOff];
    p2 = &pd->planes[2][byteOff];
    p3 = &pd->planes[3][byteOff];

    if (color & 0x01u) { *p0 = (uint8_t)(*p0 | bitMask); } else { *p0 = (uint8_t)(*p0 & notMask); }
    if (color & 0x02u) { *p1 = (uint8_t)(*p1 | bitMask); } else { *p1 = (uint8_t)(*p1 & notMask); }
    if (color & 0x04u) { *p2 = (uint8_t)(*p2 | bitMask); } else { *p2 = (uint8_t)(*p2 & notMask); }
    if (color & 0x08u) { *p3 = (uint8_t)(*p3 | bitMask); } else { *p3 = (uint8_t)(*p3 & notMask); }
}


void jlpDrawPixel(jlSurfaceT *s, uint16_t x, uint16_t y, uint8_t colorIndex) {
    X68kPlanarT *pd;
    uint8_t       nibLo;
    if (s->portData == NULL) {
        jlpGenericDrawPixel(s, x, y, colorIndex);
        return;
    }
    nibLo = (uint8_t)(colorIndex & 0x0Fu);
    pd = (X68kPlanarT *)s->portData;
    x68kPlanarSetPixel(pd, (int16_t)x, (int16_t)y, nibLo);
}


// Bresenham's diagonal line, planar-only walk. Same algorithm as
// cross-platform jlDrawLine's fallback so the pixel set matches it
// bit-for-bit.
static void x68kPlanarLine(X68kPlanarT *pd, int16_t x0, int16_t y0, int16_t x1, int16_t y1, uint8_t color) {
    int16_t dx;
    int16_t dy;
    int16_t sx;
    int16_t sy;
    int16_t err;
    int16_t e2;
    uint8_t set0;
    uint8_t set1;
    uint8_t set2;
    uint8_t set3;

    set0 = (color & 0x01u) ? 0xFFu : 0x00u;
    set1 = (color & 0x02u) ? 0xFFu : 0x00u;
    set2 = (color & 0x04u) ? 0xFFu : 0x00u;
    set3 = (color & 0x08u) ? 0xFFu : 0x00u;

    dx  = (int16_t)((x1 > x0) ? (x1 - x0) : (x0 - x1));
    dy  = (int16_t)(-((y1 > y0) ? (y1 - y0) : (y0 - y1)));
    sx  = (int16_t)((x0 < x1) ? 1 : -1);
    sy  = (int16_t)((y0 < y1) ? 1 : -1);
    err = (int16_t)(dx + dy);
    while (1) {
        AMIGA_PLANE_PUT_PIXEL(pd, x0, y0, set0, set1, set2, set3);
        if (x0 == x1 && y0 == y1) {
            break;
        }
        e2 = (int16_t)(2 * err);
        if (e2 >= dy) {
            err = (int16_t)(err + dy);
            x0  = (int16_t)(x0 + sx);
        }
        if (e2 <= dx) {
            err = (int16_t)(err + dx);
            y0  = (int16_t)(y0 + sy);
        }
    }
}


void jlpDrawLine(jlSurfaceT *s, int16_t x0, int16_t y0, int16_t x1, int16_t y1, uint8_t colorIndex) {
    X68kPlanarT *pd;
    if (s->portData == NULL) {
        return;
    }
    pd = (X68kPlanarT *)s->portData;
    x68kPlanarLine(pd, x0, y0, x1, y1, (uint8_t)(colorIndex & 0x0Fu));
}


void jlpDrawCircle(jlSurfaceT *s, int16_t cx, int16_t cy, uint16_t r, uint8_t colorIndex) {
    X68kPlanarT *pd;
    if (s->portData == NULL) {
        return;
    }
    pd = (X68kPlanarT *)s->portData;
    surface68kAmigaCircleOutline(pd->planes[0], pd->planes[1],
                                 pd->planes[2], pd->planes[3],
                                 (uint16_t)cx, (uint16_t)cy, r,
                                 (uint8_t)(colorIndex & 0x0Fu));
}


/* Single-row 4-plane span fill via shared68k asm. Caller pre-computes
 * the left/right partial-byte masks; fillByte per plane is just
 * 0xFF/0x00 based on colorIndex bit. The asm body avoids per-byte
 * function-call dispatch and the C compiler's per-iter overhead --
 * critical for jlFillCircle r=40 which pre-asm was paying ~50 ms/call
 * for 80 spans. */
static inline __attribute__((always_inline))
void x68kFillSpanInline(X68kPlanarT *pd, int16_t spanX, int16_t spanY,
                         uint16_t spanW, uint8_t colorIndex) {
    static const uint8_t kLM[8] = {0xFFu,0x7Fu,0x3Fu,0x1Fu,0x0Fu,0x07u,0x03u,0x01u};
    static const uint8_t kRM[8] = {0x80u,0xC0u,0xE0u,0xF0u,0xF8u,0xFCu,0xFEu,0xFFu};

    uint16_t byteFirst = (uint16_t)((uint16_t)spanX >> 3);
    uint16_t lastBit   = (uint16_t)(spanX + spanW - 1);
    uint16_t byteLast  = (uint16_t)(lastBit >> 3);
    uint8_t  leftMask  = kLM[(uint16_t)spanX & 7u];
    uint8_t  rightMask = kRM[lastBit & 7u];
    uint16_t rowOff    = (uint16_t)((uint16_t)spanY * X68K_BYTES_PER_ROW + byteFirst);
    uint8_t  fb0 = ((colorIndex >> 0) & 1u) ? 0xFFu : 0x00u;
    uint8_t  fb1 = ((colorIndex >> 1) & 1u) ? 0xFFu : 0x00u;
    uint8_t  fb2 = ((colorIndex >> 2) & 1u) ? 0xFFu : 0x00u;
    uint8_t  fb3 = ((colorIndex >> 3) & 1u) ? 0xFFu : 0x00u;
    uint8_t *p0  = pd->planes[0] + rowOff;
    uint8_t *p1  = pd->planes[1] + rowOff;
    uint8_t *p2  = pd->planes[2] + rowOff;
    uint8_t *p3  = pd->planes[3] + rowOff;

    if (byteFirst == byteLast) {
        /* Single-byte case kept in C: the asm path post-increments
         * the pointer between leading and trailing RMW, which would
         * read the wrong byte if both edges land on the same byte.
         * One-byte spans are rare anyway (~1 of 80 in jlFillCircle r=40)
         * so the C overhead is fine here. */
        uint8_t mask    = (uint8_t)(leftMask & rightMask);
        uint8_t notMask = (uint8_t)~mask;
        uint8_t *pp[X68K_BITPLANES];
        uint8_t  fb[X68K_BITPLANES];
        uint8_t  i;
        pp[0] = p0; pp[1] = p1; pp[2] = p2; pp[3] = p3;
        fb[0] = fb0; fb[1] = fb1; fb[2] = fb2; fb[3] = fb3;
        for (i = 0; i < X68K_BITPLANES; i++) {
            *pp[i] = (uint8_t)((*pp[i] & notMask) | (fb[i] & mask));
        }
        return;
    }

    {
        uint16_t numMid = (uint16_t)(byteLast - byteFirst - 1u);
        surface68kFillSpan4Planes(p0, p1, p2, p3, numMid, leftMask, rightMask,
                                  fb0, fb1, fb2, fb3);
    }
}


bool jlpFillCircle(jlSurfaceT *s, int16_t cx, int16_t cy, uint16_t r, uint8_t colorIndex) {
    X68kPlanarT *pd;
    uint32_t      r2;
    uint32_t      xx;
    uint32_t      yy;
    int32_t       x;
    uint16_t      y;

    if (s->portData == NULL) {
        return false;
    }
    pd = (X68kPlanarT *)s->portData;
    // Exact-disk span walk (perf-hunt-2 rank 14, the recorded "true
    // lever"; algorithm copied from the ST stPlanarCircleFill): for
    // each y offset track the largest x with x^2 + y^2 <= r^2
    // incrementally, and fill each row exactly ONCE. The old midpoint
    // loop emitted 4 octant spans per iteration whose horizontal spans
    // overlapped (~14-16% redundant bytes at r=40) and re-dispatched
    // x68kFillSpanInline for each. Row coverage is identical to
    // draw.c's jlFillCircle and the ST walker, so the golden hashes
    // are unchanged by construction. Spans stay unclipped -- draw.c
    // routes every off-surface circle through its own clipped loop
    // before jlpFillCircle is called (same contract as before).
    r2 = (uint32_t)r * (uint32_t)r;
    xx = r2;
    yy = 0u;
    x  = (int32_t)r;
    for (y = 0u; y <= r; y++) {
        uint32_t xxLimit;
        int16_t  spanX;
        uint16_t spanW;

        xxLimit = r2 - yy;
        while (xx > xxLimit) {
            xx = xx - ((uint32_t)x + (uint32_t)x - 1u);
            x--;
        }
        spanX = (int16_t)(cx - (int16_t)x);
        spanW = (uint16_t)((int16_t)x + (int16_t)x + 1);
        x68kFillSpanInline(pd, spanX, (int16_t)(cy + (int16_t)y), spanW, colorIndex);
        if (y > 0u) {
            x68kFillSpanInline(pd, spanX, (int16_t)(cy - (int16_t)y), spanW, colorIndex);
        }
        yy = yy + ((uint32_t)y + (uint32_t)y + 1u);
    }
    return true;
}


/* Plane-aware nibble at (x, y) given the 4 plane row bases. Reads one
 * byte per plane and assembles the 4-bit color from a single bit
 * position. Caller is responsible for valid (x, y). */
static uint8_t x68kNibbleFromPlanes(uint8_t * const planes[X68K_BITPLANES], int16_t x) {
    uint16_t byteOff;
    uint8_t  bitMask;
    uint8_t  color;

    byteOff = (uint16_t)((uint16_t)x >> 3);
    bitMask = (uint8_t)(0x80u >> ((uint16_t)x & 7u));
    color = 0u;
    if (planes[0][byteOff] & bitMask) color = (uint8_t)(color | 0x01u);
    if (planes[1][byteOff] & bitMask) color = (uint8_t)(color | 0x02u);
    if (planes[2][byteOff] & bitMask) color = (uint8_t)(color | 0x04u);
    if (planes[3][byteOff] & bitMask) color = (uint8_t)(color | 0x08u);
    return color;
}


/* Build the 4 plane row pointers for a given y. */
static void x68kPlaneRowPtrs(const jlSurfaceT *s, int16_t y, uint8_t **out /* [4] */) {
    X68kPlanarT *pd;
    uint16_t      yOff;
    uint8_t       i;

    pd = (X68kPlanarT *)s->portData;
    yOff = (uint16_t)((uint16_t)y * X68K_BYTES_PER_ROW);
    for (i = 0; i < X68K_BITPLANES; i++) {
        out[i] = pd->planes[i] + yOff;
    }
}


bool jlpFloodWalkPlanes(const jlSurfaceT *s, int16_t startX, int16_t y, uint8_t matchColor, uint8_t newColor, bool matchEqual, bool *seedMatched, int16_t *leftXOut, int16_t *rightXOut) {
    X68kPlanarT *pd;
    uint8_t      *rowPlanes[X68K_BITPLANES];
    int16_t       leftX;
    int16_t       rightX;
    uint8_t       pix;
    bool          pixMatch;

    pd = (X68kPlanarT *)s->portData;
    if (pd == NULL) {
        return false;
    }
    matchColor = (uint8_t)(matchColor & 0x0Fu);
    newColor   = (uint8_t)(newColor   & 0x0Fu);
    x68kPlaneRowPtrs(s, y, rowPlanes);

    pix      = x68kNibbleFromPlanes(rowPlanes, startX);
    pixMatch = (pix == matchColor);
    if (matchEqual ? !pixMatch : (pixMatch || pix == newColor)) {
        *seedMatched = false;
        return true;
    }
    *seedMatched = true;

    leftX = startX;
    while (leftX > 0) {
        pix      = x68kNibbleFromPlanes(rowPlanes, (int16_t)(leftX - 1));
        pixMatch = (pix == matchColor);
        if (matchEqual ? !pixMatch : (pixMatch || pix == newColor)) {
            break;
        }
        leftX--;
    }

    rightX = startX;
    while (rightX < SURFACE_WIDTH - 1) {
        pix      = x68kNibbleFromPlanes(rowPlanes, (int16_t)(rightX + 1));
        pixMatch = (pix == matchColor);
        if (matchEqual ? !pixMatch : (pixMatch || pix == newColor)) {
            break;
        }
        rightX++;
    }

    *leftXOut  = leftX;
    *rightXOut = rightX;
    return true;
}


bool jlpFloodScanRowPlanes(const jlSurfaceT *s, int16_t leftX, int16_t rightX, int16_t scanY, uint8_t matchColor, uint8_t newColor, bool matchEqual, uint8_t *markBuf) {
    X68kPlanarT *pd;
    uint8_t      *rowPlanes[X68K_BITPLANES];
    int16_t       byteCol;
    int16_t       byteColFirst;
    int16_t       byteColLast;
    int16_t       bit;
    int16_t       x;
    int16_t       markIdx;
    uint8_t       p0, p1, p2, p3;
    uint8_t       bitMask;
    uint8_t       pix;
    bool          pixMatch;

    pd = (X68kPlanarT *)s->portData;
    if (pd == NULL) {
        return false;
    }
    matchColor = (uint8_t)(matchColor & 0x0Fu);
    newColor   = (uint8_t)(newColor   & 0x0Fu);
    x68kPlaneRowPtrs(s, scanY, rowPlanes);

    byteColFirst = (int16_t)(leftX  >> 3);
    byteColLast  = (int16_t)(rightX >> 3);
    for (byteCol = byteColFirst; byteCol <= byteColLast; byteCol++) {
        p0 = rowPlanes[0][byteCol];
        p1 = rowPlanes[1][byteCol];
        p2 = rowPlanes[2][byteCol];
        p3 = rowPlanes[3][byteCol];
        for (bit = 0; bit < 8; bit++) {
            x = (int16_t)((byteCol << 3) + bit);
            if (x < leftX || x > rightX) {
                continue;
            }
            bitMask = (uint8_t)(0x80u >> bit);
            pix = 0u;
            if (p0 & bitMask) pix = (uint8_t)(pix | 0x01u);
            if (p1 & bitMask) pix = (uint8_t)(pix | 0x02u);
            if (p2 & bitMask) pix = (uint8_t)(pix | 0x04u);
            if (p3 & bitMask) pix = (uint8_t)(pix | 0x08u);
            pixMatch = (pix == matchColor);
            markIdx = (int16_t)(x - leftX);
            markBuf[markIdx] = (uint8_t)(matchEqual
                ? (pixMatch ? 1 : 0)
                : ((!pixMatch && pix != newColor) ? 1 : 0));
        }
    }
    return true;
}


/* ===== Phase 9 reader hooks: pure-planar Amiga implementations =====
 *
 * Cross-platform code that USED to read s->pixels (chunky shadow) now
 * goes through these. On Amiga the chunky shadow doesn't exist;
 * pixels are derived on demand by walking the plane bits.
 *
 * Per-pixel assembly: for pixel (x, y), read bit (7 - x%8) from plane
 * byte at row*40 + x/8 in each of the 4 planes. Color index = sum
 * of (bit_p << p). */

uint8_t jlpSamplePixel(const jlSurfaceT *s, int16_t x, int16_t y) {
    X68kPlanarT *pd;
    uint16_t      byteOff;
    uint8_t       bitMask;
    uint8_t       color;
    uint16_t      i;

    pd = (X68kPlanarT *)s->portData;
    if (pd == NULL) {
        return 0u;
    }
    byteOff = (uint16_t)((uint16_t)y * X68K_BYTES_PER_ROW + ((uint16_t)x >> 3));
    bitMask = (uint8_t)(0x80u >> ((uint16_t)x & 7u));
    color = 0u;
    for (i = 0; i < X68K_BITPLANES; i++) {
        if (pd->planes[i][byteOff] & bitMask) {
            color = (uint8_t)(color | (1u << i));
        }
    }
    return color;
}


/* Reverse-c2p: per row, derive 160 chunky bytes from 40 plane bytes
 * (per plane, 4 planes). Used by jlpSurfaceHash to fold the planar
 * surface into the same byte-stream the chunky ports hash, so cross-
 * port hash comparisons stay valid.
 * Walks 8 pixels per planar-byte column; per pixel assembles nibble
 * from 4 plane bits. Output: 4 chunky bytes per planar-byte column
 * (since 8 pixels = 4 chunky bytes at 2px/byte). */
static void x68kPlanesToChunkyRow(const X68kPlanarT *pd, int16_t y, uint8_t *dstChunkyRow) {
    uint16_t       col;
    uint16_t       byteOff;
    uint8_t        b0, b1, b2, b3;
    uint8_t        pix;
    uint8_t        bitMask;
    uint16_t       p;

    for (col = 0; col < X68K_BYTES_PER_ROW; col++) {
        byteOff = (uint16_t)((uint16_t)y * X68K_BYTES_PER_ROW + col);
        b0 = pd->planes[0][byteOff];
        b1 = pd->planes[1][byteOff];
        b2 = pd->planes[2][byteOff];
        b3 = pd->planes[3][byteOff];
        /* For each of 8 pixels in this planar byte (bit 7 = leftmost),
         * assemble nibble from the 4 plane bits and pack into chunky
         * bytes (high nibble = even pixel, low nibble = odd pixel). */
        for (p = 0; p < 8u; p++) {
            bitMask = (uint8_t)(0x80u >> p);
            pix = 0u;
            if (b0 & bitMask) pix = (uint8_t)(pix | 1u);
            if (b1 & bitMask) pix = (uint8_t)(pix | 2u);
            if (b2 & bitMask) pix = (uint8_t)(pix | 4u);
            if (b3 & bitMask) pix = (uint8_t)(pix | 8u);
            if ((p & 1u) == 0u) {
                dstChunkyRow[col * 4u + (p >> 1)] = (uint8_t)(pix << 4);
            } else {
                dstChunkyRow[col * 4u + (p >> 1)] = (uint8_t)(dstChunkyRow[col * 4u + (p >> 1)] | pix);
            }
        }
    }
}


uint32_t jlpSurfaceHash(const jlSurfaceT *s) {
    X68kPlanarT   *pd;
    uint16_t        lo = 0xACE1u, hi = 0x1357u;
    uint16_t        n, v;
    int16_t         row;
    uint8_t         b;
    uint8_t         chunkyRow[SURFACE_BYTES_PER_ROW];
    const uint16_t *w;

    pd = (X68kPlanarT *)s->portData;
    if (pd == NULL) {
        return 0u;
    }
    /* Pixel hash: derive chunky one row at a time, fold byte-by-byte
     * via the shared SURFACE_HASH_MIX_BYTE so cross-port hash
     * matches. */
    for (row = 0; row < SURFACE_HEIGHT; row++) {
        x68kPlanesToChunkyRow(pd, row, chunkyRow);
        for (n = 0; n < SURFACE_BYTES_PER_ROW; n++) {
            b = chunkyRow[n];
            SURFACE_HASH_MIX_BYTE(lo, hi, b);
        }
    }
    /* SCB: byte sequence, endian-independent. */
    for (n = 0; n < (uint16_t)SURFACE_HEIGHT; n++) {
        b = s->scb[n];
        SURFACE_HASH_MIX_BYTE(lo, hi, b);
    }
    /* Palette: read uint16 values, fold high-then-low for endian-
     * independence. */
    w = &s->palette[0][0];
    for (n = 0; n < (uint16_t)SURFACE_PALETTE_ENTRIES; n++) {
        v  = *w++;
        b  = (uint8_t)((v >> 8) & 0xFFu); SURFACE_HASH_MIX_BYTE(lo, hi, b);
        b  = (uint8_t)(v & 0xFFu);        SURFACE_HASH_MIX_BYTE(lo, hi, b);
    }
    return ((uint32_t)hi << 16) | (uint32_t)lo;
}


// On-disk format is the Amiga's native plane-major buffer: planes
// 0..3 written sequentially, X68K_PLANE_SIZE bytes each.
bool jlpSurfaceLoadFile(jlSurfaceT *dst, FILE *fp) {
    X68kPlanarT *pd;
    uint8_t       i;

    pd = (X68kPlanarT *)dst->portData;
    if (pd == NULL) {
        return false;
    }
    for (i = 0; i < X68K_BITPLANES; i++) {
        if (fread(pd->planes[i], 1, X68K_PLANE_SIZE, fp) != X68K_PLANE_SIZE) {
            return false;
        }
    }
    return true;
}


bool jlpSurfaceSaveFile(const jlSurfaceT *src, FILE *fp) {
    X68kPlanarT *pd;
    uint8_t       i;

    pd = (X68kPlanarT *)src->portData;
    if (pd == NULL) {
        return false;
    }
    for (i = 0; i < X68K_BITPLANES; i++) {
        if (fwrite(pd->planes[i], 1, X68K_PLANE_SIZE, fp) != X68K_PLANE_SIZE) {
            return false;
        }
    }
    return true;
}


uint8_t *jlpStageAllocPixels(void) {
    /* Phase 9: Amiga has no chunky shadow. The stage pixels pointer
     * stays NULL; cross-platform code reads pixels via jlpSamplePixel
     * (or the other chunky reader ops) which read from planes. NULL is
     * a valid return -- cross-platform stageAlloc treats NULL as
     * "port has no chunky storage" and skips the chunky memset. */
    return NULL;
}


uint8_t *jlpSurfaceAllocPixels(void) {
    /* Same rationale as jlpStageAllocPixels: no chunky on Amiga. */
    return NULL;
}


// Allocate the per-surface planar storage: an X68kPlanarT plus 4
// off-screen chip-RAM plane buffers. The stage gets its OWN shadow
// planes (NOT aliased to gPlanes / Intuition's BitMap) so drawing
// primitives that dual-write to planes don't immediately appear on
// screen -- the display is updated atomically at jlStagePresent time
// (c2p chunky->gPlanes today; memcpy shadow->gPlanes under
// JOEYLIB_PLANAR_PRESENT; pointer-swap or alias in Phase 9).
// Aliasing the stage to gPlanes was tried and reverted because every
// drawing primitive showed AS IT HAPPENED, which broke the "draw
// invisibly, palette flips with content at present" semantic Pattern
// and apps depend on. See project_planar_68k_plan.md Phase 3 notes.
//
// Returns NULL on allocation failure. Cross-platform code stores the
// result in s->portData; primitives access via (X68kPlanarT *)
// s->portData.
void *jlpSurfaceAllocPortData(jlSurfaceT *s, bool isStage) {
    X68kPlanarT *pd;
    uint16_t      i;

    (void)s;
    (void)isStage;
    pd = (X68kPlanarT *)calloc(1u, sizeof(X68kPlanarT));
    if (pd == NULL) {
        return NULL;
    }
    pd->bytesPerRow   = X68K_BYTES_PER_ROW;
    pd->bytesPerPlane = X68K_PLANE_SIZE;

    /* Both stage and non-stage get their own zeroed planes. Unlike the
     * Amiga these are plain heap: no blitter or DMA reads them, only the
     * CPU, so there is no Chip RAM constraint to satisfy. */
    for (i = 0; i < X68K_BITPLANES; i++) {
        pd->planes[i] = (uint8_t *)calloc(1u, (size_t)X68K_PLANE_SIZE);
        jlLogF("x68k: shadow plane %u on heap (CPU only, addr=$%08lX)",
                 (unsigned)i, (unsigned long)pd->planes[i]);
        if (pd->planes[i] == NULL) {
            /* Roll back any planes already allocated. */
            while (i > 0u) {
                i--;
                free(pd->planes[i]);
            }
            free(pd);
            return NULL;
        }
    }
    pd->ownsPlanes = true;
    return pd;
}


void jlpSurfaceFreePortData(jlSurfaceT *s, bool isStage, void *portData) {
    X68kPlanarT *pd;
    uint16_t      i;

    (void)s;
    (void)isStage;
    if (portData == NULL) {
        return;
    }
    pd = (X68kPlanarT *)portData;
    if (pd->ownsPlanes) {
        for (i = 0; i < X68K_BITPLANES; i++) {
            if (pd->planes[i] != NULL) {
                free(pd->planes[i]);
            }
        }
    }
    free(pd);
}
