// JoeyLib AGI interpreter, Phase 2 host. The VM in agiVm.c owns all
// game state (vars, flags, objects, text, menus); the host renders
// the VM's snapshot every frame and bridges VM callbacks to the
// platform (resource I/O, picture decoding, text rendering, key
// dispatch). One VM cycle runs every VM_FRAMES_PER_CYCLE display
// frames; per-real-second the host pumps agiVmTickSeconds so the
// engine clock advances and per-second logic.0 branches fire.
//
// Argv layout:
//   argv[1] = game directory  (defaults to ".")
//   argv[2] = starting room   (optional; 1..255 overrides logic.0's
//             hardcoded new.room target so you can skip past a title
//             screen the VM can't yet advance through. 0 means "use
//             whatever logic.0 picks", same as omitting the argument.)



#include "agi.h"

#include "joey/audio.h"
#include "joey/core.h"
#include "joey/debug.h"
#include "joey/draw.h"
#include "joey/input.h"
#include "joey/palette.h"
#include "joey/platform.h"
#include "joey/present.h"
#include "joey/sprite.h"
#include "joey/surface.h"
#include "surfaceInternal.h"

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


#define AGI_LOGIC_HDR_BYTES 2u
// Border fill color (rows above/below the AGI picture region and any
// area not painted by show.pic). AGI canonical: black (palette 0).
#define COLOR_MISSING       0u
#define CYCLE_HALT_GUARD    16u
#define EGO_START_X         80
#define EGO_START_Y         140
#define EGO_STEP_PX         2

// AGI v2 EGA layout on a 320x200 display: pic occupies y=0..167
// (160x168 doubled horizontally to 320 wide), and y=168..199 is the
// 4-text-row band below the picture. Text rows always use y = row*8,
// so pic at y=0 keeps the in-picture rows (0..20) visually aligned
// with the text rendered into them. Status line, when on, overlays
// the picture at its assigned row -- that's Sierra's canonical
// behaviour.
// Canonical AGI: 1 jiffy = 50ms, VM cycle dispatch every v10
// jiffies. KQ3 sets v10=1 -> 20 cycles/sec; default is 2 ->
// 10 cycles/sec. We drive the cycle off wall-clock so the
// rate stays correct regardless of host frame rate.
#define AGI_JIFFY_MS        50u
// Renders are wall-clock paced too. AGI canonical runs at ~20 Hz
// (one VM cycle = one rendered frame in Sierra's interpreter); we
// render slightly above that for smoother sparkle/cel cycling but
// not the full 70 Hz VBL rate, which on emulated 386 in DOSBox
// burns enough CPU on backdrop-restore + cel redraw that audio
// refill misses its 92 ms half-buffer deadline.
#define AGI_RENDER_PERIOD_MS 33u
#define STARTUP_CYCLE_LIMIT 32u
// Per Sierra KQ3 v2 EGA reference: pic starts 8 pixels down from
// the top of the 320x200 display (one 8-pixel text-row band reserved
// for the status line). Pic occupies y=8..175. Text rows use the
// row*8 absolute Y -- no pic offset added -- so they deliberately
// overlap pic art where the script wants captions inside the picture.
#define PIC_DEST_Y          8

// (Per-object save-under removed: we now restore each object's prev
// rect by memcpy from the gBackdrop surface, sized to the actual
// cel bbox at draw time. That handles arbitrary cel sizes without
// any per-object backup buffers and is faster on chunky platforms.)

// Canonical AGI ego loop indices.
#define LOOP_RIGHT          0
#define LOOP_LEFT           1
#define LOOP_DOWN           2
#define LOOP_UP             3

// Encryption key applied per-message in the AGI v2 message section.
// Same string in every supported game; XOR cycles every 11 bytes.
#define MSG_KEY             "Avis Durgan"
#define MSG_KEY_LEN         11u

// Largest decrypted message we'll surface to the VM. Game scripts
// stay well under this (most under 80 chars); the cap protects the
// scratch buffer from a corrupt resource.
#define MSG_BUFFER_BYTES    256u


// ----- Types -----

typedef struct {
    uint8_t        *raw;
    uint16_t        rawLength;
    const uint8_t  *bytecode;
    uint16_t        bytecodeLength;
} LogicCacheSlotT;

typedef struct {
    AgiViewT  view;
    bool      loaded;
} ViewCacheSlotT;


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

static void           agiSoundReset(void);
static uint32_t       agiSynthFill(void *ctx, int8_t *dst, uint32_t count);
static void           agiTickAdvance(void);
static void           agiVoiceTick(uint8_t voice);
static void           psgNoiseApply(void);
static void           psgRecompute(uint8_t chipCh);
static void           psgWriteData(uint8_t val);
static void           cbAddToPic(void *ctx, uint8_t viewId, uint8_t loop, uint8_t cel,
                                 uint8_t x, uint8_t y, uint8_t pri, uint8_t margin);
static void           cbDiscardPic(void *ctx, uint8_t picId);
static void           cbDrawPic(void *ctx, uint8_t picId);
static const uint8_t *cbFetchLogic(void *ctx, uint8_t logicId, uint16_t *outLength);
static const char    *cbFetchMessage(void *ctx, uint8_t logicId, uint8_t msgId);
static bool           cbHaveKey(void *ctx);
static bool           cbIsPlayingSound(void *ctx);
static void           cbLoadPic(void *ctx, uint8_t picId);
static void           cbOverlayPic(void *ctx, uint8_t picId);
static void           cbPlaySound(void *ctx, uint8_t soundId);
static void           cbShowPic(void *ctx);
static uint32_t       cbSoundDuration(void *ctx, uint8_t soundId);
static void           cbStopSound(void *ctx);
static uint8_t        cbViewCelCount(void *ctx, uint8_t viewId, uint8_t loopId);
static uint8_t        cbViewLoopCount(void *ctx, uint8_t viewId);
static bool           decodePicInto(uint8_t picId, bool clearFirst);
static void           agiInputCapture(void);
static void           agiParseCommand(void);
static void           dispatchAllPressedKeys(void);
static void           drawObjects(jlSurfaceT *stage);
static const AgiViewT *fetchView(uint8_t viewId);
static void           invalidateObjectSaves(void);
static uint8_t        loopForDirection(uint8_t direction, uint8_t loopCount);
static uint8_t        parseArgU8(const char *s);
static void           releaseLogicCache(void);
static void           releaseViewCache(void);
static void           renderFrame(jlSurfaceT *stage);
static uint8_t        resolveStartingRoom(int argc, char **argv);
static void           runVmCycle(void);
static void           updateEgoFromInput(void);


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

static AgiGameT          gGame;
static AgiPicT           gPic;
static jlSurfaceT       *gBackdrop;
static jlSurfaceT       *gAgiStage;
static AgiVmT            gVm;
static LogicCacheSlotT   gLogicCache[AGI_MAX_RESOURCES];
// View cache: 256 slots of ~5 KB each (~1.3 MB). Far too large for the
// IIgs bank-0 BSS, so it is jlAlloc'd at startup (Memory Manager handle
// on the IIgs, malloc elsewhere) rather than declared as a static array.
static ViewCacheSlotT   *gViewCache;
static bool              gPicReady;
static uint8_t           gOverrideRoom;
static bool              gOverrideRoomActive;
static bool              gAnyKeyHit;
// Per-object previously-drawn stage rect. After each draw we record
// the cel's exact stage bbox here; next frame's pass 1 restores that
// rect from gBackdrop, then pass 2 redraws. valid=false means the
// object wasn't drawn last frame (skip restore).
typedef struct {
    int16_t  x;
    int16_t  y;
    int16_t  w;
    int16_t  h;
    bool     valid;
} ObjPrevRectT;

static ObjPrevRectT      gObjPrev[AGI_MAX_OBJECTS];
// Per-text-row "had content last frame" flag. We restore each row's
// stripe of the backdrop at the start of every frame if it was
// dirtied last frame, so the old text disappears when the VM clears
// it (clear.lines) or moves to a different row.
static bool              gTextRowDirty[AGI_TEXT_ROWS];
// One static scratch for the most recently decrypted message. The
// fetchMessage callback contract guarantees its return pointer is
// only valid until the next call -- the VM's expandMessage copies
// the bytes it needs before calling fetchMessage again.
static char              gMessageScratch[MSG_BUFFER_BYTES];

// SN76489 PSG clock divisor base. AGI v2 uses the TI PSG's tone
// generator: tone_hz = 111860 / divisor (3.579545 MHz / 32).
#define PSG_CLOCK_HZ            111860u

// AGI sound is software-synthesized at 22050 Hz mono then pushed
// through the existing Sound Blaster auto-init DMA path via the
// jlAudioPlaySfxStream API. Three independent squarewave voices
// (16-bit phase, 8-bit amplitude) sum into the output stream; AGI
// tick logic advances every 22050/60 = 367 output samples, matching
// ScummVM's PCjr/Tandy sound model (which is also how Sierra's own
// SB drivers shipped the AGI sound after 1988).
//
// This is the "software synth to PCM" model. Pros: works on any
// SB-equipped DOS box, no hardware-IRQ headaches, sample-accurate
// note timing because we're rendering audio at 22050 Hz with the
// AGI ticks embedded. Cons: requires SB-compat audio (true for
// every target since 1989). The PCjr/Tandy native PSG path (cheaper,
// older) is a separate problem we can revisit later.
//
// SND v2 layout (per channel):
//   bytes 0..7  : 4 channel offsets (LE16 each)
//   per channel : sequence of 5-byte events. Bytes 0..1 = duration
//                 LE16 in 1/60s units, terminator 0xFFFF; bytes 2/3
//                 = raw SN76489 latch+data pair encoding the 10-bit
//                 frequency divisor (low 4 bits in byte 3, high 6
//                 bits in byte 2); byte 4 = 4-bit attenuation
//                 (0 = loudest, 15 = silent).
//   freq_hz = 111860 / divisor (PSG clock 3.579545 MHz / 32).
//
// Sample rate must match the SB DMA setup (see MIX_RATE in
// src/port/dos/audio.c). 22050 keeps 256-sample stream chunks at
// ~12 ms each, well over an AGI tick.
typedef struct {
    uint16_t  chOffset;         // cursor in this channel's event stream
    uint16_t  eventFramesRem;   // ticks until current event ends
    bool      done;             // channel hit 0xFFFF terminator
} AgiVoiceSchedT;

