// AGI v2 LOGIC VM, Phase 1 sub-A: bytecode dispatch skeleton.
//
// This pass implements the variable / flag opcodes (0x00-0x11) plus
// the basic header parser. Control flow (IF / ELSE / GOTO), test
// commands (equaln, isset, said, ...), and side-effecting opcodes
// (new.room, load.pic, animate.obj, ...) come in later sub-phases.
// Unknown opcodes halt cleanly with the offending byte recorded so
// host tests can report exactly where execution stopped.
//
// All algorithms here are re-implementations written against the
// published AGI v2 LOGIC bytecode specification; no third-party
// interpreter source is referenced.

#include "agi.h"

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

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


// LOGIC resource layout: bytes 0-1 are the little-endian length of
// the bytecode section, followed by `bytecodeLen` bytes of bytecode,
// followed by the message table. The VM walks only the bytecode.
#define AGI_LOGIC_HDR_BYTES         2u

// Implemented action opcodes (sub-A: arithmetic and flags).
#define OP_RETURN                   0x00u
#define OP_INCREMENT                0x01u
#define OP_DECREMENT                0x02u
#define OP_ASSIGNN                  0x03u
#define OP_ASSIGNV                  0x04u
#define OP_ADDN                     0x05u
#define OP_ADDV                     0x06u
#define OP_SUBN                     0x07u
#define OP_SUBV                     0x08u
#define OP_LINDIRECTV               0x09u
#define OP_RINDIRECT                0x0Au
#define OP_LINDIRECTN               0x0Bu
#define OP_SET                      0x0Cu
#define OP_RESET                    0x0Du
#define OP_TOGGLE                   0x0Eu
#define OP_SETV                     0x0Fu
#define OP_RESETV                   0x10u
#define OP_TOGGLEV                  0x11u

// Control flow (sub-B). 0xFF as an action opcode introduces an IF
// expression; the test list is terminated by an inner 0xFF, followed
// by a 2-byte LE skip offset that jumps past the IF body when the
// test is false. 0xFE is an unconditional relative GOTO (signed 16
// bit), used by the compiler to skip ELSE bodies and to form loops.
#define OP_GOTO                     0xFEu
#define OP_IF                       0xFFu

// Test command IDs (sub-B). Only valid inside an IF test list.
#define TEST_EQUALN                 0x01u
#define TEST_EQUALV                 0x02u
#define TEST_LESSN                  0x03u
#define TEST_LESSV                  0x04u
#define TEST_GREATERN               0x05u
#define TEST_GREATERV               0x06u
#define TEST_ISSET                  0x07u
#define TEST_ISSETV                 0x08u
#define TEST_HAS                    0x09u
#define TEST_OBJ_IN_ROOM            0x0Au
#define TEST_POSN                   0x0Bu
#define TEST_CONTROLLER             0x0Cu
#define TEST_HAVE_KEY               0x0Du
#define TEST_SAID                   0x0Eu
#define TEST_COMPARE_STRINGS        0x0Fu
#define TEST_OBJ_IN_BOX             0x10u
#define TEST_CENTER_POSN            0x11u
#define TEST_RIGHT_POSN             0x12u
#define TEST_MAX                    0x13u

// Markers inside an IF test list.
#define TEST_END_LIST               0xFFu
#define TEST_OR_TOGGLE              0xFCu
#define TEST_NOT                    0xFDu

// Side-effecting opcodes wired to host callbacks in sub-D. Each has
// a real case in the dispatch switch; the host's callback table
// decides what (if anything) actually happens for the call.
#define OP_NEW_ROOM                 0x12u
#define OP_NEW_ROOM_V               0x13u
#define OP_LOAD_LOGIC               0x14u
#define OP_LOAD_LOGIC_V             0x15u
#define OP_CALL                     0x16u
#define OP_CALL_V                   0x17u
#define OP_LOAD_PIC                 0x18u
#define OP_DRAW_PIC                 0x19u
#define OP_SHOW_PIC                 0x1Au
#define OP_DISCARD_PIC              0x1Bu
#define OP_OVERLAY_PIC              0x1Cu

// Object-table opcodes (animate.obj, set.view, ..., release.priority).
// These mutate the per-object slots in vm->objects[N]; per-frame
// rendering and per-cycle animation tick are handled outside the VM.
#define OP_LOAD_VIEW                0x1Eu
#define OP_LOAD_VIEW_V              0x1Fu
#define OP_DISCARD_VIEW             0x20u
#define OP_ANIMATE_OBJ              0x21u
#define OP_UNANIMATE_ALL            0x22u
#define OP_DRAW                     0x23u
#define OP_ERASE                    0x24u
#define OP_POSITION                 0x25u
#define OP_POSITION_V               0x26u
#define OP_GET_POSN                 0x27u
#define OP_REPOSITION               0x28u
#define OP_SET_VIEW                 0x29u
#define OP_SET_VIEW_V               0x2Au
#define OP_SET_LOOP                 0x2Bu
#define OP_SET_LOOP_V               0x2Cu
#define OP_FIX_LOOP                 0x2Du
#define OP_RELEASE_LOOP             0x2Eu
#define OP_SET_CEL                  0x2Fu
#define OP_SET_CEL_V                0x30u
#define OP_LAST_CEL                 0x31u
#define OP_CURRENT_CEL              0x32u
#define OP_CURRENT_LOOP             0x33u
#define OP_CURRENT_VIEW             0x34u
#define OP_NUMBER_OF_LOOPS          0x35u
#define OP_SET_PRIORITY             0x36u
#define OP_SET_PRIORITY_V           0x37u
#define OP_RELEASE_PRIORITY         0x38u
#define OP_GET_PRIORITY             0x39u
#define OP_STOP_UPDATE              0x3Au
#define OP_START_UPDATE             0x3Bu
#define OP_FORCE_UPDATE             0x3Cu
#define OP_IGNORE_HORIZON           0x3Du
#define OP_OBSERVE_HORIZON          0x3Eu
#define OP_SET_HORIZON              0x3Fu
#define OP_OBJECT_ON_WATER          0x40u
#define OP_OBJECT_ON_LAND           0x41u
#define OP_OBJECT_ON_ANYTHING       0x42u
#define OP_IGNORE_OBJS              0x43u
#define OP_OBSERVE_OBJS             0x44u
#define OP_DISTANCE                 0x45u

// Animation cycling.
#define OP_STOP_CYCLING             0x46u
#define OP_START_CYCLING            0x47u
#define OP_NORMAL_CYCLE             0x48u
#define OP_END_OF_LOOP              0x49u
#define OP_REVERSE_CYCLE            0x4Au
#define OP_REVERSE_LOOP             0x4Bu
#define OP_CYCLE_TIME               0x4Cu

// Motion.
#define OP_STOP_MOTION              0x4Du
#define OP_START_MOTION             0x4Eu
#define OP_STEP_SIZE                0x4Fu
#define OP_STEP_TIME                0x50u
#define OP_MOVE_OBJ                 0x51u
#define OP_MOVE_OBJ_V               0x52u
#define OP_FOLLOW_EGO               0x53u
#define OP_WANDER                   0x54u
#define OP_NORMAL_MOTION            0x55u
#define OP_SET_DIR                  0x56u
#define OP_GET_DIR                  0x57u

// Object collision (most stub no-op; engine doesn't model blocking).
#define OP_IGNORE_BLOCKS            0x58u
#define OP_OBSERVE_BLOCKS           0x59u
#define OP_BLOCK                    0x5Au
#define OP_UNBLOCK                  0x5Bu

// Inventory.
#define OP_GET                      0x5Cu
#define OP_GET_V                    0x5Du
#define OP_DROP                     0x5Eu
#define OP_PUT                      0x5Fu
#define OP_PUT_V                    0x60u
#define OP_GET_ROOM_V               0x61u

// Sound (stub for now; satisfies "sound finished" flag immediately).
#define OP_LOAD_SOUND               0x62u
#define OP_SOUND                    0x63u
#define OP_STOP_SOUND               0x64u

// Text.
#define OP_PRINT                    0x65u
#define OP_PRINT_V                  0x66u
#define OP_DISPLAY                  0x67u
#define OP_DISPLAY_V                0x68u
#define OP_CLEAR_LINES              0x69u
#define OP_TEXT_SCREEN              0x6Au
#define OP_GRAPHICS                 0x6Bu
#define OP_SET_CURSOR_CHAR          0x6Cu
#define OP_SET_TEXT_ATTRIBUTE       0x6Du
#define OP_SHAKE_SCREEN             0x6Eu
#define OP_CONFIGURE_SCREEN         0x6Fu
#define OP_STATUS_LINE_ON           0x70u
#define OP_STATUS_LINE_OFF          0x71u
#define OP_SET_STRING               0x72u
#define OP_GET_STRING               0x73u
#define OP_WORD_TO_STRING           0x74u
#define OP_PARSE                    0x75u
#define OP_GET_NUM                  0x76u

// Input.
#define OP_PREVENT_INPUT            0x77u
#define OP_ACCEPT_INPUT             0x78u
#define OP_SET_KEY                  0x79u

// Picture composition.
#define OP_ADD_TO_PIC               0x7Au
#define OP_ADD_TO_PIC_V             0x7Bu

// Misc.
#define OP_STATUS                   0x7Cu
#define OP_SAVE_GAME                0x7Du
#define OP_RESTORE_GAME             0x7Eu
#define OP_INIT_DISK                0x7Fu
#define OP_RESTART_GAME             0x80u
#define OP_SHOW_OBJ                 0x81u
#define OP_RANDOM                   0x82u
#define OP_PROGRAM_CONTROL          0x83u
#define OP_PLAYER_CONTROL           0x84u
#define OP_OBJ_STATUS_V             0x85u
#define OP_QUIT                     0x86u
#define OP_SHOW_MEM                 0x87u
#define OP_PAUSE                    0x88u
#define OP_ECHO_LINE                0x89u
#define OP_CANCEL_LINE              0x8Au
#define OP_INIT_JOY                 0x8Bu
#define OP_TOGGLE_MONITOR           0x8Cu
#define OP_VERSION                  0x8Du
#define OP_SCRIPT_SIZE              0x8Eu
#define OP_SET_GAME_ID              0x8Fu
#define OP_LOG                      0x90u
#define OP_SET_SCAN_START           0x91u
#define OP_RESET_SCAN_START         0x92u
#define OP_REPOSITION_TO            0x93u
#define OP_REPOSITION_TO_V          0x94u
#define OP_TRACE_ON                 0x95u
#define OP_TRACE_INFO               0x96u
#define OP_PRINT_AT                 0x97u
#define OP_PRINT_AT_V               0x98u
#define OP_DISCARD_VIEW_V           0x99u
#define OP_CLEAR_TEXT_RECT          0x9Au
#define OP_SET_UPPER_LEFT           0x9Bu
#define OP_SET_MENU                 0x9Cu
#define OP_SET_MENU_ITEM            0x9Du
#define OP_SUBMIT_MENU              0x9Eu
#define OP_ENABLE_ITEM              0x9Fu
#define OP_DISABLE_ITEM             0xA0u
#define OP_MENU_INPUT               0xA1u
#define OP_SHOW_OBJ_V               0xA2u
#define OP_OPEN_DIALOGUE            0xA3u
#define OP_CLOSE_DIALOGUE           0xA4u
#define OP_MUL_N                    0xA5u
#define OP_MUL_V                    0xA6u
#define OP_DIV_N                    0xA7u
#define OP_DIV_V                    0xA8u
#define OP_CLOSE_WINDOW             0xA9u
#define OP_SET_SIMPLE               0xAAu

