// Commodore Amiga HAL for M2 + M2.5.
//
// M2 scope:
//   * OpenScreen (Intuition) for a CUSTOMSCREEN at 320x200x4 bitplanes.
//   * Chunky 4bpp to 4 separate bitplanes c2p at present time.
//   * Partial-rect present covers only the dirty scanlines.
//
// M2.5 scope (per-scanline palette / SCB emulation):
//   * Build a user copper list that WAITs for each display scanline
//     and MOVEs the 16 color registers with that line's palette.
//   * Install it via ViewPort.UCopIns + MakeScreen + RethinkDisplay.
//   * Rebuild only when SCB or palette state differs from the last
//     presented frame (cached in gCachedScb / gCachedPalette). On
//     clean frames (typical game loop, where only pixel bytes change)
//     we skip AllocMem + MrgCop + LoadView + WaitTOF entirely.
//
// Deferred:
//   * Blitter-assisted c2p for speed on A500.
//   * Takeover mode (LoadView(NULL) + OwnBlitter + direct hardware).

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

#include "joey/debug.h"
#include "joey/tile.h"

#include <exec/types.h>
#include <exec/interrupts.h>
#include <exec/memory.h>
#include <hardware/intbits.h>
#include <intuition/intuition.h>
#include <intuition/screens.h>
#include <graphics/copper.h>
#include <graphics/gfxbase.h>
#include <graphics/gfxmacros.h>
#include <graphics/displayinfo.h>
#include <graphics/modeid.h>
#include <graphics/rastport.h>
#include <graphics/view.h>

#include <hardware/custom.h>
#include <hardware/blit.h>

#include <proto/exec.h>
#include <proto/intuition.h>
#include <proto/graphics.h>

#include "port.h"
#include "spriteInternal.h"
#include "surfaceInternal.h"

extern struct Custom custom;


// Frame-counter VBL server lives at end of file; forward-declare so
// jlpInit / jlpShutdown can install / remove it without C inferring
// implicit non-static linkage at the call sites.
static void installVblServer(void);
static void removeVblServer(void);

// ----- Constants + per-surface planar storage -----
//
// AmigaPlanarT, AMIGA_BITPLANES/BYTES_PER_ROW/PLANE_SIZE, and the
// inline portData accessor moved to amigaPlanar.h (NATIVE-PERF Phase
// 2) so spriteCompile.c's dispatchers can read plane pointers without
// a cross-TU call.
#include "amigaPlanar.h"

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

static void                amigaBlitterCopyPlanes(uint8_t *dst[AMIGA_BITPLANES], uint8_t *src[AMIGA_BITPLANES], uint16_t offsetBytes, uint16_t widthWords, uint16_t height, uint16_t modBytes);
static void                amigaBlitterCopyPlanesOwned(uint8_t *dst[AMIGA_BITPLANES], uint8_t *src[AMIGA_BITPLANES], uint16_t offsetBytes, uint16_t widthWords, uint16_t height, uint16_t modBytes);
static void                amigaBlitterFillOne(uint8_t *dst, uint16_t widthWords, uint16_t height, uint16_t dstModBytes, bool setBits);
static void                amigaBlitterMaskedFillOne(uint8_t *dst, uint16_t widthWords, uint16_t height, uint16_t dstModBytes, uint16_t fwm, uint16_t lwm, bool setBits);
static void                amigaPlanarSetPixel(AmigaPlanarT *pd, int16_t x, int16_t y, uint8_t color);
static void                amigaPresentFlushRun(AmigaPlanarT *pd, UBYTE *destPlanes[AMIGA_BITPLANES], int16_t runFirst, int16_t runLast, uint16_t byteStart, uint16_t byteLen, bool *blitOwned);
static void                amigaPresentRectInner(AmigaPlanarT *pd, UBYTE *destPlanes[AMIGA_BITPLANES], int16_t firstRow, int16_t lastRow, uint16_t byteStart, uint16_t bytesPerRow, bool *blitOwned);
static void                buildCopperList(const jlSurfaceT *src);
static void                installCopperList(void);
static void                patchBplCopperPokes(UBYTE *planes[AMIGA_BITPLANES]);
static void                scanBplCopperPokes(void);
static void                uploadFirstBandPalette(const jlSurfaceT *src);
static void                updateCopperIfNeeded(const jlSurfaceT *src);

// ----- Module state -----

struct Screen           *gScreen  = NULL;  // shared with input.c
static struct BitMap    *gBitMap  = NULL;
// Phase 10 page-flip: two CHIP plane sets. gPlanes is set A (kept
// under the original name to keep diffs minimal); gPlanesB is set B.
// gFrontIdx selects which is currently displayed via BPL DMA. The
// off-screen set is the destination for jlpPresent's shadow->CHIP
// copy, so the beam never catches the writes.
static UBYTE            *gPlanes [AMIGA_BITPLANES];
static UBYTE            *gPlanesB[AMIGA_BITPLANES];
static uint8_t           gFrontIdx = 0;
static struct UCopList  *gNewUCL   = NULL;  // built but not yet installed

// Pointers into the merged hardware copper list at the data words of
// each BPLnPTH/PTL move instruction. scanBplCopperPokes finds them
// after every installCopperList; patchBplCopperPokes writes new plane
// addresses into them per flip. NULL until the first copper list is
// installed (i.e. before the first jlpPresent).
static volatile uint16_t *gBplPokes[AMIGA_BITPLANES][2];  // [plane][0=PTH,1=PTL]

// Snapshot of the previous frame's dirty bands. With N=2 buffers,
// the back buffer holds content from 2 frames ago, so per-present we
// must refresh the union(prev, current) of dirty rows -- not just
// this frame's dirty rows -- or we get stale streaks. Initialized
// to "all dirty" so the first two presents do full-screen refreshes
// and converge the two CHIP buffers to the current shadow.
// aligned(2): the presentScan.s 4-row fast path reads these (and
// gStageMin/MaxWord, already aligned(2)) with move.l at even row
// indices, which the 68000 requires to be word-aligned.
static uint8_t  gPrevStageMinWord[SURFACE_HEIGHT] __attribute__((aligned(2)));
static uint8_t  gPrevStageMaxWord[SURFACE_HEIGHT] __attribute__((aligned(2)));

// One coalesced present run: consecutive rows [firstRow..lastRow] that
// share the same plane-byte span [byteStart, byteStart+byteLen). The
// present scan emits these into gPresentRuns; a separate loop flushes
// each (blitter or CPU per amigaPresentRectInner). Splitting scan from
// flush lets the register-pressure-bound scan move to hand-rolled asm
// (amigaPresentScan68k) without touching the flush path. Field layout
// (firstRow@0, lastRow@2, byteStart@4, byteLen@6; 8 bytes) is an ABI
// contract with that asm. At most one run per row, so SURFACE_HEIGHT
// entries always suffice.
typedef struct {
    int16_t  firstRow;
    int16_t  lastRow;
    uint16_t byteStart;
    uint16_t byteLen;
} AmigaPresentRunT;

static AmigaPresentRunT gPresentRuns[SURFACE_HEIGHT];

// Hand-rolled 68000 union scan (src/amiga/presentScan.s). Walks the four
// band arrays with dedicated address registers (no gcc spills), snapshots
// gPrev, and writes coalesced runs into gPresentRuns. Returns the run
// count in the low 16 bits, anyUnionDirty in bit 16, anyCurDirty in bit
// 17. The asm hardcodes SURFACE_HEIGHT, SURFACE_WORDS_PER_ROW, the clean
// sentinel, and the 8-byte run record; the guards below make a mismatch a
// compile error rather than a display bug.
extern uint32_t amigaPresentScan68k(const uint8_t *curMin, const uint8_t *curMax, uint8_t *prvMin, uint8_t *prvMax, AmigaPresentRunT *runList);

typedef char amigaPresentScanAbiHeight[(SURFACE_HEIGHT == 200) ? 1 : -1];
typedef char amigaPresentScanAbiWords[(SURFACE_WORDS_PER_ROW == 80) ? 1 : -1];
typedef char amigaPresentScanAbiClean[(STAGE_DIRTY_CLEAN_MIN == 0xFFu) ? 1 : -1];
typedef char amigaPresentScanAbiRec[(sizeof(AmigaPresentRunT) == 8) ? 1 : -1];

// "The previous frame's snapshot recorded at least one dirty row."
// Lets jlpPresent early-out of the whole union scan + 400-byte
// snapshot copy on consecutive idle frames: if neither the previous
// frame nor the current frame touched any row, union(prev,current) is
// empty AND the snapshot would copy clean->clean (a no-op), so there
// is nothing to present and nothing to record. Seeded true to match
// the "all dirty" bootstrap of gPrevStage* above, so the first two
// presents still run.
static bool     gPrevAnyDirty = true;