// Per-SN76489-channel state. The chip has 3 tone channels + 1 noise
// channel. Each event in the SND stream is 3 raw chip-command bytes
// (b4=atten, b3=latch-or-noise-control, b2=data) that may target
// any chip channel, not necessarily the AGI channel that owns the
// stream. We dispatch the bytes through psgWriteData (mirrors
// ScummVM's writeData) and let the chip-state machine update the
// right register. The synth then reads each tone channel's derived
// (amplitude, phaseInc) and produces samples.
typedef enum {
    PSG_GEN_TONE          = 0,
    PSG_GEN_NOISE_PERIOD  = 1,
    PSG_GEN_NOISE_WHITE   = 2
} PsgGenE;

typedef struct {
    uint16_t  freqCount;    // 10-bit tone period; noise uses small values per ScummVM
    uint8_t   attenuation;  // 0=loud, 15=silent
    uint8_t   genType;      // PsgGenE
    // Derived cache (recomputed when freqCount/attenuation change):
    uint16_t  phaseInc;
    int8_t    amplitude;
    // Synth state:
    uint16_t  phase;
} PsgChannelT;

// AGI v2 SND has 4 sub-channels: 3 tone + 1 noise. The "AGI
// channel" indexes into the event-stream offsets table at the
// start of the file; it does NOT correspond 1:1 to the chip's 4
// channels. Each AGI channel ticks an event stream and fires raw
// chip writes; the chip state is shared across all AGI channels.
#define AGI_SOUND_CHANNELS       4u
#define AGI_SOUND_TONE_VOICES    3u
#define AGI_SOUND_NOISE_CHANNEL  3u
#define AGI_SOUND_END_MARKER     0xFFFFu
// AGI sound tick rate. Sierra's PCjr/Tandy SOUND.DRV ran at 120 Hz
// (vs ScummVM's 60 Hz). All script-driven timing (v11 seconds
// counter, animation cycling, motion stepping) is derived from
// this same tick rate, NOT from wall clock -- so doubling the
// tick rate makes both the music AND v11 advance 2x faster, which
// keeps script-internal wait conditions consistent.
#define AGI_SOUND_TICKS_HZ       120u
#define AGI_SYNTH_SAMPLE_RATE    22050u
#define AGI_SYNTH_SAMPLES_PER_TICK  (AGI_SYNTH_SAMPLE_RATE / AGI_SOUND_TICKS_HZ)

// SN76489 attenuation -> int8 amplitude lookup. 2 dB per step
// (so atten=1 is 0.794x atten=0, atten=2 is 0.794x atten=1, etc.).
// Linear (15-atten)/15 mapping makes mid-volume notes far too loud
// and turns fade-outs into harsh cliffs; this table matches
// ScummVM's int16 PSG table scaled down to our 40-peak headroom
// for safe 3-voice sums into int8 PCM.
static const int8_t kAgiAttenAmp[16] = {
    40, 32, 25, 20, 16, 13, 10,  8,
     6,  5,  4,  3,  3,  2,  2,  0
};

// All synth + scheduler state runs entirely on the main thread now
// (the stream fill callback is invoked from halAudioFrameTick, not
// from any IRQ). No critical sections needed.
static uint8_t          *gSoundBytes;
static uint16_t          gSoundLength;
static AgiVoiceSchedT    gVoiceSched[AGI_SOUND_CHANNELS];
static PsgChannelT       gPsg[4];           // 3 tone + 1 noise
static uint8_t           gPsgReg;           // last register selected by a LATCH or ATTEN byte
static bool              gSoundActive;
// AGI ticks are driven from wall-clock ms, NOT from samples
// produced. The previous countdown-per-sample approach assumed
// the synth's call rate matched SB DMA's drain rate (22050/sec).
// In practice DOSBox's SB Pro emulation at cycles=2200 drains
// the DMA buffer roughly 2.1x faster than the requested time
// constant (or fires refill IRQs at half the block-size cadence,
// or some combination -- empirically observed 47-50 kHz
// production rate when we asked for 22050). Pitch is unaffected
// because phaseInc still matches the actual DMA rate, but tempo
// (ticks/sec) was running 2.1x fast. Wall-clock tick gating
// locks tempo to 60 Hz exactly regardless of DMA quirks.
#define AGI_TICK_PERIOD_US   (1000000u / AGI_SOUND_TICKS_HZ)   // 8333 us at 120 Hz
// Tick counter advanced by agiTickAdvance() from the host loop.
// All AGI script-driven timing keys off this, NOT off wall clock,
// so changing AGI_SOUND_TICKS_HZ uniformly speeds up music + v11
// + animation + script waits together. v11 advances every 60 AGI
// ticks (= 1 "AGI sec"); at AGI_SOUND_TICKS_HZ=120 that's twice
// per wall second, matching Sierra's PCjr/Tandy game-tick model.
static uint32_t          gAgiTickAccumUs;
static uint32_t          gAgiTickLastWallMs;
static uint32_t          gAgiV11SubTickAccum;   // counts AGI ticks toward next v11++


#ifdef JOEYLIB_PLATFORM_IIGS
#endif

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

static void cbAddToPic(void *ctx, uint8_t viewId, uint8_t loop, uint8_t cel,
                       uint8_t x, uint8_t y, uint8_t pri, uint8_t margin) {
    const AgiViewT  *view;

    (void)ctx;
    view = fetchView(viewId);
    if (view == NULL) {
        return;
    }
    // Mutate the in-memory pic buffer only. The companion show.pic
    // call (which always follows an add.to.pic batch in canonical
    // AGI logic) is what blits the updated buffer to the stage and
    // republishes the backdrop. Per-add.to.pic blits would compound
    // into 100s of ms of redundant pixel work on slow targets.
    agiPicAddView(&gPic, view, loop, cel, (int16_t)x, (int16_t)y, pri, margin);
}


// Accumulate typed characters into gVm.inputLine while the command line is
// active (accept.input). Printable keys append, backspace deletes, and Enter
// hands the finished line to agiParseCommand. Called every frame from the
// main loop; the on-screen echo is drawn in renderFrame.
static void agiInputCapture(void) {
    uint8_t  k;

    if (jlKeyPressed(KEY_BACKSPACE)) {
        if (gVm.inputLen > 0u) {
            gVm.inputLen--;
            gVm.inputLine[gVm.inputLen] = '\0';
        }
    }
    if (jlKeyPressed(KEY_SPACE) && gVm.inputLen < AGI_INPUT_MAX) {
        gVm.inputLine[gVm.inputLen] = ' ';
        gVm.inputLen++;
        gVm.inputLine[gVm.inputLen] = '\0';
    }
    for (k = 0u; k < 26u; k++) {
        if (jlKeyPressed((jlKeyE)((uint8_t)KEY_A + k)) && gVm.inputLen < AGI_INPUT_MAX) {
            gVm.inputLine[gVm.inputLen] = (char)('a' + k);
            gVm.inputLen++;
            gVm.inputLine[gVm.inputLen] = '\0';
        }
    }
    for (k = 0u; k < 10u; k++) {
        if (jlKeyPressed((jlKeyE)((uint8_t)KEY_0 + k)) && gVm.inputLen < AGI_INPUT_MAX) {
            gVm.inputLine[gVm.inputLen] = (char)('0' + k);
            gVm.inputLen++;
            gVm.inputLine[gVm.inputLen] = '\0';
        }
    }
    if (jlKeyPressed(KEY_RETURN)) {
        if (gVm.inputLen > 0u) {
            agiParseCommand();
        }
        gVm.inputLine[0] = '\0';
        gVm.inputLen     = 0u;
    }
}


// Tokenize gVm.inputLine into dictionary group IDs (gVm.parsedWords), using
// greedy longest-match so multi-word entries ("crystal ball", "get in") beat
// their prefixes. Ignore-words (group 0) drop out; a word absent from the
// dictionary records its 1-based position in var 9 and voids the command
// (no parsedWords, so said() won't match and the room can report the miss).
// Sets flag 2 so said() tests fire this cycle.
static void agiParseCommand(void) {
    static char  toks[AGI_MAX_PARSED][AGI_WORD_MAX_LEN + 1u];
    static uint8_t tokLen[AGI_MAX_PARSED];
    char         phrase[AGI_WORD_MAX_LEN + 1u];
    uint8_t      tokCount;
    uint8_t      ti;
    uint16_t     i;
    uint8_t      pos;
    bool         unknown;

    // Split into lowercase alphanumeric tokens (cap at AGI_MAX_PARSED).
    tokCount = 0u;
    ti       = 0u;
    for (i = 0u; i <= gVm.inputLen; i++) {
        char  c;

        c = (i < gVm.inputLen) ? gVm.inputLine[i] : ' ';
        if (c >= 'A' && c <= 'Z') {
            c = (char)(c + ('a' - 'A'));
        }
        if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')) {
            if (tokCount < AGI_MAX_PARSED && ti < AGI_WORD_MAX_LEN) {
                toks[tokCount][ti] = c;
                ti++;
            }
            continue;
        }
        if (ti > 0u && tokCount < AGI_MAX_PARSED) {
            toks[tokCount][ti] = '\0';
            tokLen[tokCount]   = ti;
            tokCount++;
        }
        ti = 0u;
    }

    gVm.parsedWordCount            = 0u;
    gVm.vars[AGI_VAR_UNKNOWN_WORD] = 0u;
    unknown = false;
    pos     = 0u;
    while (pos < tokCount) {
        uint16_t  bestId;
        uint8_t   best;
        uint8_t   span;

        best   = 0u;
        bestId = AGI_WORD_UNKNOWN;
        // Longest join first: try tokens[pos..end], then shorter, down to one.
        for (span = (uint8_t)(tokCount - pos); span >= 1u; span--) {
            uint8_t   pl;
            uint8_t   s;
            uint16_t  id;

            pl = 0u;
            for (s = 0u; s < span; s++) {
                uint8_t  j;

                if (s > 0u && pl < AGI_WORD_MAX_LEN) {
                    phrase[pl] = ' ';
                    pl++;
                }
                for (j = 0u; j < tokLen[pos + s] && pl < AGI_WORD_MAX_LEN; j++) {
                    phrase[pl] = toks[pos + s][j];
                    pl++;
                }
            }
            phrase[pl] = '\0';
            id = agiWordId(&gGame, phrase);
            if (id != AGI_WORD_UNKNOWN) {
                best   = span;
                bestId = id;
                break;
            }
        }
        if (best == 0u) {
            if (!unknown) {
                gVm.vars[AGI_VAR_UNKNOWN_WORD] = (uint8_t)(pos + 1u);
                unknown = true;
            }
            pos++;
            continue;
        }
        if (bestId != AGI_WORD_IGNORE && gVm.parsedWordCount < AGI_MAX_PARSED) {
            gVm.parsedWords[gVm.parsedWordCount] = bestId;
            gVm.parsedWordCount++;
        }
        pos = (uint8_t)(pos + best);
    }

    if (unknown) {
        gVm.parsedWordCount = 0u;
    }
    gVm.flags[AGI_FLAG_INPUT] = 1u;
}