// Special vars touched by the engine outside script control.
#define ENG_VAR_EGO_DIR             6u
#define ENG_VAR_EGO_VIEW            16u

// Inventory state lives in vm->vars[88..127] (40 slots) — each slot
// stores the room id where item N is currently (255 = ego inventory).
// AGI v2 fixes the inventory length to about 40 in most games; we
// reserve more (88..255) but cap at 64 items in practice.
#define INV_ROOM_INVENTORY          255u

// Sentinel value in kActionArgBytes meaning "no v2 opcode at this
// index"; the dispatcher halts when it sees this in the table.
#define ACTION_UNKNOWN              0xFFu


#ifdef JOEYLIB_PLATFORM_IIGS
#endif


// Result of one opcode-group handler in the split agiVmRun dispatch.
// VM_DISPATCH_CONTINUE: opcode handled, advance to the next. RETURN:
// handled, stop and let agiVmRun return vm->haltReason. UNHANDLED: opcode
// is owned by a later group's handler.
typedef enum {
    VM_DISPATCH_CONTINUE  = 0,
    VM_DISPATCH_RETURN    = 1,
    VM_DISPATCH_UNHANDLED = 2
} VmDispatchE;


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

static bool     enterLogic(AgiVmT *vm, uint8_t logicId);
static bool     evalOneTest(AgiVmT *vm, uint8_t testOp);
static bool     evalTests(AgiVmT *vm);
static bool     executeGoto(AgiVmT *vm);
static bool     executeIf(AgiVmT *vm);
static void     expandMessage(AgiVmT *vm, const char *src, char *dst, uint16_t dstCap);
static const char *fetchMessageOrEmpty(AgiVmT *vm, uint8_t logicId, uint8_t msgId);
static void     openPrintModal(AgiVmT *vm, const char *expanded);
static bool     popFrame(AgiVmT *vm);
static bool     pushFrame(AgiVmT *vm);
static bool     readByte(AgiVmT *vm, uint8_t *out);
static bool     readU16(AgiVmT *vm, uint16_t *out);
static bool     saidMatch(const AgiVmT *vm, const uint16_t *pat, uint8_t patCount);
static VmDispatchE vmStepCore(AgiVmT *vm, uint8_t op);
static VmDispatchE vmStepMisc(AgiVmT *vm, uint8_t op);
static VmDispatchE vmStepObject(AgiVmT *vm, uint8_t op);
static VmDispatchE vmStepText(AgiVmT *vm, uint8_t op);
static void     writeTextRow(AgiVmT *vm, uint8_t row, uint8_t col, const char *expanded);


// Per-action-opcode argument byte counts, indexed by opcode id. The
// real handlers (arithmetic / flags / IF / GOTO / new.room) consult
// this only as a sanity check; unrecognized opcodes (entries set to
// ACTION_UNKNOWN) halt the VM with AGI_VM_HALT_UNKNOWN_OP. Every other
// entry is treated as a stub: its argument bytes are consumed and no
// side effect is taken. This lets the VM walk arbitrary v2 logic
// without halting at every yet-to-be-implemented side-effecting
// command (load.pic / animate.obj / sound / ...).
static const uint8_t kActionArgBytes[256] = {
    /* 0x00 */ 0u, 1u, 1u, 2u, 2u, 2u, 2u, 2u, 2u, 2u, 2u, 2u, 1u, 1u, 1u, 1u,
    /* 0x10 */ 1u, 1u, 1u, 1u, 1u, 1u, 1u, 1u, 1u, 1u, 0u, 1u, 1u, 0u, 1u, 1u,
    /* 0x20 */ 1u, 1u, 0u, 1u, 1u, 3u, 3u, 3u, 3u, 2u, 2u, 2u, 2u, 1u, 1u, 2u,
    /* 0x30 */ 2u, 2u, 2u, 2u, 2u, 2u, 2u, 2u, 1u, 2u, 1u, 1u, 1u, 1u, 1u, 1u,
    /* 0x40 */ 1u, 1u, 1u, 1u, 1u, 3u, 1u, 1u, 1u, 2u, 1u, 2u, 2u, 1u, 1u, 2u,
    /* 0x50 */ 2u, 5u, 5u, 3u, 1u, 1u, 2u, 2u, 1u, 1u, 4u, 0u, 1u, 1u, 1u, 2u,
    /* 0x60 */ 2u, 2u, 1u, 2u, 0u, 1u, 1u, 3u, 3u, 3u, 0u, 0u, 1u, 2u, 1u, 3u,
    /* 0x70 */ 0u, 0u, 2u, 5u, 2u, 1u, 2u, 0u, 0u, 3u, 7u, 7u, 0u, 0u, 0u, 0u,
    /* 0x80 */ 0u, 1u, 3u, 0u, 0u, 1u, 1u, 0u, 0u, 0u, 0u, 0u, 0u, 0u, 1u, 1u,
    /* 0x90 */ 1u, 0u, 0u, 3u, 3u, 0u, 3u, 4u, 4u, 1u, 4u, 2u, 1u, 2u, 0u, 1u,
    /* 0xA0 */ 1u, 0u, 1u, 0u, 0u, 2u, 2u, 2u, 2u, 0u, 1u, 0u, 0u, 0u, 1u, 1u,
    /* 0xB0 */ 0u, 1u, 0u, 4u, 2u, 0u, 0u,
                                          ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN,
    /* 0xBA */ ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN,
    /* 0xC0 */ ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN,
    /* 0xC8 */ ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN,
    /* 0xD0 */ ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN,
    /* 0xD8 */ ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN,
    /* 0xE0 */ ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN,
    /* 0xE8 */ ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN,
    /* 0xF0 */ ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN,
    /* 0xF8 */ ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN
};


// Per-test-command argument byte counts, indexed by test opcode id.
// 0x0E (said) is variable-length and gets special-cased rather than
// indexed via this table.
static const uint8_t kTestArgBytes[TEST_MAX] = {
    /* 0x00 */ 0u,
    /* 0x01 equaln           */ 2u,
    /* 0x02 equalv           */ 2u,
    /* 0x03 lessn            */ 2u,
    /* 0x04 lessv            */ 2u,
    /* 0x05 greatern         */ 2u,
    /* 0x06 greaterv         */ 2u,
    /* 0x07 isset            */ 1u,
    /* 0x08 issetv           */ 1u,
    /* 0x09 has              */ 1u,
    /* 0x0A obj.in.room      */ 2u,
    /* 0x0B posn             */ 5u,
    /* 0x0C controller       */ 1u,
    /* 0x0D have.key         */ 0u,
    /* 0x0E said             */ 0u,
    /* 0x0F compare.strings  */ 2u,
    /* 0x10 obj.in.box       */ 5u,
    /* 0x11 center.posn      */ 5u,
    /* 0x12 right.posn       */ 5u
};


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

// Switch the VM's running logic to `logicId`. Used by OP_CALL and
// OP_CALL_V after pushFrame has saved the caller's position. Returns
// false (and sets haltReason) if the host has no bytecode for the
// requested id.
static bool enterLogic(AgiVmT *vm, uint8_t logicId) {
    const uint8_t  *bytecode;
    uint16_t        length;

    if (vm->callbacks.fetchLogic == NULL) {
        vm->haltReason = AGI_VM_HALT_NO_LOGIC;
        return false;
    }
    length   = 0u;
    bytecode = vm->callbacks.fetchLogic(vm->callbacks.ctx, logicId, &length);
    if (bytecode == NULL || length == 0u) {
        vm->haltReason = AGI_VM_HALT_NO_LOGIC;
        return false;
    }
    vm->code           = bytecode;
    vm->codeLength     = length;
    vm->pc             = 0u;
    vm->currentLogicId = logicId;
    return true;
}