// Cached SCB + palettes from the last present. jlpPresent* only needs
// to rebuild/install the copper list when SCB assignments or palette
// RGB values differ from what is already on screen; pure pixel updates
// (which dominate a typical game loop and every frame of the keys
// demo after the initial paint) leave both alone. MrgCop + LoadView +
// WaitTOF is hundreds of milliseconds on a 7 MHz 68000, so skipping
// them on clean frames is a major win.
/* Change detection is the core gStageScbDirty / gStagePaletteDirty
 * flags (surfaceInternal.h): the mutation APIs set them and
 * updateCopperIfNeeded clears them after rebuilding, replacing a
 * 712-byte cached-mirror long-compare per present (NATIVE-PERF
 * Phase 2 R9; the IIgs consumed the flags first). */

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

// Volatile view of the custom-chip register block. The Custom struct
// fields are plain (non-volatile) UWORD/APTR, so a direct
// `custom.bltcon0 = x` is eligible for -O2 reordering / dead-store
// elimination. Accessing every blitter register through a volatile *
// makes each member write volatile-qualified, which (a) keeps the
// compiler from reordering BLTSIZE (the start trigger) ahead of the
// setup writes and (b) stops it from dropping a "redundant" register
// store. This is the standard idiom for poking the custom chip from C.
static volatile struct Custom *const gCust = &custom;

// Blitter geometry shared by every block op here: each plane row is
// AMIGA_BYTES_PER_ROW (40) bytes = 20 words. BLTSIZE packs the row
// count in the high 10 bits (<< 6) and the word count in the low 6
// bits (a width of 0 would mean 64 words, but our rows are 20 wide).
#define AMIGA_BLT_WORDS_PER_ROW  (AMIGA_BYTES_PER_ROW / 2u)   // 20
#define AMIGA_BLT_SIZESHIFT      6
#define AMIGA_BLT_MASK_ALL       0xFFFFu

// BLTSIZE field guard (P7-3 audit follow-up): height occupies bits
// 15..6 (10-bit == max 1024 rows) and width bits 5..0 (6-bit == max 64
// words). Every blit here is bounded by the surface geometry -- present
// runs and copyRect pass height <= SURFACE_HEIGHT and width <=
// AMIGA_BLT_WORDS_PER_ROW -- so both fit today; this compile-time guard
// catches a future taller/wider surface before it silently truncates.
typedef char amigaBltSizeFieldsFit[(SURFACE_HEIGHT <= 1024 && AMIGA_BLT_WORDS_PER_ROW <= 64) ? 1 : -1];

// Minterms for the masked solid-fill blit (amigaBlitterMaskedFillOne):
// A is the constant $FFFF cut to the rect by the first/last-word masks,
// C and D both address the destination. Set-bits writes D = A OR C
// (force the masked bits on, preserve the rest); clear-bits writes
// D = (NOT A) AND C (force the masked bits off, preserve the rest).
#define AMIGA_BLT_MINTERM_SET    0xFAu   // D = A | C
#define AMIGA_BLT_MINTERM_CLEAR  0x0Au   // D = ~A & C

// Blitter-vs-CPU crossover for jlpSurfaceCopyRect, in per-plane words
// (height * widthWords). Below this the OwnBlitter + per-plane
// WaitBlit + register-setup fixed cost (~1,300 CPU cycles) beats a
// plain CPU word-store walk (~38 cycles per plane-row word); at the
// 16x16 clean-buffer window (16 plane words) the blitter already wins
// ~2x and the gap grows linearly with area.
#define AMIGA_COPYRECT_BLIT_MIN_WORDS 16u

// Blitter-vs-CPU crossover for the present flush (amigaPresentRectInner),
// in per-plane words (run height * aligned words). The chip->chip CPU
// copy is contention-bound (the 2026-07-09 component probe measured the
// present3win flush at ~3.3 ms for 3 tiny windows), so the blitter --
// which does chip->chip in parallel, the same win as jlpSurfaceCopyRect
// (+76%) -- is worth it well below the old conservative 128. Set to the
// copyRect break-even; present3win/gameFrameClean windows are ~32
// plane-words, comfortably above it, so they now blit. Each run still
// pays its own OwnBlitter/DisownBlitter (a later Own-once pass could
// amortize that across a multi-run present).
#define AMIGA_PRESENT_BLIT_MIN_WORDS 16u

// Blitter A->D straight copy of `height` rows of `widthWords` words
// across all four planes, starting `offsetBytes` into every plane and
// adding `modBytes` to both channel pointers after each row.
// offsetBytes = 0 / modBytes = 0 is the whole-plane contiguous copy
// (jlpSurfaceCopyPlanes); a group-snapped sub-rect passes the shared
// word-aligned byte offset and 40 - rowBytes. src[i]/dst[i] must be
// CHIP RAM and offsetBytes/modBytes even (the pointer registers have
// no A0 bit -- an odd offset would silently copy the wrong column).
// The 16-px group snap makes the row span whole words, so the edge
// masks stay all-ones. minterm $F0 (A_TO_D): D := A, no read of B/C/D.
//
// Blit BODY without OwnBlitter/DisownBlitter -- the caller has already
// OwnBlitter'd and DisownBlitters after the last body. The present flush
// uses this to OwnBlitter ONCE per present and run every dirty run's blit
// inside that single ownership (present3win no longer pays 3x
// Own/Disown). WaitBlit before each plane's register setup drains the
// prior blit (the blitter is single-resource); the final WaitBlit leaves
// it idle so a following CPU-path run or the copper flip sees complete
// planes. minterm $F0 (A_TO_D): D := A, no read of B/C/D.
static void amigaBlitterCopyPlanesOwned(uint8_t *dst[AMIGA_BITPLANES], uint8_t *src[AMIGA_BITPLANES],
                                        uint16_t offsetBytes, uint16_t widthWords, uint16_t height, uint16_t modBytes) {
    uint16_t i;
    uint16_t bltsize;

    bltsize = (uint16_t)((height << AMIGA_BLT_SIZESHIFT) | widthWords);
    for (i = 0; i < AMIGA_BITPLANES; i++) {
        WaitBlit();
        gCust->bltcon0 = (uint16_t)(SRCA | DEST | A_TO_D);   // $09F0
        gCust->bltcon1 = 0u;
        gCust->bltafwm = AMIGA_BLT_MASK_ALL;
        gCust->bltalwm = AMIGA_BLT_MASK_ALL;
        gCust->bltamod = modBytes;
        gCust->bltdmod = modBytes;
        gCust->bltapt  = (APTR)(src[i] + offsetBytes);
        gCust->bltdpt  = (APTR)(dst[i] + offsetBytes);
        gCust->bltsize = bltsize;                            // starts the blit
    }
    WaitBlit();
}


// Standalone single-blit form (OwnBlitter + body + DisownBlitter) for
// callers that do ONE blit -- jlpSurfaceCopyRect / jlpSurfaceCopyPlanes.
static void amigaBlitterCopyPlanes(uint8_t *dst[AMIGA_BITPLANES], uint8_t *src[AMIGA_BITPLANES],
                                   uint16_t offsetBytes, uint16_t widthWords, uint16_t height, uint16_t modBytes) {
    OwnBlitter();
    amigaBlitterCopyPlanesOwned(dst, src, offsetBytes, widthWords, height, modBytes);
    DisownBlitter();
}


// Blitter DEST-only block fill of one plane: `height` rows of
// `widthWords` words, with `dstModBytes` added to the dest pointer
// after each row (0 for a full-stride fill, (40 - rowBytes) for a
// sub-row rect). setBits selects the constant written: $FFFF (set,
// minterm $FF) or $0000 (clear, minterm $00). USED only -- no source
// channel is read, so the minterm alone determines the output and the
// fill needs no source data in memory. Caller owns the blitter and
// has already WaitBlit'd if a prior blit may be in flight; this
// routine WaitBlit's before its own register setup for safety when
// called back-to-back across planes.
static void amigaBlitterFillOne(uint8_t *dst, uint16_t widthWords, uint16_t height,
                                uint16_t dstModBytes, bool setBits) {
    uint16_t minterm;

    minterm = setBits ? 0x00FFu : 0x0000u;
    WaitBlit();
    gCust->bltcon0 = (uint16_t)(DEST | minterm);
    gCust->bltcon1 = 0u;
    gCust->bltafwm = AMIGA_BLT_MASK_ALL;
    gCust->bltalwm = AMIGA_BLT_MASK_ALL;
    gCust->bltdmod = dstModBytes;
    gCust->bltdpt  = (APTR)dst;
    gCust->bltsize = (uint16_t)((height << AMIGA_BLT_SIZESHIFT) | widthWords);
}