static void cbDiscardPic(void *ctx, uint8_t picId) {
    (void)ctx;
    (void)picId;
}


static void cbDrawPic(void *ctx, uint8_t picId) {
    uint8_t i;

    (void)ctx;
    jlLogF("cbDrawPic id=%u v11=%u", (unsigned)picId, (unsigned)gVm.vars[11]);
    // Dump active objects so we can see whether scene leftovers
    // (e.g. sparkle objects from previous scene) are still flagged
    // animated/drawn at scene-change time.
    for (i = 0u; i < (uint8_t)AGI_MAX_OBJECTS; i++) {
        const AgiObjectT *o = &gVm.objects[i];
        if (o->flags != 0u) {
            jlLogF("  obj[%u] flags=0x%02x view=%u loop=%u cel=%u x=%d y=%d",
                   (unsigned)i, (unsigned)o->flags,
                   (unsigned)o->viewId, (unsigned)o->loop, (unsigned)o->cel,
                   (int)o->x, (int)o->y);
        }
    }
    // Dump set flags so we can compare flag state across scene
    // transitions and identify which flag gates a wrong-path branch.
    {
        uint16_t  fi;
        char      line[128];
        size_t    used;
        line[0] = '\0';
        used    = 0u;
        for (fi = 0u; fi < AGI_VM_NUM_FLAGS; fi++) {
            if (gVm.flags[fi] != 0u) {
                int n = snprintf(line + used, sizeof(line) - used, "%u ", (unsigned)fi);
                if (n < 0 || (size_t)n >= sizeof(line) - used) {
                    jlLogF("  flagsSet=%s...(trunc)", line);
                    line[0] = '\0';
                    used    = 0u;
                } else {
                    used += (size_t)n;
                }
            }
        }
        jlLogF("  flagsSet=%s", line);
    }
    jlLogFlush();
    decodePicInto(picId, true);
}


static const uint8_t *cbFetchLogic(void *ctx, uint8_t logicId, uint16_t *outLength) {
    LogicCacheSlotT  *slot;
    uint8_t          *raw;
    uint16_t          rawLength;
    uint16_t          bcLen;

    (void)ctx;
    slot = &gLogicCache[logicId];
    if (slot->bytecode != NULL) {
        *outLength = slot->bytecodeLength;
        return slot->bytecode;
    }
    if (logicId >= gGame.resCount[AGI_RES_LOGIC]) {
        return NULL;
    }
    raw = agiResLoad(&gGame, AGI_RES_LOGIC, (uint16_t)logicId, &rawLength);
    if (raw == NULL || rawLength < AGI_LOGIC_HDR_BYTES) {
        if (raw != NULL) {
            free(raw);
        }
        return NULL;
    }
    bcLen = (uint16_t)(raw[0] | ((uint16_t)raw[1] << 8));
    if ((uint16_t)(AGI_LOGIC_HDR_BYTES + bcLen) > rawLength) {
        free(raw);
        return NULL;
    }
    slot->raw            = raw;
    slot->rawLength      = rawLength;
    slot->bytecode       = raw + AGI_LOGIC_HDR_BYTES;
    slot->bytecodeLength = bcLen;
    *outLength = bcLen;
    return slot->bytecode;
}


// Look up message `msgId` in logic `logicId`'s message table, decrypt
// it via the cycling "Avis Durgan" XOR, write the result to the
// shared scratch buffer, and return a pointer to it. Returns NULL
// on missing message; returns "" for an empty slot.
//
// AGI v2 quirks the spec requires:
//   1. The 2-byte offsets stored in the table are relative to byte 1
//      of the message section (i.e. they exclude the count byte), so
//      the actual byte position is stored_offset + 1.
//   2. The XOR key cycles continuously across the whole strings
//      block, not per-message. Key index for byte K = (K - strings
//      start byte) mod 11.
static const char *cbFetchMessage(void *ctx, uint8_t logicId, uint8_t msgId) {
    LogicCacheSlotT  *slot;
    const uint8_t    *raw;
    const uint8_t    *msgSection;
    uint16_t          msgSectionOff;
    uint16_t          rawLength;
    uint16_t          msgCount;
    uint16_t          msgOff;
    uint16_t          stringsStartOff;
    uint16_t          k;
    uint16_t          i;

    (void)ctx;
    slot = &gLogicCache[logicId];
    if (slot->raw == NULL) {
        uint16_t  ignore = 0u;
        if (cbFetchLogic(NULL, logicId, &ignore) == NULL) {
            return NULL;
        }
    }
    raw           = slot->raw;
    rawLength     = slot->rawLength;
    msgSectionOff = (uint16_t)(AGI_LOGIC_HDR_BYTES + slot->bytecodeLength);
    if (msgSectionOff >= rawLength) {
        return NULL;
    }
    msgSection = raw + msgSectionOff;
    msgCount   = (uint16_t)msgSection[0];
    if (msgId == 0u || msgId > msgCount) {
        return NULL;
    }
    // Find the lowest non-zero stored offset across all message
    // slots. That's where the encrypted strings block begins.
    stringsStartOff = 0xFFFFu;
    for (k = 1u; k <= msgCount; k++) {
        uint16_t  off;
        uint16_t  tOff = (uint16_t)(3u + (uint16_t)((k - 1u) * 2u));
        if ((uint16_t)(msgSectionOff + tOff + 1u) >= rawLength) {
            continue;
        }
        off = (uint16_t)(msgSection[tOff] | ((uint16_t)msgSection[tOff + 1u] << 8));
        if (off != 0u && off < stringsStartOff) {
            stringsStartOff = off;
        }
    }
    if (stringsStartOff == 0xFFFFu) {
        gMessageScratch[0] = '\0';
        return gMessageScratch;
    }
    {
        uint16_t  tableOff;
        uint16_t  msgStart;
        uint16_t  keyBase;

        tableOff = (uint16_t)(3u + (uint16_t)((msgId - 1u) * 2u));
        if ((uint16_t)(msgSectionOff + tableOff + 1u) >= rawLength) {
            return NULL;
        }
        msgOff = (uint16_t)(msgSection[tableOff] | ((uint16_t)msgSection[tableOff + 1u] << 8));
        if (msgOff == 0u) {
            gMessageScratch[0] = '\0';
            return gMessageScratch;
        }
        // actual byte = section_off + stored_offset + 1.
        msgStart = (uint16_t)(msgSectionOff + msgOff + 1u);
        if (msgStart >= rawLength) {
            return NULL;
        }
        // continuous key index = (msgOff - stringsStartOff) at byte 0
        // of this message, then increments per byte.
        keyBase = (uint16_t)(msgOff - stringsStartOff);
        i = 0u;
        while (msgStart + i < rawLength && i < (uint16_t)(MSG_BUFFER_BYTES - 1u)) {
            uint8_t  enc;
            uint8_t  dec;

            enc = raw[msgStart + i];
            dec = (uint8_t)(enc ^ (uint8_t)MSG_KEY[(keyBase + i) % MSG_KEY_LEN]);
            if (dec == 0u) {
                break;
            }
            gMessageScratch[i] = (char)dec;
            i++;
        }
        gMessageScratch[i] = '\0';
    }
    return gMessageScratch;
}


static bool cbHaveKey(void *ctx) {
    bool  hit;

    (void)ctx;
    hit = gAnyKeyHit;
    gAnyKeyHit = false;
    return hit;
}


static void cbLoadPic(void *ctx, uint8_t picId) {
    (void)ctx;
    (void)picId;
}


static void cbOverlayPic(void *ctx, uint8_t picId) {
    (void)ctx;
    jlLogF("cbOverlayPic id=%u v11=%u", (unsigned)picId, (unsigned)gVm.vars[11]);
    decodePicInto(picId, false);
}


static void cbShowPic(void *ctx) {
    (void)ctx;
    jlLogF("cbShowPic v11=%u", (unsigned)gVm.vars[11]);
    if (gAgiStage == NULL) {
        return;
    }
    // Clear the WHOLE stage first so the rows above/below the AGI
    // picture region (y=0..15 and y=184..199) don't retain pixels
    // from the previous scene. Without this, text drawn during the
    // logo phase at rows 22..24 ends up captured by the backdrop
    // copy below and gets restored over every subsequent frame --
    // so clear.lines never visually erases anything that sat in
    // the border bands.
    jlSurfaceClear(gAgiStage, COLOR_MISSING);
    agiPicBlit(&gPic, gAgiStage, PIC_DEST_Y);
    if (gBackdrop != NULL) {
        jlSurfaceCopy(gBackdrop, gAgiStage);
    }
    gPicReady = true;
    // The stage just got repainted; any prior save-under refers to
    // pre-paint pixels and must NOT be restored next frame (it would
    // leak the old picture over the new one).
    invalidateObjectSaves();
}


// Push the chip's noise-channel state (gPsg[3]) to the JoeyLib noise
// voice. The SN76489 freqCount maps onto the portable 0..31 pitch as
// freqCount >> 3 (rate codes 0/1/2 -> 4/8/16, preserving the chip's
// octave-per-step; mode 3 tracks tone channel 2's snapshot). The
// white/periodic bit has no portable representation and renders as
// plain hiss.
static void psgNoiseApply(void) {
    uint16_t count;
    uint8_t  pitch;

    if (gPsg[3].attenuation >= 15u || gPsg[3].freqCount == 0u) {
        jlAudioNoise(0u, 15u);
        return;
    }
    count = gPsg[3].freqCount;
    pitch = (uint8_t)(count >> 3);
    if (pitch > 31u) {
        pitch = 31u;
    }
    jlAudioNoise(pitch, gPsg[3].attenuation);
}