// Evaluate a single test command, with pc currently positioned at
// the first argument byte. Always consumes the test's full argument
// list so the caller's stream position stays in sync, even when the
// test is stubbed to a constant false. Tests this sub-phase doesn't
// implement (objects, parser, controllers, key state) read their
// args and return false; the VM walks past the IF body as if the
// test evaluated against fresh game state.
static bool evalOneTest(AgiVmT *vm, uint8_t testOp) {
    uint8_t  args[5];
    uint8_t  i;
    uint8_t  argBytes;

    if (testOp == 0u || testOp >= TEST_MAX) {
        vm->lastUnknownOp = testOp;
        vm->haltReason    = AGI_VM_HALT_UNKNOWN_OP;
        return false;
    }

    if (testOp == TEST_SAID) {
        // Variable-length: 1-byte word count, then that many 16-bit
        // little-endian word IDs. Read the pattern (always, to keep pc in
        // sync) then match it against what the player typed this cycle.
        uint16_t  pat[AGI_MAX_PARSED];
        uint8_t   count;
        uint8_t   lo;
        uint8_t   hi;
        uint8_t   k;
        bool      matched;

        if (!readByte(vm, &count)) {
            return false;
        }
        for (k = 0u; k < count; k++) {
            if (!readByte(vm, &lo) || !readByte(vm, &hi)) {
                return false;
            }
            if (k < AGI_MAX_PARSED) {
                pat[k] = (uint16_t)((uint16_t)lo | ((uint16_t)hi << 8));
            }
        }
        // said() fires only on the cycle input was entered; a match consumes
        // that input (clears flag 2) so no later said() double-handles it.
        if (vm->flags[AGI_FLAG_INPUT] == 0u || count > AGI_MAX_PARSED) {
            return false;
        }
        matched = saidMatch(vm, pat, count);
        if (matched) {
            vm->flags[AGI_FLAG_INPUT] = 0u;
        }
        return matched;
    }

    argBytes = kTestArgBytes[testOp];
    for (i = 0u; i < argBytes; i++) {
        if (!readByte(vm, &args[i])) {
            return false;
        }
    }

    switch (testOp) {
        case TEST_EQUALN:    return vm->vars[args[0]] == args[1];
        case TEST_EQUALV:    return vm->vars[args[0]] == vm->vars[args[1]];
        case TEST_LESSN:     return vm->vars[args[0]] <  args[1];
        case TEST_LESSV:     return vm->vars[args[0]] <  vm->vars[args[1]];
        case TEST_GREATERN:  return vm->vars[args[0]] >  args[1];
        case TEST_GREATERV:  return vm->vars[args[0]] >  vm->vars[args[1]];
        case TEST_ISSET:     return vm->flags[args[0]] != 0u;
        case TEST_ISSETV:    return vm->flags[vm->vars[args[0]]] != 0u;
        case TEST_HAVE_KEY:
            if (vm->callbacks.haveKey != NULL) {
                return vm->callbacks.haveKey(vm->callbacks.ctx);
            }
            return false;
        case TEST_CONTROLLER: {
            uint8_t  c = args[0];
            if (c < AGI_MAX_CONTROLLERS && vm->controllerFired[c] != 0u) {
                vm->controllerFired[c] = 0u;
                return true;
            }
            return false;
        }
        case TEST_POSN: {
            uint8_t       n  = args[0];
            int16_t       x1 = (int16_t)args[1];
            int16_t       y1 = (int16_t)args[2];
            int16_t       x2 = (int16_t)args[3];
            int16_t       y2 = (int16_t)args[4];
            const AgiObjectT *o;

            if (n >= AGI_MAX_OBJECTS) return false;
            o = &vm->objects[n];
            return (o->x >= x1 && o->x <= x2 && o->y >= y1 && o->y <= y2);
        }
        case TEST_OBJ_IN_BOX: {
            // Same shape as posn but checks the *entire* cel bbox is
            // inside the box. Without view dimensions in the VM we
            // approximate with point containment (== posn).
            uint8_t       n  = args[0];
            int16_t       x1 = (int16_t)args[1];
            int16_t       y1 = (int16_t)args[2];
            int16_t       x2 = (int16_t)args[3];
            int16_t       y2 = (int16_t)args[4];
            const AgiObjectT *o;

            if (n >= AGI_MAX_OBJECTS) return false;
            o = &vm->objects[n];
            return (o->x >= x1 && o->x <= x2 && o->y >= y1 && o->y <= y2);
        }
        case TEST_CENTER_POSN: {
            uint8_t       n  = args[0];
            int16_t       x1 = (int16_t)args[1];
            int16_t       y1 = (int16_t)args[2];
            int16_t       x2 = (int16_t)args[3];
            int16_t       y2 = (int16_t)args[4];
            const AgiObjectT *o;

            if (n >= AGI_MAX_OBJECTS) return false;
            o = &vm->objects[n];
            return (o->x >= x1 && o->x <= x2 && o->y >= y1 && o->y <= y2);
        }
        case TEST_RIGHT_POSN: {
            uint8_t       n  = args[0];
            int16_t       x1 = (int16_t)args[1];
            int16_t       y1 = (int16_t)args[2];
            int16_t       x2 = (int16_t)args[3];
            int16_t       y2 = (int16_t)args[4];
            const AgiObjectT *o;

            if (n >= AGI_MAX_OBJECTS) return false;
            o = &vm->objects[n];
            return (o->x >= x1 && o->x <= x2 && o->y >= y1 && o->y <= y2);
        }
        case TEST_HAS:
            // Inventory: var[88+arg] is the item room (255 = held).
            // Without OBJECT-table parsing, default to "not held".
            return false;
        case TEST_OBJ_IN_ROOM:
            // Same: defaults to false until OBJECT support lands.
            return false;
        default:             return false;
    }
}


// Walk the IF test list at pc. Default operator is AND; 0xFC toggles
// in/out of an OR group whose result is then ANDed into the running
// AND. 0xFD prefixes the next test with NOT. 0xFF terminates the
// list. Leaves pc positioned right after the terminator so the
// caller can read the 2-byte skip offset.
static bool evalTests(AgiVmT *vm) {
    bool     andResult;
    bool     orResult;
    bool     inOrGroup;
    bool     notNext;
    bool     testResult;
    uint8_t  op;

    andResult = true;
    orResult  = false;
    inOrGroup = false;
    notNext   = false;

    for (;;) {
        if (!readByte(vm, &op)) {
            return false;
        }
        if (op == TEST_END_LIST) {
            if (inOrGroup) {
                andResult = (bool)(andResult && orResult);
            }
            return andResult;
        }
        if (op == TEST_OR_TOGGLE) {
            if (inOrGroup) {
                andResult = (bool)(andResult && orResult);
                inOrGroup = false;
            } else {
                orResult  = false;
                inOrGroup = true;
            }
            continue;
        }
        if (op == TEST_NOT) {
            notNext = (bool)!notNext;
            continue;
        }

        testResult = evalOneTest(vm, op);
        if (vm->haltReason != AGI_VM_HALT_NONE) {
            return false;
        }
        if (notNext) {
            testResult = (bool)!testResult;
            notNext    = false;
        }
        if (inOrGroup) {
            orResult = (bool)(orResult || testResult);
        } else {
            andResult = (bool)(andResult && testResult);
        }
    }
}


// Unconditional relative jump. Offset is signed 16-bit; negative
// values allow backward jumps for loops. 16-bit arithmetic with
// separate positive / negative paths avoids dragging int32 into the
// 65816 build.
// Substitute AGI v2 message placeholders into an output buffer.
// Recognizes %v<num> (decimal var value), %0<num> (decimal var with
// leading zero pad), %s<num> (game string slot), %m<num> (recursive
// message lookup in current logic). Unknown sequences are passed
// through verbatim. Always NUL-terminates within dstCap.
static void expandMessage(AgiVmT *vm, const char *src, char *dst, uint16_t dstCap) {
    uint16_t  out;
    uint8_t   pad;
    uint8_t   varIdx;
    uint8_t   value;
    uint8_t   tmp[4];
    int8_t    digit;
    char      ch;

    out = 0u;
    if (dstCap == 0u) {
        return;
    }
    while (src != NULL && *src != '\0' && out + 1u < dstCap) {
        ch = *src++;
        if (ch != '%') {
            dst[out++] = ch;
            continue;
        }
        if (*src == '\0') {
            dst[out++] = '%';
            break;
        }
        {
            char  tag = *src++;
            uint8_t  arg = 0u;
            uint8_t  digitsRead = 0u;

            while (*src >= '0' && *src <= '9' && digitsRead < 3u) {
                arg = (uint8_t)(arg * 10u + (uint8_t)(*src - '0'));
                src++;
                digitsRead++;
            }
            if (digitsRead == 0u) {
                if (out + 2u < dstCap) {
                    dst[out++] = '%';
                    dst[out++] = tag;
                }
                continue;
            }
            switch (tag) {
                case 'v': case '0':
                    pad   = (tag == '0') ? 1u : 0u;
                    varIdx = arg;
                    value  = vm->vars[varIdx];
                    digit  = 0;
                    do {
                        tmp[digit++] = (uint8_t)(value % 10u);
                        value = (uint8_t)(value / 10u);
                    } while (value != 0u && digit < 3);
                    while (pad && digit < 3) {
                        tmp[digit++] = 0u;
                    }
                    while (digit > 0 && out + 1u < dstCap) {
                        digit--;
                        dst[out++] = (char)('0' + tmp[digit]);
                    }
                    break;
                case 's':
                    if (arg < AGI_MAX_STRINGS) {
                        const char *s = vm->strings[arg];
                        while (*s != '\0' && out + 1u < dstCap) {
                            dst[out++] = *s++;
                        }
                    }
                    break;
                case 'm': {
                    const char *nested = fetchMessageOrEmpty(vm, vm->currentLogicId, arg);
                    char        scratch[AGI_PRINT_MAX_LEN + 1u];

                    expandMessage(vm, nested, scratch, sizeof(scratch));
                    {
                        const char *s = scratch;
                        while (*s != '\0' && out + 1u < dstCap) {
                            dst[out++] = *s++;
                        }
                    }
                    break;
                }
                case 'g':
                case 'w':
                    // %g (logic 0 message — global) and %w (parser word)
                    // — fall back to no-op so the script doesn't see
                    // garbage substitutions.
                    break;
                default:
                    if (out + 2u < dstCap) {
                        dst[out++] = '%';
                        dst[out++] = tag;
                    }
                    break;
            }
        }
    }
    dst[out] = '\0';
}


static bool executeGoto(AgiVmT *vm) {
    uint16_t  raw;
    int16_t   signedOffset;
    uint16_t  magnitude;

    if (!readU16(vm, &raw)) {
        return false;
    }
    signedOffset = (int16_t)raw;
    if (signedOffset >= 0) {
        magnitude = (uint16_t)signedOffset;
        if (magnitude > (uint16_t)(vm->codeLength - vm->pc)) {
            vm->haltReason = AGI_VM_HALT_TRUNCATED;
            return false;
        }
        vm->pc = (uint16_t)(vm->pc + magnitude);
    } else {
        magnitude = (uint16_t)(-signedOffset);
        if (magnitude > vm->pc) {
            vm->haltReason = AGI_VM_HALT_TRUNCATED;
            return false;
        }
        vm->pc = (uint16_t)(vm->pc - magnitude);
    }
    return true;
}


static bool executeIf(AgiVmT *vm) {
    bool      cond;
    uint16_t  skip;

    cond = evalTests(vm);
    if (vm->haltReason != AGI_VM_HALT_NONE) {
        return false;
    }
    if (!readU16(vm, &skip)) {
        return false;
    }
    if (!cond) {
        if (skip > (uint16_t)(vm->codeLength - vm->pc)) {
            vm->haltReason = AGI_VM_HALT_TRUNCATED;
            return false;
        }
        vm->pc = (uint16_t)(vm->pc + skip);
    }
    return true;
}


static const char *fetchMessageOrEmpty(AgiVmT *vm, uint8_t logicId, uint8_t msgId) {
    const char  *msg;

    if (vm->callbacks.fetchMessage == NULL) {
        return "";
    }
    msg = vm->callbacks.fetchMessage(vm->callbacks.ctx, logicId, msgId);
    return (msg != NULL) ? msg : "";
}


// AGI print() default text-window width: ~30 columns. Sierra's
// interpreter word-wraps the message at this width before measuring
// the window. Without wrapping a long single-line message (e.g.
// KQ3's "Gwydion is a lonely lad...") overflows the screen edge.
#define AGI_PRINT_WRAP_COLS  30u


static void openPrintModal(AgiVmT *vm, const char *expanded) {
    uint16_t  i;
    uint16_t  out;
    uint16_t  cap;
    uint16_t  lineStart;       // index in printModal.message where current line begins
    uint16_t  lastSpace;       // index of last space char in current line (0 = none yet)
    uint16_t  col;             // visible columns on current line

    cap = AGI_PRINT_MAX_LEN;
    if (expanded == NULL) {
        vm->printModal.message[0] = '\0';
        vm->printModal.length     = 0u;
        vm->printModal.active = true;
        vm->haltReason        = AGI_VM_HALT_PRINT_PENDING;
        return;
    }

    // Stream characters from expanded into printModal.message, breaking
    // at AGI_PRINT_WRAP_COLS by replacing the last space on the line
    // with '\n'. Preserves explicit '\n' from the source message.
    out       = 0u;
    lineStart = 0u;
    lastSpace = 0u;
    col       = 0u;
    i         = 0u;
    while (expanded[i] != '\0' && out < cap - 1u) {
        char  c = expanded[i++];
        if (c == '\n') {
            vm->printModal.message[out++] = '\n';
            lineStart = out;
            lastSpace = 0u;
            col       = 0u;
            continue;
        }
        vm->printModal.message[out++] = c;
        if (c == ' ') {
            lastSpace = (uint16_t)(out - 1u);
        }
        col++;
        if (col >= AGI_PRINT_WRAP_COLS) {
            if (lastSpace != 0u && lastSpace >= lineStart) {
                // Replace the last space on this line with '\n' so
                // the partial word that followed wraps to the next.
                vm->printModal.message[lastSpace] = '\n';
                col       = (uint16_t)(out - lastSpace - 1u);
                lineStart = (uint16_t)(lastSpace + 1u);
                lastSpace = 0u;
            } else {
                // No space to break on: hard-wrap mid-token.
                if (out < cap - 1u) {
                    vm->printModal.message[out++] = '\n';
                }
                lineStart = out;
                lastSpace = 0u;
                col       = 0u;
            }
        }
    }
    vm->printModal.message[out] = '\0';
    vm->printModal.length       = out;
    vm->printModal.active = true;
    vm->haltReason        = AGI_VM_HALT_PRINT_PENDING;
}