// Blitter masked solid fill of one plane: `height` rows of `widthWords`
// words starting at `dst`, `dstModBytes` added to the pointer after each
// row ((40 - widthWords*2) for a sub-row rect). Handles partial-pixel
// left/right edges via the hardware first/last-word masks (fwm/lwm), so
// any rect width -- not just whole words -- fills in a single blit per
// plane with no CPU read-modify-write.
//
// A is left as a constant $FFFF in BLTADAT (SRCA off, no A DMA); the
// blitter still runs that constant through the fwm/lwm masking in the A
// data path, which is what cuts the partial edge words. C reads the
// destination so bits outside the mask survive, D writes it back. When
// widthWords == 1 the blitter ANDs fwm and lwm onto the single word, so
// a one-word-wide rect needs no special case. Caller owns the blitter;
// this WaitBlit's before its own register setup so back-to-back
// per-plane calls are safe.
static void amigaBlitterMaskedFillOne(uint8_t *dst, uint16_t widthWords, uint16_t height,
                                      uint16_t dstModBytes, uint16_t fwm, uint16_t lwm,
                                      bool setBits) {
    uint16_t minterm;

    minterm = setBits ? AMIGA_BLT_MINTERM_SET : AMIGA_BLT_MINTERM_CLEAR;
    WaitBlit();
    gCust->bltcon0 = (uint16_t)(SRCC | DEST | minterm);
    gCust->bltcon1 = 0u;
    gCust->bltadat = AMIGA_BLT_MASK_ALL;
    gCust->bltafwm = fwm;
    gCust->bltalwm = lwm;
    gCust->bltcmod = dstModBytes;
    gCust->bltdmod = dstModBytes;
    gCust->bltcpt  = (APTR)dst;
    gCust->bltdpt  = (APTR)dst;
    gCust->bltsize = (uint16_t)((height << AMIGA_BLT_SIZESHIFT) | widthWords);
}


// Build a user copper list for per-scanline palette (SCB emulation).
// One WAIT + 16 MOVEs per displayed scanline + one CEND. The list is
// stored in gNewUCL until installCopperList swaps it onto the screen.
// DyOffset tells us where display line 0 sits in hardware coordinates
// so the WAITs line up with the real visible region regardless of
// PAL/NTSC or any overscan the user may have configured.
static void buildCopperList(const jlSurfaceT *src) {
    struct UCopList *ucl;
    UWORD            line;
    UWORD            col;
    UBYTE            palIdx;
    UWORD            prevPalIdx;
    UWORD            vpos;
    UWORD            topBorder;

    ucl = (struct UCopList *)AllocMem(sizeof(struct UCopList),
                                      MEMF_PUBLIC | MEMF_CLEAR);
    if (ucl == NULL) {
        gNewUCL = NULL;
        return;
    }

    // Worst-case reservation is one band-change per scanline (16 MOVEs
    // + 1 WAIT per change), plus the terminal wait. For realistic SCB
    // tables the actual count is far smaller, but CINIT only takes a
    // single number so we size for the cap.
    CINIT(ucl, (SURFACE_HEIGHT * 17) + 1);

    // Hardware scanline where display line 0 lives. 0x2C is the
    // standard top border for a PAL screen at TopEdge=0; we hardcode
    // rather than reading ViewPort.DyOffset because DyOffset is a
    // signed +/- adjustment around the standard value, not the
    // absolute hardware line.
    // User-copper vpos values are DISPLAY-RELATIVE -- graphics.lib
    // MrgCop adds the active View's DyOffset to each WAIT before
    // emitting, so a vp=0 user WAIT lands at beam line DyOffset,
    // which is where Intuition places display line 0. Emitting at
    // vpos=line keeps merged vpos under 256 even for the last band
    // (175 + 44 = 219 < 256), avoiding MrgCop's destructive wrap-
    // handling path that would otherwise disable bitplane DMA at
    // the viewport end.
    topBorder  = 0;
    prevPalIdx = 0xFFFF;

    for (line = 0; line < SURFACE_HEIGHT; line++) {
        palIdx = src->scb[line];
        if (palIdx >= SURFACE_PALETTE_COUNT) {
            palIdx = 0;
        }
        if ((UWORD)palIdx == prevPalIdx) {
            continue;
        }

        vpos = (UWORD)(line + topBorder);
        CWAIT(ucl, vpos, 0);
        for (col = 0; col < SURFACE_COLORS_PER_PALETTE; col++) {
            CMOVE(ucl, custom.color[col], src->palette[palIdx][col]);
        }
        prevPalIdx = (UWORD)palIdx;
    }
    CEND(ucl);

    gNewUCL = ucl;
}


// Swap the freshly built user copper list onto the screen's ViewPort
// and force a full graphics-library recomputation of the hardware
// copper list. MakeScreen regenerates the viewport copper to include
// our UCopIns; MrgCop merges every viewport's copper into one hardware
// list; LoadView swaps the live copper pointers. Calling the graphics
// primitives directly (rather than only Intuition's RethinkDisplay /
// RemakeDisplay) was observed here to be the step that actually makes
// the user copper list visible -- Intuition's wrappers sometimes
// skipped the merge.
static void installCopperList(void) {
    struct View *view;

    if (gNewUCL == NULL || gScreen == NULL) {
        return;
    }
    Forbid();
    if (gScreen->ViewPort.UCopIns != NULL) {
        FreeVPortCopLists(&gScreen->ViewPort);
    }
    gScreen->ViewPort.UCopIns = gNewUCL;
    gNewUCL = NULL;
    Permit();

    MakeScreen(gScreen);

    view = ViewAddress();
    Forbid();
    MrgCop(view);
    LoadView(view);
    Permit();
    WaitTOF();

    // MrgCop rebuilt the hardware copper list from scratch, including
    // fresh BPL pointer pokes derived from gBitMap->Planes[]. Re-cache
    // pointers to those pokes so the per-frame flip in jlpPresent can
    // patch them in place without another MrgCop pass.
    scanBplCopperPokes();
}


// Walk the merged hardware copper list and remember where the
// BPL1PTH/PTL through BPL4PTH/PTL move instructions are. Each cached
// pointer addresses the DATA word of one MOVE -- patchBplCopperPokes
// can rewrite those 8 words to switch which CHIP buffer the display
// fetches from, with no graphics.library involvement.
//
// Copper MOVE encoding: word 0 = register offset (low bit clear),
// word 1 = data. WAIT/SKIP have word-0 low bit set, so they are
// skipped here. BPL pointer registers live at $E0..$F6 in increments
// of 2 ($E0 = BPL1PTH, $E2 = BPL1PTL, ..., $F4 = BPL4PTH, $F6 =
// BPL4PTL); planes beyond 4 ($F8+) are ignored.
static void scanBplCopperPokes(void) {
    struct View *view;
    volatile uint16_t *cop;
    uint16_t inst;
    uint16_t reg;
    uint16_t plane;
    uint16_t hilo;
    uint16_t  i;
    uint16_t  maxPairs;

    for (i = 0; i < AMIGA_BITPLANES; i++) {
        gBplPokes[i][0] = NULL;
        gBplPokes[i][1] = NULL;
    }

    view = ViewAddress();
    if (view == NULL || view->LOFCprList == NULL || view->LOFCprList->start == NULL) {
        return;
    }
    cop      = (volatile uint16_t *)view->LOFCprList->start;
    // MaxCount is in copper "instructions" (longs); each instruction
    // is a pair of words. Cap defensively so a malformed list can't
    // run us past the end of the buffer.
    maxPairs = (uint16_t)view->LOFCprList->MaxCount;

    for (i = 0; i < maxPairs; i++) {
        inst = cop[i * 2];
        // Bit 0 set marks WAIT/SKIP (the kind is disambiguated by
        // bit 0 of the second word). Either way, skip.
        if (inst & 0x0001u) {
            continue;
        }
        reg = (uint16_t)(inst & 0x01FEu);
        if (reg < 0x00E0u || reg > 0x00F6u) {
            continue;
        }
        plane = (uint16_t)((reg - 0x00E0u) >> 2);
        hilo  = (uint16_t)((reg & 0x0002u) >> 1);
        if (plane >= AMIGA_BITPLANES) {
            continue;
        }
        gBplPokes[plane][hilo] = &cop[i * 2 + 1];
    }
}


