// The MIT License (MIT)
//
// Copyright (C) 2026 Scott Duensing
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to
// deal in the Software without restriction, including without limitation the
// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
// sell copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.  IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
// IN THE SOFTWARE.

// vm.c -- DVX BASIC virtual machine implementation
//
// Stack-based p-code interpreter. Executes one instruction per
// basVmStep() call, or runs until completion via basVmRun().
// All I/O is through host-provided callbacks -- the VM itself
// has no platform dependencies.

#include "vm.h"
#include "../compiler/opcodes.h"
#include "basErrors.h"

#include <ctype.h>
#include <dirent.h>
#include <fnmatch.h>
#include <math.h>
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/time.h>
#include <time.h>
#include <unistd.h>

// ============================================================
// Prototypes
// ============================================================

// Steps to execute between doEvents yields during subroutine calls.
// This keeps the GUI responsive without excessive overhead from
// calling doEvents on every instruction.
#define SUB_YIELD_INTERVAL 500

// Capacity of the work buffers used by the numeric formatter shared by
// OP_PRINT_USING and OP_FORMAT. Bounds the worst-case output of a
// pathological format string or numeric magnitude.
#define BAS_FORMAT_BUF_SIZE 256

// Fixed (non-argument) stack operands an OP_CALL_METHOD carries: the
// method-name value and the control-reference value, both below the args.
#define BAS_CALL_METHOD_FIXED_OPERANDS 2

// Work-buffer capacities for the OP_INI_READ / OP_INI_WRITE opcodes: the
// longest INI line we will process, and the longest section/key name.
#define BAS_INI_LINE_LEN 512    // max INI line length
#define BAS_INI_NAME_LEN 256    // max INI section/key name length

// File attribute bits returned/accepted by GETATTR/SETATTR (BASIC vbReadOnly/
// vbDirectory).
#define BAS_ATTR_READONLY  1     // file is read-only
#define BAS_ATTR_DIRECTORY 16    // path is a directory

// Scratch buffer capacities used by individual opcode handlers.
#define BAS_INPUT_PROMPT_LEN     512   // OP_INPUT: user prompt + "? "
#define BAS_INPUT_LINE_LEN       1024  // OP_INPUT: longest line the host input callback may return
#define BAS_FILE_LINE_CHUNK      1024  // INPUT #/LINE INPUT #: fgets chunk while growing a line
#define BAS_RADIX_STR_LEN        16    // HEX$/OCT$ output ("FFFFFFFF", "37777777777")
#define BAS_TIME_STR_LEN         16    // TIME$ "HH:MM:SS"
#define BAS_DATE_STR_LEN         32    // DATE$ "MM-DD-YYYY"
#define BAS_SCI_FMT_LEN          32    // printf format built for PRINT USING ^^^^
#define BAS_FORMAT_PERCENT       "PERCENT"   // FORMAT$ named format
#define BAS_SCI_MARKER           "^^^^"      // PRINT USING scientific-notation marker

// Directory permission bits for MKDIR on POSIX hosts (rwxr-xr-x).
#define BAS_MKDIR_MODE           0755

// Largest count STRING$/SPACE$/INPUT$ accept (VB3 string space limit).
#define BAS_STRING_FUNC_MAX      65535

// TIMER: seconds per hour/minute and microseconds per second.
#define BAS_SECS_PER_HOUR        3600.0
#define BAS_SECS_PER_MINUTE      60.0
#define BAS_USEC_PER_SEC         1000000.0

// RGB packing: bit offsets and per-channel mask.
#define BAS_RGB_RED_SHIFT        16
#define BAS_RGB_GREEN_SHIFT      8
#define BAS_RGB_CHANNEL_MASK     0xFF
#define BAS_CHAR_MASK            0xFF

// Divergence between the PRINT USING and FORMAT$ numeric formatters,
// captured as options so a single helper serves both call sites.
typedef struct BasNumFormatT {
    bool    hasDecimal;
    bool    hasComma;
    bool    plusAtStart;
    bool    plusAtEnd;
    bool    minusAtEnd;
    bool    asteriskFill;
    bool    dollarFloat;
    bool    zeroPad;
    bool    formatPad;
    int32_t digitsBefore;
    int32_t digitsAfter;
} BasNumFormatT;

static BasVmResultE   allocArray(BasVmT *vm, int32_t dims, uint8_t elementType, BasArrayT **outArr);
static BasVmResultE   boundOp(BasVmT *vm, uint8_t op);
static int32_t        clampComponent(int32_t c);
static BasCallFrameT *currentFrame(BasVmT *vm);
static void           defaultPrint(void *ctx, const char *text, bool newline);
static BasValueT     *derefSlot(BasValueT *slot);
static void           dirClose(BasVmT *vm);
static const char    *dirNext(BasVmT *vm);
static bool           dispatchError(BasVmT *vm, BasVmResultE result, int32_t floorDepth, int32_t fallbackPc);
static BasVmResultE   divByZero(BasVmT *vm);
static BasVmResultE   execArith(BasVmT *vm, uint8_t op);
static BasVmResultE   execCompare(BasVmT *vm, uint8_t op);
static BasVmResultE   execFileOp(BasVmT *vm, uint8_t op);
static BasVmResultE   execFsOp(BasVmT *vm, uint8_t op);
static BasVmResultE   execLogical(BasVmT *vm, uint8_t op);
static BasVmResultE   execMath(BasVmT *vm, uint8_t op);
static BasVmResultE   execPrint(BasVmT *vm);
static BasVmResultE   execStringOp(BasVmT *vm, uint8_t op);
static BasStringT    *fillString(char ch, int32_t count);
static void           formatNumber(double n, const BasNumFormatT *f, char *out, size_t outSize);
static int32_t        forStackFloor(BasVmT *vm);
static void           forStackTrim(BasVmT *vm, int32_t newDepth);
static void           localNow(struct tm *out, int32_t *outUsec);
static int32_t        nextStatementPc(BasVmT *vm);
static bool           operandFits(BasVmT *vm, int32_t size);
static void           parseNumFormat(const char *fmt, int32_t fmtLen, BasNumFormatT *f, bool *outSci);
static bool           pop(BasVmT *vm, BasValueT *val);
static bool           popArgs(BasVmT *vm, int32_t count, ...);
static void           popCallFrame(BasVmT *vm);
static BasVmResultE   popFileChannel(BasVmT *vm, int32_t *outChannel, bool requireOpen);
static bool           popStringArg(BasVmT *vm, BasValueT *out);
static void           preserveElements(const BasArrayT *oldArr, BasArrayT *newArr);
static void           primeModuleFrame(BasVmT *vm);
static bool           push(BasVmT *vm, BasValueT val);
static void           putBounded(char *out, int32_t *idx, int32_t limit, char c);
static BasStringT    *readField(FILE *fp);
static int16_t        readInt16(BasVmT *vm);
static BasStringT    *readLine(FILE *fp);
static void           readRaw(FILE *fp, void *dst, size_t size);
static uint16_t       readUint16(BasVmT *vm);
static uint8_t        readUint8(BasVmT *vm);
static void           releaseVmState(BasVmT *vm);
static BasVmResultE   roundedOperand(BasVmT *vm, BasValueT v, int32_t *out);
static bool           runSubLoop(BasVmT *vm, int32_t savedPc, int32_t savedCallDepth, bool savedRunning);
static void           runtimeError(BasVmT *vm, int32_t errNum, const char *msg);
static void           seekRecord(BasVmT *vm, int32_t channel, int32_t recno);
static void           storeValue(BasValueT *target, BasValueT val);
static int32_t        stringFind(const BasStringT *haystack, int32_t start, const BasStringT *needle);
static BasValueT      strValue(BasStringT *s);
static BasValueT      udtDetach(BasValueT val);
static bool           validArrayDims(BasVmT *vm, int32_t dims);


bool basVmCallSub(BasVmT *vm, int32_t codeAddr) {
    // Thin wrapper: no args, no out-args.  basVmCallSubWithArgsOut handles
    // NULL args / zero counts and contains the full validation + frame setup.
    return basVmCallSubWithArgsOut(vm, codeAddr, NULL, 0, NULL, 0);
}


bool basVmCallSubWithArgs(BasVmT *vm, int32_t codeAddr, const BasValueT *args, int32_t argCount) {
    // Thin wrapper: in-args only, no out-args.
    return basVmCallSubWithArgsOut(vm, codeAddr, args, argCount, NULL, 0);
}


bool basVmCallSubWithArgsOut(BasVmT *vm, int32_t codeAddr, const BasValueT *args, int32_t argCount, BasValueT *outArgs, int32_t outCount) {
    if (!vm || !vm->module) {
        return false;
    }

    if (vm->debugPaused) {
        return false;
    }

    if (codeAddr < 0 || codeAddr >= vm->module->codeLen) {
        return false;
    }

    if (vm->callDepth >= BAS_VM_CALL_STACK_SIZE - 1) {
        return false;
    }

    int32_t savedPc        = vm->pc;
    int32_t savedCallDepth = vm->callDepth;
    bool    savedRunning   = vm->running;

    BasCallFrameT *frame = &vm->callStack[vm->callDepth++];
    frame->returnPc      = savedPc;
    frame->localCount    = BAS_VM_MAX_LOCALS;
    frame->errorHandler  = 0;
    frame->savedForDepth = vm->forDepth;
    frame->savedStmtPc   = vm->stmtPc;
    frame->savedStmtSp   = vm->stmtSp;
    memset(frame->locals, 0, sizeof(frame->locals));

    for (int32_t i = 0; i < argCount && i < BAS_VM_MAX_LOCALS; i++) {
        frame->locals[i] = basValCopy(args[i]);
    }

    vm->pc      = codeAddr;
    vm->running = true;

    // Arrange for the returning frame's locals to be copied into outArgs by
    // OP_RET/OP_RET_VAL, BEFORE those opcodes release the locals.  Reading
    // them after runSubLoop returns would be a use-after-free because the
    // RET path has already released every local in the frame.  The fields
    // are not reentrant, so save and restore them around the call in case
    // the running sub itself makes another out-arg call.
    BasValueT *savedOutArgs     = vm->outArgs;
    int32_t    savedOutArgCount = vm->outArgCount;
    int32_t    savedOutArgFrame = vm->outArgFrame;

    vm->outArgs     = (outArgs && outCount > 0) ? outArgs : NULL;
    vm->outArgCount = outCount;
    vm->outArgFrame = savedCallDepth;

    bool ok = runSubLoop(vm, savedPc, savedCallDepth, savedRunning);

    vm->outArgs     = savedOutArgs;
    vm->outArgCount = savedOutArgCount;
    vm->outArgFrame = savedOutArgFrame;

    return ok;
}


BasVmT *basVmCreate(void) {
    BasVmT *vm = (BasVmT *)calloc(1, sizeof(BasVmT));

    if (!vm) {
        return NULL;
    }

    vm->printFn         = defaultPrint;
    vm->stepOverDepth   = -1;
    vm->stepOutDepth    = -1;
    vm->runToCursorLine = -1;
    vm->outArgFrame     = -1;
    return vm;
}


void basVmDestroy(BasVmT *vm) {
    if (!vm) {
        return;
    }

    // Release eval stack, globals, call-frame locals and FOR-stack values
    releaseVmState(vm);

    // Close files
    for (int32_t i = 0; i < BAS_VM_MAX_FILES; i++) {
        if (vm->files[i].handle) {
            fclose((FILE *)vm->files[i].handle);
        }
    }

    // Close Dir$ iterator
    dirClose(vm);

    free(vm);
}


const char *basVmGetError(const BasVmT *vm) {
    return vm ? vm->errorMsg : "";
}


void basVmLoadModule(BasVmT *vm, BasModuleT *module) {
    if (module->globalCount > BAS_VM_MAX_GLOBALS) {
        vm->module = NULL;
        snprintf(vm->errorMsg, sizeof(vm->errorMsg), "Program needs %d global variables; the limit is %d", (int)module->globalCount, (int)BAS_VM_MAX_GLOBALS);
        return;
    }

    vm->module = module;
    vm->pc     = module->entryPoint;

    // Initialize globals whose declared type requires a non-numeric
    // default.  Uninitialized slots are zero (type=INTEGER, value=0),
    // which breaks STRING operations: `DIM s AS STRING : s = s + "x"`
    // would do numeric addition instead of concatenation.  STATIC
    // variables also alias global slots, so the same issue applies.
    // globalInits survives the compiler's strip pass, unlike debugVars.
    if (module->globalInits) {
        for (int32_t i = 0; i < module->globalInitCount; i++) {
            BasGlobalInitT *g = &module->globalInits[i];

            if (g->index < 0 || g->index >= BAS_VM_MAX_GLOBALS) {
                continue;
            }

            if (g->dataType == BAS_TYPE_STRING) {
                basValRelease(&vm->globals[g->index]);
                vm->globals[g->index].type   = BAS_TYPE_STRING;
                vm->globals[g->index].strVal = basStringNew("", 0);
            }
        }
    }

    // Prime the implicit main (module-level) frame.  The IDE and test
    // harness used to set this by hand; doing it here makes every entry
    // path (including compiled apps via the stub) consistent.
    primeModuleFrame(vm);
    vm->stmtPc = -1;
}


BasVmResultE basVmRun(BasVmT *vm) {
    // basVmLoadModule refused the module (too many globals): report why
    // instead of returning a clean HALTED for a program that never ran.
    if (!vm->module && vm->errorMsg[0] != '\0') {
        return BAS_VM_ERROR;
    }

    vm->running   = true;
    vm->stepCount = 0;

    while (vm->running) {
        // Check step limit
        if (vm->stepLimit > 0 && vm->stepCount >= vm->stepLimit) {
            return BAS_VM_STEP_LIMIT;
        }

        // Save PC before each instruction for RESUME support
        int32_t savedPc = vm->pc;
        BasVmResultE result = basVmStep(vm);
        vm->stepCount++;

        if (result != BAS_VM_OK) {
            // Debugger pauses are not errors: hand them straight back to
            // the host without consulting ON ERROR handlers.
            if (result == BAS_VM_BREAKPOINT) {
                vm->running = false;
                return result;
            }

            // Trappable error with a handler in scope: the dispatcher has
            // unwound to the handler frame and pointed pc at it.
            if (dispatchError(vm, result, 0, savedPc)) {
                continue;
            }

            vm->running = false;
            return result;
        }
    }

    // Host callbacks (property setters, method dispatch, etc.) can
    // abort execution by writing an error message and clearing
    // vm->running.  The step itself returns OK because the VM did
    // nothing wrong; the failure happened on the host side.  If the
    // host left an error message, surface it as BAS_VM_ERROR so the
    // caller (IDE, stub) can navigate to the line and stop the
    // program instead of treating the halt as a normal END.
    if (vm->errorMsg[0] != '\0') {
        return BAS_VM_ERROR;
    }

    return BAS_VM_HALTED;
}


void basVmRunToCursor(BasVmT *vm, int32_t line) {
    if (vm) {
        vm->runToCursorLine = line;
    }
}


void basVmSetBreakpoints(BasVmT *vm, int32_t *lines, int32_t count) {
    if (!vm) {
        return;
    }

    vm->breakpoints    = lines;
    vm->breakpointCount = count;
}


void basVmSetCurrentForm(BasVmT *vm, void *formRef) {
    vm->currentForm = formRef;
}


void basVmSetCurrentFormVars(BasVmT *vm, BasValueT *vars, int32_t count) {
    vm->currentFormVars     = vars;
    vm->currentFormVarCount = count;
}


void basVmSetDoEventsCallback(BasVmT *vm, BasDoEventsFnT fn, void *ctx) {
    vm->doEventsFn  = fn;
    vm->doEventsCtx = ctx;
}


void basVmSetExternCallbacks(BasVmT *vm, const BasExternCallbacksT *ext) {
    vm->ext = *ext;
}


void basVmSetInputCallback(BasVmT *vm, BasInputFnT fn, void *ctx) {
    vm->inputFn  = fn;
    vm->inputCtx = ctx;
}


void basVmSetPrintCallback(BasVmT *vm, BasPrintFnT fn, void *ctx) {
    vm->printFn  = fn;
    vm->printCtx = ctx;
}


void basVmSetSqlCallbacks(BasVmT *vm, const BasSqlCallbacksT *sql) {
    vm->sql = *sql;
}


void basVmSetStepLimit(BasVmT *vm, int32_t limit) {
    vm->stepLimit = limit;
}


void basVmSetUiCallbacks(BasVmT *vm, const BasUiCallbacksT *ui) {
    vm->ui = *ui;
}