// Restore the most recently pushed frame as the active context.
// Caller (OP_RETURN at depth > 0) has already confirmed callDepth > 0.
static bool popFrame(AgiVmT *vm) {
    const AgiVmFrameT  *f;

    vm->callDepth--;
    f = &vm->callStack[vm->callDepth];
    vm->code           = f->code;
    vm->codeLength     = f->codeLength;
    vm->pc             = f->pc;
    vm->currentLogicId = f->logicId;
    return true;
}


// Save the active context onto the call stack so OP_CALL can switch
// to a different logic. Returns false (and halts) if the stack is
// already full -- AGI v2 games keep call depth shallow so a small
// fixed-size stack is sufficient.
static bool pushFrame(AgiVmT *vm) {
    AgiVmFrameT  *f;

    if (vm->callDepth >= AGI_VM_CALL_STACK_MAX) {
        vm->haltReason = AGI_VM_HALT_STACK_OVER;
        return false;
    }
    f = &vm->callStack[vm->callDepth];
    f->code        = vm->code;
    f->codeLength  = vm->codeLength;
    f->pc          = vm->pc;
    f->logicId     = vm->currentLogicId;
    vm->callDepth++;
    return true;
}


static bool readByte(AgiVmT *vm, uint8_t *out) {
    if (vm->pc >= vm->codeLength) {
        vm->haltReason = AGI_VM_HALT_TRUNCATED;
        return false;
    }
    *out = vm->code[vm->pc];
    vm->pc++;
    return true;
}


static bool readU16(AgiVmT *vm, uint16_t *out) {
    uint8_t  lo;
    uint8_t  hi;

    if (!readByte(vm, &lo)) {
        return false;
    }
    if (!readByte(vm, &hi)) {
        return false;
    }
    *out = (uint16_t)(lo | ((uint16_t)hi << 8));
    return true;
}


// Match the parsed player command against a said() word pattern.
//   AGI_WORD_REST  (9999) matches the rest of the line (including nothing);
//   AGI_WORD_ANYWORD (1)  matches exactly one arbitrary word;
//   any other id matches that exact group id.
// An exact match requires every typed word to be consumed by the pattern.
static bool saidMatch(const AgiVmT *vm, const uint16_t *pat, uint8_t patCount) {
    uint8_t  pi;
    uint8_t  wi;

    pi = 0u;
    wi = 0u;
    while (pi < patCount) {
        uint16_t  pw;

        pw = pat[pi];
        if (pw == AGI_WORD_REST) {
            return true;
        }
        if (wi >= vm->parsedWordCount) {
            return false;
        }
        if (pw != AGI_WORD_ANYWORD && pw != vm->parsedWords[wi]) {
            return false;
        }
        wi++;
        pi++;
    }
    return wi == vm->parsedWordCount;
}


static void writeTextRow(AgiVmT *vm, uint8_t row, uint8_t col, const char *expanded) {
    uint16_t  i;
    uint16_t  off;

    if (row >= AGI_TEXT_ROWS || col >= AGI_TEXT_COLS) {
        return;
    }
    if (expanded == NULL) {
        return;
    }
    // Pad text[0..col-1] with spaces. Otherwise the row starts with
    // '\0' and agiTextRender skips the whole row -- display(r,c,m)
    // with c>0 produces no visible text.
    for (i = 0u; i < (uint16_t)col; i++) {
        if (vm->textRows[row].text[i] == '\0') {
            vm->textRows[row].text[i] = ' ';
        }
    }
    off = col;
    i   = 0u;
    while (expanded[i] != '\0' && off < AGI_TEXT_COLS) {
        if (expanded[i] == '\n') {
            // newline — clear rest of row, advance to next row col 0
            while (off < AGI_TEXT_COLS) {
                vm->textRows[row].text[off++] = ' ';
            }
            vm->textRows[row].text[AGI_TEXT_COLS] = '\0';
            row++;
            off = 0u;
            if (row >= AGI_TEXT_ROWS) {
                return;
            }
        } else {
            vm->textRows[row].text[off++] = expanded[i];
        }
        i++;
    }
    vm->textRows[row].text[AGI_TEXT_COLS] = '\0';
    vm->textRows[row].fg = vm->textFg;
    vm->textRows[row].bg = vm->textBg;
}


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

void agiVmAckPrint(AgiVmT *vm) {
    vm->printModal.active = false;
    vm->printModal.length = 0u;
    vm->printModal.message[0] = '\0';
    if (vm->haltReason == AGI_VM_HALT_PRINT_PENDING) {
        vm->haltReason = AGI_VM_HALT_NONE;
    }
}


void agiVmDispatchKey(AgiVmT *vm, uint8_t key1, uint8_t key2) {
    uint8_t  i;

    for (i = 0u; i < (uint8_t)AGI_MAX_KEY_BINDINGS; i++) {
        AgiKeyBindingT  *b;

        b = &vm->keyBindings[i];
        if (b->active == 0u) {
            continue;
        }
        if (b->key1 == key1 && b->key2 == key2) {
            if (b->controller < AGI_MAX_CONTROLLERS) {
                vm->controllerFired[b->controller] = 1u;
            }
        }
    }
}


void agiVmInit(AgiVmT *vm) {
    uint8_t  i;

    memset(vm, 0, sizeof(*vm));
    vm->haltReason   = AGI_VM_HALT_NONE;
    vm->statusLineOn = false;
    vm->statusLineRow = 0u;
    vm->textFg = 15u;
    vm->textBg = 0u;
    vm->horizon = 36u;          // canonical AGI default
    vm->programControl = false;
    vm->acceptInput    = false;
    vm->menuOpen       = 0xFFu;
    for (i = 0u; i < (uint8_t)AGI_TEXT_ROWS; i++) {
        vm->textRows[i].text[0] = '\0';
        vm->textRows[i].fg      = 15u;
        vm->textRows[i].bg      = 0u;
    }
    agiObjResetAll(vm);
}


bool agiVmLoadLogic(AgiVmT *vm, const uint8_t *resourceBytes, uint16_t resourceLength) {
    uint16_t  bytecodeLen;

    if (resourceLength < AGI_LOGIC_HDR_BYTES) {
        return false;
    }
    bytecodeLen = (uint16_t)(resourceBytes[0] | ((uint16_t)resourceBytes[1] << 8));
    if ((uint16_t)(AGI_LOGIC_HDR_BYTES + bytecodeLen) > resourceLength) {
        return false;
    }
    vm->code           = resourceBytes + AGI_LOGIC_HDR_BYTES;
    vm->codeLength     = bytecodeLen;
    vm->pc             = 0u;
    vm->haltReason     = AGI_VM_HALT_NONE;
    vm->lastUnknownOp  = 0u;
    vm->newRoomId      = 0u;
    vm->currentLogicId = 0u;
    vm->callDepth      = 0u;
    return true;
}


void agiVmResetToLogic(AgiVmT *vm, const uint8_t *bytecode, uint16_t bytecodeLength, uint8_t logicId) {
    vm->code           = bytecode;
    vm->codeLength     = bytecodeLength;
    vm->pc             = 0u;
    vm->haltReason     = AGI_VM_HALT_NONE;
    vm->lastUnknownOp  = 0u;
    vm->newRoomId      = 0u;
    vm->currentLogicId = logicId;
    vm->callDepth      = 0u;
}


AgiVmHaltE agiVmRun(AgiVmT *vm) {
    uint8_t      op;
    VmDispatchE  res;

    while (vm->haltReason == AGI_VM_HALT_NONE) {
        if (!readByte(vm, &op)) {
            // readByte sets haltReason = TRUNCATED on out-of-range.
            return vm->haltReason;
        }
        res = vmStepCore(vm, op);
        if (res == VM_DISPATCH_UNHANDLED) {
            res = vmStepObject(vm, op);
        }
        if (res == VM_DISPATCH_UNHANDLED) {
            res = vmStepText(vm, op);
        }
        if (res == VM_DISPATCH_UNHANDLED) {
            res = vmStepMisc(vm, op);
        }
        if (res == VM_DISPATCH_RETURN) {
            return vm->haltReason;
        }
    }
    return vm->haltReason;
}