// Rewrite the cached BPLnPTH/PTL data words to point at a fresh set
// of CHIP plane buffers. Takes effect at the next vblank when the
// copper next runs through these pokes. Disable() guards against an
// interrupt landing between word writes; the copper itself is past
// the BPL section by the time jlpPresent runs (the app calls
// jlWaitVBL before jlStagePresent, so we're already in the visible
// scan region by here), so we can't actually tear a poke in flight.
static void patchBplCopperPokes(UBYTE *planes[AMIGA_BITPLANES]) {
    uint16_t i;
    uint32_t addr;

    if (gBplPokes[0][0] == NULL) {
        return;  // first present before any installCopperList yet
    }
    Disable();
    for (i = 0; i < AMIGA_BITPLANES; i++) {
        if (gBplPokes[i][0] == NULL || gBplPokes[i][1] == NULL) {
            continue;
        }
        addr = (uint32_t)planes[i];
        *gBplPokes[i][0] = (uint16_t)(addr >> 16);     // PTH
        *gBplPokes[i][1] = (uint16_t)(addr & 0xFFFFu); // PTL
    }
    Enable();
}


// Rebuild and install the user copper list only if the palette/SCB
// state visible to the display differs from what the surface carries
// now. On clean frames we skip the AllocMem + MrgCop + LoadView +
// WaitTOF chain entirely. `src` is always gStage (jlpPresent's
// contract), so the core dirty flags are the change signal; clear
// them only after rebuilding.
static uint32_t gCopperRebuildCount = 0;
static uint32_t gPresentCallCount   = 0;

static void updateCopperIfNeeded(const jlSurfaceT *src) {
    if (!gStageScbDirty && !gStagePaletteDirty) {
        return;
    }
    gCopperRebuildCount++;
    uploadFirstBandPalette(src);
    buildCopperList(src);
    installCopperList();
    gStageScbDirty     = false;
    gStagePaletteDirty = false;
}


/* Diag hook: callable from anywhere via the linker symbol. */
void amigaDumpPresentCounters(const char *label) {
    jlLogF("amiga-perf: %s: present=%lu copperRebuild=%lu",
             label != NULL ? label : "?",
             (unsigned long)gPresentCallCount,
             (unsigned long)gCopperRebuildCount);
}


// Diag/probe hook (NATIVE-PERF W2): fold the plane set the copper
// will program BPL DMA from at the next vblank (read back from the
// live BPLnPT pokes -- the display's source of truth, so this also
// gates the flip bookkeeping and poke patching) and the stage's
// shadow planes into two djb2 sums. After any jlStagePresent the two
// MUST be equal: the union copy guarantees the freshly flipped page
// carries every row of the current shadow. Inequality == stale rows
// reaching the display -- the failure class the golden stage hashes
// cannot see. Falls back to gFrontIdx before the first copper
// install. Always compiled (the probe build links the stock library;
// gPresentCopiedBytes precedent).
void amigaProbePresentPageSums(const jlSurfaceT *src, uint32_t *frontSum, uint32_t *shadowSum) {
    AmigaPlanarT  *pd;
    const uint8_t *fp;
    const uint8_t *sp;
    uint32_t       fSum;
    uint32_t       sSum;
    uint32_t       addr;
    uint16_t       i;
    uint16_t       b;

    pd   = (AmigaPlanarT *)src->portData;
    fSum = 5381u;
    sSum = 5381u;
    for (i = 0; i < AMIGA_BITPLANES; i++) {
        if (gBplPokes[i][0] != NULL && gBplPokes[i][1] != NULL) {
            addr = ((uint32_t)*gBplPokes[i][0] << 16) | (uint32_t)*gBplPokes[i][1];
            fp   = (const uint8_t *)addr;
        } else {
            fp = (gFrontIdx == 0u) ? gPlanes[i] : gPlanesB[i];
        }
        sp = pd->planes[i];
        for (b = 0; b < (uint16_t)AMIGA_PLANE_SIZE; b++) {
            fSum = fSum * 33u + fp[b];
            sSum = sSum * 33u + sp[b];
        }
    }
    *frontSum  = fSum;
    *shadowSum = sSum;
}


// Load the first band's palette into the screen's ColorMap so the
// Intuition-generated frame-start copper writes those values on each
// frame. This acts as a safety net: even if our user copper list does
// not fire (or fires late) for the very first band, the top of the
// display still shows the correct colors because Intuition's own
// COLORxx loads happen before any user copper instruction.
static void uploadFirstBandPalette(const jlSurfaceT *src) {
    UWORD  aPalette[SURFACE_COLORS_PER_PALETTE];
    UWORD  i;
    UBYTE  palIdx;

    palIdx = src->scb[0];
    if (palIdx >= SURFACE_PALETTE_COUNT) {
        palIdx = 0;
    }
    for (i = 0; i < SURFACE_COLORS_PER_PALETTE; i++) {
        aPalette[i] = (UWORD)src->palette[palIdx][i];
    }
    LoadRGB4(&gScreen->ViewPort, aPalette, SURFACE_COLORS_PER_PALETTE);
}


// ----- HAL API (alphabetical) -----

bool jlpInit(const jlConfigT *config) {
    uint16_t i;
    uint16_t j;

    (void)config;

    // Allocate our own BitMap with explicitly-non-interleaved planes,
    // then hand it to OpenScreen via SA_BitMap. Why not let Intuition
    // build one for us:
    //   * AllocBitMap is V39+ and we target OCS / Kickstart 1.3
    //     (V34), so we have to do this manually with InitBitMap +
    //     per-plane AllocMem.
    //   * Without SA_BitMap, OpenScreen on AmigaOS 2.x+ / AROS may
    //     return a BitMap with interleaved planes (single allocation,
    //     stride = depth * bytes_per_row). Our c2pRange and every
    //     planar primitive assume stride = bytes_per_row per plane;
    //     interleaved layout would silently corrupt with no
    //     diagnostic.
    //   * Forcing the layout also locks in chip-RAM placement (display
    //     DMA can only fetch from chip RAM) regardless of host
    //     defaults.
    gBitMap = (struct BitMap *)AllocMem((ULONG)sizeof(struct BitMap),
                                        (ULONG)(MEMF_PUBLIC | MEMF_CLEAR));
    if (gBitMap == NULL) {
        return false;
    }
    InitBitMap(gBitMap, (LONG)AMIGA_BITPLANES, (LONG)SURFACE_WIDTH, (LONG)SURFACE_HEIGHT);
    // Phase 10 page-flip needs TWO CHIP plane sets so jlpPresent can
    // write into the off-screen one and an atomic copper-list patch
    // swaps which one BPL DMA fetches from. Allocate both up front;
    // bail with full rollback if either runs out of chip RAM.
    for (i = 0; i < AMIGA_BITPLANES; i++) {
        gPlanes[i]  = (UBYTE *)AllocMem((ULONG)AMIGA_PLANE_SIZE,
                                        (ULONG)(MEMF_CHIP | MEMF_CLEAR));
        gPlanesB[i] = (UBYTE *)AllocMem((ULONG)AMIGA_PLANE_SIZE,
                                        (ULONG)(MEMF_CHIP | MEMF_CLEAR));
        if (gPlanes[i] == NULL || gPlanesB[i] == NULL) {
            if (gPlanes[i] != NULL) {
                FreeMem(gPlanes[i], (ULONG)AMIGA_PLANE_SIZE);
                gPlanes[i] = NULL;
            }
            if (gPlanesB[i] != NULL) {
                FreeMem(gPlanesB[i], (ULONG)AMIGA_PLANE_SIZE);
                gPlanesB[i] = NULL;
            }
            for (j = 0; j < i; j++) {
                FreeMem(gPlanes[j],  (ULONG)AMIGA_PLANE_SIZE);
                FreeMem(gPlanesB[j], (ULONG)AMIGA_PLANE_SIZE);
                gPlanes[j]  = NULL;
                gPlanesB[j] = NULL;
            }
            FreeMem(gBitMap, (ULONG)sizeof(struct BitMap));
            gBitMap = NULL;
            return false;
        }
        // Set A is the initial front buffer; BPL DMA fetches from it
        // until the first jlpPresent flips to B.
        gBitMap->Planes[i] = gPlanes[i];
    }
    gFrontIdx = 0;
    // Bootstrap: pretend the previous frame dirtied EVERY row so the
    // first two presents do full-buffer refreshes and bring both CHIP
    // sets into sync with the shadow. After that we converge to the
    // small per-row union(prev,current) cost.
    for (i = 0; i < SURFACE_HEIGHT; i++) {
        gPrevStageMinWord[i] = 0u;
        gPrevStageMaxWord[i] = (uint8_t)(SURFACE_WORDS_PER_ROW - 1u);
    }

    // SA_DisplayID pins us to OCS PAL low-res so Intuition opens a
    // real planar screen rather than an RTG substitute. SA_BitMap
    // makes Intuition use OUR pre-allocated planes; CloseScreen will
    // not free them -- our jlpShutdown does.
    gScreen = OpenScreenTags(NULL,
        (ULONG)SA_Width,      (ULONG)SURFACE_WIDTH,
        (ULONG)SA_Height,     (ULONG)SURFACE_HEIGHT,
        (ULONG)SA_Depth,      (ULONG)AMIGA_BITPLANES,
        (ULONG)SA_DisplayID,  (ULONG)(PAL_MONITOR_ID | LORES_KEY),
        (ULONG)SA_DetailPen,  (ULONG)0,
        (ULONG)SA_BlockPen,   (ULONG)1,
        (ULONG)SA_Title,      (ULONG)"JoeyLib",
        (ULONG)SA_Type,       (ULONG)CUSTOMSCREEN,
        (ULONG)SA_Quiet,      (ULONG)TRUE,
        (ULONG)SA_BitMap,     (ULONG)gBitMap,
        TAG_DONE);

    if (gScreen == NULL) {
        for (i = 0; i < AMIGA_BITPLANES; i++) {
            if (gPlanes[i] != NULL) {
                FreeMem(gPlanes[i], (ULONG)AMIGA_PLANE_SIZE);
                gPlanes[i] = NULL;
            }
            if (gPlanesB[i] != NULL) {
                FreeMem(gPlanesB[i], (ULONG)AMIGA_PLANE_SIZE);
                gPlanesB[i] = NULL;
            }
        }
        FreeMem(gBitMap, (ULONG)sizeof(struct BitMap));
        gBitMap = NULL;
        return false;
    }
    // Force COLOR00 to black so the overscan/border region around the
    // 320x200 display is black until the app's palette load takes over
    // on the first jlStagePresent. Apps that paint a non-black bg need
    // do nothing -- their palette[0] writes the same COLOR00 once the
    // first LoadRGB4 fires from uploadScbAndPalette.
    SetRGB4(&gScreen->ViewPort, 0, 0, 0, 0);
    installVblServer();
    return true;
}