// Recompute derived (phaseInc, amplitude) for chip channel
// `chipCh` from its current freqCount + attenuation + genType.
// Called whenever the chip state for a tone channel changes; the
// noise channel isn't synthesized into the PCM stream -- it goes to
// the hardware noise voice instead.
static void psgRecompute(uint8_t chipCh) {
    PsgChannelT *p;
    uint32_t     freqHz;

    if (chipCh >= 3u) {
        psgNoiseApply();
        return;
    }
    p = &gPsg[chipCh];
    if (p->genType != PSG_GEN_TONE || p->freqCount == 0u || p->attenuation >= 15u) {
        p->phaseInc  = 0u;
        p->amplitude = 0;
        return;
    }
    freqHz       = (uint32_t)PSG_CLOCK_HZ / (uint32_t)p->freqCount;
    p->phaseInc  = (uint16_t)((freqHz * 65536u) / AGI_SYNTH_SAMPLE_RATE);
    p->amplitude = kAgiAttenAmp[p->attenuation];
}


// SN76489 byte dispatcher, byte-for-byte equivalent to ScummVM's
// SoundGenPCJr::writeData. The chip has 4 channels (3 tone + 1
// noise) and a single "last register" latch used by DATA bytes
// to know which channel to update.
//
// Byte forms (per the SN76489 datasheet):
//   1rrr1xxx (atten):  bits 6-5 = chip channel, bits 3-0 = atten.
//                      Updates _reg and the channel's attenuation.
//   11100xxx (noise):  noise channel (3); bit 2 = white/period,
//                      bits 1-0 = period code. _reg NOT updated --
//                      the DATA byte that follows still targets
//                      whatever channel was last selected by a tone
//                      latch or atten command.
//   1rrr0xxx (tone latch): bits 6-5 = chip channel, bits 3-0 =
//                      LOW 4 bits of freqCount. Sets _reg, fully
//                      replaces low nibble, marks gen as tone.
//   0xxxxxxx (data):   ORs (val & 0x3F) << 4 into freqCount of
//                      whichever channel _reg points to (the high
//                      6 bits of the 10-bit divisor). The OR is
//                      key: an event without a fresh tone latch
//                      keeps its previous low nibble, so two
//                      events can share a low-nibble setup and a
//                      noise-control byte in between leaves the
//                      tone channel's freqCount mostly intact.
static void psgWriteData(uint8_t val) {
    uint8_t  reg;

    if ((val & 0x90u) == 0x90u) {
        gPsgReg = (uint8_t)((val >> 5) & 0x03u);
        gPsg[gPsgReg].attenuation = (uint8_t)(val & 0x0Fu);
        psgRecompute(gPsgReg);
    } else if ((val & 0xF0u) == 0xE0u) {
        gPsg[3].genType = (val & 0x04u) ? PSG_GEN_NOISE_WHITE : PSG_GEN_NOISE_PERIOD;
        switch (val & 0x03u) {
            case 0u:  gPsg[3].freqCount = 32u;  break;
            case 1u:  gPsg[3].freqCount = 64u;  break;
            case 2u:  gPsg[3].freqCount = 128u; break;
            default:  gPsg[3].freqCount = (uint16_t)(gPsg[2].freqCount * 2u); break;
        }
        // No _reg update -- the DATA byte that follows still targets
        // the previously latched channel.
        psgNoiseApply();
    } else if (val & 0x80u) {
        reg = (uint8_t)((val >> 5) & 0x03u);
        gPsgReg = reg;
        gPsg[reg].freqCount = (uint16_t)(val & 0x0Fu);
        gPsg[reg].genType   = PSG_GEN_TONE;
        psgRecompute(reg);
    } else {
        gPsg[gPsgReg].freqCount |= (uint16_t)((val & 0x3Fu) << 4);
        psgRecompute(gPsgReg);
    }
}


// Silence every channel and free the owned SND buffer. Called from
// main thread; stops the SFX-stream slot first so the synth
// callback can't be invoked again against a freed buffer.
static void agiSoundReset(void) {
    uint8_t v;

    jlAudioStopSfx(0u);
    // The noise voice is live hardware, separate from the PCM stream
    // slot -- without an explicit off it latches on forever.
    jlAudioNoise(0u, 15u);
    for (v = 0u; v < AGI_SOUND_CHANNELS; v++) {
        gVoiceSched[v].chOffset       = 0u;
        gVoiceSched[v].eventFramesRem = 0u;
        gVoiceSched[v].done           = true;
    }
    for (v = 0u; v < 4u; v++) {
        gPsg[v].freqCount   = 0u;
        gPsg[v].attenuation = 15u;   // silent
        gPsg[v].genType     = PSG_GEN_TONE;
        gPsg[v].phaseInc    = 0u;
        gPsg[v].amplitude   = 0;
        gPsg[v].phase       = 0u;
    }
    gPsgReg               = 0u;
    gSoundActive          = false;
    gAgiTickAccumUs       = 0u;
    gAgiTickLastWallMs    = 0u;
    gAgiV11SubTickAccum   = 0u;
    if (gSoundBytes != NULL) {
        free(gSoundBytes);
        gSoundBytes = NULL;
    }
    gSoundLength = 0u;
}


// Advance one AGI channel by one tick. When the channel's
// duration counter rolls over, parse the 5-byte event header
// (dur LE16 + 3 chip-command bytes) and feed the 3 chip bytes
// through psgWriteData in ScummVM's order: byte 4 (atten), byte
// 3 (latch or noise control), byte 2 (data). The chip state is
// shared across all AGI channels, so an event in AGI channel N
// can update chip-channel state for any chip register, including
// the noise channel and other tone channels' attenuation.
//
// dur=0 events fire-then-skip in the same tick (AGI uses them for
// legato note transitions). Inner-loop guard caps iterations so
// a malformed channel can't lock the synth.
static void agiVoiceTick(uint8_t v) {
    AgiVoiceSchedT *sch;
    uint16_t        dur;
    uint8_t         guard;

    sch = &gVoiceSched[v];
    if (sch->done) {
        return;
    }
    guard = 16u;
    while (sch->eventFramesRem == 0u && guard != 0u) {
        guard--;
        if ((uint32_t)sch->chOffset + 4u >= (uint32_t)gSoundLength) {
            sch->done = true;
            return;
        }
        dur = (uint16_t)(gSoundBytes[sch->chOffset] |
                         ((uint16_t)gSoundBytes[sch->chOffset + 1u] << 8));
        if (dur == AGI_SOUND_END_MARKER) {
            sch->done = true;
            return;
        }
        // ScummVM order: attenuation byte first, then latch, then
        // data. Critical because the noise-control byte (0xE0-0xEF)
        // does NOT update _reg, so the data byte that follows must
        // target whatever channel the atten byte selected.
        psgWriteData(gSoundBytes[sch->chOffset + 4u]);
        psgWriteData(gSoundBytes[sch->chOffset + 3u]);
        psgWriteData(gSoundBytes[sch->chOffset + 2u]);
        sch->chOffset       = (uint16_t)(sch->chOffset + 5u);
        sch->eventFramesRem = dur;
        // Light per-event trace (no flush; buffered). Useful when
        // diagnosing whether a "weird" note is from the SND data
        // itself or from our chip emulator.
        jlLogF("  agiV%u @%u dur=%u b234=%02x %02x %02x | psg ch0/1/2/N freq=%u/%u/%u/%u atten=%u/%u/%u/%u _reg=%u",
               (unsigned)v, (unsigned)(sch->chOffset - 5u), (unsigned)dur,
               (unsigned)gSoundBytes[sch->chOffset - 3u],
               (unsigned)gSoundBytes[sch->chOffset - 2u],
               (unsigned)gSoundBytes[sch->chOffset - 1u],
               (unsigned)gPsg[0].freqCount, (unsigned)gPsg[1].freqCount,
               (unsigned)gPsg[2].freqCount, (unsigned)gPsg[3].freqCount,
               (unsigned)gPsg[0].attenuation, (unsigned)gPsg[1].attenuation,
               (unsigned)gPsg[2].attenuation, (unsigned)gPsg[3].attenuation,
               (unsigned)gPsgReg);
    }
    if (sch->eventFramesRem != 0u) {
        sch->eventFramesRem = (uint16_t)(sch->eventFramesRem - 1u);
    }
}


// Host-loop tick generator. Drives ALL AGI-time-sensitive things
// (music event advancement, v11 seconds counter increment) off the
// same wall-clock-paced tick stream. Sierra's interpreter does the
// same: one tick = one PSG event step AND one 60th of an "AGI
// second", so v11 stays in lockstep with the music regardless of
// what AGI_SOUND_TICKS_HZ is set to. Called once per host frame.
static void agiTickAdvance(void) {
    static uint32_t gLastObjDumpMs = 0;
    uint32_t  nowMs = jlMillisElapsed();
    uint32_t  deltaMs;
    uint8_t   v;

    // Periodic dump of currently-drawn objects so we can see what's
    // visible at any moment without having to log per-opcode.
    if (nowMs - gLastObjDumpMs >= 2000u || gLastObjDumpMs == 0u) {
        uint8_t i;
        jlLogF("--- obj dump @wallMs=%u v11=%u v36=%u v221=%u v222=%u v223=%u v224=%u v225=%u v226=%u ---",
               (unsigned)nowMs, (unsigned)gVm.vars[11],
               (unsigned)gVm.vars[36],
               (unsigned)gVm.vars[221], (unsigned)gVm.vars[222],
               (unsigned)gVm.vars[223], (unsigned)gVm.vars[224],
               (unsigned)gVm.vars[225], (unsigned)gVm.vars[226]);
        for (i = 0u; i < (uint8_t)AGI_MAX_OBJECTS; i++) {
            const AgiObjectT *o = &gVm.objects[i];
            if ((o->flags & (AGI_OBJ_FLAG_ANIMATED | AGI_OBJ_FLAG_DRAWN))
                == (AGI_OBJ_FLAG_ANIMATED | AGI_OBJ_FLAG_DRAWN)) {
                jlLogF("  drawn obj[%u] view=%u loop=%u cel=%u x=%d y=%d flags=0x%02x",
                       (unsigned)i, (unsigned)o->viewId, (unsigned)o->loop,
                       (unsigned)o->cel, (int)o->x, (int)o->y,
                       (unsigned)o->flags);
            }
        }
        gLastObjDumpMs = nowMs;
    }

    if (gAgiTickLastWallMs == 0u) {
        gAgiTickLastWallMs = nowMs;
        // Prime one tick so events load immediately on song start.
        gAgiTickAccumUs    = AGI_TICK_PERIOD_US;
        return;
    }
    deltaMs            = nowMs - gAgiTickLastWallMs;
    gAgiTickLastWallMs = nowMs;
    gAgiTickAccumUs    = (uint32_t)(gAgiTickAccumUs + deltaMs * 1000u);

    while (gAgiTickAccumUs >= AGI_TICK_PERIOD_US) {
        gAgiTickAccumUs -= AGI_TICK_PERIOD_US;

        // Advance music if any.
        if (gSoundActive) {
            bool allDone;
            for (v = 0u; v < AGI_SOUND_CHANNELS; v++) {
                agiVoiceTick(v);
            }
            allDone = true;
            for (v = 0u; v < AGI_SOUND_TONE_VOICES; v++) {
                if (!gVoiceSched[v].done) {
                    allDone = false;
                    break;
                }
            }
            if (allDone) {
                gSoundActive = false;
                jlAudioNoise(0u, 15u);      // percussion must not outlive the song
                jlLogF("synth EOS wallMs=%u v0done=%u v1done=%u v2done=%u v3done=%u",
                       (unsigned)nowMs,
                       (unsigned)gVoiceSched[0].done,
                       (unsigned)gVoiceSched[1].done,
                       (unsigned)gVoiceSched[2].done,
                       (unsigned)gVoiceSched[3].done);
                jlLogFlush();
            }
        }

        // v11 advances every 60 AGI ticks (= 1 AGI sec). At
        // AGI_SOUND_TICKS_HZ=120 this is 2 v11 ticks per wall
        // sec, which is what the KQ3 script expects (Sierra
        // game-tick rate, not wall clock).
        gAgiV11SubTickAccum++;
        if (gAgiV11SubTickAccum >= 60u) {
            gAgiV11SubTickAccum = 0u;
            agiVmTickSeconds(&gVm, 1u);
        }
    }
}