// Opcode dispatch is partitioned across four __attribute__((noinline))
// handlers so the llvm-mos w65816 register allocator is never asked to
// colour the whole 1200-line switch at once (it ran out of registers when
// it was a single function). Each handler returns VM_DISPATCH_CONTINUE
// (advance to the next opcode), VM_DISPATCH_RETURN (stop; agiVmRun returns
// vm->haltReason), or VM_DISPATCH_UNHANDLED (opcode is owned by a later
// group). vmStepMisc owns the default fallback and never returns UNHANDLED.
// Behaviour is byte-for-byte identical to the original monolithic switch.
//
// Group 1: RETURN, arithmetic/flags (0x00-0x11), IF/GOTO, new.room,
// load.logic, call, and the picture-cache opcodes (0x12-0x1C).
static VmDispatchE __attribute__((noinline)) vmStepCore(AgiVmT *vm, uint8_t op) {
    uint8_t  a;
    uint8_t  b;

        switch (op) {
            case OP_RETURN:
                if (vm->callDepth > 0u) {
                    popFrame(vm);
                } else {
                    vm->haltReason = AGI_VM_HALT_RETURN;
                }
                break;

            case OP_INCREMENT:
                if (!readByte(vm, &a)) {
                    return VM_DISPATCH_RETURN;
                }
                // AGI spec: increment clamps at 255.
                if (vm->vars[a] != 0xFFu) {
                    vm->vars[a] = (uint8_t)(vm->vars[a] + 1u);
                }
                break;

            case OP_DECREMENT:
                if (!readByte(vm, &a)) {
                    return VM_DISPATCH_RETURN;
                }
                // AGI spec: decrement clamps at 0 (does NOT wrap).
                // Sparkle counters like v221..v224 stay at 0 once
                // assigned 0, which is how the wizard scene suppresses
                // the logo-sparkle loop until sound 22 ends.
                if (vm->vars[a] != 0u) {
                    vm->vars[a] = (uint8_t)(vm->vars[a] - 1u);
                }
                break;

            case OP_ASSIGNN:
                if (!readByte(vm, &a) || !readByte(vm, &b)) {
                    return VM_DISPATCH_RETURN;
                }
                vm->vars[a] = b;
                break;

            case OP_ASSIGNV:
                if (!readByte(vm, &a) || !readByte(vm, &b)) {
                    return VM_DISPATCH_RETURN;
                }
                vm->vars[a] = vm->vars[b];
                break;

            case OP_ADDN:
                if (!readByte(vm, &a) || !readByte(vm, &b)) {
                    return VM_DISPATCH_RETURN;
                }
                vm->vars[a] = (uint8_t)(vm->vars[a] + b);
                break;

            case OP_ADDV:
                if (!readByte(vm, &a) || !readByte(vm, &b)) {
                    return VM_DISPATCH_RETURN;
                }
                vm->vars[a] = (uint8_t)(vm->vars[a] + vm->vars[b]);
                break;

            case OP_SUBN:
                if (!readByte(vm, &a) || !readByte(vm, &b)) {
                    return VM_DISPATCH_RETURN;
                }
                vm->vars[a] = (uint8_t)(vm->vars[a] - b);
                break;

            case OP_SUBV:
                if (!readByte(vm, &a) || !readByte(vm, &b)) {
                    return VM_DISPATCH_RETURN;
                }
                vm->vars[a] = (uint8_t)(vm->vars[a] - vm->vars[b]);
                break;

            case OP_LINDIRECTV:
                // var[a] = var[var[b]]
                if (!readByte(vm, &a) || !readByte(vm, &b)) {
                    return VM_DISPATCH_RETURN;
                }
                vm->vars[a] = vm->vars[vm->vars[b]];
                break;

            case OP_RINDIRECT:
                // var[var[a]] = var[b]
                if (!readByte(vm, &a) || !readByte(vm, &b)) {
                    return VM_DISPATCH_RETURN;
                }
                vm->vars[vm->vars[a]] = vm->vars[b];
                break;

            case OP_LINDIRECTN:
                // var[var[a]] = b   (immediate)
                if (!readByte(vm, &a) || !readByte(vm, &b)) {
                    return VM_DISPATCH_RETURN;
                }
                vm->vars[vm->vars[a]] = b;
                break;

            case OP_SET:
                if (!readByte(vm, &a)) {
                    return VM_DISPATCH_RETURN;
                }
                vm->flags[a] = 1u;
                break;

            case OP_RESET:
                if (!readByte(vm, &a)) {
                    return VM_DISPATCH_RETURN;
                }
                vm->flags[a] = 0u;
                break;

            case OP_TOGGLE:
                if (!readByte(vm, &a)) {
                    return VM_DISPATCH_RETURN;
                }
                vm->flags[a] = (uint8_t)(vm->flags[a] ? 0u : 1u);
                break;

            case OP_SETV:
                if (!readByte(vm, &a)) {
                    return VM_DISPATCH_RETURN;
                }
                vm->flags[vm->vars[a]] = 1u;
                break;

            case OP_RESETV:
                if (!readByte(vm, &a)) {
                    return VM_DISPATCH_RETURN;
                }
                vm->flags[vm->vars[a]] = 0u;
                break;

            case OP_TOGGLEV:
                if (!readByte(vm, &a)) {
                    return VM_DISPATCH_RETURN;
                }
                vm->flags[vm->vars[a]] = (uint8_t)(vm->flags[vm->vars[a]] ? 0u : 1u);
                break;

            case OP_IF:
                if (!executeIf(vm)) {
                    return VM_DISPATCH_RETURN;
                }
                break;

            case OP_GOTO:
                if (!executeGoto(vm)) {
                    return VM_DISPATCH_RETURN;
                }
                break;

            case OP_NEW_ROOM:
                if (!readByte(vm, &a)) {
                    return VM_DISPATCH_RETURN;
                }
                vm->newRoomId  = a;
                vm->haltReason = AGI_VM_HALT_NEW_ROOM;
                jlLogF("OP_NEW_ROOM -> %u (v11=%u v12=%u)",
                       (unsigned)a, (unsigned)vm->vars[11], (unsigned)vm->vars[12]);
                jlLogFlush();
                break;

            case OP_NEW_ROOM_V:
                if (!readByte(vm, &a)) {
                    return VM_DISPATCH_RETURN;
                }
                vm->newRoomId  = vm->vars[a];
                vm->haltReason = AGI_VM_HALT_NEW_ROOM;
                jlLogF("OP_NEW_ROOM_V -> %u (v11=%u v12=%u)",
                       (unsigned)vm->newRoomId, (unsigned)vm->vars[11], (unsigned)vm->vars[12]);
                jlLogFlush();
                break;

            case OP_LOAD_LOGIC:
            case OP_LOAD_LOGIC_V:
                // load.logic just primes the host's cache; the actual
                // bytecode swap happens at OP_CALL time. We touch the
                // callback so callers that prefetch can react.
                if (!readByte(vm, &a)) {
                    return VM_DISPATCH_RETURN;
                }
                if (vm->callbacks.fetchLogic != NULL) {
                    uint8_t   logicId;
                    uint16_t  ignore;

                    logicId = (op == OP_LOAD_LOGIC) ? a : vm->vars[a];
                    ignore  = 0u;
                    (void)vm->callbacks.fetchLogic(vm->callbacks.ctx, logicId, &ignore);
                }
                break;

            case OP_CALL:
            case OP_CALL_V:
                if (!readByte(vm, &a)) {
                    return VM_DISPATCH_RETURN;
                }
                {
                    uint8_t  logicId;

                    logicId = (op == OP_CALL) ? a : vm->vars[a];
                    if (!pushFrame(vm)) {
                        return VM_DISPATCH_RETURN;
                    }
                    if (!enterLogic(vm, logicId)) {
                        // Roll back the pushed frame so RETURN doesn't
                        // pop garbage state.
                        vm->callDepth--;
                        return VM_DISPATCH_RETURN;
                    }
                }
                break;

            case OP_LOAD_PIC:
                if (!readByte(vm, &a)) {
                    return VM_DISPATCH_RETURN;
                }
                if (vm->callbacks.loadPic != NULL) {
                    vm->callbacks.loadPic(vm->callbacks.ctx, vm->vars[a]);
                }
                break;

            case OP_DRAW_PIC:
                if (!readByte(vm, &a)) {
                    return VM_DISPATCH_RETURN;
                }
                if (vm->callbacks.drawPic != NULL) {
                    vm->callbacks.drawPic(vm->callbacks.ctx, vm->vars[a]);
                }
                break;

            case OP_SHOW_PIC:
                if (vm->callbacks.showPic != NULL) {
                    vm->callbacks.showPic(vm->callbacks.ctx);
                }
                break;

            case OP_DISCARD_PIC:
                if (!readByte(vm, &a)) {
                    return VM_DISPATCH_RETURN;
                }
                if (vm->callbacks.discardPic != NULL) {
                    vm->callbacks.discardPic(vm->callbacks.ctx, vm->vars[a]);
                }
                break;

            case OP_OVERLAY_PIC:
                if (!readByte(vm, &a)) {
                    return VM_DISPATCH_RETURN;
                }
                if (vm->callbacks.overlayPic != NULL) {
                    vm->callbacks.overlayPic(vm->callbacks.ctx, vm->vars[a]);
                }
                break;

            default:
                return VM_DISPATCH_UNHANDLED;
        }
        return VM_DISPATCH_CONTINUE;
}