// Phase 9 switch flip: present is now always a per-row planar memcpy
// from the off-screen shadow planes (where every drawing primitive
// dual-writes today) into gPlanes[] (the displayed BitMap). c2p is
// gone; the chunky `s->pixels` shadow is still maintained by the
// jlp<Op> primitives but no longer drives display. Phase 10 will
// either (a) BPLPTR-swap shadow <-> display planes (zero-copy) or
// (b) stop writing chunky in the fast paths to recover the dual-
// write cost. Per-row dirty tracking is reused: only dirty bands
// memcpy.
/* Flush one coalesced run of consecutive rows that share the same
 * unaligned plane-byte span [byteStart, byteStart+byteLen). W2 per-row
 * bands: jlpPresent feeds RUNS here instead of one whole-present
 * bounding box, so scattered windows no longer drag the enclosing rect
 * along (the W0 P2 3.5x over-copy). The copied-bytes counter counts
 * UNALIGNED band bytes x planes -- the same semantic the W0 baselines
 * used -- so alignment pad and FAT bridge bytes stay excluded
 * (physical bytes moved >= counter, exactly as in W0). Anyone changing
 * the copy must move the counter with it. */
static void amigaPresentFlushRun(AmigaPlanarT *pd, UBYTE *destPlanes[AMIGA_BITPLANES], int16_t runFirst, int16_t runLast, uint16_t byteStart, uint16_t byteLen, bool *blitOwned) {
    amigaPresentRectInner(pd, destPlanes, runFirst, runLast, byteStart, byteLen, blitOwned);
    gPresentCopiedBytes += (uint32_t)(runLast - runFirst + 1) * byteLen * AMIGA_BITPLANES;
}


/* Copy a rect (firstRow..lastRow inclusive, one shared byte span) from
 * each shadow plane into the displayed plane set. Since W2 this is the
 * per-RUN flusher: callers pass runs of rows with identical spans.
 * THIN arm (aligned span <= 20 B = half row): plane-outer/row-inner
 * inline long copy -- 4 plane setups per RUN, a few move.l per row.
 * FAT arm (wider): one CopyMemQuick per plane bridging the inter-row
 * gaps. CopyMemQuick is guaranteed long-move and ~1.6x the inline
 * loop's byte rate (movem bursts, no dbra per long) but REQUIRES both
 * pointers long-aligned and length a long multiple -- misalignment
 * gurus. Break-even vs inline is ~21 B/row (7.5*len + 26 vs 4.6*40
 * cyc), so the historical half-row threshold stands. byteStart rounds
 * DOWN to long boundary; copyLen rounds UP past the right edge --
 * every byte the alignment or the FAT bridge adds beyond the union
 * band lies in a run row OUTSIDE its band: untouched for >= 2 frames,
 * so the back page already equals the shadow there and the extra copy
 * is a byte-identical no-op (2-buffer convergence invariant). Aligned
 * spans never leave their 40-byte row: alignedEnd <= (39+4) & ~3 = 40,
 * inside the plane buffer (AMIGA_PLANE_SIZE = 8000). */
static void amigaPresentRectInner(AmigaPlanarT *pd, UBYTE *destPlanes[AMIGA_BITPLANES],
                                  int16_t firstRow, int16_t lastRow,
                                  uint16_t byteStart, uint16_t bytesPerRow, bool *blitOwned) {
    uint16_t alignedStart;
    uint16_t alignedEnd;
    uint16_t alignedBytesPerRow;
    uint16_t blitHeight;
    uint16_t blitWords;
    uint16_t offset;
    uint16_t copyLen;
    uint16_t i;
    int16_t  row;

    alignedStart       = (uint16_t)(byteStart & ~3u);
    alignedEnd         = (uint16_t)((byteStart + bytesPerRow + 3u) & ~3u);
    alignedBytesPerRow = (uint16_t)(alignedEnd - alignedStart);

    /* BLITTER path: for runs large enough to amortize the per-plane
     * WaitBlit + register setup, an A->D blitter copy moves the whole run
     * off the CPU at chip-DMA rate. offset and modBytes are even by
     * construction (alignedStart is long-aligned, AMIGA_BYTES_PER_ROW is
     * even) so the A0-less blitter pointers address the right columns.
     * The caller's copied-bytes counter is unchanged: the blitter moves
     * the same aligned span the CPU arms do, and the 2-buffer convergence
     * invariant makes the alignment pad a no-op exactly as for the CPU
     * copy. Both plane sets are CHIP RAM (gPlanes/gPlanesB and the shadow
     * pd->planes, which the fillRect/copyRect blitter arms already drive).
     * The present owns the blitter ONCE across all its runs (lazy on the
     * first blit here, DisownBlitter in jlpPresent's flush tail) so a
     * multi-window present pays one Own/Disown, not one per run. The body
     * ends with WaitBlit, so a later CPU-path run in the same present
     * reads a drained blitter. */
    blitHeight = (uint16_t)(lastRow - firstRow + 1);
    blitWords  = (uint16_t)(alignedBytesPerRow >> 1);
    if ((uint32_t)blitHeight * blitWords >= AMIGA_PRESENT_BLIT_MIN_WORDS) {
        offset = (uint16_t)((uint16_t)firstRow * AMIGA_BYTES_PER_ROW + alignedStart);
        if (!*blitOwned) {
            OwnBlitter();
            *blitOwned = true;
        }
        amigaBlitterCopyPlanesOwned(destPlanes, pd->planes, offset, blitWords, blitHeight,
                                    (uint16_t)(AMIGA_BYTES_PER_ROW - alignedBytesPerRow));
        return;
    }

    /* THIN path: rect is narrow enough that the bounding-box memcpy
     * would over-copy. e.g. an 8x8 rect at x=40 has 32 bytes of
     * actual data vs 284 bytes of bounding box (9x over-copy). For
     * narrow rects, per-row inline long-copy beats memcpy because
     * memcpy has function-call dispatch per call AND we only need a
     * couple of long stores per row. Threshold tuned so
     * stagePresentRect 8b lands here, stagePresentRect F doesn't. */
    if (alignedBytesPerRow <= (AMIGA_BYTES_PER_ROW / 2u)) {
        offset = (uint16_t)((uint16_t)firstRow * AMIGA_BYTES_PER_ROW + alignedStart);
        for (i = 0; i < AMIGA_BITPLANES; i++) {
            uint8_t *src = pd->planes[i] + offset;
            uint8_t *dst = destPlanes[i] + offset;
            for (row = firstRow; row <= lastRow; row++) {
                uint32_t       *d32 = (uint32_t *)dst;
                const uint32_t *s32 = (const uint32_t *)src;
                uint16_t        n   = (uint16_t)(alignedBytesPerRow >> 2);
                do {
                    *d32++ = *s32++;
                } while (--n);
                src += AMIGA_BYTES_PER_ROW;
                dst += AMIGA_BYTES_PER_ROW;
            }
        }
        return;
    }

    /* FAT path: bounding-box copy. exec CopyMemQuick uses 32-bit
     * moves and ~5x beats libnix memcpy for full-screen-sized
     * blocks. Long alignment is guaranteed: offset is rounded down
     * to a long boundary (alignedStart) and copyLen is the sum of
     * row-strided long-aligned bytesPerRow + (lastRow-firstRow) *
     * AMIGA_BYTES_PER_ROW (40, which is 4 * 10, so also long-
     * multiple). pd->planes[i] and destPlanes[i] are both
     * AllocMem-returned and thus long-aligned. */
    offset  = (uint16_t)((uint16_t)firstRow * AMIGA_BYTES_PER_ROW + alignedStart);
    copyLen = (uint16_t)((uint16_t)(lastRow - firstRow) * AMIGA_BYTES_PER_ROW
                         + alignedBytesPerRow);
    for (i = 0; i < AMIGA_BITPLANES; i++) {
        CopyMemQuick(pd->planes[i] + offset, destPlanes[i] + offset, (ULONG)copyLen);
    }
}