// Stream-fill callback for the SB DMA path. Pure sample generator
// now -- all tick advancement is done by agiTickAdvance() from the
// host loop. The synth just reads the latest chip state cached by
// psgRecompute and emits signed 8-bit PCM.
static uint32_t agiSynthFill(void *ctx, int8_t *dst, uint32_t count) {
    uint32_t  i;
    uint8_t   v;
    int       sum;
    int8_t    voiceSample;

    (void)ctx;
    if (!gSoundActive) {
        return 0u;
    }
    for (i = 0u; i < count; i++) {
        sum = 0;
        for (v = 0u; v < AGI_SOUND_TONE_VOICES; v++) {
            voiceSample = (gPsg[v].phase & 0x8000u)
                          ? gPsg[v].amplitude
                          : (int8_t)(-gPsg[v].amplitude);
            sum += voiceSample;
            gPsg[v].phase = (uint16_t)(gPsg[v].phase + gPsg[v].phaseInc);
        }
        if (sum > 127)  { sum = 127; }
        if (sum < -128) { sum = -128; }
        dst[i] = (int8_t)sum;
    }
    return count;
}


static void cbPlaySound(void *ctx, uint8_t soundId) {
    uint8_t   *bytes;
    uint16_t   length;
    uint8_t    v;
    uint16_t   off;

    (void)ctx;
    agiSoundReset();
    if ((uint16_t)soundId >= gGame.resCount[AGI_RES_SOUND]) {
        return;
    }
    bytes = agiResLoad(&gGame, AGI_RES_SOUND, (uint16_t)soundId, &length);
    if (bytes == NULL) {
        return;
    }
    if (length < 8u) {
        free(bytes);
        return;
    }
    gSoundBytes  = bytes;
    gSoundLength = length;
    jlLogF("cbPlaySound id=%u len=%u offs=%u/%u/%u/%u wallMs=%u",
           (unsigned)soundId, (unsigned)length,
           (unsigned)(bytes[0] | (bytes[1] << 8)),
           (unsigned)(bytes[2] | (bytes[3] << 8)),
           (unsigned)(bytes[4] | (bytes[5] << 8)),
           (unsigned)(bytes[6] | (bytes[7] << 8)),
           (unsigned)jlMillisElapsed());
    jlLogFlush();
    for (v = 0u; v < AGI_SOUND_CHANNELS; v++) {
        off = (uint16_t)(bytes[v * 2u] | ((uint16_t)bytes[v * 2u + 1u] << 8));
        gVoiceSched[v].chOffset       = off;
        gVoiceSched[v].eventFramesRem = 0u;
        gVoiceSched[v].done           = (off == 0u) || ((uint32_t)off + 4u >= (uint32_t)length);
    }
    // Chip state was zeroed by agiSoundReset above; nothing more
    // to init here. First agiVoiceTick will fire the events that
    // configure freqCount / attenuation / genType.
    // Wall-clock-driven tick scheduler: cleared in agiSoundReset
    // above; agiSynthFill's first invocation primes the timestamp.
    gSoundActive          = true;
    // Push the synth into SFX slot 0 of the SB DMA mixer at the
    // exact output rate so no resampling happens between the synth
    // and the chip.
    jlAudioPlaySfxStream(0u, agiSynthFill, NULL, AGI_SYNTH_SAMPLE_RATE);
}


// VM polls this each cycle to detect natural sound completion.
// True while any tone voice still has events left; false once
// every voice has run off its 0xFFFF terminator (or stopSound
// was called).
static bool cbIsPlayingSound(void *ctx) {
    (void)ctx;
    return gSoundActive;
}


static void cbStopSound(void *ctx) {
    (void)ctx;
    jlLog("cbStopSound");
    jlLogFlush();
    agiSoundReset();
}


// View metadata callbacks for the VM's cycle / last.cel /
// number.of.loops opcodes. fetchView walks the view-cache, faulting
// the view in from VOL on a cold hit, so the first call after a
// view is referenced may decode the resource. Returns 0 if the
// view cannot be loaded; the VM treats 0 as "still loading" (no
// premature end.of.loop fire) and the host renderer's own clamps
// keep the screen from drawing garbage cels.
static uint8_t cbViewCelCount(void *ctx, uint8_t viewId, uint8_t loopId) {
    const AgiViewT *view;

    (void)ctx;
    view = fetchView(viewId);
    if (view == NULL || loopId >= AGI_MAX_LOOPS_PER_VIEW) {
        return 0u;
    }
    return view->loops[loopId].celCount;
}


static uint8_t cbViewLoopCount(void *ctx, uint8_t viewId) {
    const AgiViewT *view;

    (void)ctx;
    view = fetchView(viewId);
    if (view == NULL) {
        return 0u;
    }
    return view->loopCount;
}


// (Pre-render synthesizeSnd path was replaced by the streaming
// generator agiSoundFill above; see that for the SND event format
// and PSG synthesis details.)
#if 0
static bool synthesizeSnd_removed(const uint8_t *bytes, uint16_t length, uint8_t **outPcm, uint32_t *outLen) {
    uint32_t  maxTicks;
    uint32_t  durSamples;
    uint8_t  *pcm;
    uint8_t   ch;

    if (length < 8u) {
        return false;
    }
    maxTicks = 0u;
    for (ch = 0u; ch < 4u; ch++) {
        uint16_t  off;
        uint16_t  i;
        uint32_t  ticks;

        off = (uint16_t)(bytes[ch * 2u] | ((uint16_t)bytes[ch * 2u + 1u] << 8));
        if (off >= length) {
            continue;
        }
        ticks = 0u;
        i = off;
        while ((uint32_t)i + 4u < (uint32_t)length) {
            uint16_t  dur;

            dur = (uint16_t)(bytes[i] | ((uint16_t)bytes[i + 1u] << 8));
            if (dur == 0xFFFFu) {
                break;
            }
            ticks = (uint32_t)(ticks + dur);
            i = (uint16_t)(i + 5u);
        }
        if (ticks > maxTicks) {
            maxTicks = ticks;
        }
    }
    if (maxTicks == 0u) {
        return false;
    }
    durSamples = (maxTicks * (uint32_t)SOUND_SAMPLE_RATE) / 60u;
    if (durSamples == 0u) {
        return false;
    }
    // audioSfxOverlayMix uses a 16.16 fixed-point position counter;
    // input samples beyond 2^16 - 1 cause pos to wrap and the slot
    // to loop the buffer forever instead of completing. Truncate
    // here so longer sounds at least play their opening bars.
    if (durSamples > SOUND_MAX_SAMPLES) {
        durSamples = SOUND_MAX_SAMPLES;
    }
    pcm = (uint8_t *)malloc(durSamples);
    if (pcm == NULL) {
        return false;
    }
    memset(pcm, 0, durSamples);   // signed silence = 0

    for (ch = 0u; ch < SOUND_SYNTH_CHANNELS; ch++) {
        uint16_t  off;
        uint16_t  i;
        uint32_t  sampleIdx;
        // 16.16 phase accumulator: 0..0xFFFF = one square-wave
        // cycle. Stepping by phaseStep per output sample yields
        // sub-sample precision so 717 Hz, 466 Hz, etc. stay at the
        // correct frequency instead of quantizing to the nearest
        // integer half-period (which scrambled the melody at
        // lower sample rates).
        uint32_t  phaseAcc;

        off = (uint16_t)(bytes[ch * 2u] | ((uint16_t)bytes[ch * 2u + 1u] << 8));
        if (off >= length) {
            continue;
        }
        sampleIdx = 0u;
        phaseAcc  = 0u;
        i = off;
        while ((uint32_t)i + 4u < (uint32_t)length && sampleIdx < durSamples) {
            uint16_t  dur;
            uint16_t  divisor;
            uint8_t   atten;
            int8_t    amp;
            uint32_t  durSamps;
            uint32_t  phaseStep;
            uint32_t  s;

            dur = (uint16_t)(bytes[i] | ((uint16_t)bytes[i + 1u] << 8));
            if (dur == 0xFFFFu) {
                break;
            }
            divisor = (uint16_t)(((bytes[i + 2u] & 0x3Fu) << 4) | (bytes[i + 3u] & 0x0Fu));
            atten   = (uint8_t)(bytes[i + 4u] & 0x0Fu);
            amp     = kAgiSoundAmp[atten];
            durSamps = ((uint32_t)dur * (uint32_t)SOUND_SAMPLE_RATE) / 60u;
            if (divisor == 0u || amp == 0) {
                // Silent event -- just advance sampleIdx without
                // touching pcm (already zeroed) or stepping phase.
                if (sampleIdx + durSamps > durSamples) {
                    sampleIdx = durSamples;
                } else {
                    sampleIdx = sampleIdx + durSamps;
                }
                i = (uint16_t)(i + 5u);
                continue;
            }
            // freq_hz = PSG_CLOCK_HZ / divisor.
            // phaseStep (16.16, one cycle = 0x10000) per output sample
            //          = (freq_hz << 16) / SOUND_SAMPLE_RATE
            //          = (PSG_CLOCK_HZ << 16) / (divisor * SOUND_SAMPLE_RATE)
            phaseStep = ((uint32_t)PSG_CLOCK_HZ << 16) / ((uint32_t)divisor * SOUND_SAMPLE_RATE);
            for (s = 0u; s < durSamps && sampleIdx < durSamples; s++, sampleIdx++) {
                int8_t   sval;
                int16_t  mixed;

                sval = (phaseAcc & 0x8000u) ? (int8_t)(-amp) : amp;
                mixed = (int16_t)((int8_t)pcm[sampleIdx]) + sval;
                if (mixed > 127) { mixed = 127; }
                if (mixed < -128) { mixed = -128; }
                pcm[sampleIdx] = (uint8_t)mixed;
                phaseAcc = (uint32_t)((phaseAcc + phaseStep) & 0xFFFFu);
            }
            i = (uint16_t)(i + 5u);
        }
    }

    *outPcm = pcm;
    *outLen = durSamples;
    return true;
}