// Group 2: object table (set.view/loop/cel/priority, draw/erase, get.posn,
// etc.), the lumped view-cache and reposition aliases (0x94/0x99),
// object-collision/block opcodes, animation cycling, and motion (0x1E-0x5B).
static VmDispatchE __attribute__((noinline)) vmStepObject(AgiVmT *vm, uint8_t op) {
    uint8_t  a;
    uint8_t  b;
    uint8_t  c;
    uint8_t  d;
    uint8_t  e;

        switch (op) {
            // ----- view cache hints (no-op; host loads on demand) -----
            case OP_LOAD_VIEW:
            case OP_LOAD_VIEW_V:
            case OP_DISCARD_VIEW:
            case OP_DISCARD_VIEW_V:
                if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; }
                break;

            // ----- object table -----
            case OP_ANIMATE_OBJ:
                if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; }
                jlLogF("OP_ANIMATE_OBJ %u", (unsigned)a);
                if (a < AGI_MAX_OBJECTS) {
                    agiObjReset(&vm->objects[a]);
                    vm->objects[a].flags |= AGI_OBJ_FLAG_ANIMATED | AGI_OBJ_FLAG_UPDATING | AGI_OBJ_FLAG_CYCLING;
                }
                break;

            case OP_UNANIMATE_ALL:
                jlLog("OP_UNANIMATE_ALL");
                agiObjResetAll(vm);
                break;

            case OP_DRAW:
                if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; }
                jlLogF("OP_DRAW %u", (unsigned)a);
                if (a < AGI_MAX_OBJECTS) {
                    vm->objects[a].flags |= AGI_OBJ_FLAG_DRAWN;
                }
                break;

            case OP_ERASE:
                if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; }
                jlLogF("OP_ERASE %u", (unsigned)a);
                if (a < AGI_MAX_OBJECTS) {
                    vm->objects[a].flags &= (uint8_t)~AGI_OBJ_FLAG_DRAWN;
                }
                break;

            case OP_POSITION:
                if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c)) {
                    return VM_DISPATCH_RETURN;
                }
                jlLogF("OP_POSITION %u (%u,%u)", (unsigned)a, (unsigned)b, (unsigned)c);
                if (a < AGI_MAX_OBJECTS) {
                    vm->objects[a].x = (int16_t)b;
                    vm->objects[a].y = (int16_t)c;
                }
                break;

            case OP_POSITION_V:
            case OP_REPOSITION_TO_V:
                if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c)) {
                    return VM_DISPATCH_RETURN;
                }
                if (a < AGI_MAX_OBJECTS) {
                    vm->objects[a].x = (int16_t)vm->vars[b];
                    vm->objects[a].y = (int16_t)vm->vars[c];
                    jlLogF("OP_POSITION_V/RTO_V %u <- v%u/v%u = (%d,%d)",
                           (unsigned)a, (unsigned)b, (unsigned)c,
                           (int)vm->objects[a].x, (int)vm->objects[a].y);
                }
                break;

            case OP_GET_POSN:
                if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c)) {
                    return VM_DISPATCH_RETURN;
                }
                if (a < AGI_MAX_OBJECTS) {
                    vm->vars[b] = (uint8_t)vm->objects[a].x;
                    vm->vars[c] = (uint8_t)vm->objects[a].y;
                }
                break;

            case OP_REPOSITION:
                // reposition(N, vdx, vdy) — apply signed deltas (vars hold int8_t-style)
                if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c)) {
                    return VM_DISPATCH_RETURN;
                }
                if (a < AGI_MAX_OBJECTS) {
                    int8_t  dx = (int8_t)vm->vars[b];
                    int8_t  dy = (int8_t)vm->vars[c];
                    vm->objects[a].x = (int16_t)(vm->objects[a].x + dx);
                    vm->objects[a].y = (int16_t)(vm->objects[a].y + dy);
                }
                break;

            case OP_REPOSITION_TO:
                if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c)) {
                    return VM_DISPATCH_RETURN;
                }
                jlLogF("OP_REPOSITION_TO %u (%u,%u)", (unsigned)a, (unsigned)b, (unsigned)c);
                if (a < AGI_MAX_OBJECTS) {
                    vm->objects[a].x = (int16_t)b;
                    vm->objects[a].y = (int16_t)c;
                }
                break;

            case OP_SET_VIEW:
                if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; }
                jlLogF("OP_SET_VIEW obj=%u view=%u", (unsigned)a, (unsigned)b);
                if (a < AGI_MAX_OBJECTS) {
                    vm->objects[a].viewId = b;
                    vm->objects[a].cel    = 0u;
                }
                break;

            case OP_SET_VIEW_V:
                if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; }
                jlLogF("OP_SET_VIEW_V obj=%u view=v%u (=%u)",
                       (unsigned)a, (unsigned)b, (unsigned)vm->vars[b]);
                if (a < AGI_MAX_OBJECTS) {
                    vm->objects[a].viewId = vm->vars[b];
                    vm->objects[a].cel    = 0u;
                }
                break;

            case OP_SET_LOOP:
                if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; }
                jlLogF("OP_SET_LOOP obj=%u loop=%u", (unsigned)a, (unsigned)b);
                if (a < AGI_MAX_OBJECTS) {
                    vm->objects[a].loop = b;
                    vm->objects[a].cel  = 0u;
                }
                break;

            case OP_SET_LOOP_V:
                if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; }
                jlLogF("OP_SET_LOOP_V obj=%u loop=v%u (=%u)",
                       (unsigned)a, (unsigned)b, (unsigned)vm->vars[b]);
                if (a < AGI_MAX_OBJECTS) {
                    vm->objects[a].loop = vm->vars[b];
                    vm->objects[a].cel  = 0u;
                }
                break;

            case OP_FIX_LOOP:
                if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; }
                if (a < AGI_MAX_OBJECTS) { vm->objects[a].flags |= AGI_OBJ_FLAG_LOOP_FIXED; }
                break;

            case OP_RELEASE_LOOP:
                if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; }
                if (a < AGI_MAX_OBJECTS) { vm->objects[a].flags &= (uint8_t)~AGI_OBJ_FLAG_LOOP_FIXED; }
                break;

            case OP_SET_CEL:
                if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; }
                if (a < AGI_MAX_OBJECTS) { vm->objects[a].cel = b; vm->objects[a].cycleTick = 0u; }
                break;

            case OP_SET_CEL_V:
                if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; }
                if (a < AGI_MAX_OBJECTS) { vm->objects[a].cel = vm->vars[b]; vm->objects[a].cycleTick = 0u; }
                break;

            case OP_LAST_CEL:
                if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; }
                if (a < AGI_MAX_OBJECTS && vm->callbacks.viewCelCount != NULL) {
                    uint8_t  c = vm->callbacks.viewCelCount(vm->callbacks.ctx,
                                                            vm->objects[a].viewId,
                                                            vm->objects[a].loop);
                    vm->vars[b] = (c > 0u) ? (uint8_t)(c - 1u) : 0u;
                } else {
                    vm->vars[b] = 0u;
                }
                break;

            case OP_CURRENT_CEL:
                if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; }
                vm->vars[b] = (a < AGI_MAX_OBJECTS) ? vm->objects[a].cel : 0u;
                break;

            case OP_CURRENT_LOOP:
                if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; }
                vm->vars[b] = (a < AGI_MAX_OBJECTS) ? vm->objects[a].loop : 0u;
                break;

            case OP_CURRENT_VIEW:
                if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; }
                vm->vars[b] = (a < AGI_MAX_OBJECTS) ? vm->objects[a].viewId : 0u;
                break;

            case OP_NUMBER_OF_LOOPS:
                if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; }
                if (a < AGI_MAX_OBJECTS && vm->callbacks.viewLoopCount != NULL) {
                    uint8_t  c = vm->callbacks.viewLoopCount(vm->callbacks.ctx,
                                                             vm->objects[a].viewId);
                    vm->vars[b] = (c > 0u) ? c : 1u;
                } else {
                    vm->vars[b] = 1u;
                }
                break;

            case OP_SET_PRIORITY:
                if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; }
                if (a < AGI_MAX_OBJECTS) {
                    vm->objects[a].priority = b;
                    vm->objects[a].flags   |= AGI_OBJ_FLAG_PRI_FIXED;
                }
                break;

            case OP_SET_PRIORITY_V:
                if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; }
                if (a < AGI_MAX_OBJECTS) {
                    vm->objects[a].priority = vm->vars[b];
                    vm->objects[a].flags   |= AGI_OBJ_FLAG_PRI_FIXED;
                }
                break;

            case OP_RELEASE_PRIORITY:
                if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; }
                if (a < AGI_MAX_OBJECTS) { vm->objects[a].flags &= (uint8_t)~AGI_OBJ_FLAG_PRI_FIXED; }
                break;

            case OP_GET_PRIORITY:
                if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; }
                vm->vars[b] = (a < AGI_MAX_OBJECTS) ? vm->objects[a].priority : 0u;
                break;

            case OP_STOP_UPDATE:
                if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; }
                if (a < AGI_MAX_OBJECTS) { vm->objects[a].flags &= (uint8_t)~AGI_OBJ_FLAG_UPDATING; }
                break;

            case OP_START_UPDATE:
                if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; }
                if (a < AGI_MAX_OBJECTS) { vm->objects[a].flags |= AGI_OBJ_FLAG_UPDATING; }
                break;

            case OP_FORCE_UPDATE:
                if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; }
                // Force-redraw hint; we always redraw, so no-op apart from arg consume.
                break;

            case OP_IGNORE_HORIZON:
                if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; }
                if (a < AGI_MAX_OBJECTS) { vm->objects[a].flags |= AGI_OBJ_FLAG_HORIZON_IGN; }
                break;

            case OP_OBSERVE_HORIZON:
                if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; }
                if (a < AGI_MAX_OBJECTS) { vm->objects[a].flags &= (uint8_t)~AGI_OBJ_FLAG_HORIZON_IGN; }
                break;

            case OP_SET_HORIZON:
                if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; }
                vm->horizon = a;
                break;

            case OP_OBJECT_ON_WATER:
            case OP_OBJECT_ON_LAND:
            case OP_OBJECT_ON_ANYTHING:
            case OP_IGNORE_BLOCKS:
            case OP_OBSERVE_BLOCKS:
                if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; }
                break;

            case OP_IGNORE_OBJS:
                if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; }
                if (a < AGI_MAX_OBJECTS) { vm->objects[a].flags |= AGI_OBJ_FLAG_OBJS_IGN; }
                break;

            case OP_OBSERVE_OBJS:
                if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; }
                if (a < AGI_MAX_OBJECTS) { vm->objects[a].flags &= (uint8_t)~AGI_OBJ_FLAG_OBJS_IGN; }
                break;

            case OP_DISTANCE: {
                int16_t  dx;
                int16_t  dy;

                if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c)) {
                    return VM_DISPATCH_RETURN;
                }
                if (a < AGI_MAX_OBJECTS && b < AGI_MAX_OBJECTS) {
                    dx = (int16_t)(vm->objects[a].x - vm->objects[b].x);
                    dy = (int16_t)(vm->objects[a].y - vm->objects[b].y);
                    if (dx < 0) { dx = (int16_t)(-dx); }
                    if (dy < 0) { dy = (int16_t)(-dy); }
                    vm->vars[c] = (uint8_t)((dx + dy > 255) ? 255 : (dx + dy));
                } else {
                    vm->vars[c] = 255u;
                }
                break;
            }

            case OP_BLOCK:
                // 4 args: (x1, y1, x2, y2)
                if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c) || !readByte(vm, &d)) {
                    return VM_DISPATCH_RETURN;
                }
                break;

            case OP_UNBLOCK:
                break;

            // ----- animation cycling -----
            case OP_STOP_CYCLING:
                if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; }
                if (a < AGI_MAX_OBJECTS) { vm->objects[a].flags &= (uint8_t)~AGI_OBJ_FLAG_CYCLING; }
                break;

            case OP_START_CYCLING:
                if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; }
                if (a < AGI_MAX_OBJECTS) { vm->objects[a].flags |= AGI_OBJ_FLAG_CYCLING; }
                break;

            case OP_NORMAL_CYCLE:
                if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; }
                if (a < AGI_MAX_OBJECTS) {
                    vm->objects[a].cycleMode     = AGI_CYCLE_NORMAL;
                    vm->objects[a].endOfLoopFlag = 0u;
                    vm->objects[a].flags        |= AGI_OBJ_FLAG_CYCLING;
                }
                break;

            case OP_END_OF_LOOP:
                if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; }
                if (a < AGI_MAX_OBJECTS) {
                    vm->objects[a].cycleMode     = AGI_CYCLE_END_LOOP;
                    vm->objects[a].endOfLoopFlag = b;
                    vm->objects[a].flags        |= AGI_OBJ_FLAG_CYCLING;
                    vm->flags[b] = 0u;
                }
                break;

            case OP_REVERSE_CYCLE:
                if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; }
                if (a < AGI_MAX_OBJECTS) {
                    vm->objects[a].cycleMode     = AGI_CYCLE_REVERSE;
                    vm->objects[a].endOfLoopFlag = 0u;
                    vm->objects[a].flags        |= AGI_OBJ_FLAG_CYCLING;
                }
                break;

            case OP_REVERSE_LOOP:
                if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; }
                if (a < AGI_MAX_OBJECTS) {
                    vm->objects[a].cycleMode     = AGI_CYCLE_REV_LOOP;
                    vm->objects[a].endOfLoopFlag = b;
                    vm->objects[a].flags        |= AGI_OBJ_FLAG_CYCLING;
                    vm->flags[b] = 0u;
                }
                break;

            case OP_CYCLE_TIME:
                if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; }
                if (a < AGI_MAX_OBJECTS) {
                    vm->objects[a].cycleTime = vm->vars[b];
                    if (vm->objects[a].cycleTime == 0u) { vm->objects[a].cycleTime = 1u; }
                    vm->objects[a].cycleTick = 0u;
                }
                break;

            // ----- motion -----
            case OP_STOP_MOTION:
                if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; }
                if (a < AGI_MAX_OBJECTS) { vm->objects[a].direction = 0u; }
                break;

            case OP_START_MOTION:
                if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; }
                if (a < AGI_MAX_OBJECTS) {
                    if (a == 0u) {
                        vm->objects[0].direction = vm->vars[ENG_VAR_EGO_DIR];
                    } else {
                        // Re-allow motion; direction stays whatever it was.
                    }
                }
                break;

            case OP_STEP_SIZE:
                if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; }
                if (a < AGI_MAX_OBJECTS) {
                    vm->objects[a].stepSize = vm->vars[b];
                    if (vm->objects[a].stepSize == 0u) { vm->objects[a].stepSize = 1u; }
                }
                break;

            case OP_STEP_TIME:
                if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; }
                if (a < AGI_MAX_OBJECTS) {
                    vm->objects[a].stepTime = vm->vars[b];
                    if (vm->objects[a].stepTime == 0u) { vm->objects[a].stepTime = 1u; }
                    vm->objects[a].stepTick = 0u;
                }
                break;

            case OP_MOVE_OBJ:
                // n, x, y, vstep, flag
                if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c) || !readByte(vm, &d) || !readByte(vm, &e)) {
                    return VM_DISPATCH_RETURN;
                }
                jlLogF("OP_MOVE_OBJ %u -> (%u,%u) step=%u doneFlag=%u",
                       (unsigned)a, (unsigned)b, (unsigned)c, (unsigned)d, (unsigned)e);
                if (a < AGI_MAX_OBJECTS) {
                    vm->objects[a].targetX        = (int16_t)b;
                    vm->objects[a].targetY        = (int16_t)c;
                    vm->objects[a].moveStepBackup = vm->objects[a].stepSize;
                    vm->objects[a].stepSize       = (d == 0u) ? vm->objects[a].stepSize : d;
                    vm->objects[a].moveDoneFlag   = e;
                    vm->objects[a].motionType     = AGI_MOTION_MOVE_OBJ;
                    vm->flags[e] = 0u;
                }
                break;

            case OP_MOVE_OBJ_V:
                if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c) || !readByte(vm, &d) || !readByte(vm, &e)) {
                    return VM_DISPATCH_RETURN;
                }
                jlLogF("OP_MOVE_OBJ_V %u -> v%u/v%u=(%u,%u) vstep=v%u=%u doneFlag=%u",
                       (unsigned)a, (unsigned)b, (unsigned)c,
                       (unsigned)vm->vars[b], (unsigned)vm->vars[c],
                       (unsigned)d, (unsigned)vm->vars[d], (unsigned)e);
                if (a < AGI_MAX_OBJECTS) {
                    vm->objects[a].targetX        = (int16_t)vm->vars[b];
                    vm->objects[a].targetY        = (int16_t)vm->vars[c];
                    vm->objects[a].moveStepBackup = vm->objects[a].stepSize;
                    vm->objects[a].stepSize       = (vm->vars[d] == 0u) ? vm->objects[a].stepSize : vm->vars[d];
                    vm->objects[a].moveDoneFlag   = e;
                    vm->objects[a].motionType     = AGI_MOTION_MOVE_OBJ;
                    vm->flags[e] = 0u;
                }
                break;

            case OP_FOLLOW_EGO:
                if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c)) {
                    return VM_DISPATCH_RETURN;
                }
                if (a < AGI_MAX_OBJECTS) {
                    vm->objects[a].followStep      = b;
                    vm->objects[a].followDoneFlag  = c;
                    vm->objects[a].motionType      = AGI_MOTION_FOLLOW_EGO;
                }
                break;

            case OP_WANDER:
                if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; }
                if (a < AGI_MAX_OBJECTS) {
                    vm->objects[a].motionType = AGI_MOTION_WANDER;
                    vm->objects[a].wanderTick = 0u;
                }
                break;

            case OP_NORMAL_MOTION:
                if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; }
                if (a < AGI_MAX_OBJECTS) { vm->objects[a].motionType = AGI_MOTION_NORMAL; }
                break;

            case OP_SET_DIR:
                if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; }
                jlLogF("OP_SET_DIR obj=%u dir=v%u (=%u)",
                       (unsigned)a, (unsigned)b, (unsigned)vm->vars[b]);
                if (a < AGI_MAX_OBJECTS) {
                    vm->objects[a].direction = vm->vars[b];
                    if (a == 0u) { vm->vars[ENG_VAR_EGO_DIR] = vm->objects[0].direction; }
                }
                break;

            case OP_GET_DIR:
                if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; }
                vm->vars[b] = (a < AGI_MAX_OBJECTS) ? vm->objects[a].direction : 0u;
                break;

            default:
                return VM_DISPATCH_UNHANDLED;
        }
        return VM_DISPATCH_CONTINUE;
}