void jlpPresent(const jlSurfaceT *src) {
    AmigaPlanarT *pd;
    int16_t       y;
    uint32_t      scanRes;
    uint16_t      numRuns;
    uint16_t      r;
    bool          anyUnionDirty;
    bool          anyCurDirty;
    UBYTE       **backPlanes;
    uint8_t       backIdx;

    gPresentCallCount++;
    if (src == NULL || gScreen == NULL) {
        return;
    }
    updateCopperIfNeeded(src);

    pd = (AmigaPlanarT *)src->portData;
    if (pd == NULL) {
        return;
    }

    // Idle-frame early-out. When the previous frame's snapshot recorded
    // no dirty rows (gPrevAnyDirty == false) AND the current frame
    // touched no row, union(prev,current) is empty: there is nothing to
    // copy, no buffer to flip, and the snapshot below would only copy
    // clean->clean. Skip the whole 200-row union scan and the 400-byte
    // snapshot copy in that case. Group test, 4 rows per longword: a
    // clean row's minWord is the 0xFF CLEAN sentinel and any touched
    // row's is <= STAGE_DIRTY_FULL_MAX (79), so 4 clean rows read
    // exactly 0xFFFFFFFF -- 50 long tests replace 200 byte tests on
    // the common all-clean idle frame (gStageMinWord is aligned(2),
    // all the 68000 needs for move.l). A failing group byte-walks its
    // 4 rows with the same per-row test as before, so the first-dirty
    // row and the scanned-rows accounting are exactly the
    // byte-walker's. Copper/palette updates already ran above, so
    // display state stays correct. This cannot be taken while a stale
    // band from the previous frame is still pending (gPrevAnyDirty
    // guards that), preserving the 2-buffer staleness convergence.
    if (!gPrevAnyDirty) {
        typedef uint32_t __attribute__((may_alias)) BandGroupT;
        const BandGroupT *minGroups = (const BandGroupT *)(const void *)gStageMinWord;
        bool              anyDirty  = false;
        uint8_t           g;

        y = SURFACE_HEIGHT;
        for (g = 0u; g < (uint8_t)(SURFACE_HEIGHT / 4u); g++) {
            if (minGroups[g] != 0xFFFFFFFFul) {
                int16_t yy;
                for (yy = (int16_t)((uint16_t)g * 4u); yy < (int16_t)((uint16_t)g * 4u + 4u); yy++) {
                    if (STAGE_DIRTY_ROW_TOUCHED(yy)) {
                        anyDirty = true;
                        y        = yy;
                        break;
                    }
                }
                if (anyDirty) {
                    break;
                }
            }
        }
        gPresentScannedRows += (uint32_t)(anyDirty ? (y + 1) : SURFACE_HEIGHT);
        if (!anyDirty) {
            return;
        }
    }

    // Phase 10 page-flip:
    //   * Write to the OFF-screen plane set so the beam never crosses
    //     in-flight pixels (no tearing regardless of copy speed).
    //   * Union(prev,current) dirty rows: the back buffer holds
    //     content from 2 frames ago, so anything dirty in either of
    //     the last two frames may differ from the current shadow.
    //   * After the copy, patch the copper list's BPLnPT pokes to
    //     point at the freshly-drawn set. The copper picks up the
    //     new pokes at the next vblank and BPL DMA fetches from the
    //     new buffer on the following frame.
    backIdx    = (uint8_t)(gFrontIdx ^ 1u);
    backPlanes = (backIdx == 0u) ? gPlanes : gPlanesB;

    // The present copy reads pd->planes (the shadow) with the CPU. The
    // bulk plane ops run on the blitter and WaitBlit before returning,
    // so the blitter is normally idle here; wait anyway before the
    // first CPU read so a racing blit can never hand the display a
    // half-written row. Hoisted above the scan (a ~30-cycle DMACONR
    // poll when idle) so every flush below is covered.
    WaitBlit();

    // W2 per-row present bands (the ST run-coalescing pattern, adapted
    // to 4 separate plane buffers), hand-rolled in src/amiga/presentScan.s
    // (Phase 7). ONE 200-row pass folds union(prev,current) per row --
    // word = 4 px, plane byte = 8 px: start = min>>1, end = max>>1 for
    // both parities, so the span covers the band exactly -- snapshots
    // gPrev, and coalesces consecutive same-span rows into runs written
    // to gPresentRuns. Scattered small windows copy only their own rows
    // and bytes; a solid band (P1/gameFrame) is a single run. This scan
    // was the Amiga present's dominant fixed cost: in C it was register-
    // pressure-bound on gcc-amigaos (four band walkers + run state
    // spilled), so it moved to asm where every walker lives in an address
    // register. Returns the run count (low 16) plus anyUnionDirty (bit
    // 16) and anyCurDirty (bit 17) -- the two flags the flip and
    // idle-early-out logic below need.
    scanRes       = amigaPresentScan68k(gStageMinWord, gStageMaxWord, gPrevStageMinWord, gPrevStageMaxWord, gPresentRuns);
    numRuns       = (uint16_t)scanRes;
    anyUnionDirty = (scanRes & 0x00010000UL) != 0u;
    anyCurDirty   = (scanRes & 0x00020000UL) != 0u;
    gPresentScannedRows += SURFACE_HEIGHT;

    // Flush the collected runs. Kept separate from the scan so the
    // scan lives in hand-rolled asm (presentScan.s) without touching the
    // blitter/CPU flush path or the copied-bytes counter it owns.
    // blitOwned: the first run that takes the blitter path OwnBlitter's;
    // every later blitter run reuses that ownership; the DisownBlitter is
    // paid once after the loop. All-CPU presents never own it.
    {
        bool blitOwned = false;
        for (r = 0; r < numRuns; r++) {
            amigaPresentFlushRun(pd, backPlanes, gPresentRuns[r].firstRow, gPresentRuns[r].lastRow, gPresentRuns[r].byteStart, gPresentRuns[r].byteLen, &blitOwned);
        }
        if (blitOwned) {
            DisownBlitter();
        }
    }

    if (anyUnionDirty) {
        // Patch copper-list BPL pokes to the freshly drawn buffer.
        // Takes effect at the next vblank. Keep gBitMap->Planes in
        // sync so a later updateCopperIfNeeded rebuild bakes the
        // same pointers. Only flip when there was actually something
        // to draw -- flipping to a stale buffer on an idle frame
        // would oscillate the display between two stale views.
        // anyUnionDirty carries the same truth value the old
        // firstRow >= 0 test did.
        {
            uint16_t i;
            for (i = 0; i < AMIGA_BITPLANES; i++) {
                gBitMap->Planes[i] = backPlanes[i];
            }
        }
        patchBplCopperPokes(backPlanes);
        gFrontIdx = backIdx;
    }

    // Snapshot (gPrevStageMinWord/MaxWord <- cur) and gPrevAnyDirty are
    // now folded into the scan loop above, eliminating the second
    // 200-row pass this block used to run. gPrevAnyDirty records whether
    // the CURRENT frame touched any row so the next present can take the
    // idle-frame early-out. jlStagePresent clears the live cur bands
    // after jlpPresent returns; the fused snapshot already captured them.
    gPrevAnyDirty = anyCurDirty;
}


// WaitTOF() blocks the calling task until the next "top of frame"
// VBlank interrupt -- 50 Hz on PAL, 60 Hz on NTSC. graphics.library
// is auto-opened by libnix so no extra plumbing is needed.
void jlpWaitVBL(void) {
    WaitTOF();
}


// Frame counter via a VBL interrupt server (AddIntServer on
// INTB_VERTB). Polling VPOSR/VHPOSR is unreliable across chipsets
// and OS versions -- the bit positions vary and the polling rate has
// to win against the high-bit window per frame, which it doesn't
// when the caller's loop body is long. The interrupt server fires
// exactly once per VBlank regardless of caller cadence.
//
// jlpFrameCount just reads the volatile counter -- no edge detection
// needed in the polling path.

static volatile uint16_t gFrameCount  = 0;
static struct Interrupt  gVblIntServer;
static bool              gVblInstalled = false;