static void cbPlaySound(void *ctx, uint8_t soundId) {
    uint8_t   *bytes;
    uint16_t   length;
    uint8_t   *pcm;
    uint32_t   pcmLen;

    (void)ctx;
    if (!gAudioReady) {
        return;
    }
    // Cache hit: same sound replayed -- just restart the slot,
    // skip the file load + synth (instant restart, no freeze).
    if (gSoundPcmValid && gSoundPcm != NULL && gSoundPcmId == soundId) {
        jlAudioStopSfx(0u);
        jlAudioPlaySfx(0u, gSoundPcm, gSoundPcmLen, (uint16_t)SOUND_SAMPLE_RATE);
        return;
    }
    // Cache miss: tear down prior buffer, synth a new one.
    if (gSoundPcm != NULL) {
        jlAudioStopSfx(0u);
        free(gSoundPcm);
        gSoundPcm    = NULL;
        gSoundPcmLen = 0u;
    }
    gSoundPcmValid = false;
    if ((uint16_t)soundId >= gGame.resCount[AGI_RES_SOUND]) {
        return;
    }
    bytes = agiResLoad(&gGame, AGI_RES_SOUND, (uint16_t)soundId, &length);
    if (bytes == NULL) {
        return;
    }
    pcm    = NULL;
    pcmLen = 0u;
    if (synthesizeSnd(bytes, length, &pcm, &pcmLen)) {
        gSoundPcm      = pcm;
        gSoundPcmLen   = pcmLen;
        gSoundPcmId    = soundId;
        gSoundPcmValid = true;
        jlAudioPlaySfx(0u, gSoundPcm, gSoundPcmLen, (uint16_t)SOUND_SAMPLE_RATE);
    }
    free(bytes);
}


static void cbStopSound(void *ctx) {
    (void)ctx;
    if (!gAudioReady) {
        return;
    }
    // Stop the slot but keep the PCM cached -- a quick stop/start
    // (very common in AGI v2 state machines) then replays without
    // a re-synth freeze.
    jlAudioStopSfx(0u);
}
#endif


// Parse an AGI v2 SND resource and return its playback duration in
// milliseconds (max across the 4 PSG channels). Used to defer the
// sound-done flag firing until the music would naturally end --
// drives KQ3's title state-1 / state-3 shortcut which advances the
// countdown to 2 sec once flag 9 (sound finished) is set.
//
// SND v2 layout:
//   bytes 0..7  : 4 channel offsets (2-byte LE each)
//   per channel : sequence of 5-byte events (duration LE16, freq LE16,
//                 attenuation), terminated by duration = 0xFFFF.
// Duration units are 1/60 sec (NTSC vblank ticks).
static uint32_t cbSoundDuration(void *ctx, uint8_t soundId) {
    uint8_t  *bytes;
    uint16_t  length;
    uint32_t  maxTicks;
    uint8_t   ch;

    (void)ctx;
    if ((uint16_t)soundId >= gGame.resCount[AGI_RES_SOUND]) {
        return 0u;
    }
    bytes = agiResLoad(&gGame, AGI_RES_SOUND, (uint16_t)soundId, &length);
    if (bytes == NULL) {
        return 0u;
    }
    if (length < 8u) {
        free(bytes);
        return 0u;
    }
    maxTicks = 0u;
    for (ch = 0u; ch < 4u; ch++) {
        uint16_t  off;
        uint16_t  i;
        uint32_t  ticks;

        off = (uint16_t)(bytes[ch * 2u] | ((uint16_t)bytes[ch * 2u + 1u] << 8));
        ticks = 0u;
        i = off;
        while ((uint32_t)i + 4u < (uint32_t)length) {
            uint16_t  dur;

            dur = (uint16_t)(bytes[i] | ((uint16_t)bytes[i + 1u] << 8));
            if (dur == 0xFFFFu) {
                break;
            }
            ticks = (uint32_t)(ticks + dur);
            i = (uint16_t)(i + 5u);
        }
        if (ticks > maxTicks) {
            maxTicks = ticks;
        }
    }
    free(bytes);
    // 60 ticks / sec -> ms = ticks * 1000 / 60 = ticks * 50 / 3
    return (maxTicks * 1000u) / 60u;
}


static bool decodePicInto(uint8_t picId, bool clearFirst) {
    uint8_t   *bytes;
    uint16_t   length;
    bool       ok;

    if ((uint16_t)picId >= gGame.resCount[AGI_RES_PIC]) {
        return false;
    }
    bytes = agiResLoad(&gGame, AGI_RES_PIC, (uint16_t)picId, &length);
    if (bytes == NULL) {
        return false;
    }
    if (clearFirst) {
        agiPicClear(&gPic);
    }
    ok = agiPicDecode(&gPic, bytes, length);
    free(bytes);
    return ok;
}


// Walk every key the JoeyLib enum knows about; for each one that
// went down this frame, look it up against vm->keyBindings via
// agiVmDispatchKey. Function keys map to the AGI extended scancode
// pair (0, 59..68); printable ASCII keys map to (asciiCode, 0).
static void dispatchAllPressedKeys(void) {
    static const uint8_t  fnScancodes[10] = { 59, 60, 61, 62, 63, 64, 65, 66, 67, 68 };
    static const jlKeyE   fnKeys[10] = {
        KEY_F1, KEY_F2, KEY_F3, KEY_F4, KEY_F5,
        KEY_F6, KEY_F7, KEY_F8, KEY_F9, KEY_F10
    };
    uint8_t  k;
    uint8_t  i;
    bool     captureText;

    // While the command line is live (and no modal is up), printable keys,
    // Enter, Space and Backspace belong to the input line (agiInputCapture),
    // not to controller/key-binding dispatch. Tab, function keys and the
    // joystick stay as controllers so status/menu/action keys still work.
    captureText = gVm.acceptInput && !gVm.printModal.active;

    if (!captureText) {
        if (jlKeyPressed(KEY_RETURN)) {
            agiVmDispatchKey(&gVm, 13u, 0u);
            gAnyKeyHit = true;
        }
        if (jlKeyPressed(KEY_SPACE)) {
            agiVmDispatchKey(&gVm, 32u, 0u);
            gAnyKeyHit = true;
        }
        if (jlKeyPressed(KEY_BACKSPACE)) {
            agiVmDispatchKey(&gVm, 8u, 0u);
            gAnyKeyHit = true;
        }
        for (k = 0u; k < 26u; k++) {
            if (jlKeyPressed((jlKeyE)((uint8_t)KEY_A + k))) {
                agiVmDispatchKey(&gVm, (uint8_t)('a' + k), 0u);
                gAnyKeyHit = true;
            }
        }
        for (k = 0u; k < 10u; k++) {
            if (jlKeyPressed((jlKeyE)((uint8_t)KEY_0 + k))) {
                agiVmDispatchKey(&gVm, (uint8_t)('0' + k), 0u);
                gAnyKeyHit = true;
            }
        }
    }
    if (jlKeyPressed(KEY_TAB)) {
        agiVmDispatchKey(&gVm, 9u, 0u);
        gAnyKeyHit = true;
    }
    for (i = 0u; i < 10u; i++) {
        if (jlKeyPressed(fnKeys[i])) {
            agiVmDispatchKey(&gVm, 0u, fnScancodes[i]);
            gAnyKeyHit = true;
        }
    }
    if (jlJoystickConnected(JOYSTICK_0)) {
        if (jlJoyPressed(JOYSTICK_0, JOY_BUTTON_0) || jlJoyPressed(JOYSTICK_0, JOY_BUTTON_1)) {
            agiVmDispatchKey(&gVm, 13u, 0u);  // joystick fire == ENTER
            gAnyKeyHit = true;
        }
    }
}


// Restore the exact stage rect `(x, y, w, h)` from the gBackdrop
// surface. Chunky-fast: a single memcpy per row from backdrop pixels
// to stage pixels via the library's rect copy (hunt-3 rank 3). The
// old body hand-rolled a chunky memcpy loop and, on the planar 68k
// ports (stage->pixels NULL post-Phase-9/10), fell back to a FULL
// 32KB jlSurfaceCopy per call -- 3-5 times per frame -- which also
// re-marked the whole stage AND the SCB/palette dirty, defeating the
// group-skip presents. jlSurfaceCopyRect does everything this needs:
// clips, widens x to the 16-px group grid (safe -- the erase-all-
// then-redraw-all object pass repaints anything the widening grazes),
// routes to each port's fast copy (ST move.l spans, Amiga blitter
// arm, IIgs inline word, DOS memcpy), and marks exactly the rect.
static void restoreRectFromBackdrop(jlSurfaceT *stage, int16_t x, int16_t y, int16_t w, int16_t h) {
    if (stage == NULL || gBackdrop == NULL || w <= 0 || h <= 0) {
        return;
    }
    jlSurfaceCopyRect(stage, gBackdrop, x, y, (uint16_t)w, (uint16_t)h);
}