// Group 3: inventory state (0x5C-0x61), sound (0x62-0x64), and the text
// opcodes (0x65-0x76, plus the 0x9A clear.text.rect alias lumped here).
static VmDispatchE __attribute__((noinline)) vmStepText(AgiVmT *vm, uint8_t op) {
    uint8_t  a;
    uint8_t  b;
    uint8_t  c;
    uint8_t  d;
    uint8_t  e;

        switch (op) {
            // ----- inventory (state only; reads OBJECT data via host later) -----
            case OP_GET:
            case OP_DROP:
                if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; }
                break;

            case OP_GET_V:
                if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; }
                break;

            case OP_PUT:
            case OP_PUT_V:
                if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; }
                break;

            case OP_GET_ROOM_V:
                if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; }
                vm->vars[b] = 0u;
                break;

            // ----- sound (no-op; complete callback fires immediately) -----
            case OP_LOAD_SOUND:
                if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; }
                break;

            case OP_SOUND:
                if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; }
                jlLogF("OP_SOUND id=%u flag=%u (prev playing=%u prevFlag=%u)",
                       (unsigned)a, (unsigned)b,
                       (unsigned)vm->soundPlaying, (unsigned)vm->soundDoneFlag);
                jlLogFlush();
                // AGI v2 sound(N, F): both args are LITERAL.
                // (No _V variant exists for sound; first arg is
                // always the sound resource id, second is the
                // completion flag id.) Don't index through vars.
                //
                // If a previous sound is still armed, fire its
                // done flag now (the script's about to wait on the
                // new sound; the old one's completion is the new
                // sound's start, not whenever playback ended).
                if (vm->soundPlaying) {
                    if (vm->soundDoneFlag != 0u) {
                        vm->flags[vm->soundDoneFlag] = 1u;
                    }
                    vm->flags[9] = 1u;
                    vm->soundPlaying = false;
                }
                if (vm->callbacks.playSound != NULL && vm->callbacks.isPlayingSound != NULL) {
                    vm->soundDoneFlag = b;
                    vm->flags[9]      = 0u;
                    // ScummVM startSound does setFlagOrVar(_endflag,
                    // false) here -- without it the script's
                    // `while (!flag[b]) wait` loop sees a stale 1
                    // from a previous sound() and skips waiting.
                    vm->flags[b]      = 0u;
                    vm->callbacks.playSound(vm->callbacks.ctx, a);
                    vm->soundPlaying  = true;
                } else {
                    // Legacy fire-immediately when the host can't
                    // report playback state. Keeps non-AGI uses of
                    // this VM (and any test harness without audio)
                    // from blocking forever on a wait-for-sound flag.
                    vm->flags[b] = 1u;
                    vm->flags[9] = 1u;
                }
                break;

            case OP_STOP_SOUND:
                jlLogF("OP_STOP_SOUND (playing=%u flag=%u)",
                       (unsigned)vm->soundPlaying,
                       (unsigned)vm->soundDoneFlag);
                jlLogFlush();
                if (vm->callbacks.stopSound != NULL) {
                    vm->callbacks.stopSound(vm->callbacks.ctx);
                }
                if (vm->soundPlaying) {
                    if (vm->soundDoneFlag != 0u) {
                        vm->flags[vm->soundDoneFlag] = 1u;
                    }
                    vm->flags[9]     = 1u;
                    vm->soundPlaying = false;
                }
                break;

            // ----- text -----
            case OP_PRINT: {
                char  expanded[AGI_PRINT_MAX_LEN + 1u];
                if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; }
                expandMessage(vm, fetchMessageOrEmpty(vm, vm->currentLogicId, a), expanded, sizeof(expanded));
                openPrintModal(vm, expanded);
                return VM_DISPATCH_RETURN;
            }

            case OP_PRINT_V: {
                char  expanded[AGI_PRINT_MAX_LEN + 1u];
                if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; }
                expandMessage(vm, fetchMessageOrEmpty(vm, vm->currentLogicId, vm->vars[a]), expanded, sizeof(expanded));
                openPrintModal(vm, expanded);
                return VM_DISPATCH_RETURN;
            }

            case OP_DISPLAY: {
                char  expanded[AGI_PRINT_MAX_LEN + 1u];
                if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c)) {
                    return VM_DISPATCH_RETURN;
                }
                expandMessage(vm, fetchMessageOrEmpty(vm, vm->currentLogicId, c), expanded, sizeof(expanded));
                jlLogF("OP_DISPLAY row=%u col=%u msg=%u text=\"%s\"",
                       (unsigned)a, (unsigned)b, (unsigned)c, expanded);
                writeTextRow(vm, a, b, expanded);
                break;
            }

            case OP_DISPLAY_V: {
                char  expanded[AGI_PRINT_MAX_LEN + 1u];
                if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c)) {
                    return VM_DISPATCH_RETURN;
                }
                expandMessage(vm, fetchMessageOrEmpty(vm, vm->currentLogicId, vm->vars[c]), expanded, sizeof(expanded));
                writeTextRow(vm, vm->vars[a], vm->vars[b], expanded);
                break;
            }

            case OP_CLEAR_LINES: {
                uint8_t  r;
                if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c)) {
                    return VM_DISPATCH_RETURN;
                }
                jlLogF("OP_CLEAR_LINES rows=%u..%u bg=%u",
                       (unsigned)a, (unsigned)b, (unsigned)c);
                if (a >= AGI_TEXT_ROWS) a = AGI_TEXT_ROWS - 1u;
                if (b >= AGI_TEXT_ROWS) b = AGI_TEXT_ROWS - 1u;
                for (r = a; r <= b; r++) {
                    vm->textRows[r].text[0] = '\0';
                    vm->textRows[r].bg = c;
                }
                break;
            }

            case OP_CLEAR_TEXT_RECT: {
                uint8_t  r;
                if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c) || !readByte(vm, &d) || !readByte(vm, &e)) {
                    return VM_DISPATCH_RETURN;
                }
                if (c >= AGI_TEXT_ROWS) c = AGI_TEXT_ROWS - 1u;
                for (r = a; r <= c; r++) {
                    vm->textRows[r].text[0] = '\0';
                    vm->textRows[r].bg      = e;
                }
                break;
            }

            case OP_TEXT_SCREEN:
            case OP_GRAPHICS:
                break;

            case OP_SET_CURSOR_CHAR:
                if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; }
                break;

            case OP_SET_TEXT_ATTRIBUTE:
                if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; }
                vm->textFg = a;
                vm->textBg = b;
                break;

            case OP_SHAKE_SCREEN:
                if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; }
                break;

            case OP_CONFIGURE_SCREEN:
                if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c)) {
                    return VM_DISPATCH_RETURN;
                }
                break;

            case OP_STATUS_LINE_ON:
                vm->statusLineOn = true;
                break;

            case OP_STATUS_LINE_OFF:
                vm->statusLineOn = false;
                break;

            case OP_SET_STRING: {
                char  expanded[AGI_STRING_LEN + 1u];
                uint8_t  i;

                if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; }
                if (a < AGI_MAX_STRINGS) {
                    expandMessage(vm, fetchMessageOrEmpty(vm, vm->currentLogicId, b), expanded, sizeof(expanded));
                    for (i = 0u; i < (uint8_t)AGI_STRING_LEN; i++) {
                        vm->strings[a][i] = expanded[i];
                        if (expanded[i] == '\0') break;
                    }
                    vm->strings[a][AGI_STRING_LEN] = '\0';
                }
                break;
            }

            case OP_GET_STRING:
                // get.string(s, msgId, row, col, max) — parser input;
                // no parser yet, just consume args.
                if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c) || !readByte(vm, &d) || !readByte(vm, &e)) {
                    return VM_DISPATCH_RETURN;
                }
                break;

            case OP_WORD_TO_STRING:
                if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; }
                break;

            case OP_PARSE:
                if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; }
                break;

            case OP_GET_NUM:
                if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; }
                vm->vars[b] = 0u;
                break;

            default:
                return VM_DISPATCH_UNHANDLED;
        }
        return VM_DISPATCH_CONTINUE;
}