// Server protocol: called by the interrupt dispatcher with A1 =
// is_Data, A6 = ExecBase. We compile without fbaserel so the small-
// data ABI is not in use; gFrameCount is a plain absolute global and
// no __saveds is needed to reach it. Return 0 (D0 zero, Z flag set)
// so the interrupt dispatcher treats this server as "not claimed" and
// continues walking the INTB_VERTB chain, letting other VBL servers
// further down the priority list still fire. Returning non-zero would
// claim the interrupt and terminate the chain.
static ULONG vblServer(void) {
    gFrameCount = (uint16_t)(gFrameCount + 1u);
    return 0;
}


static void installVblServer(void) {
    if (gVblInstalled) {
        return;
    }
    gVblIntServer.is_Node.ln_Type = NT_INTERRUPT;
    gVblIntServer.is_Node.ln_Pri  = -60;
    gVblIntServer.is_Node.ln_Name = (char *)"jlFrameCount";
    gVblIntServer.is_Data         = NULL;
    gVblIntServer.is_Code         = (void (*)())vblServer;
    AddIntServer(INTB_VERTB, &gVblIntServer);
    gVblInstalled = true;
}


static void removeVblServer(void) {
    if (!gVblInstalled) {
        return;
    }
    RemIntServer(INTB_VERTB, &gVblIntServer);
    gVblInstalled = false;
}


uint16_t jlpFrameCount(void) {
    return gFrameCount;
}


uint16_t jlpFrameHz(void) {
    /* PAL by default. The toolchain doesn't currently switch modes
     * at runtime; if we ever expose NTSC this returns 60. */
    return 50u;
}


// Wall-clock ms: frame-counter approximation, same fallback as the
// IIgs. A timer.device-based ReadEClock impl would give microsecond
// resolution and decouple from VBL load, but adds a 7-line library
// open / close dance for what AGI doesn't notice yet (audio is
// stubbed on Amiga -- see jlpAudioVoice). Revisit when 3-voice
// audio actually lands on Amiga.


void jlpShutdown(void) {
    uint16_t i;

    amigaDumpPresentCounters("jlpShutdown");
    // Tear down the VBL server before closing the screen so the
    // interrupt chain is clean if anything else is watching.
    removeVblServer();
    if (gScreen != NULL) {
        // CloseScreen should free attached UCopList, but be explicit
        // to catch any case where the screen close path skips it.
        Forbid();
        if (gScreen->ViewPort.UCopIns != NULL) {
            FreeVPortCopLists(&gScreen->ViewPort);
        }
        Permit();
        CloseScreen(gScreen);
        gScreen = NULL;
    }
    // We allocated the BitMap and its planes manually (see jlpInit)
    // so SA_BitMap could pin Intuition to non-interleaved layout.
    // CloseScreen with an SA_BitMap'd screen does NOT free our
    // BitMap or planes -- we own them and must clean up here.
    if (gBitMap != NULL) {
        for (i = 0; i < AMIGA_BITPLANES; i++) {
            if (gPlanes[i] != NULL) {
                FreeMem(gPlanes[i], (ULONG)AMIGA_PLANE_SIZE);
                gPlanes[i] = NULL;
            }
            if (gPlanesB[i] != NULL) {
                FreeMem(gPlanesB[i], (ULONG)AMIGA_PLANE_SIZE);
                gPlanesB[i] = NULL;
            }
        }
        FreeMem(gBitMap, (ULONG)sizeof(struct BitMap));
        gBitMap = NULL;
    }
    // gNewUCL is never live at shutdown: buildCopperList and
    // installCopperList run back-to-back inside updateCopperIfNeeded,
    // so the built list is always consumed (gNewUCL == NULL) before we
    // get here. A bare FreeMem of the UCopList struct would also leak
    // the attached CopList chain that CINIT/CWAIT/CMOVE allocate, so we
    // deliberately do NOT attempt to free it here.
}


// Shared 68k fast paths from src/shared68k/surface68k.s. Only the
// planar span fill and the planar circle outline apply on Amiga (pure
// planar after Phase 9); the chunky-layout primitives that file also
// exports are unused here.
extern 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);
extern 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);


// Amiga is pure planar (s->pixels is NULL). Each jlp<Op> below does the planar
// work directly on the bitplanes in s->portData (AmigaPlanarT); the degenerate
// portData == NULL case defers to the chunky-C generic (a no-op there since
// s->pixels is NULL). The gate is portData presence, not stage identity, so
// non-stage surfaces created by jlSurfaceCreate use their own planes.
void jlpSurfaceClear(jlSurfaceT *s, uint8_t doubled) {
    AmigaPlanarT *pd;
    uint8_t       colorIndex;
    uint16_t      i;
    bool          setBits;

    if (s->portData == NULL) {
        jlpGenericSurfaceClear(s, doubled);
        return;
    }
    colorIndex = (uint8_t)(doubled & 0x0Fu);
    pd = (AmigaPlanarT *)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
    // WaitBlit before DisownBlitter guarantees the planes are fully
    // written before any CPU read (a subsequent draw or the present
    // copy) touches them.
    OwnBlitter();
    for (i = 0; i < AMIGA_BITPLANES; i++) {
        setBits = ((colorIndex >> i) & 1u) != 0u;
        amigaBlitterFillOne(pd->planes[i], AMIGA_BLT_WORDS_PER_ROW, SURFACE_HEIGHT, 0u, setBits);
    }
    WaitBlit();
    DisownBlitter();
}


// 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) {
    AmigaPlanarT *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 = (AmigaPlanarT *)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)(AMIGA_BLT_MASK_ALL >> ((uint16_t)x & 15u));
    lwm        = (uint16_t)(AMIGA_BLT_MASK_ALL << (15u - (((uint16_t)x + w - 1u) & 15u)));
    dstMod     = (uint16_t)(AMIGA_BYTES_PER_ROW - (widthWords << 1));

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

        OwnBlitter();
        for (plane = 0; plane < AMIGA_BITPLANES; plane++) {
            bitVal  = (uint8_t)((colorIndex >> plane) & 1u);
            rowBase = pd->planes[plane] + (uint16_t)y * AMIGA_BYTES_PER_ROW;
            amigaBlitterFillOne(rowBase, AMIGA_BLT_WORDS_PER_ROW, (uint16_t)h, 0u, bitVal != 0u);
        }
        WaitBlit();
        DisownBlitter();
        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
     * amigaFillSpanInline directly, CPU-side.) widthWords == 1 needs no
     * special case: the blitter ANDs both edge masks onto the single
     * word. */
    OwnBlitter();
    for (plane = 0; plane < AMIGA_BITPLANES; plane++) {
        bitVal = (uint8_t)((colorIndex >> plane) & 1u);
        dst    = pd->planes[plane]
                 + (uint16_t)y * AMIGA_BYTES_PER_ROW
                 + (uint16_t)(wordFirst << 1);
        amigaBlitterMaskedFillOne(dst, widthWords, (uint16_t)h, dstMod, fwm, lwm, bitVal != 0u);
    }
    WaitBlit();
    DisownBlitter();
}


// 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 AmigaPlanarT *pd;
    uint16_t            pairs;
    uint16_t            row;
    uint16_t            pair;
    uint8_t            *dst;

    pd = (const AmigaPlanarT *)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 * AMIGA_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 += AMIGA_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) * AMIGA_BYTES_PER_ROW;
        const uint8_t *p1 = pd->planes[1] + ((uint16_t)y + row) * AMIGA_BYTES_PER_ROW;
        const uint8_t *p2 = pd->planes[2] + ((uint16_t)y + row) * AMIGA_BYTES_PER_ROW;
        const uint8_t *p3 = pd->planes[3] + ((uint16_t)y + row) * AMIGA_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) {
    AmigaPlanarT  *pd;
    uint16_t       pairs;
    uint16_t       row;
    uint16_t       pair;
    const uint8_t *src;

    pd = (AmigaPlanarT *)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 * AMIGA_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 += AMIGA_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) * AMIGA_BYTES_PER_ROW;
        uint8_t *p1 = pd->planes[1] + ((uint16_t)y + row) * AMIGA_BYTES_PER_ROW;
        uint8_t *p2 = pd->planes[2] + ((uint16_t)y + row) * AMIGA_BYTES_PER_ROW;
        uint8_t *p3 = pd->planes[3] + ((uint16_t)y + row) * AMIGA_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 amigaPlanarSetPixel 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 amigaPlanarSetPixel
 * 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) {
    AmigaPlanarT  *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 = (AmigaPlanarT *)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) * AMIGA_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, AMIGA_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)AMIGA_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)AMIGA_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) {
    AmigaPlanarT *dstPd;
    AmigaPlanarT *srcPd;

    dstPd = (AmigaPlanarT *)dst->portData;
    srcPd = (AmigaPlanarT *)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 owns the blitter, copies all four planes, and WaitBlit's
    // before DisownBlitter so the dest planes are complete before any
    // CPU read.
    amigaBlitterCopyPlanes(dstPd->planes, srcPd->planes, 0u,
                           AMIGA_BLT_WORDS_PER_ROW, 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
// AMIGA_COPYRECT_BLIT_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) {
    AmigaPlanarT   *dstPd;
    AmigaPlanarT   *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 = (AmigaPlanarT *)dst->portData;
    srcPd = (AmigaPlanarT *)src->portData;
    if (dstPd == NULL || srcPd == NULL) {
        jlpGenericSurfaceCopyRect(dst, src, x, y, w, h);
        return;
    }
    off        = (uint16_t)(y * AMIGA_BYTES_PER_ROW + (x >> 3));
    rowBytes   = (uint16_t)(w >> 3);
    widthWords = (uint16_t)(rowBytes >> 1);
    if ((uint16_t)(h * widthWords) >= AMIGA_COPYRECT_BLIT_MIN_WORDS) {
        amigaBlitterCopyPlanes(dstPd->planes, srcPd->planes, off, widthWords, h, (uint16_t)(AMIGA_BYTES_PER_ROW - rowBytes));
        return;
    }
    for (plane = 0; plane < AMIGA_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 += AMIGA_BLT_WORDS_PER_ROW;
            s += AMIGA_BLT_WORDS_PER_ROW;
        }
    }
}