// Walk vm->objects[] and (re)draw every animated+drawn object. Per
// frame:
//   pass 1: restore the prev-frame rect of every previously-drawn
//           obj from the backdrop. Erases the prev draw without
//           needing per-object save buffers.
//   pass 2: for each visible obj, compute its cel's stage bbox,
//           draw the cel, and record the bbox as its new prev rect.
//
// Two passes are required so an obj's restore can't accidentally
// erase another obj's draw -- all restores happen before any draws.
static void drawObjects(jlSurfaceT *stage) {
    uint8_t  i;

    // Pass 1: restore prev-frame rect from backdrop for every obj
    // that was drawn last frame.
    for (i = 0u; i < (uint8_t)AGI_MAX_OBJECTS; i++) {
        if (gObjPrev[i].valid) {
            restoreRectFromBackdrop(stage, gObjPrev[i].x, gObjPrev[i].y,
                                    gObjPrev[i].w, gObjPrev[i].h);
            gObjPrev[i].valid = false;
        }
    }

    // Pass 2: draw current frame's visible objects.
    for (i = 0u; i < (uint8_t)AGI_MAX_OBJECTS; i++) {
        const AgiObjectT  *obj;
        const AgiViewT    *view;
        uint8_t            loop;
        uint8_t            cel;
        uint8_t            actorPri;
        int16_t            stageX;
        int16_t            stageY;
        int16_t            stageW;
        int16_t            stageH;

        obj = &gVm.objects[i];
        if ((obj->flags & AGI_OBJ_FLAG_ANIMATED) == 0u) {
            continue;
        }
        if ((obj->flags & AGI_OBJ_FLAG_DRAWN) == 0u) {
            continue;
        }
        view = fetchView(obj->viewId);
        if (view == NULL || view->loopCount == 0u) {
            continue;
        }
        loop = obj->loop;
        // Auto-loop from direction only applies to MOVING objects.
        // A stopped obj keeps whatever loop the script (e.g.
        // set.loop) assigned -- otherwise KQ3's title-screen wizard
        // eyes (obj 0, view=65 loop=1, direction=0) get clobbered
        // to loop 0 (the body view) by the stopped-default branch.
        if ((obj->flags & AGI_OBJ_FLAG_LOOP_FIXED) == 0u &&
            obj->direction != 0u) {
            uint8_t  autoLoop = loopForDirection(obj->direction, view->loopCount);
            if (autoLoop < view->loopCount) {
                loop = autoLoop;
            }
        }
        if (loop >= view->loopCount) {
            loop = 0u;
        }
        cel = obj->cel;
        if (cel >= view->loops[loop].celCount) {
            cel = 0u;
        }
        if ((obj->flags & AGI_OBJ_FLAG_PRI_FIXED) != 0u && obj->priority >= AGI_PRIORITY_BG) {
            actorPri = obj->priority;
        } else {
            actorPri = agiActorPriorityForY(obj->y);
        }
        agiViewDraw(view, loop, cel,
                    (int16_t)obj->x, (int16_t)obj->y,
                    actorPri, gPic.priority,
                    stage, PIC_DEST_Y);
        // Record the cel's exact stage bbox so next frame's restore
        // covers only that region (works for cels of any size, fixes
        // the trailing-pixel artifact wider/taller cels suffered with
        // the previous fixed-size save-under).
        {
            uint8_t  cw = view->loops[loop].cels[cel].width;
            uint8_t  ch = view->loops[loop].cels[cel].height;
            stageX = (int16_t)(obj->x * 2);
            stageY = (int16_t)(PIC_DEST_Y + obj->y - (int16_t)ch + 1);
            stageW = (int16_t)(cw * 2);
            stageH = (int16_t)ch;
            gObjPrev[i].x     = stageX;
            gObjPrev[i].y     = stageY;
            gObjPrev[i].w     = stageW;
            gObjPrev[i].h     = stageH;
            gObjPrev[i].valid = true;
        }
    }
}


// Invalidate every object's prev-rect entry. Called when the picture
// changes (show.pic / add.to.pic) so we don't restore stale pixels
// over the new backdrop on the next frame.
static void invalidateObjectSaves(void) {
    uint8_t  i;

    for (i = 0u; i < (uint8_t)AGI_MAX_OBJECTS; i++) {
        gObjPrev[i].valid = false;
    }
}


static const AgiViewT *fetchView(uint8_t viewId) {
    ViewCacheSlotT  *slot;
    uint8_t         *bytes;
    uint16_t         length;

    slot = &gViewCache[viewId];
    if (slot->loaded) {
        return &slot->view;
    }
    if (viewId >= gGame.resCount[AGI_RES_VIEW]) {
        return NULL;
    }
    bytes = agiResLoad(&gGame, AGI_RES_VIEW, (uint16_t)viewId, &length);
    if (bytes == NULL) {
        return NULL;
    }
    if (!agiViewParse(&slot->view, bytes, length)) {
        return NULL;
    }
    slot->loaded = true;
    return &slot->view;
}


// AGI's canonical loop assignment from direction (idx 0 = right):
//   right=0, left=1, down=2, up=3
// Diagonal directions reuse the dominant axis loop.
static uint8_t loopForDirection(uint8_t direction, uint8_t loopCount) {
    uint8_t  loop;

    if (loopCount <= 1u) {
        return 0u;
    }
    switch (direction) {
        case 1u: loop = 3u; break;        // N
        case 2u: loop = 0u; break;        // NE -> right
        case 3u: loop = 0u; break;        // E
        case 4u: loop = 0u; break;        // SE -> right
        case 5u: loop = 2u; break;        // S
        case 6u: loop = 1u; break;        // SW -> left
        case 7u: loop = 1u; break;        // W
        case 8u: loop = 1u; break;        // NW -> left
        default: loop = 2u; break;        // stopped -> down
    }
    if (loop >= loopCount) {
        return 0u;
    }
    return loop;
}


static uint8_t parseArgU8(const char *s) {
    uint16_t  val;
    uint8_t   digit;

    if (s == NULL) {
        return 0u;
    }
    val = 0u;
    while (*s != '\0') {
        if (*s < '0' || *s > '9') {
            return 0u;
        }
        digit = (uint8_t)(*s - '0');
        if (val > 25u || (val == 25u && digit > 5u)) {
            return 0u;
        }
        val = (uint16_t)(val * 10u + digit);
        s++;
    }
    return (uint8_t)val;
}


static void releaseLogicCache(void) {
    uint16_t  i;

    for (i = 0u; i < AGI_MAX_RESOURCES; i++) {
        if (gLogicCache[i].raw != NULL) {
            free(gLogicCache[i].raw);
            gLogicCache[i].raw      = NULL;
            gLogicCache[i].bytecode = NULL;
        }
    }
}


static void releaseViewCache(void) {
    uint16_t  i;

    for (i = 0u; i < AGI_MAX_RESOURCES; i++) {
        if (gViewCache[i].loaded) {
            agiViewFree(&gViewCache[i].view);
            gViewCache[i].loaded = false;
        }
    }
}


static void renderFrame(jlSurfaceT *stage) {
    uint8_t  r;

    if (!gPicReady) {
        jlStagePresent();
        return;
    }
    // Step 1: restore text rows that had content last frame so a VM
    // that cleared them this frame (clear.lines / row reassign) sees
    // them disappear from the stage.
    for (r = 0u; r < (uint8_t)AGI_TEXT_ROWS; r++) {
        if (gTextRowDirty[r]) {
            restoreRectFromBackdrop(stage, 0, (int16_t)((int16_t)r * 8),
                                    (int16_t)SURFACE_WIDTH, 8);
            gTextRowDirty[r] = false;
        }
    }
    // Step 2: restore + draw animated/drawn objects.
    drawObjects(stage);
    // Command line: echo "> " + the typed text (with a cursor) on the input
    // row while accept.input is live; blank our prompt when it turns off so
    // the line disappears. This reuses the text-row plane so agiTextRender
    // and the dirty-row bookkeeping below draw and wipe it like any message.
    {
        char  *dst = gVm.textRows[AGI_INPUT_ROW].text;

        if (gVm.acceptInput) {
            uint8_t  n;
            uint8_t  i;

            n = 0u;
            dst[n++] = '>';
            dst[n++] = ' ';
            for (i = 0u; i < gVm.inputLen && n < (uint8_t)AGI_TEXT_COLS; i++) {
                dst[n++] = gVm.inputLine[i];
            }
            if (n < (uint8_t)AGI_TEXT_COLS) {
                dst[n++] = '_';
            }
            dst[n] = '\0';
        } else if (dst[0] == '>') {
            dst[0] = '\0';
        }
    }
    // Step 3: draw current text rows on top. Mark dirty as we go so
    // step 1 of the next frame knows to wipe them.
    {
        bool    statusOn = gVm.statusLineOn;
        uint8_t statusRow = gVm.statusLineRow;

        if (statusOn) {
            gTextRowDirty[statusRow] = true;
        }
        for (r = 0u; r < (uint8_t)AGI_TEXT_ROWS; r++) {
            if (gVm.textRows[r].text[0] != '\0') {
                gTextRowDirty[r] = true;
            }
        }
    }
    agiTextRender(&gVm, stage);
    jlStagePresent();
}


static uint8_t resolveStartingRoom(int argc, char **argv) {
#if defined(JOEYLIB_PLATFORM_IIGS)
    // GS/OS launches an S16 app with no usable argv (the crt0 leaves
    // argc/argv uninitialized -- garbage), so always start at the title
    // room. Game data comes from DATA/ regardless of argv now.
    (void)argc;
    (void)argv;
    return 0u;
#else
    if (argc <= 1 || argv[1] == NULL) {
        return 0u;
    }
    return parseArgU8(argv[1]);
#endif
}