BasVmResultE basVmStep(BasVmT *vm) {
    // A prior step's operand read ran past code[] and clamped pc to codeLen.
    // Check this BEFORE the halt guard below, otherwise pc == codeLen would
    // report a clean BAS_VM_HALTED and mask the corruption. badOperand can
    // only have been set while vm->module was non-NULL, so no NULL check here.
    if (vm->badOperand) {
        vm->badOperand = false;
        vm->running    = false;
        return BAS_VM_BAD_OPCODE;
    }

    if (!vm->module || vm->pc < 0 || vm->pc >= vm->module->codeLen) {
        vm->running = false;
        return BAS_VM_HALTED;
    }

    vm->currentOpPc = vm->pc;
    uint8_t op      = vm->module->code[vm->pc++];

    switch (op) {
        case OP_NOP:
            break;

        // ============================================================
        // Stack operations
        // ============================================================

        case OP_PUSH_INT16: {
            int16_t val = readInt16(vm);

            if (!push(vm, basValInteger(val))) {
                return BAS_VM_STACK_OVERFLOW;
            }

            break;
        }

        case OP_PUSH_INT32: {
            int16_t lo = readInt16(vm);
            int16_t hi = readInt16(vm);
            // Reconstruct through unsigned math: left-shifting a negative
            // sign-extended high half would be signed-overflow UB.
            int32_t val = (int32_t)(((uint32_t)(uint16_t)hi << 16) | (uint16_t)lo);

            if (!push(vm, basValLong(val))) {
                return BAS_VM_STACK_OVERFLOW;
            }

            break;
        }

        case OP_PUSH_FLT64: {
            double val = 0.0;

            if (operandFits(vm, (int32_t)sizeof(double))) {
                memcpy(&val, &vm->module->code[vm->pc], sizeof(double));
                vm->pc += sizeof(double);
            }

            if (!push(vm, basValDouble(val))) {
                return BAS_VM_STACK_OVERFLOW;
            }

            break;
        }

        case OP_PUSH_STR: {
            uint16_t    idx = readUint16(vm);
            BasStringT *str = (idx < (uint16_t)vm->module->constCount) ? vm->module->constants[idx] : basEmptyString;

            if (!push(vm, basValString(str))) {
                return BAS_VM_STACK_OVERFLOW;
            }

            break;
        }

        case OP_PUSH_TRUE:
            if (!push(vm, basValBool(true))) {
                return BAS_VM_STACK_OVERFLOW;
            }
            break;

        case OP_PUSH_FALSE:
            if (!push(vm, basValBool(false))) {
                return BAS_VM_STACK_OVERFLOW;
            }
            break;

        case OP_POP: {
            BasValueT val;

            if (!pop(vm, &val)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            basValRelease(&val);
            break;
        }

        case OP_DUP: {
            if (vm->sp < 1) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            if (!push(vm, basValCopy(vm->stack[vm->sp - 1]))) {
                return BAS_VM_STACK_OVERFLOW;
            }

            break;
        }

        case OP_STMT:
            // Release-build statement boundary: same bookkeeping as OP_LINE
            // minus the line number and debugger checks.
            vm->stmtSp = vm->sp;
            vm->stmtPc = vm->currentOpPc;
            break;

        // ============================================================
        // Variable access
        // ============================================================

        case OP_LOAD_LOCAL: {
            uint16_t idx = readUint16(vm);
            BasCallFrameT *frame = currentFrame(vm);

            if (!frame || idx >= (uint16_t)frame->localCount) {
                runtimeError(vm, BAS_ERR_SUBSCRIPT_RANGE, "Invalid local variable index");
                return BAS_VM_ERROR;
            }

            // ByRef: if the local holds a reference, dereference it
            BasValueT *slot = derefSlot(&frame->locals[idx]);

            if (!slot) {
                runtimeError(vm, BAS_ERR_SUBSCRIPT_RANGE, "Subscript out of range");
                return BAS_VM_SUBSCRIPT_RANGE;
            }

            if (!push(vm, basValCopy(*slot))) {
                return BAS_VM_STACK_OVERFLOW;
            }

            break;
        }

        case OP_STORE_LOCAL: {
            uint16_t idx = readUint16(vm);
            BasCallFrameT *frame = currentFrame(vm);
            BasValueT val;

            if (!pop(vm, &val)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            if (!frame || idx >= (uint16_t)frame->localCount) {
                basValRelease(&val);
                runtimeError(vm, BAS_ERR_SUBSCRIPT_RANGE, "Invalid local variable index");
                return BAS_VM_ERROR;
            }

            // ByRef: if the local holds a reference, store through it
            BasValueT *slot = derefSlot(&frame->locals[idx]);

            if (!slot) {
                basValRelease(&val);
                runtimeError(vm, BAS_ERR_SUBSCRIPT_RANGE, "Subscript out of range");
                return BAS_VM_SUBSCRIPT_RANGE;
            }

            storeValue(slot, val);
            break;
        }

        case OP_LOAD_GLOBAL: {
            uint16_t idx = readUint16(vm);

            if (idx >= BAS_VM_MAX_GLOBALS) {
                runtimeError(vm, BAS_ERR_SUBSCRIPT_RANGE, "Invalid global variable index");
                return BAS_VM_ERROR;
            }

            if (!push(vm, basValCopy(vm->globals[idx]))) {
                return BAS_VM_STACK_OVERFLOW;
            }

            break;
        }

        case OP_STORE_GLOBAL: {
            uint16_t idx = readUint16(vm);
            BasValueT val;

            if (!pop(vm, &val)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            if (idx >= BAS_VM_MAX_GLOBALS) {
                basValRelease(&val);
                runtimeError(vm, BAS_ERR_SUBSCRIPT_RANGE, "Invalid global variable index");
                return BAS_VM_ERROR;
            }

            storeValue(&vm->globals[idx], val);
            break;
        }

        case OP_PUSH_LOCAL_ADDR: {
            uint16_t idx = readUint16(vm);
            BasCallFrameT *frame = currentFrame(vm);

            if (!frame || idx >= (uint16_t)frame->localCount) {
                runtimeError(vm, BAS_ERR_SUBSCRIPT_RANGE, "Invalid local variable index");
                return BAS_VM_ERROR;
            }

            BasValueT ref;
            ref.type   = BAS_TYPE_REF;
            ref.refVal = &frame->locals[idx];

            if (!push(vm, ref)) {
                return BAS_VM_STACK_OVERFLOW;
            }

            break;
        }

        case OP_PUSH_GLOBAL_ADDR: {
            uint16_t idx = readUint16(vm);

            if (idx >= BAS_VM_MAX_GLOBALS) {
                runtimeError(vm, BAS_ERR_SUBSCRIPT_RANGE, "Invalid global variable index");
                return BAS_VM_ERROR;
            }

            BasValueT ref;
            ref.type   = BAS_TYPE_REF;
            ref.refVal = &vm->globals[idx];

            if (!push(vm, ref)) {
                return BAS_VM_STACK_OVERFLOW;
            }

            break;
        }

        // ============================================================
        // Arithmetic
        // ============================================================

        case OP_ADD_INT:
        case OP_SUB_INT:
        case OP_MUL_INT:
        case OP_IDIV_INT:
        case OP_MOD_INT:
        case OP_DIV_FLT:
        case OP_POW:
            return execArith(vm, op);

        case OP_NEG_INT: {
            if (vm->sp < 1) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            BasValueT *top = &vm->stack[vm->sp - 1];

            if (top->type == BAS_TYPE_INTEGER) {
                // Unsigned negation avoids signed-overflow UB on INT16_MIN while
                // preserving the two's-complement wrap the runtime relies on.
                top->intVal = (int16_t)(0u - (unsigned)top->intVal);
            } else if (top->type == BAS_TYPE_LONG) {
                // Same wrap: -INT32_MIN must round-trip to INT32_MIN so the QB
                // idiom PRINT -2147483648 (LONG_MIN) yields the wrapped literal.
                top->longVal = (int32_t)(0u - (uint32_t)top->longVal);
            } else {
                double n = basValToNumber(*top);
                basValRelease(top);
                *top = basValDouble(-n);
            }

            break;
        }

        // ============================================================
        // String operations
        // ============================================================

        case OP_STR_CONCAT:
        case OP_STR_LEFT:
        case OP_STR_RIGHT:
        case OP_STR_MID:
        case OP_STR_MID2:
        case OP_STR_LEN:
        case OP_STR_INSTR:
        case OP_STR_INSTR3:
        case OP_STR_UCASE:
        case OP_STR_LCASE:
        case OP_STR_TRIM:
        case OP_STR_LTRIM:
        case OP_STR_RTRIM:
        case OP_STR_CHR:
        case OP_STR_ASC:
        case OP_STR_SPACE:
        case OP_STR_FIXLEN:
        case OP_STR_MID_ASGN:
            return execStringOp(vm, op);

        // ============================================================
        // Comparison
        // ============================================================

        case OP_CMP_EQ:
        case OP_CMP_NE:
        case OP_CMP_LT:
        case OP_CMP_GT:
        case OP_CMP_LE:
        case OP_CMP_GE:
            return execCompare(vm, op);

        // ============================================================
        // Logical / bitwise
        // ============================================================

        case OP_AND:
        case OP_OR:
        case OP_NOT:
        case OP_XOR:
        case OP_EQV:
        case OP_IMP:
            return execLogical(vm, op);

        // ============================================================
        // Control flow
        // ============================================================

        case OP_JMP: {
            int16_t offset = readInt16(vm);
            vm->pc += offset;
            break;
        }

        case OP_JMP_TRUE: {
            int16_t offset = readInt16(vm);
            BasValueT val;

            if (!pop(vm, &val)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            if (basValIsTruthy(val)) {
                vm->pc += offset;
            }

            basValRelease(&val);
            break;
        }

        case OP_JMP_FALSE: {
            int16_t offset = readInt16(vm);
            BasValueT val;

            if (!pop(vm, &val)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            if (!basValIsTruthy(val)) {
                vm->pc += offset;
            }

            basValRelease(&val);
            break;
        }

        case OP_CALL: {
            uint16_t addr     = readUint16(vm);
            uint8_t  argc     = readUint8(vm);
            uint8_t  baseSlot = readUint8(vm);

            // Guard the locals window: baseSlot and argc come straight from
            // bytecode, and a corrupt module could otherwise make the arg
            // copy below write past locals[].  Drain the pushed args first
            // so the eval stack stays balanced.
            if ((int32_t)baseSlot + (int32_t)argc > BAS_VM_MAX_LOCALS) {
                for (int32_t i = 0; i < (int32_t)argc; i++) {
                    BasValueT tmp;

                    if (pop(vm, &tmp)) {
                        basValRelease(&tmp);
                    }
                }

                runtimeError(vm, BAS_ERR_ILLEGAL_FUNC_CALL, "CALL argument slots out of range");
                return BAS_VM_ERROR;
            }

            if (vm->callDepth >= BAS_VM_CALL_STACK_SIZE) {
                return BAS_VM_CALL_OVERFLOW;
            }

            BasCallFrameT *frame = &vm->callStack[vm->callDepth++];
            frame->returnPc      = vm->pc;
            frame->localCount    = BAS_VM_MAX_LOCALS;
            frame->errorHandler  = 0;
            frame->savedForDepth = vm->forDepth;
            frame->savedStmtPc   = vm->stmtPc;
            frame->savedStmtSp   = vm->stmtSp;

            // Zero all local slots
            memset(frame->locals, 0, sizeof(frame->locals));

            // Pop arguments into locals starting at baseSlot (in reverse
            // order).  A BYVAL TYPE argument must be the callee's own copy.
            for (int32_t i = baseSlot + argc - 1; i >= baseSlot; i--) {
                if (!pop(vm, &frame->locals[i])) {
                    return BAS_VM_STACK_UNDERFLOW;
                }

                frame->locals[i] = udtDetach(frame->locals[i]);
            }

            // The callee starts with no handler; if it raises an error
            // the dispatcher will unwind to the nearest frame with one.
            vm->pc = addr;
            break;
        }

        case OP_RET: {
            if (vm->callDepth <= 0) {
                vm->running = false;
                return BAS_VM_HALTED;
            }

            popCallFrame(vm);
            break;
        }

        case OP_RET_VAL: {
            // Like RET but leaves TOS as the return value
            BasValueT retVal;

            if (!pop(vm, &retVal)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            if (vm->callDepth <= 0) {
                basValRelease(&retVal);
                vm->running = false;
                return BAS_VM_HALTED;
            }

            popCallFrame(vm);

            if (!push(vm, retVal)) {
                return BAS_VM_STACK_OVERFLOW;
            }

            break;
        }

        case OP_GOSUB_RET: {
            // GOSUB return: pop integer from eval stack, set PC to that value
            BasValueT retAddr;

            if (!pop(vm, &retAddr)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            vm->pc = basValToInt32(retAddr);
            basValRelease(&retAddr);
            break;
        }

        case OP_FOR_INIT: {
            uint16_t varIdx     = readUint16(vm);
            uint8_t  scopeTag   = readUint8(vm);
            int16_t  skipOffset = readInt16(vm);
            BasValueT stepVal;
            BasValueT limitVal;

            if (!popArgs(vm, 2, &stepVal, &limitVal)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            // Re-entering a FOR whose frame is still live (a GOTO back to
            // the FOR statement, or a jump that skipped its NEXT) must not
            // stack a second frame: discard the old context and everything
            // nested inside it, as classic BASIC does.  Only frames owned
            // by the current call frame are considered.
            int32_t forFloor = forStackFloor(vm);

            for (int32_t k = vm->forDepth - 1; k >= forFloor; k--) {
                if (vm->forStack[k].varIdx == (int32_t)varIdx && vm->forStack[k].scopeTag == scopeTag) {
                    forStackTrim(vm, k);
                    break;
                }
            }

            if (vm->forDepth >= BAS_VM_MAX_FOR_DEPTH) {
                basValRelease(&stepVal);
                basValRelease(&limitVal);
                runtimeError(vm, BAS_ERR_FOR_NESTING, "FOR loop nesting too deep");
                return BAS_VM_ERROR;
            }

            BasForStateT *fs = &vm->forStack[vm->forDepth++];
            fs->varIdx  = varIdx;
            fs->scopeTag = scopeTag;
            fs->limit   = limitVal;
            fs->step    = stepVal;
            fs->loopTop = vm->pc;

            // Entry check: if the loop's range is already empty (e.g.
            // FOR i = 10 TO 5 with positive step), skip the body and
            // pop the FOR state.  QBASIC semantics: body executes zero
            // times when the range is empty.
            BasValueT *varSlot = NULL;

            if (scopeTag == SCOPE_LOCAL) {
                BasCallFrameT *frame = currentFrame(vm);

                if (frame && varIdx < (uint16_t)frame->localCount) {
                    varSlot = &frame->locals[varIdx];
                }
            } else if (scopeTag == SCOPE_FORM) {
                if (vm->currentFormVars && varIdx < (uint16_t)vm->currentFormVarCount) {
                    varSlot = &vm->currentFormVars[varIdx];
                }
            } else {
                if (varIdx < BAS_VM_MAX_GLOBALS) {
                    varSlot = &vm->globals[varIdx];
                }
            }

            // ByRef loop variable (e.g. FOR over a BYREF SUB parameter):
            // the local slot holds a reference, so operate on the caller's
            // storage just as OP_LOAD_LOCAL/OP_STORE_LOCAL do.
            if (varSlot) {
                varSlot = derefSlot(varSlot);
            }

            if (varSlot) {
                double curVal = basValToNumber(*varSlot);
                double stepNum = basValToNumber(fs->step);
                double limNum  = basValToNumber(fs->limit);
                bool   enter   = (stepNum >= 0) ? (curVal <= limNum) : (curVal >= limNum);

                if (!enter) {
                    basValRelease(&fs->limit);
                    basValRelease(&fs->step);
                    vm->forDepth--;
                    vm->pc += skipOffset;
                }
            }

            break;
        }

        case OP_FOR_NEXT: {
            uint16_t varIdx        = readUint16(vm);
            uint8_t  scopeTag      = readUint8(vm);
            int16_t  loopTopOffset = readInt16(vm);

            if (vm->forDepth <= 0) {
                runtimeError(vm, BAS_ERR_NEXT_WITHOUT_FOR, "NEXT without FOR");
                return BAS_VM_ERROR;
            }

            BasForStateT *fs = &vm->forStack[vm->forDepth - 1];

            if (fs->varIdx != (int32_t)varIdx || fs->scopeTag != scopeTag) {
                // A jump out of an inner FOR (EXIT DO, GOTO) leaves its
                // frame on the FOR stack.  Search down to this call frame's
                // floor for the loop this NEXT belongs to and discard the
                // stale frames above it, matching classic BASIC semantics.
                int32_t forFloor = forStackFloor(vm);
                int32_t matchIdx = -1;

                for (int32_t k = vm->forDepth - 2; k >= forFloor; k--) {
                    if (vm->forStack[k].varIdx == (int32_t)varIdx && vm->forStack[k].scopeTag == scopeTag) {
                        matchIdx = k;
                        break;
                    }
                }

                if (matchIdx < 0) {
                    runtimeError(vm, BAS_ERR_NEXT_WITHOUT_FOR, "NEXT variable mismatch");
                    return BAS_VM_ERROR;
                }

                forStackTrim(vm, matchIdx + 1);
                fs = &vm->forStack[matchIdx];
            }

            // Get pointer to the loop variable
            BasValueT *varSlot;

            if (scopeTag == SCOPE_LOCAL) {
                BasCallFrameT *frame = currentFrame(vm);

                if (!frame || varIdx >= (uint16_t)frame->localCount) {
                    runtimeError(vm, BAS_ERR_SUBSCRIPT_RANGE, "Invalid local variable index");
                    return BAS_VM_ERROR;
                }

                varSlot = &frame->locals[varIdx];
            } else if (scopeTag == SCOPE_FORM) {
                if (!vm->currentFormVars || varIdx >= (uint16_t)vm->currentFormVarCount) {
                    runtimeError(vm, BAS_ERR_SUBSCRIPT_RANGE, "Invalid form variable index");
                    return BAS_VM_ERROR;
                }

                varSlot = &vm->currentFormVars[varIdx];
            } else {
                if (varIdx >= BAS_VM_MAX_GLOBALS) {
                    runtimeError(vm, BAS_ERR_SUBSCRIPT_RANGE, "Invalid global variable index");
                    return BAS_VM_ERROR;
                }

                varSlot = &vm->globals[varIdx];
            }

            // ByRef loop variable: dereference so the increment writes the
            // caller's storage, matching OP_STORE_LOCAL.
            varSlot = derefSlot(varSlot);

            if (!varSlot) {
                runtimeError(vm, BAS_ERR_SUBSCRIPT_RANGE, "Subscript out of range");
                return BAS_VM_SUBSCRIPT_RANGE;
            }

            // Increment: var = var + step. Preserve the loop variable's
            // original type so extern calls that pass it as an int32_t
            // argument don't get an 8-byte double instead.  Exception:
            // if STEP is fractional, an integer slot would truncate the
            // increment and spin forever (FOR a = 0 TO 6.3 STEP 0.2 with
            // `a` stored as INTEGER from the literal 0).  Promote to
            // DOUBLE in that case so the loop can actually progress.
            double  varVal  = basValToNumber(*varSlot);
            double  stepVal = basValToNumber(fs->step);
            double  limVal  = basValToNumber(fs->limit);
            uint8_t varType = varSlot->type;
            varVal += stepVal;

            basValRelease(varSlot);

            bool stepIsFractional = (stepVal != floor(stepVal));

            if (stepIsFractional) {
                *varSlot = basValDouble(varVal);
            } else if (varType == BAS_TYPE_INTEGER) {
                // Promote out-of-range counters instead of truncating, so the
                // slot read back next iteration matches the un-truncated varVal
                // used by the continue test (otherwise an INTEGER counter that
                // crosses 32767 wraps and the loop never terminates).
                if (varVal >= (double)INT16_MIN && varVal <= (double)INT16_MAX) {
                    *varSlot = basValInteger((int16_t)varVal);
                } else if (varVal >= (double)INT32_MIN && varVal <= (double)INT32_MAX) {
                    *varSlot = basValLong((int32_t)varVal);
                } else {
                    *varSlot = basValDouble(varVal);
                }
            } else if (varType == BAS_TYPE_LONG) {
                if (varVal >= (double)INT32_MIN && varVal <= (double)INT32_MAX) {
                    *varSlot = basValLong((int32_t)varVal);
                } else {
                    *varSlot = basValDouble(varVal);
                }
            } else if (varType == BAS_TYPE_SINGLE) {
                *varSlot = basValSingle((float)varVal);
            } else {
                *varSlot = basValDouble(varVal);
            }

            // Test: if step > 0 then continue while var <= limit
            //       if step < 0 then continue while var >= limit
            bool cont;

            if (stepVal >= 0) {
                cont = (varVal <= limVal);
            } else {
                cont = (varVal >= limVal);
            }

            if (cont) {
                vm->pc += loopTopOffset;
            } else {
                // Loop done -- pop FOR state
                basValRelease(&fs->limit);
                basValRelease(&fs->step);
                vm->forDepth--;
            }

            break;
        }

        case OP_FOR_POP: {
            if (vm->forDepth <= 0) {
                runtimeError(vm, BAS_ERR_NEXT_WITHOUT_FOR, "FOR stack underflow");
                return BAS_VM_ERROR;
            }

            BasForStateT *fs = &vm->forStack[--vm->forDepth];
            basValRelease(&fs->limit);
            basValRelease(&fs->step);
            break;
        }

        // ============================================================
        // Type conversion
        // ============================================================

        case OP_CONV_INT_FLT: {
            if (vm->sp < 1) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            BasValueT *top = &vm->stack[vm->sp - 1];
            double n = basValToNumber(*top);
            basValRelease(top);
            *top = basValSingle((float)n);
            break;
        }

        case OP_CONV_FLT_INT:
        case OP_CONV_STR_INT: {
            if (vm->sp < 1) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            BasValueT *top = &vm->stack[vm->sp - 1];
            int32_t    n;

            if (!basValRoundToInt32(*top, INT16_MIN, INT16_MAX, &n)) {
                runtimeError(vm, BAS_ERR_OVERFLOW, "Overflow");
                return BAS_VM_ERROR;
            }

            basValRelease(top);
            *top = basValInteger((int16_t)n);
            break;
        }

        case OP_CONV_INT_STR: {
            if (vm->sp < 1) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            BasValueT *top = &vm->stack[vm->sp - 1];
            BasValueT conv = basValToString(*top);
            basValRelease(top);
            *top = conv;
            break;
        }

        case OP_CONV_STR_FLT: {
            if (vm->sp < 1) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            BasValueT *top = &vm->stack[vm->sp - 1];
            BasValueT conv = basValToDouble(*top);
            basValRelease(top);
            *top = conv;
            break;
        }

        case OP_CONV_INT_LONG: {
            if (vm->sp < 1) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            BasValueT *top = &vm->stack[vm->sp - 1];
            int32_t    n;

            if (!basValRoundToInt32(*top, INT32_MIN, INT32_MAX, &n)) {
                runtimeError(vm, BAS_ERR_OVERFLOW, "Overflow");
                return BAS_VM_ERROR;
            }

            basValRelease(top);
            *top = basValLong(n);
            break;
        }

        // ============================================================
        // I/O
        // ============================================================

        case OP_PRINT:
            return execPrint(vm);

        case OP_PRINT_NL:
            if (vm->printFn) {
                vm->printFn(vm->printCtx, "", true);
            }
            break;

        case OP_PRINT_TAB:
            if (vm->printFn) {
                vm->printFn(vm->printCtx, "\t", false);
            }
            break;

        case OP_PRINT_SPC_N: {
            BasValueT nVal;

            if (!pop(vm, &nVal)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            int32_t count = basValToInt32(nVal);
            basValRelease(&nVal);

            if (count < 0) {
                count = 0;
            }

            if (count > BAS_PRINT_SPC_MAX) {
                count = BAS_PRINT_SPC_MAX;
            }

            char spaces[BAS_PRINT_SPC_MAX + 1];
            memset(spaces, ' ', count);
            spaces[count] = '\0';

            if (vm->printFn) {
                vm->printFn(vm->printCtx, spaces, false);
            }

            break;
        }

        case OP_PRINT_TAB_N: {
            BasValueT nVal;

            if (!pop(vm, &nVal)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            int32_t col = basValToInt32(nVal);
            basValRelease(&nVal);

            if (col < 1) {
                col = 1;
            }

            if (col > BAS_PRINT_SPC_MAX) {
                col = BAS_PRINT_SPC_MAX;
            }

            // TAB outputs spaces to reach the specified column
            // For simplicity, just output (col-1) spaces
            char spaces[BAS_PRINT_SPC_MAX + 1];
            int32_t count = col - 1;
            memset(spaces, ' ', count);
            spaces[count] = '\0';

            if (vm->printFn) {
                vm->printFn(vm->printCtx, spaces, false);
            }

            break;
        }

        case OP_PRINT_USING: {
            // Pop value and format string (format is below value on stack)
            BasValueT val;
            BasValueT fmtVal;

            if (!popArgs(vm, 2, &val, &fmtVal)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            BasValueT fmtStr = basValToString(fmtVal);
            basValRelease(&fmtVal);

            const char *fmt = fmtStr.strVal->data;
            int32_t fmtLen  = fmtStr.strVal->len;
            char buf[BAS_FORMAT_BUF_SIZE];

            if (val.type == BAS_TYPE_STRING) {
                // String formatting
                const char *src = val.strVal ? val.strVal->data : "";

                if (fmtLen > 0 && fmt[0] == '!') {
                    // First character only
                    buf[0] = src[0] ? src[0] : ' ';
                    buf[1] = '\0';
                } else if (fmtLen > 0 && fmt[0] == '&') {
                    // Entire string
                    snprintf(buf, sizeof(buf), "%s", src);
                } else if (fmtLen >= 2 && fmt[0] == '\\') {
                    // Fixed-width: count characters between backslashes
                    int32_t width = 2;
                    for (int32_t i = 1; i < fmtLen; i++) {
                        if (fmt[i] == '\\') {
                            width = i + 1;
                            break;
                        }
                        width = i + 2;
                    }

                    // The format is a runtime string, so width is
                    // attacker-controlled; clamp so the pad loop and the
                    // buf[width] terminator stay inside buf (overflowed
                    // the interpreter stack otherwise).
                    if (width > BAS_FORMAT_BUF_SIZE - 1) {
                        width = BAS_FORMAT_BUF_SIZE - 1;
                    }

                    int32_t srcLen  = (int32_t)strlen(src);
                    int32_t copyLen = srcLen < width ? srcLen : width;
                    memcpy(buf, src, copyLen);
                    for (int32_t i = copyLen; i < width; i++) {
                        buf[i] = ' ';
                    }
                    buf[width] = '\0';
                } else {
                    snprintf(buf, sizeof(buf), "%s", src);
                }
            } else {
                // Numeric formatting
                double        n = basValToNumber(val);
                BasNumFormatT f;
                bool          sciNotation;

                parseNumFormat(fmt, fmtLen, &f, &sciNotation);

                if (sciNotation) {
                    char    sciFmt[BAS_SCI_FMT_LEN];
                    int32_t decimals = f.hasDecimal ? f.digitsAfter : 0;

                    snprintf(sciFmt, sizeof(sciFmt), "%%.%dE", (int)decimals);
                    snprintf(buf, sizeof(buf), sciFmt, n);
                } else {
                    formatNumber(n, &f, buf, sizeof(buf));
                }
            }

            basValRelease(&val);

            // Push format string back (PRINT USING reuses it for multiple values)
            if (!push(vm, fmtStr)) {
                return BAS_VM_STACK_OVERFLOW;
            }

            // Push formatted result
            if (!push(vm, basValStringFromC(buf))) {
                return BAS_VM_STACK_OVERFLOW;
            }

            break;
        }

        case OP_INPUT: {
            // Pop the prompt string pushed by the parser
            BasValueT promptVal;

            if (!pop(vm, &promptVal)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            // Build the full prompt: "user prompt? "
            char promptBuf[BAS_INPUT_PROMPT_LEN];
            const char *userPrompt = "";

            if (promptVal.type == BAS_TYPE_STRING && promptVal.strVal) {
                userPrompt = promptVal.strVal->data;
            }

            if (userPrompt[0]) {
                snprintf(promptBuf, sizeof(promptBuf), "%s? ", userPrompt);
            } else {
                snprintf(promptBuf, sizeof(promptBuf), "? ");
            }

            char buf[BAS_INPUT_LINE_LEN];
            buf[0] = '\0';

            if (vm->inputFn) {
                if (!vm->inputFn(vm->inputCtx, promptBuf, buf, sizeof(buf))) {
                    buf[0] = '\0';
                }
            }

            basValRelease(&promptVal);

            if (!push(vm, basValStringFromC(buf))) {
                return BAS_VM_STACK_OVERFLOW;
            }

            break;
        }

        // ============================================================
        // Math built-ins
        // ============================================================

        case OP_MATH_ABS:
        case OP_MATH_INT:
        case OP_MATH_FIX:
        case OP_MATH_SGN:
        case OP_MATH_SQR:
        case OP_MATH_SIN:
        case OP_MATH_COS:
        case OP_MATH_TAN:
        case OP_MATH_ATN:
        case OP_MATH_LOG:
        case OP_MATH_EXP:
        case OP_MATH_RND:
        case OP_MATH_RANDOMIZE:
            return execMath(vm, op);

        case OP_RGB: {
            // RGB(r, g, b) -> long color 0x00RRGGBB
            BasValueT vb;
            BasValueT vg;
            BasValueT vr;

            if (!popArgs(vm, 3, &vb, &vg, &vr)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            int32_t r = clampComponent(basValToInt32(vr));
            int32_t g = clampComponent(basValToInt32(vg));
            int32_t b = clampComponent(basValToInt32(vb));

            basValRelease(&vr);
            basValRelease(&vg);
            basValRelease(&vb);

            if (!push(vm, basValLong((r << BAS_RGB_RED_SHIFT) | (g << BAS_RGB_GREEN_SHIFT) | b))) {
                return BAS_VM_STACK_OVERFLOW;
            }

            break;
        }

        case OP_GET_RED:
        case OP_GET_GREEN:
        case OP_GET_BLUE: {
            BasValueT vc;

            if (!pop(vm, &vc)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            int32_t color = basValToInt32(vc);
            basValRelease(&vc);

            int32_t component;

            if (op == OP_GET_RED) {
                component = (color >> BAS_RGB_RED_SHIFT) & BAS_RGB_CHANNEL_MASK;
            } else if (op == OP_GET_GREEN) {
                component = (color >> BAS_RGB_GREEN_SHIFT) & BAS_RGB_CHANNEL_MASK;
            } else {
                component = color & BAS_RGB_CHANNEL_MASK;
            }

            if (!push(vm, basValInteger((int16_t)component))) {
                return BAS_VM_STACK_OVERFLOW;
            }

            break;
        }

        // ============================================================
        // Conversion built-ins
        // ============================================================

        case OP_STR_VAL: {
            if (vm->sp < 1) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            BasValueT *top = &vm->stack[vm->sp - 1];
            double n = basValToNumber(*top);
            basValRelease(top);
            *top = basValDouble(n);
            break;
        }

        case OP_STR_STRF: {
            // STR$(n): VB convention -- leading space for positive, "-" for negative
            if (vm->sp < 1) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            BasValueT  *top = &vm->stack[vm->sp - 1];
            BasValueT   num = *top;

            // Same digits as PRINT (basValFormatString), so STR$ and PRINT
            // never disagree; booleans and strings go through as numbers.
            if (num.type == BAS_TYPE_BOOLEAN || num.type == BAS_TYPE_STRING) {
                num = basValDouble(basValToNumber(num));
            }

            BasStringT *digits = basValFormatString(num);
            BasStringT *result;

            if (digits->data[0] == '-') {
                result = basStringRef(digits);
            } else {
                BasStringT *space = basStringNew(" ", 1);
                result = basStringConcat(space, digits);
                basStringUnref(space);
            }

            basStringUnref(digits);
            basValRelease(top);
            *top = strValue(result);
            break;
        }

        case OP_STR_HEX: {
            if (vm->sp < 1) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            BasValueT *top = &vm->stack[vm->sp - 1];
            int32_t    n   = basValToInt32(*top);
            char       buf[BAS_RADIX_STR_LEN];

            snprintf(buf, sizeof(buf), "%X", (unsigned int)n);
            basValRelease(top);
            *top = basValStringFromC(buf);
            break;
        }

        case OP_STR_OCT: {
            if (vm->sp < 1) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            BasValueT *top = &vm->stack[vm->sp - 1];
            int32_t    n   = basValToInt32(*top);
            char       buf[BAS_RADIX_STR_LEN];

            snprintf(buf, sizeof(buf), "%o", (unsigned int)n);
            basValRelease(top);
            *top = basValStringFromC(buf);
            break;
        }

        case OP_CONV_BOOL: {
            if (vm->sp < 1) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            BasValueT *top = &vm->stack[vm->sp - 1];
            bool truthy;

            if (top->type == BAS_TYPE_STRING) {
                truthy = (top->strVal && top->strVal->len > 0);
            } else {
                truthy = (basValToNumber(*top) != 0.0);
            }

            basValRelease(top);
            top->type    = BAS_TYPE_BOOLEAN;
            top->boolVal = truthy ? -1 : 0;
            break;
        }

        case OP_STR_STRING: {
            // STRING$(n, char)
            BasValueT charVal;
            BasValueT countVal;

            if (!popArgs(vm, 2, &charVal, &countVal)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            int32_t count = basValToInt32(countVal);
            basValRelease(&countVal);

            char ch;

            if (charVal.type == BAS_TYPE_STRING && charVal.strVal && charVal.strVal->len > 0) {
                ch = charVal.strVal->data[0];
            } else {
                ch = (char)(basValToInt32(charVal) & BAS_CHAR_MASK);
            }

            basValRelease(&charVal);

            if (count < 0 || count > BAS_STRING_FUNC_MAX) {
                runtimeError(vm, BAS_ERR_ILLEGAL_FUNC_CALL, "Illegal function call");
                return BAS_VM_ERROR;
            }

            if (!push(vm, strValue(fillString(ch, count)))) {
                return BAS_VM_STACK_OVERFLOW;
            }

            break;
        }

        // ============================================================
        // Extended built-ins
        // ============================================================

        case OP_MATH_TIMER: {
            // Push seconds since midnight as a double, with sub-second
            // resolution so timing loops do not stall for up to a second.
            struct tm t;
            int32_t   usec;

            localNow(&t, &usec);

            double secs = (double)t.tm_hour * BAS_SECS_PER_HOUR + (double)t.tm_min * BAS_SECS_PER_MINUTE + (double)t.tm_sec + (double)usec / BAS_USEC_PER_SEC;

            if (!push(vm, basValDouble(secs))) {
                return BAS_VM_STACK_OVERFLOW;
            }

            break;
        }

        case OP_DATE_STR: {
            // Push DATE$ as "MM-DD-YYYY"
            struct tm t;
            char      buf[BAS_DATE_STR_LEN];

            localNow(&t, NULL);
            snprintf(buf, sizeof(buf), "%02d-%02d-%04d", t.tm_mon + 1, t.tm_mday, t.tm_year + 1900);

            if (!push(vm, basValStringFromC(buf))) {
                return BAS_VM_STACK_OVERFLOW;
            }

            break;
        }

        case OP_TIME_STR: {
            // Push TIME$ as "HH:MM:SS"
            struct tm t;
            char      buf[BAS_TIME_STR_LEN];

            localNow(&t, NULL);
            snprintf(buf, sizeof(buf), "%02d:%02d:%02d", t.tm_hour, t.tm_min, t.tm_sec);

            if (!push(vm, basValStringFromC(buf))) {
                return BAS_VM_STACK_OVERFLOW;
            }

            break;
        }

        case OP_SLEEP: {
            // Pop seconds, sleep
            BasValueT val;

            if (!pop(vm, &val)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            int32_t secs = basValToInt32(val);
            basValRelease(&val);

            if (secs > 0) {
                sleep((unsigned int)secs);
            }

            break;
        }

        case OP_ENVIRON: {
            // Pop env var name, push value string
            BasValueT nameVal;

            if (!pop(vm, &nameVal)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            BasValueT nameStr = basValToString(nameVal);
            basValRelease(&nameVal);

            const char *envVal = getenv(nameStr.strVal->data);
            basValRelease(&nameStr);

            if (envVal == NULL) {
                envVal = "";
            }

            if (!push(vm, basValStringFromC(envVal))) {
                return BAS_VM_STACK_OVERFLOW;
            }

            break;
        }

        // ============================================================
        // File I/O
        // ============================================================

        case OP_FILE_OPEN:
        case OP_FILE_CLOSE:
        case OP_FILE_PRINT:
        case OP_FILE_INPUT:
        case OP_FILE_EOF:
        case OP_FILE_LINE_INPUT:
        case OP_FILE_WRITE:
        case OP_FILE_WRITE_SEP:
        case OP_FILE_WRITE_NL:
        case OP_FILE_GET:
        case OP_FILE_PUT:
        case OP_FILE_SEEK:
        case OP_FILE_LOF:
        case OP_FILE_LOC:
        case OP_FILE_FREEFILE:
        case OP_FILE_INPUT_N:
            return execFileOp(vm, op);

        case OP_FS_KILL:
        case OP_FS_NAME:
        case OP_FS_FILECOPY:
        case OP_FS_MKDIR:
        case OP_FS_RMDIR:
        case OP_FS_CHDIR:
        case OP_FS_CHDRIVE:
        case OP_FS_CURDIR:
        case OP_FS_DIR:
        case OP_FS_DIR_NEXT:
        case OP_FS_FILELEN:
        case OP_FS_GETATTR:
        case OP_FS_SETATTR:
            return execFsOp(vm, op);

        // ============================================================
        // DoEvents
        // ============================================================

        case OP_DO_EVENTS:
            if (vm->doEventsFn) {
                if (!vm->doEventsFn(vm->doEventsCtx)) {
                    vm->running = false;
                    return BAS_VM_HALTED;
                }
            }
            break;

        // ============================================================
        // Error handling
        // ============================================================

        case OP_ON_ERROR: {
            int16_t handler = readInt16(vm);
            int32_t target  = (handler == 0) ? 0 : vm->pc + handler;

            // Record the handler on the current call frame.  The error
            // dispatcher walks frames (innermost outward) to find a
            // handler and unwinds the stack to that frame before
            // jumping.  Module-level code lives in frame 0.
            currentFrame(vm)->errorHandler = target;
            break;
        }

        case OP_ERR_NUM:
            if (!push(vm, basValInteger((int16_t)vm->errorNumber))) {
                return BAS_VM_STACK_OVERFLOW;
            }
            break;

        case OP_RESUME:
        case OP_RESUME_NEXT:
            // RESUME re-executes the statement that raised the error;
            // RESUME NEXT continues at the statement after it.  Outside a
            // handler there is nothing to resume (QBASIC error 20).
            if (!vm->inErrorHandler) {
                runtimeError(vm, BAS_ERR_RESUME_WITHOUT_ERR, "RESUME without error");
                return BAS_VM_ERROR;
            }

            vm->pc             = (op == OP_RESUME) ? vm->errorPc : vm->errorNextPc;
            vm->errorNumber    = 0;
            vm->errorMsg[0]    = '\0';
            vm->inErrorHandler = false;
            break;

        case OP_RAISE_ERR: {
            BasValueT errVal;

            if (!pop(vm, &errVal)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            int32_t errNum = basValToInt32(errVal);
            basValRelease(&errVal);
            runtimeError(vm, errNum, "User-defined error");
            return BAS_VM_ERROR;
        }

        // ============================================================
        // Array / UDT operations
        // ============================================================

        case OP_DIM_ARRAY: {
            uint8_t dims        = readUint8(vm);
            uint8_t elementType = readUint8(vm);

            // dims=0 with elementType=BAS_TYPE_UDT means allocate a UDT instance
            if (dims == 0 && elementType == BAS_TYPE_UDT) {
                BasValueT fieldCountVal;
                BasValueT typeIdVal;

                if (!popArgs(vm, 2, &fieldCountVal, &typeIdVal)) {
                    return BAS_VM_STACK_UNDERFLOW;
                }

                int32_t fieldCount = basValToInt32(fieldCountVal);
                int32_t typeId     = basValToInt32(typeIdVal);
                basValRelease(&fieldCountVal);
                basValRelease(&typeIdVal);

                BasUdtT *udt = basUdtNew(typeId, fieldCount);

                if (!udt) {
                    runtimeError(vm, BAS_ERR_OUT_OF_MEMORY, "Out of memory allocating TYPE");
                    return BAS_VM_OUT_OF_MEMORY;
                }

                BasValueT udtVal;
                udtVal.type   = BAS_TYPE_UDT;
                udtVal.udtVal = udt;

                if (!push(vm, udtVal)) {
                    return BAS_VM_STACK_OVERFLOW;
                }

                break;
            }

            BasArrayT   *arr;
            BasVmResultE rc = allocArray(vm, dims, elementType, &arr);

            if (rc != BAS_VM_OK) {
                return rc;
            }

            BasValueT arrVal;
            arrVal.type   = BAS_TYPE_ARRAY;
            arrVal.arrVal = arr;

            if (!push(vm, arrVal)) {
                return BAS_VM_STACK_OVERFLOW;
            }

            break;
        }

        case OP_LOAD_ARRAY: {
            uint8_t dims = readUint8(vm);

            if (!validArrayDims(vm, dims)) {
                return BAS_VM_ERROR;
            }

            int32_t indices[BAS_ARRAY_MAX_DIMS];

            // Pop indices in reverse order
            for (int32_t d = dims - 1; d >= 0; d--) {
                BasValueT idxVal;

                if (!pop(vm, &idxVal)) {
                    return BAS_VM_STACK_UNDERFLOW;
                }

                indices[d] = basValToInt32(idxVal);
                basValRelease(&idxVal);
            }

            // Pop array reference
            BasValueT arrRef;

            if (!pop(vm, &arrRef)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            if (arrRef.type != BAS_TYPE_ARRAY || !arrRef.arrVal) {
                basValRelease(&arrRef);
                runtimeError(vm, BAS_ERR_TYPE_MISMATCH, "Not an array");
                return BAS_VM_TYPE_MISMATCH;
            }

            int32_t flatIdx = basArrayIndex(arrRef.arrVal, indices, dims);

            if (flatIdx < 0) {
                basValRelease(&arrRef);
                runtimeError(vm, BAS_ERR_SUBSCRIPT_RANGE, "Subscript out of range");
                return BAS_VM_SUBSCRIPT_RANGE;
            }

            BasValueT elem = basValCopy(arrRef.arrVal->elements[flatIdx]);
            basValRelease(&arrRef);

            if (!push(vm, elem)) {
                return BAS_VM_STACK_OVERFLOW;
            }

            break;
        }

        case OP_PUSH_ARR_ADDR: {
            // Pass `arr(i)` as a BYREF parameter: push a BAS_TYPE_ELEM_REF
            // that owns a counted reference to the array plus the element
            // index.  Holding the reference keeps the element storage alive
            // even if the callee REDIMs or ERASEs the array variable, and
            // the index is resolved on every load/store instead of caching
            // a raw element pointer.
            uint8_t dims = readUint8(vm);

            if (!validArrayDims(vm, dims)) {
                return BAS_VM_ERROR;
            }

            int32_t indices[BAS_ARRAY_MAX_DIMS];

            for (int32_t d = dims - 1; d >= 0; d--) {
                BasValueT idxVal;

                if (!pop(vm, &idxVal)) {
                    return BAS_VM_STACK_UNDERFLOW;
                }

                indices[d] = basValToInt32(idxVal);
                basValRelease(&idxVal);
            }

            BasValueT arrRef;

            if (!pop(vm, &arrRef)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            if (arrRef.type != BAS_TYPE_ARRAY || !arrRef.arrVal) {
                basValRelease(&arrRef);
                runtimeError(vm, BAS_ERR_TYPE_MISMATCH, "Not an array");
                return BAS_VM_TYPE_MISMATCH;
            }

            int32_t flatIdx = basArrayIndex(arrRef.arrVal, indices, dims);

            if (flatIdx < 0) {
                basValRelease(&arrRef);
                runtimeError(vm, BAS_ERR_SUBSCRIPT_RANGE, "Subscript out of range");
                return BAS_VM_SUBSCRIPT_RANGE;
            }

            // The popped arrRef's reference count transfers to the REF.
            BasValueT ref;
            ref.type        = BAS_TYPE_ELEM_REF;
            ref.elemRef.arr = arrRef.arrVal;
            ref.elemRef.idx = flatIdx;

            if (!push(vm, ref)) {
                return BAS_VM_STACK_OVERFLOW;
            }

            break;
        }

        case OP_STORE_ARRAY: {
            uint8_t dims = readUint8(vm);

            if (!validArrayDims(vm, dims)) {
                return BAS_VM_ERROR;
            }

            // Pop value to store
            BasValueT storeVal;

            if (!pop(vm, &storeVal)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            // Pop indices in reverse order
            int32_t indices[BAS_ARRAY_MAX_DIMS];

            for (int32_t d = dims - 1; d >= 0; d--) {
                BasValueT idxVal;

                if (!pop(vm, &idxVal)) {
                    basValRelease(&storeVal);
                    return BAS_VM_STACK_UNDERFLOW;
                }

                indices[d] = basValToInt32(idxVal);
                basValRelease(&idxVal);
            }

            // Pop array reference
            BasValueT arrRef;

            if (!pop(vm, &arrRef)) {
                basValRelease(&storeVal);
                return BAS_VM_STACK_UNDERFLOW;
            }

            if (arrRef.type != BAS_TYPE_ARRAY || !arrRef.arrVal) {
                basValRelease(&arrRef);
                basValRelease(&storeVal);
                runtimeError(vm, BAS_ERR_TYPE_MISMATCH, "Not an array");
                return BAS_VM_TYPE_MISMATCH;
            }

            int32_t flatIdx = basArrayIndex(arrRef.arrVal, indices, dims);

            if (flatIdx < 0) {
                basValRelease(&arrRef);
                basValRelease(&storeVal);
                runtimeError(vm, BAS_ERR_SUBSCRIPT_RANGE, "Subscript out of range");
                return BAS_VM_SUBSCRIPT_RANGE;
            }

            storeValue(&arrRef.arrVal->elements[flatIdx], storeVal);
            basValRelease(&arrRef);
            break;
        }

        case OP_STORE_ARRAY_FIELD: {
            uint8_t dims = readUint8(vm);
            uint16_t fieldIdx = readUint16(vm);

            if (!validArrayDims(vm, dims)) {
                return BAS_VM_ERROR;
            }

            // Pop value to store
            BasValueT storeVal;

            if (!pop(vm, &storeVal)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            // Pop indices in reverse order
            int32_t indices[BAS_ARRAY_MAX_DIMS];

            for (int32_t d = dims - 1; d >= 0; d--) {
                BasValueT idxVal;

                if (!pop(vm, &idxVal)) {
                    basValRelease(&storeVal);
                    return BAS_VM_STACK_UNDERFLOW;
                }

                indices[d] = basValToInt32(idxVal);
                basValRelease(&idxVal);
            }

            // Pop array reference
            BasValueT arrRef;

            if (!pop(vm, &arrRef)) {
                basValRelease(&storeVal);
                return BAS_VM_STACK_UNDERFLOW;
            }

            if (arrRef.type != BAS_TYPE_ARRAY || !arrRef.arrVal) {
                basValRelease(&arrRef);
                basValRelease(&storeVal);
                runtimeError(vm, BAS_ERR_TYPE_MISMATCH, "Not an array");
                return BAS_VM_TYPE_MISMATCH;
            }

            int32_t flatIdx = basArrayIndex(arrRef.arrVal, indices, dims);

            if (flatIdx < 0) {
                basValRelease(&arrRef);
                basValRelease(&storeVal);
                runtimeError(vm, BAS_ERR_SUBSCRIPT_RANGE, "Subscript out of range");
                return BAS_VM_SUBSCRIPT_RANGE;
            }

            // Element must be a UDT
            BasValueT *elem = &arrRef.arrVal->elements[flatIdx];

            if (elem->type != BAS_TYPE_UDT || !elem->udtVal) {
                basValRelease(&arrRef);
                basValRelease(&storeVal);
                runtimeError(vm, BAS_ERR_TYPE_MISMATCH, "Array element is not a TYPE instance");
                return BAS_VM_TYPE_MISMATCH;
            }

            if (fieldIdx >= (uint16_t)elem->udtVal->fieldCount) {
                basValRelease(&arrRef);
                basValRelease(&storeVal);
                runtimeError(vm, BAS_ERR_SUBSCRIPT_RANGE, "Invalid field index");
                return BAS_VM_ERROR;
            }

            storeValue(&elem->udtVal->fields[fieldIdx], storeVal);
            basValRelease(&arrRef);
            break;
        }

        case OP_REDIM: {
            uint8_t dims        = readUint8(vm);
            uint8_t preserve    = readUint8(vm);
            uint8_t elementType = readUint8(vm);

            // Same stack layout as OP_DIM_ARRAY (bounds, then UDT typeId and
            // fieldCount), with the old array reference underneath.
            BasArrayT   *newArr;
            BasVmResultE rc = allocArray(vm, dims, elementType, &newArr);

            if (rc != BAS_VM_OK) {
                return rc;
            }

            BasValueT oldRef;

            if (!pop(vm, &oldRef)) {
                basArrayUnref(newArr);
                return BAS_VM_STACK_UNDERFLOW;
            }

            // PRESERVE copies the elements whose subscripts exist in both
            // shapes, element by element, so a multi-dimensional array with
            // a changed row stride keeps every value at its own subscript.
            if (preserve && oldRef.type == BAS_TYPE_ARRAY && oldRef.arrVal && oldRef.arrVal->dims == newArr->dims) {
                preserveElements(oldRef.arrVal, newArr);
            }

            basValRelease(&oldRef);

            BasValueT arrVal;
            arrVal.type   = BAS_TYPE_ARRAY;
            arrVal.arrVal = newArr;

            if (!push(vm, arrVal)) {
                return BAS_VM_STACK_OVERFLOW;
            }

            break;
        }

        case OP_ERASE: {
            BasValueT arrRef;

            if (!pop(vm, &arrRef)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            basValRelease(&arrRef);

            // Push an empty/zero value to store back
            BasValueT empty;
            memset(&empty, 0, sizeof(empty));

            if (!push(vm, empty)) {
                return BAS_VM_STACK_OVERFLOW;
            }

            break;
        }

        case OP_LBOUND:
        case OP_UBOUND:
            return boundOp(vm, op);

        case OP_LOAD_FIELD: {
            uint16_t fieldIdx = readUint16(vm);
            BasValueT udtRef;

            if (!pop(vm, &udtRef)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            if (udtRef.type != BAS_TYPE_UDT || !udtRef.udtVal) {
                basValRelease(&udtRef);
                runtimeError(vm, BAS_ERR_TYPE_MISMATCH, "Not a TYPE instance");
                return BAS_VM_TYPE_MISMATCH;
            }

            if (fieldIdx >= (uint16_t)udtRef.udtVal->fieldCount) {
                basValRelease(&udtRef);
                runtimeError(vm, BAS_ERR_SUBSCRIPT_RANGE, "Invalid field index");
                return BAS_VM_ERROR;
            }

            BasValueT fieldVal = basValCopy(udtRef.udtVal->fields[fieldIdx]);
            basValRelease(&udtRef);

            if (!push(vm, fieldVal)) {
                return BAS_VM_STACK_OVERFLOW;
            }

            break;
        }

        case OP_STORE_FIELD: {
            uint16_t fieldIdx = readUint16(vm);
            BasValueT storeVal;

            if (!pop(vm, &storeVal)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            BasValueT udtRef;

            if (!pop(vm, &udtRef)) {
                basValRelease(&storeVal);
                return BAS_VM_STACK_UNDERFLOW;
            }

            if (udtRef.type != BAS_TYPE_UDT || !udtRef.udtVal) {
                basValRelease(&udtRef);
                basValRelease(&storeVal);
                runtimeError(vm, BAS_ERR_TYPE_MISMATCH, "Not a TYPE instance");
                return BAS_VM_TYPE_MISMATCH;
            }

            if (fieldIdx >= (uint16_t)udtRef.udtVal->fieldCount) {
                basValRelease(&udtRef);
                basValRelease(&storeVal);
                runtimeError(vm, BAS_ERR_SUBSCRIPT_RANGE, "Invalid field index");
                return BAS_VM_ERROR;
            }

            storeValue(&udtRef.udtVal->fields[fieldIdx], storeVal);
            basValRelease(&udtRef);
            break;
        }

        // ============================================================
        // DATA/READ/RESTORE
        // ============================================================

        case OP_READ_DATA: {
            if (vm->dataPtr >= vm->module->dataCount) {
                runtimeError(vm, BAS_ERR_OUT_OF_DATA, "Out of DATA");
                return BAS_VM_ERROR;
            }

            if (!push(vm, basValCopy(vm->module->dataPool[vm->dataPtr++]))) {
                return BAS_VM_STACK_OVERFLOW;
            }

            break;
        }

        case OP_RESTORE:
            vm->dataPtr = 0;
            break;

        // ============================================================
        // FORMAT$
        // ============================================================

        case OP_FORMAT: {
            // Pop format string, then value
            BasValueT fmtVal;
            BasValueT val;

            if (!popArgs(vm, 2, &fmtVal, &val)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            BasValueT fmtStr = basValToString(fmtVal);
            basValRelease(&fmtVal);

            const char *fmt    = fmtStr.strVal->data;
            int32_t     fmtLen = fmtStr.strVal->len;
            double      n      = basValToNumber(val);
            basValRelease(&val);

            char buf[BAS_FORMAT_BUF_SIZE];
            buf[0] = '\0';

            if (strcasecmp(fmt, BAS_FORMAT_PERCENT) == 0) {
                snprintf(buf, sizeof(buf), "%.0f%%", n * 100.0);
            } else {
                BasNumFormatT f;
                bool          sciNotation;

                parseNumFormat(fmt, fmtLen, &f, &sciNotation);

                // FORMAT$ has no ** / $$ fills; digit positions before the
                // number are zero-filled whenever a 0 placeholder appears.
                f.asteriskFill = false;
                f.dollarFloat  = false;
                f.formatPad    = true;

                formatNumber(n, &f, buf, sizeof(buf));
            }

            basValRelease(&fmtStr);

            if (!push(vm, basValStringFromC(buf))) {
                return BAS_VM_STACK_OVERFLOW;
            }

            break;
        }

        // ============================================================
        // SHELL
        // ============================================================

        case OP_SHELL: {
            BasValueT cmdVal;

            if (!pop(vm, &cmdVal)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            BasValueT cmdStr = basValToString(cmdVal);
            basValRelease(&cmdVal);

            int32_t result = 0;

            if (cmdStr.strVal && cmdStr.strVal->len > 0) {
                result = system(cmdStr.strVal->data);
            }

            basValRelease(&cmdStr);

            if (!push(vm, basValInteger((int16_t)result))) {
                return BAS_VM_STACK_OVERFLOW;
            }

            break;
        }

        // ============================================================
        // COMPARE_MODE
        // ============================================================

        case OP_COMPARE_MODE: {
            uint8_t mode = readUint8(vm);
            vm->compareTextMode = (mode == BAS_COMPARE_MODE_TEXT);
            break;
        }

        // ============================================================
        // Halt
        // ============================================================

        case OP_END:
        case OP_HALT:
            // A handler that ends the program has dealt with the error:
            // clear it so the host does not report a trapped error as an
            // unhandled one.
            if (vm->inErrorHandler) {
                vm->inErrorHandler = false;
                vm->errorNumber    = 0;
                vm->errorMsg[0]    = '\0';
            }

            vm->running = false;
            vm->ended   = vm->ended || (op == OP_END);
            return BAS_VM_HALTED;

        // ============================================================
        // UI / Event opcodes (name-based resolution)
        // ============================================================

        case OP_LOAD_PROP: {
            BasValueT propNameVal;
            BasValueT ctrlRefVal;

            if (!popArgs(vm, 2, &propNameVal, &ctrlRefVal)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            BasValueT result = basValInteger(0);

            if (vm->ui.getProp && ctrlRefVal.type == BAS_TYPE_OBJECT) {
                BasValueT sv = basValToString(propNameVal);
                result = vm->ui.getProp(vm->ui.ctx, ctrlRefVal.objVal, sv.strVal->data);
                basValRelease(&sv);
            }

            basValRelease(&propNameVal);
            basValRelease(&ctrlRefVal);

            if (!push(vm, result)) {
                return BAS_VM_STACK_OVERFLOW;
            }

            break;
        }

        case OP_STORE_PROP: {
            BasValueT value;
            BasValueT propNameVal;
            BasValueT ctrlRefVal;

            if (!popArgs(vm, 3, &value, &propNameVal, &ctrlRefVal)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            if (vm->ui.setProp && ctrlRefVal.type == BAS_TYPE_OBJECT) {
                BasValueT sv = basValToString(propNameVal);
                vm->ui.setProp(vm->ui.ctx, ctrlRefVal.objVal, sv.strVal->data, value);
                basValRelease(&sv);
            }

            basValRelease(&value);
            basValRelease(&propNameVal);
            basValRelease(&ctrlRefVal);
            break;
        }

        case OP_CALL_METHOD: {
            uint8_t argc = readUint8(vm);
            BasValueT methodNameVal;
            BasValueT ctrlRefVal;

            // Pop args in reverse
            BasValueT args[BAS_VM_MAX_CALL_ARGS];
            int32_t argCount = argc;

            // Reject an over-limit argc rather than silently truncating, which
            // would leak the un-popped args and desync the eval stack. Drain
            // every pushed operand (args + method name + control ref) first to
            // keep the stack balanced, then raise the error.
            if (argc > BAS_VM_MAX_CALL_ARGS) {
                int32_t drainCount = argc + BAS_CALL_METHOD_FIXED_OPERANDS;

                for (int32_t i = 0; i < drainCount; i++) {
                    BasValueT tmp;

                    if (pop(vm, &tmp)) {
                        basValRelease(&tmp);
                    }
                }

                runtimeError(vm, BAS_ERR_ILLEGAL_FUNC_CALL, "Too many arguments in method call");
                return BAS_VM_ERROR;
            }

            for (int32_t i = argCount - 1; i >= 0; i--) {
                if (!pop(vm, &args[i])) {
                    for (int32_t j = i + 1; j < argCount; j++) {
                        basValRelease(&args[j]);
                    }

                    return BAS_VM_STACK_UNDERFLOW;
                }
            }

            if (!pop(vm, &methodNameVal)) {
                for (int32_t i = 0; i < argCount; i++) {
                    basValRelease(&args[i]);
                }

                return BAS_VM_STACK_UNDERFLOW;
            }

            if (!pop(vm, &ctrlRefVal)) {
                // methodNameVal was already popped -- release it too or
                // its reference leaks on this underflow path.
                basValRelease(&methodNameVal);

                for (int32_t i = 0; i < argCount; i++) {
                    basValRelease(&args[i]);
                }

                return BAS_VM_STACK_UNDERFLOW;
            }

            BasValueT result = basValInteger(0);

            if (vm->ui.callMethod && ctrlRefVal.type == BAS_TYPE_OBJECT) {
                BasValueT sv = basValToString(methodNameVal);
                result = vm->ui.callMethod(vm->ui.ctx, ctrlRefVal.objVal, sv.strVal->data, args, argCount);
                basValRelease(&sv);
            }

            for (int32_t i = 0; i < argCount; i++) {
                basValRelease(&args[i]);
            }

            basValRelease(&methodNameVal);
            basValRelease(&ctrlRefVal);

            if (!push(vm, result)) {
                return BAS_VM_STACK_OVERFLOW;
            }

            break;
        }

        case OP_LOAD_FORM: {
            BasValueT nameVal;

            if (!pop(vm, &nameVal)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            void *formRef = NULL;

            if (vm->ui.loadForm) {
                BasValueT sv = basValToString(nameVal);
                formRef = vm->ui.loadForm(vm->ui.ctx, sv.strVal->data);
                basValRelease(&sv);
            }

            // Set as current form for subsequent FIND_CTRL calls
            if (formRef) {
                vm->currentForm = formRef;
            }

            basValRelease(&nameVal);

            if (!push(vm, basValObject(formRef))) {
                return BAS_VM_STACK_OVERFLOW;
            }

            break;
        }

        case OP_UNLOAD_FORM: {
            BasValueT formVal;

            if (!pop(vm, &formVal)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            if (vm->ui.unloadForm && formVal.type == BAS_TYPE_OBJECT) {
                vm->ui.unloadForm(vm->ui.ctx, formVal.objVal);
            }

            basValRelease(&formVal);
            break;
        }

        case OP_SHOW_FORM: {
            uint8_t modal = readUint8(vm);
            BasValueT formVal;

            if (!pop(vm, &formVal)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            if (vm->ui.showForm && formVal.type == BAS_TYPE_OBJECT) {
                vm->ui.showForm(vm->ui.ctx, formVal.objVal, modal != 0);
            }

            basValRelease(&formVal);
            break;
        }

        case OP_HIDE_FORM: {
            BasValueT formVal;

            if (!pop(vm, &formVal)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            if (vm->ui.hideForm && formVal.type == BAS_TYPE_OBJECT) {
                vm->ui.hideForm(vm->ui.ctx, formVal.objVal);
            }

            basValRelease(&formVal);
            break;
        }

        case OP_MSGBOX: {
            // Stack: [message, flags, title] -- title on top
            BasValueT titleVal;
            BasValueT flagsVal;
            BasValueT msgVal;

            if (!popArgs(vm, 3, &titleVal, &flagsVal, &msgVal)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            int32_t flags  = basValToInt32(flagsVal);
            int32_t result = 1; // default OK

            if (vm->ui.msgBox) {
                BasValueT sv = basValToString(msgVal);
                BasValueT tv = basValToString(titleVal);
                result = vm->ui.msgBox(vm->ui.ctx, sv.strVal->data, flags, tv.strVal->data);
                basValRelease(&sv);
                basValRelease(&tv);
            }

            basValRelease(&titleVal);
            basValRelease(&flagsVal);
            basValRelease(&msgVal);

            if (!push(vm, basValInteger((int16_t)result))) {
                return BAS_VM_STACK_OVERFLOW;
            }

            break;
        }

        case OP_INPUTBOX: {
            BasValueT defaultVal;
            BasValueT titleVal;
            BasValueT promptVal;

            if (!popArgs(vm, 3, &defaultVal, &titleVal, &promptVal)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            BasStringT *result = NULL;

            if (vm->ui.inputBox) {
                BasValueT sp = basValToString(promptVal);
                BasValueT st = basValToString(titleVal);
                BasValueT sd = basValToString(defaultVal);
                result = vm->ui.inputBox(vm->ui.ctx, sp.strVal->data, st.strVal->data, sd.strVal->data);
                basValRelease(&sp);
                basValRelease(&st);
                basValRelease(&sd);
            }

            basValRelease(&defaultVal);
            basValRelease(&titleVal);
            basValRelease(&promptVal);

            if (!push(vm, strValue(result ? result : basStringRef(basEmptyString)))) {
                return BAS_VM_STACK_OVERFLOW;
            }

            break;
        }

        case OP_ME_REF:
            if (!push(vm, basValObject(vm->currentForm))) {
                return BAS_VM_STACK_OVERFLOW;
            }

            break;

        case OP_CREATE_CTRL: {
            BasValueT nameVal;
            BasValueT typeVal;
            BasValueT formVal;

            if (!popArgs(vm, 3, &nameVal, &typeVal, &formVal)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            void *ctrlRef = NULL;

            if (vm->ui.createCtrl && formVal.type == BAS_TYPE_OBJECT) {
                BasValueT sv1 = basValToString(typeVal);
                BasValueT sv2 = basValToString(nameVal);
                ctrlRef = vm->ui.createCtrl(vm->ui.ctx, formVal.objVal, sv1.strVal->data, sv2.strVal->data);
                basValRelease(&sv1);
                basValRelease(&sv2);
            }

            basValRelease(&nameVal);
            basValRelease(&typeVal);
            basValRelease(&formVal);

            if (!push(vm, basValObject(ctrlRef))) {
                return BAS_VM_STACK_OVERFLOW;
            }

            break;
        }

        case OP_CREATE_CTRL_EX: {
            // Stack: [formRef, typeStr, nameStr, parentRef] -- parent on top
            BasValueT parentVal;
            BasValueT nameVal;
            BasValueT typeVal;
            BasValueT formVal;

            if (!popArgs(vm, 4, &parentVal, &nameVal, &typeVal, &formVal)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            void *ctrlRef = NULL;

            if (vm->ui.createCtrlEx && formVal.type == BAS_TYPE_OBJECT) {
                BasValueT sv1 = basValToString(typeVal);
                BasValueT sv2 = basValToString(nameVal);
                void *parentRef = (parentVal.type == BAS_TYPE_OBJECT) ? parentVal.objVal : NULL;
                ctrlRef = vm->ui.createCtrlEx(vm->ui.ctx, formVal.objVal, sv1.strVal->data, sv2.strVal->data, parentRef);
                basValRelease(&sv1);
                basValRelease(&sv2);
            }

            basValRelease(&parentVal);
            basValRelease(&nameVal);
            basValRelease(&typeVal);
            basValRelease(&formVal);

            if (!push(vm, basValObject(ctrlRef))) {
                return BAS_VM_STACK_OVERFLOW;
            }

            break;
        }

        case OP_FIND_CTRL: {
            BasValueT nameVal;
            BasValueT formVal;

            if (!popArgs(vm, 2, &nameVal, &formVal)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            void *ctrlRef = NULL;

            if (vm->ui.findCtrl) {
                // Use current form if formRef is NULL or not an object
                void *formRef = (formVal.type == BAS_TYPE_OBJECT) ? formVal.objVal : vm->currentForm;
                BasValueT sv = basValToString(nameVal);
                ctrlRef = vm->ui.findCtrl(vm->ui.ctx, formRef, sv.strVal->data);
                basValRelease(&sv);
            }

            basValRelease(&nameVal);
            basValRelease(&formVal);

            if (!push(vm, basValObject(ctrlRef))) {
                return BAS_VM_STACK_OVERFLOW;
            }

            break;
        }

        case OP_FIND_CTRL_IDX: {
            // Stack: [formRef, ctrlName, index] -- index on top
            BasValueT idxVal;
            BasValueT nameVal;
            BasValueT formVal;

            if (!popArgs(vm, 3, &idxVal, &nameVal, &formVal)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            void *ctrlRef = NULL;

            if (vm->ui.findCtrlIdx) {
                void *formRef = (formVal.type == BAS_TYPE_OBJECT) ? formVal.objVal : vm->currentForm;
                int32_t index = basValToInt32(idxVal);
                BasValueT sv  = basValToString(nameVal);
                ctrlRef = vm->ui.findCtrlIdx(vm->ui.ctx, formRef, sv.strVal->data, index);
                basValRelease(&sv);
            }

            basValRelease(&idxVal);
            basValRelease(&nameVal);
            basValRelease(&formVal);

            if (!push(vm, basValObject(ctrlRef))) {
                return BAS_VM_STACK_OVERFLOW;
            }

            break;
        }

        case OP_CREATE_FORM: {
            // Stack: [nameStr, width, height] -- height on top
            BasValueT heightVal;
            BasValueT widthVal;
            BasValueT nameVal;

            if (!popArgs(vm, 3, &heightVal, &widthVal, &nameVal)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            void *formRef = NULL;

            if (vm->ui.createForm) {
                BasValueT sv = basValToString(nameVal);
                int32_t w = basValToInt32(widthVal);
                int32_t h = basValToInt32(heightVal);
                formRef = vm->ui.createForm(vm->ui.ctx, sv.strVal->data, w, h);
                basValRelease(&sv);
            }

            basValRelease(&nameVal);
            basValRelease(&widthVal);
            basValRelease(&heightVal);

            if (!push(vm, basValObject(formRef))) {
                return BAS_VM_STACK_OVERFLOW;
            }

            break;
        }

        case OP_SET_EVENT: {
            // Stack: [ctrlRef, eventNameStr, handlerNameStr] -- handler on top
            BasValueT handlerVal;
            BasValueT eventVal;
            BasValueT ctrlVal;

            if (!popArgs(vm, 3, &handlerVal, &eventVal, &ctrlVal)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            if (vm->ui.setEvent && ctrlVal.type == BAS_TYPE_OBJECT) {
                BasValueT sv1 = basValToString(eventVal);
                BasValueT sv2 = basValToString(handlerVal);
                vm->ui.setEvent(vm->ui.ctx, ctrlVal.objVal, sv1.strVal->data, sv2.strVal->data);
                basValRelease(&sv1);
                basValRelease(&sv2);
            }

            basValRelease(&handlerVal);
            basValRelease(&eventVal);
            basValRelease(&ctrlVal);
            break;
        }

        case OP_REMOVE_CTRL: {
            // Stack: [formRef, ctrlNameStr] -- name on top
            BasValueT nameVal;
            BasValueT formVal;

            if (!popArgs(vm, 2, &nameVal, &formVal)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            if (vm->ui.removeCtrl && formVal.type == BAS_TYPE_OBJECT) {
                BasValueT sv = basValToString(nameVal);
                vm->ui.removeCtrl(vm->ui.ctx, formVal.objVal, sv.strVal->data);
                basValRelease(&sv);
            }

            basValRelease(&nameVal);
            basValRelease(&formVal);
            break;
        }

        // ============================================================
        // Form-level variables
        // ============================================================

        case OP_LOAD_FORM_VAR: {
            uint16_t idx = readUint16(vm);

            if (!vm->currentFormVars || idx >= (uint16_t)vm->currentFormVarCount) {
                runtimeError(vm, BAS_ERR_SUBSCRIPT_RANGE, "Form variable access outside form context");
                return BAS_VM_ERROR;
            }

            if (!push(vm, basValCopy(vm->currentFormVars[idx]))) {
                return BAS_VM_STACK_OVERFLOW;
            }

            break;
        }

        case OP_STORE_FORM_VAR: {
            uint16_t idx = readUint16(vm);
            BasValueT val;

            if (!pop(vm, &val)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            if (!vm->currentFormVars || idx >= (uint16_t)vm->currentFormVarCount) {
                basValRelease(&val);
                runtimeError(vm, BAS_ERR_SUBSCRIPT_RANGE, "Form variable access outside form context");
                return BAS_VM_ERROR;
            }

            storeValue(&vm->currentFormVars[idx], val);
            break;
        }

        case OP_PUSH_FORM_ADDR: {
            uint16_t idx = readUint16(vm);

            if (!vm->currentFormVars || idx >= (uint16_t)vm->currentFormVarCount) {
                runtimeError(vm, BAS_ERR_SUBSCRIPT_RANGE, "Form variable address outside form context");
                return BAS_VM_ERROR;
            }

            BasValueT ref;
            ref.type   = BAS_TYPE_REF;
            ref.refVal = &vm->currentFormVars[idx];

            if (!push(vm, ref)) {
                return BAS_VM_STACK_OVERFLOW;
            }

            break;
        }

        // ============================================================
        // External library calls
        // ============================================================

        case OP_CALL_EXTERN: {
            uint16_t libNameIdx  = readUint16(vm);
            uint16_t funcNameIdx = readUint16(vm);
            uint8_t  argc        = readUint8(vm);
            uint8_t  retType     = readUint8(vm);

            // Look up in cache
            void *funcPtr = NULL;
            int32_t cacheIdx = -1;

            for (int32_t i = 0; i < vm->externCacheCount; i++) {
                if (vm->externCache[i].libNameIdx == libNameIdx &&
                    vm->externCache[i].funcNameIdx == funcNameIdx) {
                    funcPtr  = vm->externCache[i].funcPtr;
                    cacheIdx = i;
                    break;
                }
            }

            // Resolve on first call
            if (cacheIdx < 0) {
                const char *libName  = "";
                const char *funcName = "";

                if (libNameIdx < (uint16_t)vm->module->constCount) {
                    libName = vm->module->constants[libNameIdx]->data;
                }

                if (funcNameIdx < (uint16_t)vm->module->constCount) {
                    funcName = vm->module->constants[funcNameIdx]->data;
                }

                if (vm->ext.resolveExtern) {
                    funcPtr = vm->ext.resolveExtern(vm->ext.ctx, libName, funcName);
                }

                if (!funcPtr) {
                    // Pop args to keep stack balanced
                    for (int32_t i = 0; i < argc; i++) {
                        BasValueT tmp;
                        if (pop(vm, &tmp)) {
                            basValRelease(&tmp);
                        }
                    }

                    char msg[BAS_VM_ERROR_MSG_LEN];

                    snprintf(msg, sizeof(msg), "External function not found: %s", funcName[0] ? funcName : "?");
                    runtimeError(vm, BAS_ERR_EXTERNAL_NOT_FOUND, msg);
                    return BAS_VM_ERROR;
                }

                // Cache it
                if (vm->externCacheCount < BAS_EXTERN_CACHE_SIZE) {
                    vm->externCache[vm->externCacheCount].libNameIdx  = libNameIdx;
                    vm->externCache[vm->externCacheCount].funcNameIdx = funcNameIdx;
                    vm->externCache[vm->externCacheCount].funcPtr     = funcPtr;
                    vm->externCacheCount++;
                }
            }

            // Pop arguments (reverse order)
            BasValueT args[BAS_VM_MAX_CALL_ARGS];
            int32_t argCount = argc;

            // Reject an over-limit argc rather than silently truncating, which
            // would leak the un-popped args and desync the eval stack. Drain
            // the pushed args (extern has no extra fixed operands) to keep the
            // stack balanced, then raise the error.
            if (argc > BAS_VM_MAX_CALL_ARGS) {
                for (int32_t i = 0; i < argc; i++) {
                    BasValueT tmp;

                    if (pop(vm, &tmp)) {
                        basValRelease(&tmp);
                    }
                }

                runtimeError(vm, BAS_ERR_ILLEGAL_FUNC_CALL, "Too many arguments in external call");
                return BAS_VM_ERROR;
            }

            for (int32_t i = argCount - 1; i >= 0; i--) {
                if (!pop(vm, &args[i])) {
                    for (int32_t j = i + 1; j < argCount; j++) {
                        basValRelease(&args[j]);
                    }

                    return BAS_VM_STACK_UNDERFLOW;
                }
            }

            // Call through host callback
            BasValueT result = basValInteger(0);

            if (vm->ext.callExtern) {
                const char *libName  = libNameIdx < (uint16_t)vm->module->constCount
                                       ? vm->module->constants[libNameIdx]->data : "";
                const char *funcName = funcNameIdx < (uint16_t)vm->module->constCount
                                       ? vm->module->constants[funcNameIdx]->data : "";
                result = vm->ext.callExtern(vm->ext.ctx, funcPtr, libName, funcName, args, argCount, retType);
            }

            for (int32_t i = 0; i < argCount; i++) {
                basValRelease(&args[i]);
            }

            // Push return value (void functions still push a dummy 0)
            if (!push(vm, result)) {
                return BAS_VM_STACK_OVERFLOW;
            }

            break;
        }

        // ============================================================
        // Debugger statement boundaries
        // ============================================================

        case OP_LINE: {
            uint16_t lineNum = readUint16(vm);
            vm->currentLine  = lineNum;

            // Snapshot the eval-stack depth at this statement boundary so an
            // ON ERROR GOTO dispatch can release any operands left half-pushed
            // by an error mid-expression and reset sp to here.  Also remember
            // where the statement starts: RESUME re-executes it from here and
            // RESUME NEXT scans from here for the following statement.
            vm->stmtSp = vm->sp;
            vm->stmtPc = vm->currentOpPc;

            // Step into: break at any OP_LINE
            if (vm->debugBreak) {
                vm->debugBreak = false;
                return BAS_VM_BREAKPOINT;
            }

            // Step over: break when call depth returns to target level
            if (vm->stepOverDepth >= 0 && vm->callDepth <= vm->stepOverDepth) {
                vm->stepOverDepth = -1;
                return BAS_VM_BREAKPOINT;
            }

            // Step out: break when call depth drops below target
            if (vm->stepOutDepth >= 0 && vm->callDepth < vm->stepOutDepth) {
                vm->stepOutDepth = -1;
                return BAS_VM_BREAKPOINT;
            }

            // Run to cursor
            if (vm->runToCursorLine >= 0 && (int32_t)lineNum == vm->runToCursorLine) {
                vm->runToCursorLine = -1;
                return BAS_VM_BREAKPOINT;
            }

            // Breakpoint check (linear scan, typically < 20 entries).
            // Gated on non-zero count so release/no-breakpoint programs pay
            // no per-line overhead at all.
            if (vm->breakpointCount > 0) {
                for (int32_t i = 0; i < vm->breakpointCount; i++) {
                    if (vm->breakpoints[i] == (int32_t)lineNum) {
                        return BAS_VM_BREAKPOINT;
                    }
                }
            }

            break;
        }

        case OP_APP_PATH:
        case OP_APP_CONFIG:
        case OP_APP_DATA: {
            const char *path = (op == OP_APP_PATH) ? vm->appPath : (op == OP_APP_CONFIG) ? vm->appConfig : vm->appData;

            if (!push(vm, basValStringFromC(path))) {
                return BAS_VM_STACK_OVERFLOW;
            }

            break;
        }

        case OP_INI_READ: {
            // Stack: file, section, key, default -> result string
            BasValueT defVal;
            BasValueT keyVal;
            BasValueT secVal;
            BasValueT fileVal;

            if (!popArgs(vm, 4, &defVal, &keyVal, &secVal, &fileVal)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            BasStringT *fileStr = basValFormatString(fileVal);
            BasStringT *secStr  = basValFormatString(secVal);
            BasStringT *keyStr  = basValFormatString(keyVal);
            BasStringT *defStr  = basValFormatString(defVal);
            const char *result  = defStr->data;

            // line[] must outlive the block below: on a key match 'result'
            // points into it and is consumed by the push() after fclose().
            char line[BAS_INI_LINE_LEN];

            FILE *fp = fopen(fileStr->data, "r");
            if (fp) {
                bool inSection = false;
                while (fgets(line, sizeof(line), fp)) {
                    // Strip trailing whitespace
                    char *end = line + strlen(line) - 1;
                    while (end >= line && (*end == '\r' || *end == '\n' || *end == ' ')) {
                        *end-- = '\0';
                    }
                    char *p = (char *)dvxSkipWs(line);
                    if (*p == '\0' || *p == ';' || *p == '#') {
                        continue;
                    }
                    if (*p == '[') {
                        char *rb = strchr(p, ']');
                        if (rb) {
                            *rb = '\0';
                            inSection = (strcasecmp(p + 1, secStr->data) == 0);
                        }
                        continue;
                    }
                    if (inSection) {
                        char *eq = strchr(p, '=');
                        if (eq) {
                            *eq = '\0';
                            char *k = p;
                            char *ke = eq - 1;
                            while (ke >= k && (*ke == ' ' || *ke == '\t')) {
                                *ke-- = '\0';
                            }
                            if (strcasecmp(k, keyStr->data) == 0) {
                                result = dvxSkipWs(eq + 1);
                                break;
                            }
                        }
                    }
                }
                fclose(fp);
            }

            BasValueT resultVal = basValStringFromC(result);

            basStringUnref(fileStr);
            basStringUnref(secStr);
            basStringUnref(keyStr);
            basStringUnref(defStr);
            basValRelease(&fileVal);
            basValRelease(&secVal);
            basValRelease(&keyVal);
            basValRelease(&defVal);

            if (!push(vm, resultVal)) {
                return BAS_VM_STACK_OVERFLOW;
            }

            break;
        }

        case OP_INI_WRITE: {
            // Stack: file, section, key, value
            BasValueT valVal;
            BasValueT keyVal;
            BasValueT secVal;
            BasValueT fileVal;

            if (!popArgs(vm, 4, &valVal, &keyVal, &secVal, &fileVal)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            BasStringT *fileStr = basValFormatString(fileVal);
            BasStringT *secStr  = basValFormatString(secVal);
            BasStringT *keyStr  = basValFormatString(keyVal);
            BasStringT *valStr  = basValFormatString(valVal);

            // Read existing file into memory
            char *content = platformReadFile(fileStr->data, NULL);

            // Write updated file
            FILE *fp = fopen(fileStr->data, "w");
            if (fp) {
                bool keyWritten = false;
                bool inSection = false;
                bool sectionFound = false;

                if (content) {
                    char *line = content;
                    while (line && *line) {
                        char *eol = strchr(line, '\n');
                        char lineBuf[BAS_INI_LINE_LEN];
                        int32_t ll;
                        if (eol) {
                            ll = (int32_t)(eol - line);
                            if (ll > 0 && line[ll - 1] == '\r') {
                                ll--;
                            }
                            if (ll >= (int32_t)sizeof(lineBuf)) {
                                ll = (int32_t)sizeof(lineBuf) - 1;
                            }
                            memcpy(lineBuf, line, ll);
                            lineBuf[ll] = '\0';
                            line = eol + 1;
                        } else {
                            snprintf(lineBuf, sizeof(lineBuf), "%s", line);
                            ll = (int32_t)strlen(lineBuf);
                            line = NULL;
                        }

                        char *p = (char *)dvxSkipWs(lineBuf);

                        if (*p == '[') {
                            // Write pending key before leaving section
                            if (inSection && !keyWritten) {
                                fprintf(fp, "%s = %s\n", keyStr->data, valStr->data);
                                keyWritten = true;
                            }
                            char *rb = strchr(p, ']');
                            if (rb) {
                                char secName[BAS_INI_NAME_LEN];
                                int32_t sn = (int32_t)(rb - p - 1);
                                if (sn >= (int32_t)sizeof(secName)) {
                                    sn = (int32_t)sizeof(secName) - 1;
                                }
                                memcpy(secName, p + 1, sn);
                                secName[sn] = '\0';
                                inSection = (strcasecmp(secName, secStr->data) == 0);
                                if (inSection) {
                                    sectionFound = true;
                                }
                            }
                            fprintf(fp, "%s\n", lineBuf);
                            continue;
                        }

                        if (inSection && !keyWritten) {
                            char *eq = strchr(p, '=');
                            if (eq) {
                                char k[BAS_INI_NAME_LEN];
                                int32_t kl = (int32_t)(eq - p);
                                while (kl > 0 && (p[kl - 1] == ' ' || p[kl - 1] == '\t')) {
                                    kl--;
                                }
                                if (kl >= (int32_t)sizeof(k)) {
                                    kl = (int32_t)sizeof(k) - 1;
                                }
                                memcpy(k, p, kl);
                                k[kl] = '\0';
                                if (strcasecmp(k, keyStr->data) == 0) {
                                    fprintf(fp, "%s = %s\n", keyStr->data, valStr->data);
                                    keyWritten = true;
                                    continue;
                                }
                            }
                        }

                        fprintf(fp, "%s\n", lineBuf);
                    }
                }

                // Key not found in existing section -- append
                if (!keyWritten) {
                    if (!sectionFound) {
                        fprintf(fp, "[%s]\n", secStr->data);
                    }
                    fprintf(fp, "%s = %s\n", keyStr->data, valStr->data);
                }

                fclose(fp);
            }

            free(content);
            basStringUnref(fileStr);
            basStringUnref(secStr);
            basStringUnref(keyStr);
            basStringUnref(valStr);
            basValRelease(&fileVal);
            basValRelease(&secVal);
            basValRelease(&keyVal);
            basValRelease(&valVal);
            break;
        }

        default:
            runtimeError(vm, BAS_ERR_BAD_OPCODE, "Bad opcode");
            return BAS_VM_BAD_OPCODE;
    }

    return BAS_VM_OK;
}


void basVmStepInto(BasVmT *vm) {
    if (vm) {
        vm->debugBreak = true;
    }
}


void basVmStepOut(BasVmT *vm) {
    if (vm) {
        vm->stepOutDepth = vm->callDepth;
    }
}


void basVmStepOver(BasVmT *vm) {
    if (vm) {
        vm->stepOverDepth = vm->callDepth;
    }
}


// Shared body of OP_DIM_ARRAY / OP_REDIM: pops the UDT typeId/fieldCount
// pair (UDT element type only) and the (lbound, ubound) pairs for each
// dimension, allocates the array and seeds UDT elements with fresh
// instances.  On success *outArr holds a refCount-1 array.
static BasVmResultE allocArray(BasVmT *vm, int32_t dims, uint8_t elementType, BasArrayT **outArr) {
    if (!validArrayDims(vm, dims)) {
        return BAS_VM_ERROR;
    }

    int32_t udtTypeId   = -1;
    int32_t udtFieldCnt = 0;

    if (elementType == BAS_TYPE_UDT) {
        BasValueT fcVal;
        BasValueT tiVal;

        if (!popArgs(vm, 2, &fcVal, &tiVal)) {
            return BAS_VM_STACK_UNDERFLOW;
        }

        udtFieldCnt = basValToInt32(fcVal);
        udtTypeId   = basValToInt32(tiVal);
        basValRelease(&fcVal);
        basValRelease(&tiVal);
    }

    int32_t lbounds[BAS_ARRAY_MAX_DIMS];
    int32_t ubounds[BAS_ARRAY_MAX_DIMS];

    // Pop bounds in reverse order (last dim first)
    for (int32_t d = dims - 1; d >= 0; d--) {
        BasValueT ubVal;
        BasValueT lbVal;

        if (!popArgs(vm, 2, &ubVal, &lbVal)) {
            return BAS_VM_STACK_UNDERFLOW;
        }

        ubounds[d] = basValToInt32(ubVal);
        lbounds[d] = basValToInt32(lbVal);
        basValRelease(&ubVal);
        basValRelease(&lbVal);
    }

    BasArrayT *arr = basArrayNew(dims, lbounds, ubounds, elementType);

    if (!arr) {
        runtimeError(vm, BAS_ERR_OUT_OF_MEMORY, "Out of memory allocating array");
        return BAS_VM_OUT_OF_MEMORY;
    }

    // Initialize UDT array elements with proper UDT instances
    if (elementType == BAS_TYPE_UDT && udtTypeId >= 0) {
        for (int32_t i = 0; i < arr->totalElements; i++) {
            BasUdtT *udt = basUdtNew(udtTypeId, udtFieldCnt);

            if (!udt) {
                basArrayFree(arr);
                runtimeError(vm, BAS_ERR_OUT_OF_MEMORY, "Out of memory allocating TYPE array elements");
                return BAS_VM_OUT_OF_MEMORY;
            }

            arr->elements[i].type   = BAS_TYPE_UDT;
            arr->elements[i].udtVal = udt;
        }
    }

    *outArr = arr;
    return BAS_VM_OK;
}


// OP_LBOUND / OP_UBOUND: [uint8 dim] array ref on stack -> LONG bound.
static BasVmResultE boundOp(BasVmT *vm, uint8_t op) {
    uint8_t   dim = readUint8(vm);
    BasValueT arrRef;

    if (!pop(vm, &arrRef)) {
        return BAS_VM_STACK_UNDERFLOW;
    }

    if (arrRef.type != BAS_TYPE_ARRAY || !arrRef.arrVal) {
        basValRelease(&arrRef);
        runtimeError(vm, BAS_ERR_TYPE_MISMATCH, "Not an array");
        return BAS_VM_TYPE_MISMATCH;
    }

    if (dim < 1 || dim > (uint8_t)arrRef.arrVal->dims) {
        basValRelease(&arrRef);
        runtimeError(vm, BAS_ERR_SUBSCRIPT_RANGE, (op == OP_LBOUND) ? "Invalid dimension for LBOUND" : "Invalid dimension for UBOUND");
        return BAS_VM_SUBSCRIPT_RANGE;
    }

    int32_t bound = (op == OP_LBOUND) ? arrRef.arrVal->lbound[dim - 1] : arrRef.arrVal->ubound[dim - 1];
    basValRelease(&arrRef);

    if (!push(vm, basValLong(bound))) {
        return BAS_VM_STACK_OVERFLOW;
    }

    return BAS_VM_OK;
}


// Clamps an RGB channel value into [0, BAS_RGB_COMPONENT_MAX].
static int32_t clampComponent(int32_t c) {
    if (c < 0) {
        return 0;
    }

    if (c > BAS_RGB_COMPONENT_MAX) {
        return BAS_RGB_COMPONENT_MAX;
    }

    return c;
}


static BasCallFrameT *currentFrame(BasVmT *vm) {
    if (vm->callDepth <= 0) {
        // Module-level: use callStack[0] as implicit main frame
        return &vm->callStack[0];
    }

    return &vm->callStack[vm->callDepth - 1];
}


static void defaultPrint(void *ctx, const char *text, bool newline) {
    (void)ctx;
    fputs(text, stdout);

    if (newline) {
        fputc('\n', stdout);
    }
}


// Resolves a variable slot through any ByRef indirection it holds: a plain
// slot is returned as-is, a BAS_TYPE_REF yields the referenced slot, and a
// BAS_TYPE_ELEM_REF yields the array element it names (NULL if the index
// no longer exists in that array).
static BasValueT *derefSlot(BasValueT *slot) {
    if (slot->type == BAS_TYPE_REF) {
        return slot->refVal;
    }

    if (slot->type == BAS_TYPE_ELEM_REF) {
        BasArrayT *arr = slot->elemRef.arr;

        if (!arr || slot->elemRef.idx < 0 || slot->elemRef.idx >= arr->totalElements) {
            return NULL;
        }

        return &arr->elements[slot->elemRef.idx];
    }

    return slot;
}


// Dir$ state lives in BasVmT (vm->dirHandle/dirPattern/dirPath) so that a
// second embedded VM cannot close another VM's in-progress enumeration.
static void dirClose(BasVmT *vm) {
    if (vm->dirHandle) {
        closedir((DIR *)vm->dirHandle);
        vm->dirHandle = NULL;
    }
}


static const char *dirNext(BasVmT *vm) {
    if (!vm->dirHandle) {
        return NULL;
    }

    struct dirent *ent;

    while ((ent = readdir((DIR *)vm->dirHandle)) != NULL) {
        if (fnmatch(vm->dirPattern, ent->d_name, FNM_CASEFOLD) == 0) {
            return ent->d_name;
        }
    }

    dirClose(vm);
    return NULL;
}


// ON ERROR dispatch shared by basVmRun and runSubLoop.  Given a failing
// step result, unwinds call frames (never below floorDepth) until it finds
// one whose ON ERROR GOTO registered a handler, truncates the eval stack to
// the failing statement's boundary, records the RESUME / RESUME NEXT
// targets and points pc at the handler.  Returns true when execution should
// continue in the handler, false when the error is untrapped (frames
// without a handler have already been discarded).
//
// Without the unwind an error raised inside a called SUB would fire the
// outer SUB's handler but OP_RET would then pop the inner frame and resume
// after the call site (effectively RESUME NEXT semantics), which is not
// what ON ERROR promises.
static bool dispatchError(BasVmT *vm, BasVmResultE result, int32_t floorDepth, int32_t fallbackPc) {
    if (vm->inErrorHandler || result == BAS_VM_HALTED || result == BAS_VM_BAD_OPCODE) {
        return false;
    }

    // Eval/call stack exhaustion bypasses runtimeError(), so give the
    // handler a real ERR value instead of 0.
    if (result == BAS_VM_STACK_OVERFLOW || result == BAS_VM_STACK_UNDERFLOW || result == BAS_VM_CALL_OVERFLOW) {
        runtimeError(vm, BAS_ERR_OUT_OF_MEMORY, "Out of stack space");
    }

    int32_t target   = 0;
    int32_t resumePc = -1;

    while (vm->callDepth > floorDepth) {
        BasCallFrameT *frame = &vm->callStack[vm->callDepth - 1];

        if (frame->errorHandler != 0) {
            target = frame->errorHandler;
            break;
        }

        // No handler on this frame -- discard it and keep unwinding.
        // Frame-local values need releasing.
        for (int32_t li = 0; li < frame->localCount; li++) {
            basValRelease(&frame->locals[li]);
        }

        // Restore the caller's context: drop the callee's FOR frames and
        // bring back the statement boundary of the calling statement.
        // RESUME must land in the handler frame's code, never in the
        // discarded callee's.
        forStackTrim(vm, frame->savedForDepth);
        vm->stmtPc = frame->savedStmtPc;
        vm->stmtSp = frame->savedStmtSp;
        resumePc   = frame->returnPc;

        vm->callDepth--;
    }

    if (target == 0) {
        return false;
    }

    // Release any operands the failed statement half-pushed before the
    // error fired, then truncate the eval stack back to the statement
    // boundary so trapped errors in a loop don't leak values and grow sp
    // without bound.
    if (vm->sp > vm->stmtSp) {
        for (int32_t si = vm->stmtSp; si < vm->sp; si++) {
            basValRelease(&vm->stack[si]);
        }

        vm->sp = vm->stmtSp;
    }

    // The eval stack is now at the failing statement's boundary, so RESUME
    // must re-run that statement from its start and RESUME NEXT must
    // continue at the next statement -- resuming mid-statement would pop
    // values from beneath the truncated boundary.  Without statement info
    // fall back to the call site after an unwind, or to the failing
    // instruction.
    int32_t nextPc = nextStatementPc(vm);

    vm->errorPc        = (vm->stmtPc >= 0) ? vm->stmtPc : ((resumePc >= 0) ? resumePc : fallbackPc);
    vm->errorNextPc    = (nextPc >= 0) ? nextPc : ((resumePc >= 0) ? resumePc : vm->pc);
    vm->inErrorHandler = true;
    vm->pc             = target;
    return true;
}


static BasVmResultE divByZero(BasVmT *vm) {
    runtimeError(vm, BAS_ERR_DIV_BY_ZERO, "Division by zero");
    return BAS_VM_DIV_BY_ZERO;
}


static BasVmResultE execArith(BasVmT *vm, uint8_t op) {
    BasValueT b;
    BasValueT a;

    if (!popArgs(vm, 2, &b, &a)) {
        return BAS_VM_STACK_UNDERFLOW;
    }

    // VB behavior: + on two strings concatenates
    if (op == OP_ADD_INT && a.type == BAS_TYPE_STRING && b.type == BAS_TYPE_STRING) {
        BasStringT *sa  = a.strVal ? a.strVal : basEmptyString;
        BasStringT *sb  = b.strVal ? b.strVal : basEmptyString;
        BasStringT *cat = basStringConcat(sa, sb);

        basValRelease(&a);
        basValRelease(&b);

        if (!push(vm, strValue(cat))) {
            return BAS_VM_STACK_OVERFLOW;
        }

        return BAS_VM_OK;
    }

    // Integer-integer fast path -- avoids double conversion and FPU for
    // tight integer loops.  Only promotes on overflow/division range.
    if (op != OP_POW
            && op != OP_DIV_FLT
            && (a.type == BAS_TYPE_INTEGER || a.type == BAS_TYPE_LONG)
            && (b.type == BAS_TYPE_INTEGER || b.type == BAS_TYPE_LONG)) {
        int32_t ia      = (a.type == BAS_TYPE_INTEGER) ? (int32_t)a.intVal : a.longVal;
        int32_t ib      = (b.type == BAS_TYPE_INTEGER) ? (int32_t)b.intVal : b.longVal;
        int64_t wide    = 0;
        bool    handled = true;

        switch (op) {
            case OP_ADD_INT:
                wide = (int64_t)ia + (int64_t)ib;
                break;

            case OP_SUB_INT:
                wide = (int64_t)ia - (int64_t)ib;
                break;

            case OP_MUL_INT:
                wide = (int64_t)ia * (int64_t)ib;
                break;

            case OP_IDIV_INT:
                if (ib == 0) {
                    return divByZero(vm);
                }

                wide = (int64_t)ia / (int64_t)ib;
                break;

            case OP_MOD_INT:
                if (ib == 0) {
                    return divByZero(vm);
                }

                // INT32_MIN MOD -1 is 0 mathematically; the int64 division
                // avoids the int32 overflow trap.
                wide = (int64_t)ia % (int64_t)ib;
                break;

            default:
                handled = false;
                break;
        }

        if (handled && wide >= INT32_MIN && wide <= INT32_MAX) {
            int32_t ir = (int32_t)wide;

            if (!push(vm, (ir >= INT16_MIN && ir <= INT16_MAX) ? basValInteger((int16_t)ir) : basValLong(ir))) {
                return BAS_VM_STACK_OVERFLOW;
            }

            return BAS_VM_OK;
        }
    }

    // Capture operand types BEFORE release so we know whether to keep
    // the result as a float.  The parser always emits OP_ADD_INT (the
    // VM promotes based on operand types), so the opcode alone can't
    // tell us whether this is an integer or floating-point op.
    uint8_t aType    = a.type;
    uint8_t bType    = b.type;
    bool    hadFloat = (aType == BAS_TYPE_SINGLE || aType == BAS_TYPE_DOUBLE ||
                        bType == BAS_TYPE_SINGLE || bType == BAS_TYPE_DOUBLE);

    double na = basValToNumber(a);
    double nb = basValToNumber(b);
    basValRelease(&a);
    basValRelease(&b);

    double result;

    switch (op) {
        case OP_ADD_INT:
            result = na + nb;
            break;

        case OP_SUB_INT:
            result = na - nb;
            break;

        case OP_MUL_INT:
            result = na * nb;
            break;

        case OP_IDIV_INT:
        case OP_MOD_INT: {
            // \ and MOD round their operands to LONG first (11.5 \ 2 = 6,
            // 7.5 MOD 2 = 0) and raise Overflow when they do not fit.
            int32_t      ia;
            int32_t      ib;
            BasVmResultE rc = roundedOperand(vm, basValDouble(na), &ia);

            if (rc != BAS_VM_OK) {
                return rc;
            }

            rc = roundedOperand(vm, basValDouble(nb), &ib);

            if (rc != BAS_VM_OK) {
                return rc;
            }

            if (ib == 0) {
                return divByZero(vm);
            }

            result = (op == OP_IDIV_INT) ? (double)((int64_t)ia / (int64_t)ib) : (double)((int64_t)ia % (int64_t)ib);
            break;
        }

        case OP_DIV_FLT:
            if (nb == 0.0) {
                return divByZero(vm);
            }

            result = na / nb;
            break;

        case OP_POW:
            result = pow(na, nb);
            break;

        default:
            result = 0.0;
            break;
    }

    // Return appropriate type.  An op that would normally produce an
    // integer result (OP_ADD_INT, etc.) still has to yield a float when
    // either operand was a float -- otherwise 1.5 + 2.25 truncates to 3.
    bool      intOp = (op == OP_ADD_INT || op == OP_SUB_INT || op == OP_MUL_INT || op == OP_IDIV_INT || op == OP_MOD_INT);
    BasValueT out;

    if (intOp && !hadFloat && result >= (double)INT16_MIN && result <= (double)INT16_MAX) {
        out = basValInteger((int16_t)result);
    } else if (intOp && !hadFloat && result >= (double)INT32_MIN && result <= (double)INT32_MAX) {
        out = basValLong((int32_t)result);
    } else {
        out = basValDouble(result);
    }

    if (!push(vm, out)) {
        return BAS_VM_STACK_OVERFLOW;
    }

    return BAS_VM_OK;
}


static BasVmResultE execCompare(BasVmT *vm, uint8_t op) {
    BasValueT b;
    BasValueT a;

    if (!popArgs(vm, 2, &b, &a)) {
        return BAS_VM_STACK_UNDERFLOW;
    }

    int32_t cmp;

    // Same-type integer fast path: loop conditions like i < n hit this on
    // every iteration.  Skip basValCompare's mixed-type handling.
    if (a.type == BAS_TYPE_INTEGER && b.type == BAS_TYPE_INTEGER) {
        cmp = (a.intVal < b.intVal) ? -1 : (a.intVal > b.intVal ? 1 : 0);
    } else if (a.type == BAS_TYPE_LONG && b.type == BAS_TYPE_LONG) {
        cmp = (a.longVal < b.longVal) ? -1 : (a.longVal > b.longVal ? 1 : 0);
    } else {
        cmp = vm->compareTextMode ? basValCompareCI(a, b) : basValCompare(a, b);
    }

    basValRelease(&a);
    basValRelease(&b);

    bool result;

    switch (op) {
        case OP_CMP_EQ:
            result = (cmp == 0);
            break;

        case OP_CMP_NE:
            result = (cmp != 0);
            break;

        case OP_CMP_LT:
            result = (cmp < 0);
            break;

        case OP_CMP_GT:
            result = (cmp > 0);
            break;

        case OP_CMP_LE:
            result = (cmp <= 0);
            break;

        case OP_CMP_GE:
            result = (cmp >= 0);
            break;

        default:
            result = false;
            break;
    }

    if (!push(vm, basValBool(result))) {
        return BAS_VM_STACK_OVERFLOW;
    }

    return BAS_VM_OK;
}


static BasVmResultE execFileOp(BasVmT *vm, uint8_t op) {
    switch (op) {
        case OP_FILE_OPEN: {
            uint8_t   mode = readUint8(vm);
            BasValueT recLenVal;

            if (!pop(vm, &recLenVal)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            int32_t recLen = basValToInt32(recLenVal);
            basValRelease(&recLenVal);

            int32_t       channel;
            BasVmResultE  chResult = popFileChannel(vm, &channel, false);

            if (chResult != BAS_VM_OK) {
                // popFileChannel consumed only the channel; the filename
                // operand below it must come off too or the untrapped error
                // path strands it on the eval stack.  A channel underflow
                // means the stack was empty, so this pop is a no-op there.
                BasValueT discardVal;

                if (pop(vm, &discardVal)) {
                    basValRelease(&discardVal);
                }

                return chResult;
            }

            BasValueT fnStr;

            if (!popStringArg(vm, &fnStr)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            if (recLen < 1 || recLen > BAS_VM_MAX_RECORD_LEN) {
                basValRelease(&fnStr);
                runtimeError(vm, BAS_ERR_BAD_RECORD_LEN, "Bad record length");
                return BAS_VM_FILE_ERROR;
            }

            // Close existing file on this channel
            if (vm->files[channel].handle) {
                fclose((FILE *)vm->files[channel].handle);
                vm->files[channel].handle = NULL;
                vm->files[channel].mode   = BAS_FILE_MODE_CLOSED;
            }

            const char *modeStr;

            switch (mode) {
                case BAS_FILE_MODE_INPUT:
                    modeStr = "rb";
                    break;

                case BAS_FILE_MODE_OUTPUT:
                    modeStr = "wb";
                    break;

                case BAS_FILE_MODE_APPEND:
                    modeStr = "ab";
                    break;

                case BAS_FILE_MODE_RANDOM:
                case BAS_FILE_MODE_BINARY:
                    modeStr = "r+b";
                    break;

                default:
                    basValRelease(&fnStr);
                    runtimeError(vm, BAS_ERR_BAD_FILE_MODE, "Bad file mode");
                    return BAS_VM_FILE_ERROR;
            }

            // For RANDOM/BINARY: create file if it doesn't exist, then reopen r+b
            if (mode == BAS_FILE_MODE_RANDOM || mode == BAS_FILE_MODE_BINARY) {
                FILE *test = fopen(fnStr.strVal->data, "r");

                if (!test) {
                    // Create the file
                    test = fopen(fnStr.strVal->data, "w+b");
                }

                if (test) {
                    fclose(test);
                }
            }

            FILE *fp = fopen(fnStr.strVal->data, modeStr);
            basValRelease(&fnStr);

            if (!fp) {
                runtimeError(vm, BAS_ERR_FILE_NOT_FOUND, "File not found or cannot open");
                return BAS_VM_FILE_ERROR;
            }

            vm->files[channel].handle = fp;
            vm->files[channel].mode   = mode;
            vm->files[channel].recLen = recLen;
            break;
        }

        case OP_FILE_CLOSE: {
            int32_t       channel;
            BasVmResultE  chResult = popFileChannel(vm, &channel, false);

            if (chResult != BAS_VM_OK) {
                return chResult;
            }

            if (vm->files[channel].handle) {
                fclose((FILE *)vm->files[channel].handle);
                vm->files[channel].handle = NULL;
                vm->files[channel].mode   = BAS_FILE_MODE_CLOSED;
            }

            break;
        }

        case OP_FILE_PRINT: {
            // Writes one value without a trailing newline. The parser emits
            // OP_FILE_WRITE_NL separately when the PRINT statement finishes
            // without a trailing ; so multi-value PRINT with `;` separator
            // works correctly.
            BasValueT val;

            if (!pop(vm, &val)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            int32_t       channel;
            BasVmResultE  chResult = popFileChannel(vm, &channel, true);

            if (chResult != BAS_VM_OK) {
                basValRelease(&val);
                return chResult;
            }

            BasStringT *s = basValFormatString(val);
            basValRelease(&val);
            fputs(s->data, (FILE *)vm->files[channel].handle);
            basStringUnref(s);
            break;
        }

        case OP_FILE_INPUT:
        case OP_FILE_LINE_INPUT: {
            // INPUT # reads one comma-delimited, optionally quoted field;
            // LINE INPUT # reads the whole line.  Neither has a length cap.
            int32_t       channel;
            BasVmResultE  chResult = popFileChannel(vm, &channel, true);

            if (chResult != BAS_VM_OK) {
                return chResult;
            }

            FILE       *fp = (FILE *)vm->files[channel].handle;
            BasStringT *s  = (op == OP_FILE_INPUT) ? readField(fp) : readLine(fp);

            if (!push(vm, strValue(s))) {
                return BAS_VM_STACK_OVERFLOW;
            }

            break;
        }

        case OP_FILE_EOF: {
            int32_t       channel;
            BasVmResultE  chResult = popFileChannel(vm, &channel, true);

            if (chResult != BAS_VM_OK) {
                return chResult;
            }

            FILE *fp    = (FILE *)vm->files[channel].handle;
            long curPos = ftell(fp);
            fseek(fp, 0, SEEK_END);
            long endPos = ftell(fp);
            fseek(fp, curPos, SEEK_SET);
            bool isEof  = (curPos >= endPos);

            if (!push(vm, basValBool(isEof))) {
                return BAS_VM_STACK_OVERFLOW;
            }

            break;
        }

        case OP_FILE_WRITE: {
            // Pop value and channel, write value in WRITE format
            BasValueT val;

            if (!pop(vm, &val)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            int32_t       channel;
            BasVmResultE  chResult = popFileChannel(vm, &channel, true);

            if (chResult != BAS_VM_OK) {
                basValRelease(&val);
                return chResult;
            }

            FILE *fp = (FILE *)vm->files[channel].handle;

            if (val.type == BAS_TYPE_STRING) {
                // Strings: enclosed in quotes
                fputc('"', fp);

                if (val.strVal) {
                    fwrite(val.strVal->data, 1, val.strVal->len, fp);
                }

                fputc('"', fp);
            } else {
                // Numbers: no leading space (unlike PRINT)
                BasStringT *s = basValFormatString(val);
                fputs(s->data, fp);
                basStringUnref(s);
            }

            basValRelease(&val);
            break;
        }

        case OP_FILE_WRITE_SEP:
        case OP_FILE_WRITE_NL: {
            // Pop channel, write the comma separator or record newline
            int32_t       channel;
            BasVmResultE  chResult = popFileChannel(vm, &channel, true);

            if (chResult != BAS_VM_OK) {
                return chResult;
            }

            fputc((op == OP_FILE_WRITE_SEP) ? ',' : '\n', (FILE *)vm->files[channel].handle);
            break;
        }

        case OP_FILE_GET: {
            // Pop type, [current string value], recno, channel; read
            // data; push value.  A STRING target carries its current
            // value so BINARY mode knows how many bytes to read.
            BasValueT typeVal;

            if (!pop(vm, &typeVal)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            int32_t dataType = basValToInt32(typeVal);
            int32_t strLen   = 0;
            basValRelease(&typeVal);

            if (dataType == BAS_TYPE_STRING) {
                BasValueT curVal;

                if (!pop(vm, &curVal)) {
                    return BAS_VM_STACK_UNDERFLOW;
                }

                strLen = (curVal.type == BAS_TYPE_STRING && curVal.strVal) ? curVal.strVal->len : 0;
                basValRelease(&curVal);
            }

            BasValueT recnoVal;

            if (!pop(vm, &recnoVal)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            int32_t recno = basValToInt32(recnoVal);
            basValRelease(&recnoVal);

            int32_t       channel;
            BasVmResultE  chResult = popFileChannel(vm, &channel, true);

            if (chResult != BAS_VM_OK) {
                return chResult;
            }

            FILE *fp = (FILE *)vm->files[channel].handle;
            seekRecord(vm, channel, recno);

            BasValueT result;
            memset(&result, 0, sizeof(result));

            switch (dataType) {
                case BAS_TYPE_INTEGER: {
                    int16_t val = 0;
                    readRaw(fp, &val, sizeof(val));
                    result = basValInteger(val);
                    break;
                }

                case BAS_TYPE_LONG: {
                    int32_t val = 0;
                    readRaw(fp, &val, sizeof(val));
                    result = basValLong(val);
                    break;
                }

                case BAS_TYPE_SINGLE: {
                    float val = 0.0f;
                    readRaw(fp, &val, sizeof(val));
                    result = basValSingle(val);
                    break;
                }

                case BAS_TYPE_DOUBLE: {
                    double val = 0.0;
                    readRaw(fp, &val, sizeof(val));
                    result = basValDouble(val);
                    break;
                }

                case BAS_TYPE_STRING: {
                    // BINARY reads as many raw bytes as the variable
                    // already holds (QB semantics); RANDOM records carry
                    // an int16 length prefix written by PUT.
                    int32_t len = strLen;

                    if (vm->files[channel].mode != BAS_FILE_MODE_BINARY) {
                        int16_t prefix = 0;
                        readRaw(fp, &prefix, sizeof(prefix));
                        len = (prefix < 0) ? 0 : prefix;
                    }

                    char *buf = (char *)malloc(len + 1);

                    if (buf) {
                        // Size the string by the bytes actually read so a
                        // short or truncated record yields a shorter string
                        // rather than exposing uninitialized heap.
                        int32_t bytesRead = (int32_t)fread(buf, 1, len, fp);
                        result = strValue(basStringNew(buf, bytesRead));
                        free(buf);
                    } else {
                        result = basValString(NULL);
                    }

                    break;
                }

                default:
                    result = basValInteger(0);
                    break;
            }

            if (!push(vm, result)) {
                return BAS_VM_STACK_OVERFLOW;
            }

            break;
        }

        case OP_FILE_PUT: {
            // Pop value, recno, channel; write data
            BasValueT val;
            BasValueT recnoVal;

            if (!popArgs(vm, 2, &val, &recnoVal)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            int32_t recno = basValToInt32(recnoVal);
            basValRelease(&recnoVal);

            int32_t       channel;
            BasVmResultE  chResult = popFileChannel(vm, &channel, true);

            if (chResult != BAS_VM_OK) {
                basValRelease(&val);
                return chResult;
            }

            FILE *fp = (FILE *)vm->files[channel].handle;
            seekRecord(vm, channel, recno);

            switch (val.type) {
                case BAS_TYPE_INTEGER: {
                    int16_t v = val.intVal;
                    fwrite(&v, sizeof(v), 1, fp);
                    break;
                }

                case BAS_TYPE_LONG: {
                    int32_t v = val.longVal;
                    fwrite(&v, sizeof(v), 1, fp);
                    break;
                }

                case BAS_TYPE_SINGLE: {
                    float v = val.sngVal;
                    fwrite(&v, sizeof(v), 1, fp);
                    break;
                }

                case BAS_TYPE_DOUBLE: {
                    double v = val.dblVal;
                    fwrite(&v, sizeof(v), 1, fp);
                    break;
                }

                case BAS_TYPE_STRING: {
                    // RANDOM records carry an int16 length prefix so GET can
                    // size the string; BINARY writes the raw bytes.  Clamp
                    // before the int16 cast so an over-long string can't
                    // wrap to a negative prefix.
                    int32_t slen = val.strVal ? val.strVal->len : 0;

                    if (vm->files[channel].mode == BAS_FILE_MODE_BINARY) {
                        if (slen > 0) {
                            fwrite(val.strVal->data, 1, slen, fp);
                        }

                        break;
                    }

                    if (slen > INT16_MAX) {
                        slen = INT16_MAX;
                    }

                    int16_t len = (int16_t)slen;
                    fwrite(&len, sizeof(len), 1, fp);

                    if (len > 0) {
                        fwrite(val.strVal->data, 1, len, fp);
                    }

                    break;
                }

                default: {
                    int16_t zero = 0;
                    fwrite(&zero, sizeof(zero), 1, fp);
                    break;
                }
            }

            fflush(fp);
            basValRelease(&val);
            break;
        }

        case OP_FILE_SEEK: {
            // Pop position and channel, seek
            BasValueT posVal;

            if (!pop(vm, &posVal)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            int32_t pos = basValToInt32(posVal);
            basValRelease(&posVal);

            int32_t       channel;
            BasVmResultE  chResult = popFileChannel(vm, &channel, true);

            if (chResult != BAS_VM_OK) {
                return chResult;
            }

            // QB SEEK is 1-based
            fseek((FILE *)vm->files[channel].handle, (long)(pos - 1), SEEK_SET);
            break;
        }

        case OP_FILE_LOF: {
            // Pop channel, push file length
            int32_t       channel;
            BasVmResultE  chResult = popFileChannel(vm, &channel, true);

            if (chResult != BAS_VM_OK) {
                return chResult;
            }

            FILE *fp = (FILE *)vm->files[channel].handle;
            long savedPos = ftell(fp);
            fseek(fp, 0, SEEK_END);
            long length = ftell(fp);
            fseek(fp, savedPos, SEEK_SET);

            if (!push(vm, basValLong((int32_t)length))) {
                return BAS_VM_STACK_OVERFLOW;
            }

            break;
        }

        case OP_FILE_LOC: {
            // Pop channel, push current position
            int32_t       channel;
            BasVmResultE  chResult = popFileChannel(vm, &channel, true);

            if (chResult != BAS_VM_OK) {
                return chResult;
            }

            long pos = ftell((FILE *)vm->files[channel].handle);

            // QB returns 1-based position
            if (!push(vm, basValLong((int32_t)(pos + 1)))) {
                return BAS_VM_STACK_OVERFLOW;
            }

            break;
        }

        case OP_FILE_FREEFILE: {
            // Push the next available file channel number
            int32_t freeNum = 0;

            for (int32_t i = 1; i < BAS_VM_MAX_FILES; i++) {
                if (!vm->files[i].handle) {
                    freeNum = i;
                    break;
                }
            }

            if (freeNum == 0) {
                runtimeError(vm, BAS_ERR_TOO_MANY_FILES, "Too many files open");
                return BAS_VM_FILE_ERROR;
            }

            if (!push(vm, basValInteger((int16_t)freeNum))) {
                return BAS_VM_STACK_OVERFLOW;
            }

            break;
        }

        case OP_FILE_INPUT_N: {
            // Pop channel and n, read n chars, push string
            int32_t       channel;
            BasVmResultE  chResult = popFileChannel(vm, &channel, true);

            if (chResult != BAS_VM_OK) {
                // Same shape as OP_FILE_OPEN: the count operand below the
                // channel must be drained on the error path.
                BasValueT discardVal;

                if (pop(vm, &discardVal)) {
                    basValRelease(&discardVal);
                }

                return chResult;
            }

            BasValueT nVal;

            if (!pop(vm, &nVal)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            int32_t n = basValToInt32(nVal);
            basValRelease(&nVal);

            if (n < 0 || n > BAS_STRING_FUNC_MAX) {
                runtimeError(vm, BAS_ERR_ILLEGAL_FUNC_CALL, "Illegal function call");
                return BAS_VM_ERROR;
            }

            char *buf = (char *)malloc(n + 1);

            if (!buf) {
                runtimeError(vm, BAS_ERR_OUT_OF_MEMORY, "Out of memory");
                return BAS_VM_OUT_OF_MEMORY;
            }

            int32_t     bytesRead = (int32_t)fread(buf, 1, n, (FILE *)vm->files[channel].handle);
            BasStringT *s         = basStringNew(buf, bytesRead);
            free(buf);

            if (!push(vm, strValue(s))) {
                return BAS_VM_STACK_OVERFLOW;
            }

            break;
        }

        default:
            return BAS_VM_BAD_OPCODE;
    }

    return BAS_VM_OK;
}


static BasVmResultE execFsOp(BasVmT *vm, uint8_t op) {
    switch (op) {
        case OP_FS_KILL: {
            BasValueT fnStr;

            if (!popStringArg(vm, &fnStr)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            int32_t rc = remove(fnStr.strVal->data);
            basValRelease(&fnStr);

            if (rc != 0) {
                runtimeError(vm, BAS_ERR_FILE_NOT_FOUND, "File not found");
                return BAS_VM_FILE_ERROR;
            }

            break;
        }

        case OP_FS_NAME: {
            BasValueT newStr;
            BasValueT oldStr;

            if (!popStringArg(vm, &newStr)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            if (!popStringArg(vm, &oldStr)) {
                basValRelease(&newStr);
                return BAS_VM_STACK_UNDERFLOW;
            }

            int32_t rc = rename(oldStr.strVal->data, newStr.strVal->data);
            basValRelease(&oldStr);
            basValRelease(&newStr);

            if (rc != 0) {
                runtimeError(vm, BAS_ERR_FILE_EXISTS, "File already exists or rename failed");
                return BAS_VM_FILE_ERROR;
            }

            break;
        }

        case OP_FS_FILECOPY: {
            BasValueT dstStr;
            BasValueT srcStr;

            if (!popStringArg(vm, &dstStr)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            if (!popStringArg(vm, &srcStr)) {
                basValRelease(&dstStr);
                return BAS_VM_STACK_UNDERFLOW;
            }

            bool copied = platformCopyFile(srcStr.strVal->data, dstStr.strVal->data);

            basValRelease(&srcStr);
            basValRelease(&dstStr);

            if (!copied) {
                runtimeError(vm, BAS_ERR_FILE_NOT_FOUND, "File not found or cannot create destination");
                return BAS_VM_FILE_ERROR;
            }

            break;
        }

        case OP_FS_MKDIR: {
            BasValueT pathStr;

            if (!popStringArg(vm, &pathStr)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            #ifdef _WIN32
            int32_t rc = mkdir(pathStr.strVal->data);
            #else
            int32_t rc = mkdir(pathStr.strVal->data, BAS_MKDIR_MODE);
            #endif

            basValRelease(&pathStr);

            if (rc != 0) {
                runtimeError(vm, BAS_ERR_PATH_FILE_ACCESS, "Path/File access error");
                return BAS_VM_FILE_ERROR;
            }

            break;
        }

        case OP_FS_RMDIR: {
            BasValueT pathStr;

            if (!popStringArg(vm, &pathStr)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            int32_t rc = rmdir(pathStr.strVal->data);
            basValRelease(&pathStr);

            if (rc != 0) {
                runtimeError(vm, BAS_ERR_PATH_FILE_ACCESS, "Path/File access error");
                return BAS_VM_FILE_ERROR;
            }

            break;
        }

        case OP_FS_CHDIR: {
            BasValueT pathStr;

            if (!popStringArg(vm, &pathStr)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            int32_t rc = platformChdir(pathStr.strVal->data);
            basValRelease(&pathStr);

            if (rc != 0) {
                runtimeError(vm, BAS_ERR_PATH_NOT_FOUND, "Path not found");
                return BAS_VM_FILE_ERROR;
            }

            break;
        }

        case OP_FS_CHDRIVE: {
            BasValueT driveStr;

            if (!popStringArg(vm, &driveStr)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            // Drive letters are a DOS-ism; platformChdir is a no-op on
            // platforms without them.
            if (driveStr.strVal->data[0]) {
                char drivePath[4];
                drivePath[0] = driveStr.strVal->data[0];
                drivePath[1] = ':';
                drivePath[2] = '\0';
                platformChdir(drivePath);
            }

            basValRelease(&driveStr);
            break;
        }

        case OP_FS_CURDIR: {
            char cwd[DVX_MAX_PATH];

            if (!getcwd(cwd, sizeof(cwd))) {
                cwd[0] = '\0';
            }

            if (!push(vm, basValStringFromC(cwd))) {
                return BAS_VM_STACK_OVERFLOW;
            }

            break;
        }

        case OP_FS_DIR: {
            BasValueT patStr;

            if (!popStringArg(vm, &patStr)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            dirClose(vm);

            // Split pattern into directory + filename pattern
            const char *pat = patStr.strVal->data;
            const char *lastSep = NULL;

            for (const char *c = pat; *c; c++) {
                if (*c == '/' || *c == '\\') {
                    lastSep = c;
                }
            }

            if (lastSep) {
                int32_t dirLen = (int32_t)(lastSep - pat);

                if (dirLen >= (int32_t)sizeof(vm->dirPath)) {
                    dirLen = (int32_t)sizeof(vm->dirPath) - 1;
                }

                memcpy(vm->dirPath, pat, dirLen);
                vm->dirPath[dirLen] = '\0';
                snprintf(vm->dirPattern, sizeof(vm->dirPattern), "%s", lastSep + 1);
            } else {
                snprintf(vm->dirPath, sizeof(vm->dirPath), ".");
                snprintf(vm->dirPattern, sizeof(vm->dirPattern), "%s", pat);
            }

            basValRelease(&patStr);

            if (vm->dirPattern[0] == '\0') {
                snprintf(vm->dirPattern, sizeof(vm->dirPattern), "*");
            }

            vm->dirHandle = opendir(vm->dirPath);

            if (!push(vm, basValStringFromC(dirNext(vm)))) {
                return BAS_VM_STACK_OVERFLOW;
            }

            break;
        }

        case OP_FS_DIR_NEXT: {
            if (!push(vm, basValStringFromC(dirNext(vm)))) {
                return BAS_VM_STACK_OVERFLOW;
            }

            break;
        }

        case OP_FS_FILELEN: {
            BasValueT fnStr;

            if (!popStringArg(vm, &fnStr)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            struct stat st;
            int32_t     size = 0;

            if (stat(fnStr.strVal->data, &st) == 0) {
                size = (int32_t)st.st_size;
            }

            basValRelease(&fnStr);

            if (!push(vm, basValLong(size))) {
                return BAS_VM_STACK_OVERFLOW;
            }

            break;
        }

        case OP_FS_GETATTR: {
            BasValueT fnStr;

            if (!popStringArg(vm, &fnStr)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            struct stat st;
            int32_t     attrs = 0;

            if (stat(fnStr.strVal->data, &st) == 0) {
                if (S_ISDIR(st.st_mode)) {
                    attrs |= BAS_ATTR_DIRECTORY;
                }

                if (!(st.st_mode & S_IWUSR)) {
                    attrs |= BAS_ATTR_READONLY;
                }
            }

            basValRelease(&fnStr);

            if (!push(vm, basValInteger((int16_t)attrs))) {
                return BAS_VM_STACK_OVERFLOW;
            }

            break;
        }

        case OP_FS_SETATTR: {
            BasValueT attrVal;
            BasValueT fnStr;

            if (!pop(vm, &attrVal)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            if (!popStringArg(vm, &fnStr)) {
                basValRelease(&attrVal);
                return BAS_VM_STACK_UNDERFLOW;
            }

            int32_t attrs = basValToInt32(attrVal);
            basValRelease(&attrVal);

            struct stat st;

            if (stat(fnStr.strVal->data, &st) == 0) {
                mode_t mode = st.st_mode;

                if (attrs & BAS_ATTR_READONLY) {
                    mode &= ~(S_IWUSR | S_IWGRP | S_IWOTH);
                } else {
                    mode |= S_IWUSR;
                }

                chmod(fnStr.strVal->data, mode);
            }

            basValRelease(&fnStr);
            break;
        }

        default:
            return BAS_VM_BAD_OPCODE;
    }

    return BAS_VM_OK;
}


static BasVmResultE execLogical(BasVmT *vm, uint8_t op) {
    // Bitwise operators work on LONG operands: round (never truncate) so
    // NOT 0.6 is NOT 1 = -2, and raise Overflow when a value does not fit.
    if (op == OP_NOT) {
        if (vm->sp < 1) {
            return BAS_VM_STACK_UNDERFLOW;
        }

        BasValueT   *top = &vm->stack[vm->sp - 1];
        int32_t      n;
        BasVmResultE rc  = roundedOperand(vm, *top, &n);

        if (rc != BAS_VM_OK) {
            return rc;
        }

        basValRelease(top);
        int32_t r = ~n;

        // Promote to LONG when the result exceeds INTEGER range, matching
        // execArith -- truncation corrupts LONG masks like NOT &H10000.
        *top = (r >= INT16_MIN && r <= INT16_MAX) ? basValInteger((int16_t)r) : basValLong(r);
        return BAS_VM_OK;
    }

    BasValueT b;
    BasValueT a;

    if (!popArgs(vm, 2, &b, &a)) {
        return BAS_VM_STACK_UNDERFLOW;
    }

    int32_t      na;
    int32_t      nb;
    BasVmResultE rc = roundedOperand(vm, a, &na);

    if (rc == BAS_VM_OK) {
        rc = roundedOperand(vm, b, &nb);
    }

    basValRelease(&a);
    basValRelease(&b);

    if (rc != BAS_VM_OK) {
        return rc;
    }

    int32_t result;

    switch (op) {
        case OP_AND:
            result = na & nb;
            break;

        case OP_OR:
            result = na | nb;
            break;

        case OP_XOR:
            result = na ^ nb;
            break;

        case OP_EQV:
            result = ~(na ^ nb);
            break;

        case OP_IMP:
            result = (~na) | nb;
            break;

        default:
            result = 0;
            break;
    }

    // Promote to LONG when the result exceeds INTEGER range, matching
    // execArith -- truncation corrupts LONG masks like c& AND &HFF0000.
    if (!push(vm, (result >= INT16_MIN && result <= INT16_MAX) ? basValInteger((int16_t)result) : basValLong(result))) {
        return BAS_VM_STACK_OVERFLOW;
    }

    return BAS_VM_OK;
}


static BasVmResultE execMath(BasVmT *vm, uint8_t op) {
    if (op == OP_MATH_RND) {
        // Pop the dummy arg (parser pushes -1 for RND())
        BasValueT dummy;

        if (!pop(vm, &dummy)) {
            return BAS_VM_STACK_UNDERFLOW;
        }

        basValRelease(&dummy);

        double r = (double)rand() / (double)RAND_MAX;

        if (!push(vm, basValSingle((float)r))) {
            return BAS_VM_STACK_OVERFLOW;
        }

        return BAS_VM_OK;
    }

    if (op == OP_MATH_RANDOMIZE) {
        BasValueT val;

        if (!pop(vm, &val)) {
            return BAS_VM_STACK_UNDERFLOW;
        }

        double n = basValToNumber(val);
        basValRelease(&val);

        if (n == BAS_RANDOMIZE_TIMER_SEED) {
            srand((unsigned int)time(NULL));
        } else {
            srand((unsigned int)n);
        }

        return BAS_VM_OK;
    }

    // All other math ops take one argument
    if (vm->sp < 1) {
        return BAS_VM_STACK_UNDERFLOW;
    }

    BasValueT *top = &vm->stack[vm->sp - 1];
    double     n   = basValToNumber(*top);
    double     result;

    // Domain errors are Illegal function call in BASIC, never NaN.
    if ((op == OP_MATH_SQR && n < 0) || (op == OP_MATH_LOG && n <= 0)) {
        runtimeError(vm, BAS_ERR_ILLEGAL_FUNC_CALL, "Illegal function call");
        return BAS_VM_ERROR;
    }

    switch (op) {
        case OP_MATH_ABS:
            result = fabs(n);
            break;

        case OP_MATH_INT:
            result = floor(n);
            break;

        case OP_MATH_FIX:
            result = (n >= 0) ? floor(n) : ceil(n);
            break;

        case OP_MATH_SGN:
            result = (n > 0) ? 1.0 : (n < 0) ? -1.0 : 0.0;
            break;

        case OP_MATH_SQR:
            result = sqrt(n);
            break;

        case OP_MATH_SIN:
            result = sin(n);
            break;

        case OP_MATH_COS:
            result = cos(n);
            break;

        case OP_MATH_TAN:
            result = tan(n);
            break;

        case OP_MATH_ATN:
            result = atan(n);
            break;

        case OP_MATH_LOG:
            result = log(n);
            break;

        case OP_MATH_EXP:
            result = exp(n);
            break;

        default:
            result = 0.0;
            break;
    }

    basValRelease(top);
    *top = basValDouble(result);
    return BAS_VM_OK;
}


static BasVmResultE execPrint(BasVmT *vm) {
    BasValueT val;

    if (!pop(vm, &val)) {
        return BAS_VM_STACK_UNDERFLOW;
    }

    // QB prints numeric values with a trailing space
    bool isNumeric = (val.type != BAS_TYPE_STRING);

    BasStringT *s = basValFormatString(val);
    basValRelease(&val);

    if (vm->printFn) {
        vm->printFn(vm->printCtx, s->data, false);

        if (isNumeric) {
            vm->printFn(vm->printCtx, " ", false);
        }
    }

    basStringUnref(s);
    return BAS_VM_OK;
}


static BasVmResultE execStringOp(BasVmT *vm, uint8_t op) {
    switch (op) {
        case OP_STR_CONCAT: {
            BasValueT sb;
            BasValueT sa;

            if (!popStringArg(vm, &sb)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            if (!popStringArg(vm, &sa)) {
                basValRelease(&sb);
                return BAS_VM_STACK_UNDERFLOW;
            }

            BasStringT *result = basStringConcat(sa.strVal, sb.strVal);
            basValRelease(&sa);
            basValRelease(&sb);

            if (!push(vm, strValue(result))) {
                return BAS_VM_STACK_OVERFLOW;
            }

            return BAS_VM_OK;
        }

        case OP_STR_LEFT:
        case OP_STR_RIGHT: {
            BasValueT nVal;
            BasValueT sv;

            if (!pop(vm, &nVal)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            if (!popStringArg(vm, &sv)) {
                basValRelease(&nVal);
                return BAS_VM_STACK_UNDERFLOW;
            }

            int32_t n = basValToInt32(nVal);
            basValRelease(&nVal);

            if (n < 0) {
                basValRelease(&sv);
                runtimeError(vm, BAS_ERR_ILLEGAL_FUNC_CALL, "Illegal function call");
                return BAS_VM_ERROR;
            }

            int32_t start = 0;

            if (op == OP_STR_RIGHT) {
                start = sv.strVal->len - n;

                if (start < 0) {
                    start = 0;
                }
            }

            BasStringT *result = basStringSub(sv.strVal, start, n);
            basValRelease(&sv);

            if (!push(vm, strValue(result))) {
                return BAS_VM_STACK_OVERFLOW;
            }

            return BAS_VM_OK;
        }

        case OP_STR_MID:
        case OP_STR_MID2: {
            // MID$(s$, start[, len]); a missing len means "to the end".
            BasValueT lenVal;
            BasValueT startVal;
            BasValueT sv;
            int32_t   len = -1;

            if (op == OP_STR_MID) {
                if (!pop(vm, &lenVal)) {
                    return BAS_VM_STACK_UNDERFLOW;
                }

                len = basValToInt32(lenVal);
                basValRelease(&lenVal);
            }

            if (!pop(vm, &startVal)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            int32_t start = basValToInt32(startVal);
            basValRelease(&startVal);

            if (!popStringArg(vm, &sv)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            if (start < 1 || (op == OP_STR_MID && len < 0)) {
                basValRelease(&sv);
                runtimeError(vm, BAS_ERR_ILLEGAL_FUNC_CALL, "Illegal function call");
                return BAS_VM_ERROR;
            }

            BasStringT *result = basStringSub(sv.strVal, start - 1, len);
            basValRelease(&sv);

            if (!push(vm, strValue(result))) {
                return BAS_VM_STACK_OVERFLOW;
            }

            return BAS_VM_OK;
        }

        case OP_STR_LEN: {
            if (vm->sp < 1) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            BasValueT *top = &vm->stack[vm->sp - 1];
            BasValueT  sv  = basValToString(*top);
            int32_t    len = sv.strVal->len;
            basValRelease(&sv);
            basValRelease(top);
            *top = basValLong(len);
            return BAS_VM_OK;
        }

        case OP_STR_INSTR:
        case OP_STR_INSTR3: {
            // INSTR([start,] string, find); positions are 1-based, LONG.
            BasValueT fv;
            BasValueT sv;
            BasValueT startVal;
            int32_t   startPos = 0;

            if (!popStringArg(vm, &fv)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            if (!popStringArg(vm, &sv)) {
                basValRelease(&fv);
                return BAS_VM_STACK_UNDERFLOW;
            }

            if (op == OP_STR_INSTR3) {
                if (!pop(vm, &startVal)) {
                    basValRelease(&fv);
                    basValRelease(&sv);
                    return BAS_VM_STACK_UNDERFLOW;
                }

                startPos = basValToInt32(startVal) - 1;
                basValRelease(&startVal);
            }

            int32_t pos = 0;

            if (startPos >= 0 && startPos < sv.strVal->len) {
                int32_t found = stringFind(sv.strVal, startPos, fv.strVal);

                if (found >= 0) {
                    pos = found + 1;
                }
            }

            basValRelease(&sv);
            basValRelease(&fv);

            if (!push(vm, basValLong(pos))) {
                return BAS_VM_STACK_OVERFLOW;
            }

            return BAS_VM_OK;
        }

        case OP_STR_UCASE:
        case OP_STR_LCASE:
        case OP_STR_TRIM:
        case OP_STR_LTRIM:
        case OP_STR_RTRIM: {
            if (vm->sp < 1) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            BasValueT *top = &vm->stack[vm->sp - 1];
            BasValueT  sv  = basValToString(*top);
            basValRelease(top);

            BasStringT *src = sv.strVal;
            BasStringT *result;

            if (op == OP_STR_UCASE || op == OP_STR_LCASE) {
                result = basStringNew(src->data, src->len);

                for (int32_t i = 0; i < result->len; i++) {
                    if (op == OP_STR_UCASE && result->data[i] >= 'a' && result->data[i] <= 'z') {
                        result->data[i] -= ('a' - 'A');
                    } else if (op == OP_STR_LCASE && result->data[i] >= 'A' && result->data[i] <= 'Z') {
                        result->data[i] += ('a' - 'A');
                    }
                }
            } else {
                int32_t start = 0;
                int32_t end   = src->len;

                if (op == OP_STR_LTRIM || op == OP_STR_TRIM) {
                    while (start < end && src->data[start] == ' ') {
                        start++;
                    }
                }

                if (op == OP_STR_RTRIM || op == OP_STR_TRIM) {
                    while (end > start && src->data[end - 1] == ' ') {
                        end--;
                    }
                }

                result = basStringSub(src, start, end - start);
            }

            basValRelease(&sv);
            *top = strValue(result);
            return BAS_VM_OK;
        }

        case OP_STR_CHR: {
            if (vm->sp < 1) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            // A one-byte string, length-sized so CHR$(0) is a real NUL
            // character rather than an empty string.
            BasValueT *top = &vm->stack[vm->sp - 1];
            char       ch  = (char)(basValToInt32(*top) & BAS_CHAR_MASK);
            basValRelease(top);
            *top = strValue(basStringNew(&ch, 1));
            return BAS_VM_OK;
        }

        case OP_STR_ASC: {
            if (vm->sp < 1) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            BasValueT *top  = &vm->stack[vm->sp - 1];
            BasValueT  sv   = basValToString(*top);
            int32_t    code = (sv.strVal->len > 0) ? (unsigned char)sv.strVal->data[0] : 0;
            basValRelease(&sv);
            basValRelease(top);
            *top = basValInteger((int16_t)code);
            return BAS_VM_OK;
        }

        case OP_STR_SPACE: {
            if (vm->sp < 1) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            BasValueT *top = &vm->stack[vm->sp - 1];
            int32_t    n   = basValToInt32(*top);

            if (n < 0 || n > BAS_STRING_FUNC_MAX) {
                runtimeError(vm, BAS_ERR_ILLEGAL_FUNC_CALL, "Illegal function call");
                return BAS_VM_ERROR;
            }

            basValRelease(top);
            *top = strValue(fillString(' ', n));
            return BAS_VM_OK;
        }

        case OP_STR_FIXLEN: {
            // [uint16 len] pop string, pad/truncate to fixed length, push result
            uint16_t fixLen = readUint16(vm);

            if (vm->sp < 1) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            BasValueT *top = &vm->stack[vm->sp - 1];
            BasValueT  sv  = basValToString(*top);
            basValRelease(top);

            BasStringT *src    = sv.strVal;
            BasStringT *result = basStringAlloc(fixLen + 1);

            if (result->cap >= (int32_t)fixLen + 1) {
                int32_t copyLen = src->len < (int32_t)fixLen ? src->len : (int32_t)fixLen;

                result->len = fixLen;
                memcpy(result->data, src->data, copyLen);

                // Pad with spaces
                for (int32_t i = copyLen; i < (int32_t)fixLen; i++) {
                    result->data[i] = ' ';
                }

                result->data[fixLen] = '\0';
            }

            basValRelease(&sv);
            *top = strValue(result);
            return BAS_VM_OK;
        }

        case OP_STR_MID_ASGN: {
            // Pop replacement, len, start, str; push modified string
            BasValueT replVal;
            BasValueT lenVal;
            BasValueT startVal;
            BasValueT strVal;

            if (!popArgs(vm, 4, &replVal, &lenVal, &startVal, &strVal)) {
                return BAS_VM_STACK_UNDERFLOW;
            }

            BasValueT sv    = basValToString(strVal);
            BasValueT rv    = basValToString(replVal);
            int32_t   start = basValToInt32(startVal) - 1; // 1-based to 0-based
            int32_t   len   = basValToInt32(lenVal);
            basValRelease(&strVal);
            basValRelease(&startVal);
            basValRelease(&lenVal);
            basValRelease(&replVal);

            BasStringT *src     = sv.strVal;
            BasStringT *repl    = rv.strVal;
            int32_t     srcLen  = src->len;
            int32_t     replLen = repl->len;

            // If len is 0, use replacement length
            if (len <= 0) {
                len = replLen;
            }

            // Clamp to available replacement length
            if (len > replLen) {
                len = replLen;
            }

            // Create a copy of the original string
            BasStringT *result = basStringNew(src->data, srcLen);

            // Replace characters
            if (start >= 0 && start < srcLen && len > 0) {
                int32_t maxReplace = srcLen - start;

                if (len > maxReplace) {
                    len = maxReplace;
                }

                memcpy(result->data + start, repl->data, len);
            }

            basValRelease(&sv);
            basValRelease(&rv);

            if (!push(vm, strValue(result))) {
                return BAS_VM_STACK_OVERFLOW;
            }

            return BAS_VM_OK;
        }

        default:
            return BAS_VM_BAD_OPCODE;
    }
}


// A new string of count copies of ch.
static BasStringT *fillString(char ch, int32_t count) {
    BasStringT *s = basStringAlloc(count + 1);

    if (s->cap >= count + 1) {
        memset(s->data, ch, count);
        s->data[count] = '\0';
        s->len         = count;
    }

    return s;
}


// Shared numeric formatter for OP_PRINT_USING (numeric branch) and OP_FORMAT
// (non-PERCENT branch). Renders n into out per the flags in f. Every cursor
// write is bounded against outSize, and every intermediate buffer is sized to
// BAS_FORMAT_BUF_SIZE and clamped, so a pathological format string or numeric
// magnitude cannot overflow the caller's stack-resident buffer.
static void formatNumber(double n, const BasNumFormatT *f, char *out, size_t outSize) {
    bool    isNeg    = (n < 0);
    double  absN     = isNeg ? -n : n;
    int32_t decimals = f->hasDecimal ? f->digitsAfter : 0;
    int32_t limit    = (int32_t)outSize - 1;
    int32_t idx      = 0;
    char    numBuf[BAS_FORMAT_BUF_SIZE];

    snprintf(numBuf, sizeof(numBuf), "%.*f", (int)decimals, absN);

    // Split into integer and decimal parts.
    char    intPart[BAS_FORMAT_BUF_SIZE];
    char    decPart[BAS_FORMAT_BUF_SIZE];
    char   *dot = strchr(numBuf, '.');

    intPart[0] = '\0';
    decPart[0] = '\0';

    if (dot) {
        int32_t intLen = (int32_t)(dot - numBuf);

        if (intLen > (int32_t)sizeof(intPart) - 1) {
            intLen = (int32_t)sizeof(intPart) - 1;
        }

        memcpy(intPart, numBuf, intLen);
        intPart[intLen] = '\0';
        strncpy(decPart, dot + 1, sizeof(decPart) - 1);
        decPart[sizeof(decPart) - 1] = '\0';
    } else {
        strncpy(intPart, numBuf, sizeof(intPart) - 1);
        intPart[sizeof(intPart) - 1] = '\0';
    }

    // Apply thousands separator.
    char fmtIntPart[BAS_FORMAT_BUF_SIZE];

    if (f->hasComma) {
        int32_t srcLen = (int32_t)strlen(intPart);
        int32_t dstIdx = 0;

        for (int32_t i = 0; i < srcLen; i++) {
            if (i > 0 && (srcLen - i) % 3 == 0) {
                putBounded(fmtIntPart, &dstIdx, (int32_t)sizeof(fmtIntPart) - 1, ',');
            }

            putBounded(fmtIntPart, &dstIdx, (int32_t)sizeof(fmtIntPart) - 1, intPart[i]);
        }

        fmtIntPart[dstIdx] = '\0';
    } else {
        strncpy(fmtIntPart, intPart, sizeof(fmtIntPart) - 1);
        fmtIntPart[sizeof(fmtIntPart) - 1] = '\0';
    }

    // Sign prefix. FORMAT$ (formatPad) and PRINT USING differ here, so each
    // call site's original rule is reproduced exactly.
    if (f->formatPad) {
        // FORMAT$: leading '-' for negatives whenever a sign position exists,
        // leading '+' only for plusAtStart positives.
        if (f->plusAtStart || f->plusAtEnd) {
            if (isNeg) {
                putBounded(out, &idx, limit, '-');
            } else if (f->plusAtStart) {
                putBounded(out, &idx, limit, '+');
            }
        } else if (isNeg) {
            putBounded(out, &idx, limit, '-');
        }
    } else {
        // PRINT USING: plusAtStart always shows a sign; otherwise a leading
        // '-' for negatives unless the minus is deferred to the end.
        if (f->plusAtStart) {
            putBounded(out, &idx, limit, isNeg ? '-' : '+');
        } else if (isNeg && !f->minusAtEnd) {
            putBounded(out, &idx, limit, '-');
        }
    }

    // Dollar sign (PRINT USING $$ only).
    if (f->dollarFloat) {
        putBounded(out, &idx, limit, '$');
    }

    // Pad leading. PRINT USING fills the whole pad region with fillChar
    // (space or '*'). FORMAT$ (formatPad) fills the positions before the
    // number with '0' when a zero digit precedes (zeroPad) else ' ', and the
    // remaining positions with '0'.
    int32_t fmtIntLen = (int32_t)strlen(fmtIntPart);
    int32_t intRawLen = (int32_t)strlen(intPart);
    int32_t padNeeded = f->digitsBefore - fmtIntLen;
    char    fillChar  = f->asteriskFill ? '*' : ' ';

    for (int32_t i = 0; i < padNeeded; i++) {
        char padChar;

        if (f->formatPad) {
            if (i < padNeeded - intRawLen) {
                padChar = f->zeroPad ? '0' : ' ';
            } else {
                padChar = '0';
            }
        } else {
            padChar = fillChar;
        }

        putBounded(out, &idx, limit, padChar);
    }

    // Integer part.
    for (int32_t i = 0; fmtIntPart[i]; i++) {
        putBounded(out, &idx, limit, fmtIntPart[i]);
    }

    // Decimal part. Missing digits fill with '0' (PRINT USING behavior);
    // FORMAT$ always has exactly 'decimals' digits so this never triggers.
    // decimals comes from the runtime format string, so it can exceed the
    // digits snprintf produced into decPart -- never read past decLen.
    if (f->hasDecimal) {
        int32_t decLen = (int32_t)strlen(decPart);

        putBounded(out, &idx, limit, '.');

        for (int32_t i = 0; i < decimals; i++) {
            putBounded(out, &idx, limit, (i < decLen) ? decPart[i] : '0');
        }
    }

    // Trailing sign. FORMAT$ already emitted the leading '-' for negatives,
    // so its trailing plusEnd path only shows '+' for positives. PRINT USING
    // defers the sign to the end, so its plusEnd path shows '-' for negatives.
    if (f->formatPad) {
        if (f->plusAtEnd && !isNeg) {
            putBounded(out, &idx, limit, '+');
        } else if (f->minusAtEnd && isNeg) {
            putBounded(out, &idx, limit, '-');
        } else if (f->minusAtEnd && !isNeg) {
            putBounded(out, &idx, limit, ' ');
        }
    } else {
        if (f->plusAtEnd) {
            putBounded(out, &idx, limit, isNeg ? '-' : '+');
        } else if (f->minusAtEnd) {
            putBounded(out, &idx, limit, isNeg ? '-' : ' ');
        }
    }

    out[idx] = '\0';
}


// Lowest forStack index the currently-executing call frame may unwind
// to.  FOR frames below this belong to callers and must never be
// touched by NEXT mismatch recovery or FOR re-entry cleanup.
static int32_t forStackFloor(BasVmT *vm) {
    if (vm->callDepth > 0) {
        return vm->callStack[vm->callDepth - 1].savedForDepth;
    }

    return 0;
}


// Release FOR-stack entries above newDepth and truncate the stack to it.
// Used when returning from a call frame, unwinding to an error handler,
// and discarding stale frames left by EXIT DO / GOTO jumps out of FORs.
static void forStackTrim(BasVmT *vm, int32_t newDepth) {
    while (vm->forDepth > newDepth) {
        BasForStateT *fs = &vm->forStack[--vm->forDepth];
        basValRelease(&fs->limit);
        basValRelease(&fs->step);
    }
}


// Local wall-clock time, with the sub-second part in microseconds when
// outUsec is non-NULL.  Shared by TIMER, DATE$ and TIME$.
static void localNow(struct tm *out, int32_t *outUsec) {
    struct timeval tv;

    gettimeofday(&tv, NULL);

    time_t     secs  = tv.tv_sec;
    struct tm *local = localtime(&secs);

    if (local) {
        *out = *local;
    } else {
        memset(out, 0, sizeof(*out));
    }

    if (outUsec) {
        *outUsec = (int32_t)tv.tv_usec;
    }
}


// Returns the bytecode address the error dispatcher should record for
// RESUME NEXT: the statement that follows the failing one.  Walks the
// bytecode instruction by instruction from the failing statement's
// OP_LINE/OP_STMT (vm->stmtPc).  The walk lands on the next statement
// boundary or on a structural opcode that IS what runs after the statement
// (a loop-closing OP_JMP/OP_FOR_NEXT, a SUB's OP_RET epilogue,
// OP_END/OP_HALT); a GOSUB's PUSH_INT32+JMP pair is stepped over as a unit
// so the walk continues at its return address.  Returns -1 when no
// statement boundary is known or when the walk reaches an opcode that pops
// values the trap already released (conditional jumps, OP_RET_VAL); the
// caller then falls back to a coarser resume target.
static int32_t nextStatementPc(BasVmT *vm) {
    if (vm->stmtPc < 0 || !vm->module) {
        return -1;
    }

    const uint8_t *code    = vm->module->code;
    int32_t        codeLen = vm->module->codeLen;
    int32_t        pc      = vm->stmtPc;

    while (pc < codeLen) {
        uint8_t op = code[pc];

        if (pc > vm->stmtPc) {
            // Step over a GOSUB call sequence so a failed GOSUB statement
            // resumes after the call.
            if (basIsGosubPush(code, codeLen, pc)) {
                pc += BAS_GOSUB_PATTERN_LEN;
                continue;
            }

            if (op == OP_LINE || op == OP_STMT || op == OP_JMP || op == OP_FOR_NEXT || op == OP_RET || op == OP_END || op == OP_HALT) {
                return pc;
            }

            if (op == OP_JMP_TRUE || op == OP_JMP_FALSE || op == OP_RET_VAL || op == OP_GOSUB_RET) {
                return -1;
            }
        }

        int32_t operand = basOpcodeOperandSize(op);

        if (operand < 0) {
            return -1;
        }

        pc += 1 + operand;
    }

    return -1;
}


// Returns whether `size` bytes can be read at vm->pc without running past
// the end of code[]. On overflow it flags the VM and clamps pc to codeLen so
// the next basVmStep entry raises BAS_VM_BAD_OPCODE. Compared as
// `codeLen - size` (not `pc + size`) to avoid signed overflow; codeLen is
// always >= 0 and size is a small operand width.
static inline bool operandFits(BasVmT *vm, int32_t size) {
    if (vm->pc < 0 || vm->pc > vm->module->codeLen - size) {
        vm->badOperand = true;
        vm->pc         = vm->module->codeLen;
        return false;
    }

    return true;
}


// Scans a PRINT USING / FORMAT$ numeric format string into f.  Both
// statements share the placeholder grammar: '#', '0' (and '*' before the
// decimal point) are digit positions, '.' and ',' set the decimal and
// thousands flags, a leading '+' or a trailing '+'/'-' selects the sign
// style, leading "**" and "$$" select fills, and "^^^^" requests
// scientific notation (reported through outSci).
static void parseNumFormat(const char *fmt, int32_t fmtLen, BasNumFormatT *f, bool *outSci) {
    memset(f, 0, sizeof(*f));

    f->asteriskFill = (fmtLen >= 2 && fmt[0] == '*' && fmt[1] == '*');

    int32_t scanStart = f->asteriskFill ? 2 : 0;

    f->dollarFloat = (fmtLen >= scanStart + 2 && fmt[scanStart] == '$' && fmt[scanStart + 1] == '$');
    f->plusAtStart = (fmtLen > 0 && fmt[0] == '+');
    f->plusAtEnd   = (fmtLen > 0 && fmt[fmtLen - 1] == '+');
    f->minusAtEnd  = (fmtLen > 0 && fmt[fmtLen - 1] == '-');
    *outSci        = (strstr(fmt, BAS_SCI_MARKER) != NULL);

    for (int32_t i = 0; i < fmtLen; i++) {
        if (fmt[i] == '.') {
            f->hasDecimal = true;
        } else if (fmt[i] == ',') {
            f->hasComma = true;
        } else if (fmt[i] == '#' || fmt[i] == '0' || (fmt[i] == '*' && !f->hasDecimal)) {
            if (f->hasDecimal) {
                f->digitsAfter++;
            } else {
                f->digitsBefore++;
            }

            if (fmt[i] == '0' && !f->hasDecimal) {
                f->zeroPad = true;
            }
        }
    }
}


static bool pop(BasVmT *vm, BasValueT *val) {
    if (vm->sp <= 0) {
        return false;
    }

    *val = vm->stack[--vm->sp];
    return true;
}


// Pops count values, top of stack first, into the BasValueT pointers that
// follow.  On underflow every value already popped is released so a
// malformed program cannot leak them, and false is returned.
static bool popArgs(BasVmT *vm, int32_t count, ...) {
    va_list ap;

    va_start(ap, count);

    for (int32_t i = 0; i < count; i++) {
        BasValueT *out = va_arg(ap, BasValueT *);

        if (!pop(vm, out)) {
            va_end(ap);
            va_start(ap, count);

            for (int32_t j = 0; j < i; j++) {
                basValRelease(va_arg(ap, BasValueT *));
            }

            va_end(ap);
            return false;
        }
    }

    va_end(ap);
    return true;
}


// Pops the top call frame (the shared SUB/FUNCTION return epilogue used by
// OP_RET and OP_RET_VAL).  The caller MUST have already verified callDepth > 0.
// Copies out-args, releases locals and restores PC.
static void popCallFrame(BasVmT *vm) {
    BasCallFrameT *frame = &vm->callStack[--vm->callDepth];

    // Copy out-args (for basVmCallSubWithArgsOut) BEFORE releasing the
    // locals -- reading them after release is a use-after-free.
    if (vm->outArgs && vm->callDepth == vm->outArgFrame) {
        for (int32_t i = 0; i < vm->outArgCount && i < frame->localCount; i++) {
            vm->outArgs[i] = basValCopy(frame->locals[i]);
        }

        vm->outArgFrame = -1;
    }

    // Release locals
    for (int32_t i = 0; i < frame->localCount; i++) {
        basValRelease(&frame->locals[i]);
    }

    // Discard any FOR frames the callee left open (EXIT SUB/FUNCTION
    // from inside a FOR body jumps straight to this epilogue) and
    // restore the caller's statement boundary for error dispatch.
    forStackTrim(vm, frame->savedForDepth);
    vm->stmtPc = frame->savedStmtPc;
    vm->stmtSp = frame->savedStmtSp;

    bool hadHandler = (frame->errorHandler != 0);
    frame->errorHandler = 0;
    vm->pc = frame->returnPc;

    // If this SUB owned the active error handler, any handler body that ran
    // inside it is now done -- clear the flag so the next error can trap again.
    // QBASIC documents this: an unresumed handler is cleared when the procedure
    // that installed it returns.
    if (hadHandler) {
        vm->inErrorHandler = false;
        vm->errorNumber    = 0;
        vm->errorMsg[0]    = '\0';
    }
}


static BasVmResultE popFileChannel(BasVmT *vm, int32_t *outChannel, bool requireOpen) {
    BasValueT channelVal;

    if (!pop(vm, &channelVal)) {
        return BAS_VM_STACK_UNDERFLOW;
    }

    int32_t channel = basValToInt32(channelVal);
    basValRelease(&channelVal);

    if (channel < 1 || channel >= BAS_VM_MAX_FILES || (requireOpen && !vm->files[channel].handle)) {
        runtimeError(vm, BAS_ERR_BAD_FILE_NUM, requireOpen ? "Bad file number or file not open" : "Bad file channel number");
        return BAS_VM_FILE_ERROR;
    }

    *outChannel = channel;
    return BAS_VM_OK;
}


// Pops one operand and converts it to a STRING value (*out always holds a
// non-NULL strVal on success).  Returns false on stack underflow.
static bool popStringArg(BasVmT *vm, BasValueT *out) {
    BasValueT raw;

    if (!pop(vm, &raw)) {
        return false;
    }

    *out = basValToString(raw);
    basValRelease(&raw);
    return true;
}


// REDIM PRESERVE: copies every element whose subscripts are valid in both
// the old and the new array, walking the new array's subscript space and
// mapping each position back through the old array's own strides.  Both
// arrays must have the same dimension count.
static void preserveElements(const BasArrayT *oldArr, BasArrayT *newArr) {
    int32_t idx[BAS_ARRAY_MAX_DIMS];

    for (int32_t d = 0; d < newArr->dims; d++) {
        idx[d] = newArr->lbound[d];
    }

    for (int32_t flat = 0; flat < newArr->totalElements; flat++) {
        int32_t oldFlat = basArrayIndex((BasArrayT *)oldArr, idx, newArr->dims);

        if (oldFlat >= 0) {
            basValRelease(&newArr->elements[flat]);
            newArr->elements[flat] = basValCopy(oldArr->elements[oldFlat]);
        }

        // Advance the subscript counter, last dimension fastest.
        for (int32_t d = newArr->dims - 1; d >= 0; d--) {
            if (++idx[d] <= newArr->ubound[d]) {
                break;
            }

            idx[d] = newArr->lbound[d];
        }
    }
}


// Primes callStack[0] as the implicit main (module-level) frame.  Module
// code runs in frame 0; without callDepth >= 1 the error dispatcher's
// 'while (callDepth > 0)' unwind never inspects that frame, so a
// module-level ON ERROR GOTO never traps.  localCount lets RET and
// frame-local cleanup see the right slot count.
static void primeModuleFrame(BasVmT *vm) {
    vm->callDepth                  = 1;
    vm->callStack[0].localCount    = vm->module->globalCount > BAS_VM_MAX_LOCALS ? BAS_VM_MAX_LOCALS : vm->module->globalCount;
    vm->callStack[0].errorHandler  = 0;
    vm->callStack[0].savedForDepth = 0;
    vm->callStack[0].savedStmtPc   = -1;
    vm->callStack[0].savedStmtSp   = 0;
    // Module-level vars are globals, so frame-0 locals are unused, but
    // clear them so re-priming a used VM can't release stale values when
    // the error dispatcher or teardown walks localCount.
    memset(vm->callStack[0].locals, 0, sizeof(vm->callStack[0].locals));
}


// Pushes val, taking ownership of it: on stack overflow the value is
// released here so no caller has to remember to.
static bool push(BasVmT *vm, BasValueT val) {
    if (vm->sp >= BAS_VM_STACK_SIZE) {
        basValRelease(&val);
        return false;
    }

    vm->stack[vm->sp++] = val;
    return true;
}


// Bounded single-character append used throughout formatNumber: writes c at
// out[*idx] and advances *idx only while it stays under limit, so no formatter
// cursor write can overflow the caller's buffer.
static void putBounded(char *out, int32_t *idx, int32_t limit, char c) {
    if (*idx < limit) {
        out[(*idx)++] = c;
    }
}


// INPUT #: reads one field the way WRITE # wrote it -- leading blanks are
// skipped, a quoted field runs to the closing quote, an unquoted field runs
// to the next comma or end of line, and the field's delimiter (comma or
// line end) is consumed.  Returns the field as a new string.
static BasStringT *readField(FILE *fp) {
    int32_t ch = fgetc(fp);

    while (ch == ' ' || ch == '\t') {
        ch = fgetc(fp);
    }

    if (ch == EOF) {
        return basStringRef(basEmptyString);
    }

    // An empty line between records is its own (empty) field.
    if (ch == '\n' || ch == '\r') {
        if (ch == '\r') {
            ch = fgetc(fp);

            if (ch != '\n' && ch != EOF) {
                ungetc(ch, fp);
            }
        }

        return basStringRef(basEmptyString);
    }

    int32_t cap    = BAS_FILE_LINE_CHUNK;
    int32_t len    = 0;
    char   *buf    = (char *)malloc(cap);
    bool    quoted = (ch == '"');

    if (!buf) {
        return basStringRef(basEmptyString);
    }

    if (!quoted) {
        ungetc(ch, fp);
    }

    for (;;) {
        ch = fgetc(fp);

        if (ch == EOF) {
            break;
        }

        if (quoted) {
            if (ch == '"') {
                // Skip to the field delimiter that follows the closing quote.
                do {
                    ch = fgetc(fp);
                } while (ch == ' ' || ch == '\t');

                if (ch != ',' && ch != '\n' && ch != '\r' && ch != EOF) {
                    ungetc(ch, fp);
                }

                break;
            }
        } else if (ch == ',' || ch == '\n' || ch == '\r') {
            break;
        }

        if (len + 1 >= cap) {
            int32_t newCap = cap * 2;
            char   *grown  = (char *)realloc(buf, newCap);

            if (!grown) {
                break;
            }

            buf = grown;
            cap = newCap;
        }

        buf[len++] = (char)ch;
    }

    // Swallow the LF of a CR LF pair so the next field starts cleanly.
    if (ch == '\r') {
        ch = fgetc(fp);

        if (ch != '\n' && ch != EOF) {
            ungetc(ch, fp);
        }
    }

    // Unquoted fields drop trailing blanks, as QB does.
    if (!quoted) {
        while (len > 0 && (buf[len - 1] == ' ' || buf[len - 1] == '\t')) {
            len--;
        }
    }

    BasStringT *s = basStringNew(buf, len);
    free(buf);
    return s;
}


// memcpy with constant size is folded to a single load by the compiler and
// is alignment-safe (bytecode operands aren't guaranteed 2-byte aligned).
static inline int16_t readInt16(BasVmT *vm) {
    int16_t val = 0;

    if (operandFits(vm, (int32_t)sizeof(int16_t))) {
        memcpy(&val, &vm->module->code[vm->pc], sizeof(int16_t));
        vm->pc += sizeof(int16_t);
    }

    return val;
}


// LINE INPUT #: reads a whole line of any length (fgets chunks are joined
// until the newline arrives), with the trailing CR/LF stripped.  Returns an
// empty string at end of file.
static BasStringT *readLine(FILE *fp) {
    int32_t cap = BAS_FILE_LINE_CHUNK;
    int32_t len = 0;
    char   *buf = (char *)malloc(cap);

    if (!buf) {
        return basStringRef(basEmptyString);
    }

    buf[0] = '\0';

    while (fgets(buf + len, cap - len, fp)) {
        len += (int32_t)strlen(buf + len);

        if (len > 0 && buf[len - 1] == '\n') {
            break;
        }

        if (len + 1 >= cap - 1) {
            int32_t newCap = cap * 2;
            char   *grown  = (char *)realloc(buf, newCap);

            if (!grown) {
                break;
            }

            buf = grown;
            cap = newCap;
        }
    }

    while (len > 0 && (buf[len - 1] == '\n' || buf[len - 1] == '\r')) {
        len--;
    }

    BasStringT *s = basStringNew(buf, len);
    free(buf);
    return s;
}


// GET/PUT record read whose short-read result is deliberately ignored: a
// record past end of file yields the caller's zero default.
static void readRaw(FILE *fp, void *dst, size_t size) {
    size_t got = fread(dst, 1, size, fp);
    (void)got;
}


static inline uint16_t readUint16(BasVmT *vm) {
    uint16_t val = 0;

    if (operandFits(vm, (int32_t)sizeof(uint16_t))) {
        memcpy(&val, &vm->module->code[vm->pc], sizeof(uint16_t));
        vm->pc += sizeof(uint16_t);
    }

    return val;
}


static inline uint8_t readUint8(BasVmT *vm) {
    if (!operandFits(vm, (int32_t)sizeof(uint8_t))) {
        return 0;
    }

    return vm->module->code[vm->pc++];
}


// Releases every refcounted value the VM holds: the eval stack, the
// globals, all call-frame locals across callDepth, and the FOR-stack
// limit/step across forDepth.  Must run BEFORE the caller zeroes
// sp/callDepth/forDepth, because the loops use those counts.
static void releaseVmState(BasVmT *vm) {
    // Release eval stack values
    for (int32_t i = 0; i < vm->sp; i++) {
        basValRelease(&vm->stack[i]);
    }

    // Release global variables
    for (int32_t i = 0; i < BAS_VM_MAX_GLOBALS; i++) {
        basValRelease(&vm->globals[i]);
    }

    // Release call frame locals
    for (int32_t d = 0; d < vm->callDepth; d++) {
        for (int32_t i = 0; i < vm->callStack[d].localCount; i++) {
            basValRelease(&vm->callStack[d].locals[i]);
        }
    }

    // Release FOR stack
    for (int32_t i = 0; i < vm->forDepth; i++) {
        basValRelease(&vm->forStack[i].limit);
        basValRelease(&vm->forStack[i].step);
    }
}


// Rounds a numeric operand to LONG (banker's rounding) for the integer
// operators; raises Overflow when it does not fit.
static BasVmResultE roundedOperand(BasVmT *vm, BasValueT v, int32_t *out) {
    if (!basValRoundToInt32(v, INT32_MIN, INT32_MAX, out)) {
        runtimeError(vm, BAS_ERR_OVERFLOW, "Overflow");
        return BAS_VM_ERROR;
    }

    return BAS_VM_OK;
}


// Executes instructions until callDepth drops back to savedCallDepth
// (meaning the subroutine returned). Periodically yields via the
// doEvents callback to keep the GUI responsive during long-running
// event handlers.
static bool runSubLoop(BasVmT *vm, int32_t savedPc, int32_t savedCallDepth, bool savedRunning) {
    int32_t stepsSinceYield = 0;
    bool    hadBreakpoint   = false;

    while (vm->running && vm->callDepth > savedCallDepth) {
        int32_t stepPc = vm->pc;  // save for ON ERROR dispatch
        BasVmResultE result = basVmStep(vm);

        if (result == BAS_VM_HALTED) {
            break;
        }

        if (result == BAS_VM_BREAKPOINT) {
            // Pause in place: yield to the GUI until the host resumes.
            // This ensures the sub runs to completion before returning
            // to the caller (critical for form init code, event handlers).
            vm->running  = false;
            hadBreakpoint = true;

            // Notify host to update debug UI (highlight line, locals, etc.)
            if (vm->breakpointFn) {
                vm->breakpointFn(vm->breakpointCtx, vm->currentLine);
            }

            while (!vm->running && vm->doEventsFn) {
                if (!vm->doEventsFn(vm->doEventsCtx)) {
                    // Host is shutting down
                    vm->pc      = savedPc;
                    vm->running = savedRunning;
                    return false;
                }
            }

            // User resumed -- continue executing the sub
            stepsSinceYield = 0;
            continue;
        }

        if (result != BAS_VM_OK) {
            // Try ON ERROR GOTO within the current sub-call boundary
            // (savedCallDepth) so event handlers fired via basVmCallSub
            // behave like module-level code when it comes to trapping.
            if (dispatchError(vm, result, savedCallDepth, stepPc)) {
                stepsSinceYield = 0;
                continue;
            }

            // Frames the dispatcher did not unwind (error raised inside a
            // handler, bad opcode) still hold refcounted locals; discard
            // them the same way so force-restoring callDepth cannot orphan
            // them above releaseVmState's reach.  No out-arg copy-back and
            // no popCallFrame here: this is the failure path, and
            // popCallFrame would clear the error state the host reads.
            while (vm->callDepth > savedCallDepth) {
                BasCallFrameT *frame = &vm->callStack[vm->callDepth - 1];

                for (int32_t li = 0; li < frame->localCount; li++) {
                    basValRelease(&frame->locals[li]);
                }

                forStackTrim(vm, frame->savedForDepth);
                vm->stmtPc = frame->savedStmtPc;
                vm->stmtSp = frame->savedStmtSp;

                vm->callDepth--;
            }

            vm->pc      = savedPc;
            vm->running = savedRunning;
            return false;
        }

        // Yield periodically to keep the GUI responsive
        if (++stepsSinceYield >= SUB_YIELD_INTERVAL) {
            stepsSinceYield = 0;

            if (vm->doEventsFn) {
                if (!vm->doEventsFn(vm->doEventsCtx)) {
                    vm->running = false;
                    break;
                }
            }
        }
    }

    // END/OP_HALT and doEvents aborts break out of the loop with the
    // callee's frames still pushed (OP_RET never ran for them).  Pop them
    // properly -- out-arg copy-back, local release, FOR-stack trim --
    // instead of force-lowering callDepth, which would strand the frames'
    // refcounted locals above callDepth where releaseVmState never walks,
    // and would drop ByRef out-args (e.g. Cancel = 1 set before END).
    while (vm->callDepth > savedCallDepth) {
        popCallFrame(vm);
    }

    vm->pc      = savedPc;
    vm->running = savedRunning;

    // If we paused at a breakpoint during this sub, notify the host
    // so it can pause the event loop before any new event fires.
    if (hadBreakpoint && vm->breakpointFn) {
        vm->breakpointFn(vm->breakpointCtx, -1);
    }

    return true;
}


static void runtimeError(BasVmT *vm, int32_t errNum, const char *msg) {
    vm->errorNumber = errNum;
    vm->errorLine   = vm->currentLine;

    if (vm->currentLine > 0) {
        snprintf(vm->errorMsg, sizeof(vm->errorMsg), "Runtime error %d on line %d: %s", (int)errNum, (int)vm->currentLine, msg);
    } else {
        snprintf(vm->errorMsg, sizeof(vm->errorMsg), "Runtime error %d at PC %d: %s", (int)errNum, (int)vm->pc, msg);
    }
}


// GET/PUT positioning.  RANDOM channels address fixed-size records; BINARY
// channels address bytes.  Both are 1-based; 0 keeps the current position.
static void seekRecord(BasVmT *vm, int32_t channel, int32_t recno) {
    if (recno <= 0) {
        return;
    }

    long offset = (vm->files[channel].mode == BAS_FILE_MODE_BINARY) ? (long)(recno - 1) : (long)(recno - 1) * vm->files[channel].recLen;

    fseek((FILE *)vm->files[channel].handle, offset, SEEK_SET);
}


// Replaces *target with val, releasing the old value.  A TYPE value that is
// still shared with another variable is deep-copied first so assignment has
// value semantics (b = a must not alias a).
static void storeValue(BasValueT *target, BasValueT val) {
    basValRelease(target);
    *target = udtDetach(val);
}


// Length-aware substring search (strings may contain NUL bytes).  Returns
// the 0-based offset of the first occurrence of needle in haystack at or
// after start, or -1.  An empty needle matches at start (INSTR semantics).
static int32_t stringFind(const BasStringT *haystack, int32_t start, const BasStringT *needle) {
    if (needle->len == 0) {
        return start;
    }

    for (int32_t i = start; i + needle->len <= haystack->len; i++) {
        if (memcmp(haystack->data + i, needle->data, needle->len) == 0) {
            return i;
        }
    }

    return -1;
}


// Wraps a string the caller already owns a reference to (no extra ref).
static BasValueT strValue(BasStringT *s) {
    BasValueT v;
    v.type   = BAS_TYPE_STRING;
    v.strVal = s;
    return v;
}


// Gives val its own TYPE instance when the one it carries is shared.  The
// shared reference is released; a clone failure leaves the value shared.
static BasValueT udtDetach(BasValueT val) {
    if (val.type != BAS_TYPE_UDT || !val.udtVal || val.udtVal->refCount <= 1) {
        return val;
    }

    BasUdtT *copy = basUdtClone(val.udtVal);

    if (!copy) {
        return val;
    }

    basUdtUnref(val.udtVal);
    val.udtVal = copy;
    return val;
}


static bool validArrayDims(BasVmT *vm, int32_t dims) {
    if (dims < 1 || dims > BAS_ARRAY_MAX_DIMS) {
        runtimeError(vm, BAS_ERR_SUBSCRIPT_RANGE, "Invalid array dimension count");
        return false;
    }

    return true;
}