// 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_) * AMIGA_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 amigaPlanarSetPixel(AmigaPlanarT *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 * AMIGA_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) {
    AmigaPlanarT *pd;
    uint8_t       nibLo;
    if (s->portData == NULL) {
        jlpGenericDrawPixel(s, x, y, colorIndex);
        return;
    }
    nibLo = (uint8_t)(colorIndex & 0x0Fu);
    pd = (AmigaPlanarT *)s->portData;
    amigaPlanarSetPixel(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 amigaPlanarLine(AmigaPlanarT *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) {
    AmigaPlanarT *pd;
    if (s->portData == NULL) {
        return;
    }
    pd = (AmigaPlanarT *)s->portData;
    amigaPlanarLine(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) {
    AmigaPlanarT *pd;
    if (s->portData == NULL) {
        return;
    }
    pd = (AmigaPlanarT *)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 amigaFillSpanInline(AmigaPlanarT *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 * AMIGA_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[AMIGA_BITPLANES];
        uint8_t  fb[AMIGA_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 < AMIGA_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) {
    AmigaPlanarT *pd;
    uint32_t      r2;
    uint32_t      xx;
    uint32_t      yy;
    int32_t       x;
    uint16_t      y;

    if (s->portData == NULL) {
        return false;
    }
    pd = (AmigaPlanarT *)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
    // amigaFillSpanInline 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);
        amigaFillSpanInline(pd, spanX, (int16_t)(cy + (int16_t)y), spanW, colorIndex);
        if (y > 0u) {
            amigaFillSpanInline(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 amigaNibbleFromPlanes(uint8_t * const planes[AMIGA_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 amigaPlaneRowPtrs(const jlSurfaceT *s, int16_t y, uint8_t **out /* [4] */) {
    AmigaPlanarT *pd;
    uint16_t      yOff;
    uint8_t       i;

    pd = (AmigaPlanarT *)s->portData;
    yOff = (uint16_t)((uint16_t)y * AMIGA_BYTES_PER_ROW);
    for (i = 0; i < AMIGA_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) {
    AmigaPlanarT *pd;
    uint8_t      *rowPlanes[AMIGA_BITPLANES];
    int16_t       leftX;
    int16_t       rightX;
    uint8_t       pix;
    bool          pixMatch;

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

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

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

    rightX = startX;
    while (rightX < SURFACE_WIDTH - 1) {
        pix      = amigaNibbleFromPlanes(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) {
    AmigaPlanarT *pd;
    uint8_t      *rowPlanes[AMIGA_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 = (AmigaPlanarT *)s->portData;
    if (pd == NULL) {
        return false;
    }
    matchColor = (uint8_t)(matchColor & 0x0Fu);
    newColor   = (uint8_t)(newColor   & 0x0Fu);
    amigaPlaneRowPtrs(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) {
    AmigaPlanarT *pd;
    uint16_t      byteOff;
    uint8_t       bitMask;
    uint8_t       color;
    uint16_t      i;

    pd = (AmigaPlanarT *)s->portData;
    if (pd == NULL) {
        return 0u;
    }
    byteOff = (uint16_t)((uint16_t)y * AMIGA_BYTES_PER_ROW + ((uint16_t)x >> 3));
    bitMask = (uint8_t)(0x80u >> ((uint16_t)x & 7u));
    color = 0u;
    for (i = 0; i < AMIGA_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 amigaPlanesToChunkyRow(const AmigaPlanarT *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 < AMIGA_BYTES_PER_ROW; col++) {
        byteOff = (uint16_t)((uint16_t)y * AMIGA_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) {
    AmigaPlanarT   *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 = (AmigaPlanarT *)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++) {
        amigaPlanesToChunkyRow(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, AMIGA_PLANE_SIZE bytes each.
bool jlpSurfaceLoadFile(jlSurfaceT *dst, FILE *fp) {
    AmigaPlanarT *pd;
    uint8_t       i;

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


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

    pd = (AmigaPlanarT *)src->portData;
    if (pd == NULL) {
        return false;
    }
    for (i = 0; i < AMIGA_BITPLANES; i++) {
        if (fwrite(pd->planes[i], 1, AMIGA_PLANE_SIZE, fp) != AMIGA_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 AmigaPlanarT plus 4
// off-screen chip-RAM plane buffers. The stage gets its OWN shadow
// planes (NOT aliased to gPlanes / Intuition's BitMap) so drawing
// 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 (AmigaPlanarT *)
// s->portData.
void *jlpSurfaceAllocPortData(jlSurfaceT *s, bool isStage) {
    AmigaPlanarT *pd;
    uint16_t      i;

    (void)s;
    (void)isStage;
    pd = (AmigaPlanarT *)AllocMem((ULONG)sizeof(AmigaPlanarT),
                                  (ULONG)(MEMF_CHIP | MEMF_CLEAR));
    if (pd == NULL) {
        return NULL;
    }
    pd->bytesPerRow   = AMIGA_BYTES_PER_ROW;
    pd->bytesPerPlane = AMIGA_PLANE_SIZE;

    /* Both stage and non-stage: AllocMem fresh planes, MEMF_CLEAR
     * for the JoeyLib contract that color 0 = black at surface
     * allocation. Stage uses these as off-screen back planes (display
     * is gPlanes[], CHIP, updated only by jlpPresent). Non-stage uses
     * these as the surface's only planes.
     *
     * MEMF_CHIP because the bulk plane ops (clear, full-row fill,
     * surface copy) now run on the BLITTER, which can only reach chip
     * RAM. This is a deliberate performance tradeoff: chip RMW is
     * slower than fast RAM for per-pixel CPU draws (line / circle /
     * single pixel), but the blitter wins big on the full-plane
     * clear/fill/copy paths and runs in parallel with the CPU. Net is
     * workload-dependent; see the blitter helpers above. The planes
     * must be MEMF_CHIP unconditionally now -- a fast-RAM fallback
     * would put the blitter helpers out of reach and corrupt output. */
    for (i = 0; i < AMIGA_BITPLANES; i++) {
        pd->planes[i] = (uint8_t *)AllocMem((ULONG)AMIGA_PLANE_SIZE,
                                            (ULONG)(MEMF_CHIP | MEMF_CLEAR));
        jlLogF("amiga: shadow plane %u in CHIP (blitter, addr=$%08lX)",
                 (unsigned)i, (unsigned long)pd->planes[i]);
        if (pd->planes[i] == NULL) {
            /* Roll back any planes already allocated. */
            while (i > 0u) {
                i--;
                FreeMem(pd->planes[i], (ULONG)AMIGA_PLANE_SIZE);
            }
            FreeMem(pd, (ULONG)sizeof(AmigaPlanarT));
            return NULL;
        }
    }
    pd->ownsPlanes = true;
    return pd;
}


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

    (void)s;
    (void)isStage;
    if (portData == NULL) {
        return;
    }
    pd = (AmigaPlanarT *)portData;
    if (pd->ownsPlanes) {
        for (i = 0; i < AMIGA_BITPLANES; i++) {
            if (pd->planes[i] != NULL) {
                FreeMem(pd->planes[i], (ULONG)AMIGA_PLANE_SIZE);
            }
        }
    }
    FreeMem(pd, (ULONG)sizeof(AmigaPlanarT));
}