static void runVmCycle(void) {
    AgiVmHaltE      reason;
    const uint8_t  *logic0;
    uint16_t        length;
    uint8_t         prevRoom;
    uint8_t         i;
    uint8_t         guard;

    // Block the cycle entirely while a print() modal is up; the
    // host's input loop dismisses it on the next ack key.
    if (gVm.printModal.active) {
        return;
    }

    guard = CYCLE_HALT_GUARD;
    for (;;) {
        if (guard == 0u) {
            return;
        }
        guard--;
        reason = agiVmRun(&gVm);
        if (reason == AGI_VM_HALT_PRINT_PENDING) {
            return;
        }
        if (reason == AGI_VM_HALT_QUIT) {
            return;
        }
        if (reason == AGI_VM_HALT_RETURN) {
            agiVmTickAnimation(&gVm);
            gVm.flags[5] = 0u;
            // The player's command was offered to every logic this cycle;
            // consume it so said() can't re-fire it on later cycles. (A
            // matching said() already cleared it; this covers the no-match
            // case.)
            gVm.flags[AGI_FLAG_INPUT] = 0u;
            length = 0u;
            logic0 = cbFetchLogic(NULL, 0u, &length);
            if (logic0 != NULL) {
                agiVmResetToLogic(&gVm, logic0, length, 0u);
            }
            return;
        }
        if (reason == AGI_VM_HALT_NEW_ROOM) {
            prevRoom = gVm.vars[0];
            if (gOverrideRoomActive) {
                gVm.vars[0]         = gOverrideRoom;
                gOverrideRoomActive = false;
            } else {
                gVm.vars[0] = gVm.newRoomId;
            }
            gVm.vars[1] = prevRoom;
            for (i = 0u; i < 32u; i++) {
                gVm.flags[i] = 0u;
            }
            gVm.flags[5] = 1u;
            // Clear the object table on room change so leftover NPCs
            // don't bleed into the new scene. Ego (object 0) stays
            // animated/drawn; the new room's init re-runs animate.obj
            // with the same setup if it wants ego on screen.
            for (i = 1u; i < (uint8_t)AGI_MAX_OBJECTS; i++) {
                agiObjReset(&gVm.objects[i]);
            }
            // Clear text rows. Sierra's new.room expects a fresh text
            // band -- without this, v226=3 credits from LOGIC.45 stay
            // visible after the room.46 transition.
            for (i = 0u; i < (uint8_t)AGI_TEXT_ROWS; i++) {
                gVm.textRows[i].text[0] = '\0';
            }
            length = 0u;
            logic0 = cbFetchLogic(NULL, 0u, &length);
            if (logic0 != NULL) {
                agiVmResetToLogic(&gVm, logic0, length, 0u);
            }
            continue;
        }
        // Unknown opcode, truncated stream, missing logic, stack
        // overflow: stop cycling this frame and let the host render
        // what we have.
        return;
    }
}


// Player-control input mapping for ego (when programControl is false).
// Sets var 6 (ego direction) so the engine's motion tick moves the
// player normally. Diagonals not bound; AGI canonical games rarely
// use them via keyboard.
static void updateEgoFromInput(void) {
    uint8_t  newDir;

    if (gVm.programControl) {
        return;
    }
    newDir = 0u;
    if (jlKeyDown(KEY_UP))    { newDir = 1u; }
    if (jlKeyDown(KEY_RIGHT)) { newDir = 3u; }
    if (jlKeyDown(KEY_DOWN))  { newDir = 5u; }
    if (jlKeyDown(KEY_LEFT))  { newDir = 7u; }
    if (newDir != 0u || jlKeyPressed(KEY_UP) || jlKeyPressed(KEY_DOWN) || jlKeyPressed(KEY_LEFT) || jlKeyPressed(KEY_RIGHT)) {
        gVm.objects[0].direction = newDir;
        gVm.vars[6]              = newDir;
    } else if (!jlKeyDown(KEY_UP) && !jlKeyDown(KEY_DOWN) && !jlKeyDown(KEY_LEFT) && !jlKeyDown(KEY_RIGHT)) {
        gVm.objects[0].direction = 0u;
        gVm.vars[6]              = 0u;
    }
}


#ifdef JOEYLIB_PLATFORM_IIGS
#endif

// ----- Entry point -----

int main(int argc, char **argv) {
    jlConfigT         cfg;
    AgiVmCallbacksT   cb;
    const uint8_t    *logic0;
    uint16_t          length;
    uint8_t           startingRoom;
    bool              picOk;
    bool              vmReady;

    cfg.codegenBytes = 0u;     // no sprites needed -- backdrop-restore path is direct memcpy
    // AGI sound is software-synthesized PSG squarewaves pushed
    // through the Sound Blaster auto-init DMA path (see agiSynthFill
    // and src/port/dos/audio.c). The audio pool covers the 4 KB
    // DMA buffer + libxmp-lite SMIX state.
    cfg.audioBytes   = 32u * 1024u;
    if (!jlInit(&cfg)) {
        return 1;
    }

    jlLogReset();
    jlLogF("AGI TRACE build=%s %s", __DATE__, __TIME__);
    jlLogFlush();
    gAgiStage = jlStageGet();
    jlPaletteSet(gAgiStage, 0u, kAgiPalette);
    jlRandomSeed(0xA61DA61Du);

    // Bring up the SB DMA + mixer. AGI music feeds the SFX stream
    // path inside jlAudioFrameTick, which depends on a successful
    // init here.
    (void)jlAudioInit();

    {
        uint8_t  i;
        for (i = 0u; i < (uint8_t)AGI_MAX_OBJECTS; i++) {
            gObjPrev[i].valid = false;
        }
    }


    // The view cache is too large for bank-0 BSS on the IIgs; pull it
    // from the platform's large-allocator. jlAlloc does not zero its
    // result, so clear it so every slot starts with loaded == false.
    gViewCache = (ViewCacheSlotT *)jlAlloc(sizeof(ViewCacheSlotT) * AGI_MAX_RESOURCES);
    if (gViewCache == NULL) {
        jlShutdown();
        return 1;
    }
    memset(gViewCache, 0, sizeof(ViewCacheSlotT) * AGI_MAX_RESOURCES);

    startingRoom        = resolveStartingRoom(argc, argv);
    gOverrideRoom       = startingRoom;
    gOverrideRoomActive = (startingRoom != 0u);
    picOk               = false;
    vmReady             = false;
    gBackdrop           = NULL;
    gPicReady           = false;
    gAnyKeyHit          = false;

    if (agiResOpen(&gGame)) {
        picOk  = agiPicAlloc(&gPic);
        if (picOk) {
            agiPicClear(&gPic);
            gBackdrop = jlSurfaceCreate();

            agiVmInit(&gVm);
            cb.fetchLogic    = cbFetchLogic;
            cb.loadPic       = cbLoadPic;
            cb.drawPic       = cbDrawPic;
            cb.showPic       = cbShowPic;
            cb.discardPic    = cbDiscardPic;
            cb.overlayPic    = cbOverlayPic;
            cb.haveKey       = cbHaveKey;
            cb.fetchMessage  = cbFetchMessage;
            cb.addToPic      = cbAddToPic;
            cb.soundDuration   = cbSoundDuration;
            cb.playSound       = cbPlaySound;
            cb.stopSound       = cbStopSound;
            cb.isPlayingSound  = cbIsPlayingSound;
            cb.viewCelCount    = cbViewCelCount;
            cb.viewLoopCount   = cbViewLoopCount;
            cb.ctx             = NULL;
            agiVmSetCallbacks(&gVm, &cb);

            length = 0u;
            logic0 = cbFetchLogic(NULL, 0u, &length);
            if (logic0 != NULL) {
                agiVmResetToLogic(&gVm, logic0, length, 0u);
                vmReady = true;
            }
        }
    }

    jlSurfaceClear(gAgiStage, COLOR_MISSING);
    if (gBackdrop != NULL) {
        jlSurfaceClear(gBackdrop, COLOR_MISSING);
    }

    if (vmReady) {
        uint8_t  pump;

        for (pump = 0u; pump < STARTUP_CYCLE_LIMIT; pump++) {
            if (gPicReady) {
                break;
            }
            runVmCycle();
        }
    }

    {
        uint32_t  lastVmCycleMs = 0u;
        uint32_t  lastRenderMs  = 0u;

        for (;;) {
            jlInputPoll();
            if (jlKeyPressed(KEY_ESCAPE)) {
                break;
            }
            gAnyKeyHit = false;
            dispatchAllPressedKeys();

            // When the command line is live (and no modal is up), typed
            // characters build the input line instead of firing controllers.
            if (gVm.acceptInput && !gVm.printModal.active) {
                agiInputCapture();
            }

            // Pump the AGI tick generator EVERY frame, even with a
            // print modal up. This drives music event playback AND
            // the v11 seconds counter -- both keyed to the same
            // AGI tick rate so script timing stays consistent with
            // music tempo. Pausing this would freeze the AGI clock.
            agiTickAdvance();

            // If a print() modal is up, any key dismisses it and resumes
            // the VM next cycle. Don't run the VM or update inputs while
            // the modal is alive.
            if (gVm.printModal.active) {
                if (gAnyKeyHit) {
                    agiVmAckPrint(&gVm);
                }
            } else {
                uint32_t  nowMs = jlMillisElapsed();
                uint8_t   delayJiffies;
                uint32_t  cyclePeriodMs;

                updateEgoFromInput();
                delayJiffies  = gVm.vars[10];
                if (delayJiffies == 0u) { delayJiffies = 1u; }
                cyclePeriodMs = (uint32_t)delayJiffies * (uint32_t)AGI_JIFFY_MS;
                if (vmReady && (nowMs - lastVmCycleMs) >= cyclePeriodMs) {
                    runVmCycle();
                    lastVmCycleMs = nowMs;
                }
                if (gVm.haltReason == AGI_VM_HALT_QUIT) {
                    break;
                }
            }

            // Wall-clock-paced render. Audio refill still runs every
            // loop iteration so DMA half-buffers get serviced even
            // when we're skipping the visual update.
            {
                uint32_t  nowMs2 = jlMillisElapsed();
                if (nowMs2 - lastRenderMs >= AGI_RENDER_PERIOD_MS) {
                    renderFrame(gAgiStage);
                    lastRenderMs = nowMs2;
                } else {
                    jlStagePresent();
                }
            }
            // Pump the SB DMA mixer. Refills any half-buffer the
            // ISR flagged for refill since last call, which in turn
            // pulls the next chunk from agiSynthFill (when music
            // is armed) or libxmp + SFX overlay (when not).
            jlAudioFrameTick();
#if defined(JOEYLIB_PLATFORM_IIGS)
            // The IIgs noise voice is an ~80 ms one-shot; re-assert
            // held noise once per frame so it sustains (same model as
            // the music sequencer's per-pump re-assert).
            if (gSoundActive && gPsg[3].attenuation < 15u) {
                psgNoiseApply();
            }
#endif
            jlWaitVBL();
        }
    }

    agiSoundReset();
    jlAudioShutdown();
    releaseLogicCache();
    releaseViewCache();
    jlFree(gViewCache);
    gViewCache = NULL;
    if (gBackdrop != NULL) {
        jlSurfaceDestroy(gBackdrop);
    }
    if (picOk) {
        agiPicFree(&gPic);
    }
    agiResClose(&gGame);
    jlShutdown();
    return 0;
}