// Group 4: input (0x77-0x79), add.to.pic (0x7A-0x7B), the misc/system
// opcodes, print.at aliases, menus, and mul/div (0x7C-0xAA). Owns the
// default fallback (kActionArgBytes stub-consume / unknown-op halt), so it
// never returns VM_DISPATCH_UNHANDLED.
static VmDispatchE __attribute__((noinline)) vmStepMisc(AgiVmT *vm, uint8_t op) {
    uint8_t  a;
    uint8_t  b;
    uint8_t  c;
    uint8_t  d;
    uint8_t  e;
    uint8_t  f;
    uint8_t  g;

        switch (op) {
            // ----- input -----
            case OP_PREVENT_INPUT:
                vm->acceptInput = false;
                break;

            case OP_ACCEPT_INPUT:
                vm->acceptInput = true;
                break;

            case OP_SET_KEY: {
                uint8_t  i;

                if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c)) {
                    return VM_DISPATCH_RETURN;
                }
                for (i = 0u; i < (uint8_t)AGI_MAX_KEY_BINDINGS; i++) {
                    if (vm->keyBindings[i].active == 0u) {
                        vm->keyBindings[i].key1       = a;
                        vm->keyBindings[i].key2       = b;
                        vm->keyBindings[i].controller = c;
                        vm->keyBindings[i].active     = 1u;
                        break;
                    }
                }
                break;
            }

            // ----- picture composition -----
            case OP_ADD_TO_PIC:
                if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c) || !readByte(vm, &d) || !readByte(vm, &e) || !readByte(vm, &f) || !readByte(vm, &g)) {
                    return VM_DISPATCH_RETURN;
                }
                if (vm->callbacks.addToPic != NULL) {
                    vm->callbacks.addToPic(vm->callbacks.ctx, a, b, c, d, e, f, g);
                }
                break;

            case OP_ADD_TO_PIC_V:
                if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c) || !readByte(vm, &d) || !readByte(vm, &e) || !readByte(vm, &f) || !readByte(vm, &g)) {
                    return VM_DISPATCH_RETURN;
                }
                if (vm->callbacks.addToPic != NULL) {
                    vm->callbacks.addToPic(vm->callbacks.ctx,
                                           vm->vars[a], vm->vars[b], vm->vars[c],
                                           vm->vars[d], vm->vars[e], vm->vars[f], vm->vars[g]);
                }
                break;

            // ----- misc state / no-ops -----
            case OP_STATUS:
                break;

            case OP_SAVE_GAME:
            case OP_RESTORE_GAME:
                break;

            case OP_INIT_DISK:
                break;

            case OP_RESTART_GAME:
                vm->haltReason = AGI_VM_HALT_QUIT;
                return VM_DISPATCH_RETURN;

            case OP_SHOW_OBJ:
                if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; }
                break;

            case OP_RANDOM:
                if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c)) {
                    return VM_DISPATCH_RETURN;
                }
                {
                    uint16_t  span;
                    span = (uint16_t)((b > a) ? (b - a + 1u) : 1u);
                    vm->vars[c] = (uint8_t)(a + (uint8_t)(jlRandomRange(span)));
                }
                break;

            case OP_PROGRAM_CONTROL:
                vm->programControl = true;
                break;

            case OP_PLAYER_CONTROL:
                vm->programControl = false;
                break;

            case OP_OBJ_STATUS_V:
                if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; }
                break;

            case OP_QUIT:
                if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; }
                vm->haltReason = AGI_VM_HALT_QUIT;
                return VM_DISPATCH_RETURN;

            case OP_SHOW_MEM:
                break;

            case OP_PAUSE:
                openPrintModal(vm, "Game Paused.\n\nPress ENTER to continue.");
                return VM_DISPATCH_RETURN;

            case OP_ECHO_LINE:
            case OP_CANCEL_LINE:
                break;

            case OP_INIT_JOY:
                break;

            case OP_TOGGLE_MONITOR:
                break;

            case OP_VERSION:
                break;

            case OP_SCRIPT_SIZE:
                if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; }
                break;

            case OP_SET_GAME_ID:
                if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; }
                break;

            case OP_LOG:
                if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; }
                break;

            case OP_SET_SCAN_START:
            case OP_RESET_SCAN_START:
                break;

            case OP_TRACE_ON:
                break;

            case OP_TRACE_INFO:
                if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c)) {
                    return VM_DISPATCH_RETURN;
                }
                break;

            case OP_PRINT_AT: {
                char  expanded[AGI_PRINT_MAX_LEN + 1u];
                if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c) || !readByte(vm, &d)) {
                    return VM_DISPATCH_RETURN;
                }
                expandMessage(vm, fetchMessageOrEmpty(vm, vm->currentLogicId, a), expanded, sizeof(expanded));
                openPrintModal(vm, expanded);
                return VM_DISPATCH_RETURN;
            }

            case OP_PRINT_AT_V: {
                char  expanded[AGI_PRINT_MAX_LEN + 1u];
                if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c) || !readByte(vm, &d)) {
                    return VM_DISPATCH_RETURN;
                }
                expandMessage(vm, fetchMessageOrEmpty(vm, vm->currentLogicId, vm->vars[a]), expanded, sizeof(expanded));
                openPrintModal(vm, expanded);
                return VM_DISPATCH_RETURN;
            }

            case OP_SET_UPPER_LEFT:
                if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; }
                break;

            // ----- menus -----
            case OP_SET_MENU:
                if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; }
                if (vm->menuCount < AGI_MAX_MENUS) {
                    vm->menus[vm->menuCount].nameMsgId = a;
                    vm->menus[vm->menuCount].itemCount = 0u;
                    vm->menuCount++;
                }
                break;

            case OP_SET_MENU_ITEM:
                if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; }
                if (vm->menuCount > 0u) {
                    AgiMenuT  *m = &vm->menus[vm->menuCount - 1u];
                    if (m->itemCount < AGI_MAX_MENU_ITEMS) {
                        m->items[m->itemCount].messageId  = a;
                        m->items[m->itemCount].controller = b;
                        m->items[m->itemCount].enabled    = 1u;
                        m->itemCount++;
                    }
                }
                break;

            case OP_SUBMIT_MENU:
                break;

            case OP_ENABLE_ITEM:
            case OP_DISABLE_ITEM: {
                uint8_t  m;
                uint8_t  i;
                uint8_t  enable;

                if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; }
                enable = (op == OP_ENABLE_ITEM) ? 1u : 0u;
                for (m = 0u; m < vm->menuCount; m++) {
                    for (i = 0u; i < vm->menus[m].itemCount; i++) {
                        if (vm->menus[m].items[i].controller == a) {
                            vm->menus[m].items[i].enabled = enable;
                        }
                    }
                }
                break;
            }

            case OP_MENU_INPUT:
                // Real menu UI is not wired; just no-op so scripts don't stall.
                break;

            case OP_SHOW_OBJ_V:
                if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; }
                break;

            case OP_OPEN_DIALOGUE:
            case OP_CLOSE_DIALOGUE:
                break;

            case OP_MUL_N:
                if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; }
                vm->vars[a] = (uint8_t)(vm->vars[a] * b);
                break;

            case OP_MUL_V:
                if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; }
                vm->vars[a] = (uint8_t)(vm->vars[a] * vm->vars[b]);
                break;

            case OP_DIV_N:
                if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; }
                if (b != 0u) {
                    vm->vars[a] = (uint8_t)(vm->vars[a] / b);
                }
                break;

            case OP_DIV_V:
                if (!readByte(vm, &a) || !readByte(vm, &b)) { return VM_DISPATCH_RETURN; }
                if (vm->vars[b] != 0u) {
                    vm->vars[a] = (uint8_t)(vm->vars[a] / vm->vars[b]);
                }
                break;

            case OP_CLOSE_WINDOW:
                vm->printModal.active = false;
                break;

            case OP_SET_SIMPLE:
                if (!readByte(vm, &a)) { return VM_DISPATCH_RETURN; }
                break;

            default: {
                uint8_t  argBytes;
                uint8_t  i;
                uint8_t  scratch;

                argBytes = kActionArgBytes[op];
                if (argBytes == ACTION_UNKNOWN) {
                    vm->lastUnknownOp = op;
                    vm->haltReason    = AGI_VM_HALT_UNKNOWN_OP;
                    vm->pc            = (uint16_t)(vm->pc - 1u);
                    break;
                }
                for (i = 0u; i < argBytes; i++) {
                    if (!readByte(vm, &scratch)) {
                        return VM_DISPATCH_RETURN;
                    }
                }
                break;
            }
        }
        return VM_DISPATCH_CONTINUE;
}


void agiVmSetCallbacks(AgiVmT *vm, const AgiVmCallbacksT *callbacks) {
    if (callbacks == NULL) {
        memset(&vm->callbacks, 0, sizeof(vm->callbacks));
    } else {
        vm->callbacks = *callbacks;
    }
}


void agiVmTickSeconds(AgiVmT *vm, uint8_t seconds) {
    uint16_t  s;
    uint16_t  m;
    uint16_t  h;
    uint16_t  carry;

    if (seconds == 0u) {
        return;
    }
    // Sound completion is no longer driven from here -- the per-cycle
    // poll in agiVmTickAnimation reads callbacks.isPlayingSound and
    // fires the done flags on the live host->silent edge. Keeps the
    // VM in lock-step with what the user actually hears.

    s = (uint16_t)((uint16_t)vm->vars[11] + (uint16_t)seconds);
    carry = (uint16_t)(s / 60u);
    vm->vars[11] = (uint8_t)(s % 60u);
    if (carry == 0u) {
        return;
    }
    m = (uint16_t)((uint16_t)vm->vars[12] + carry);
    carry = (uint16_t)(m / 60u);
    vm->vars[12] = (uint8_t)(m % 60u);
    if (carry == 0u) {
        return;
    }
    h = (uint16_t)((uint16_t)vm->vars[13] + carry);
    vm->vars[13] = (uint8_t)(h % 24u);
}
