// AGI v2 LOGIC VM, Phase 1 sub-A: bytecode dispatch skeleton. // // This pass implements the variable / flag opcodes (0x00-0x11) plus // the basic header parser. Control flow (IF / ELSE / GOTO), test // commands (equaln, isset, said, ...), and side-effecting opcodes // (new.room, load.pic, animate.obj, ...) come in later sub-phases. // Unknown opcodes halt cleanly with the offending byte recorded so // host tests can report exactly where execution stopped. // // All algorithms here are re-implementations written against the // published AGI v2 LOGIC bytecode specification; no third-party // interpreter source is referenced. #include "agi.h" #include "joey/core.h" #include "joey/debug.h" #include #include // LOGIC resource layout: bytes 0-1 are the little-endian length of // the bytecode section, followed by `bytecodeLen` bytes of bytecode, // followed by the message table. The VM walks only the bytecode. #define AGI_LOGIC_HDR_BYTES 2u // Implemented action opcodes (sub-A: arithmetic and flags). #define OP_RETURN 0x00u #define OP_INCREMENT 0x01u #define OP_DECREMENT 0x02u #define OP_ASSIGNN 0x03u #define OP_ASSIGNV 0x04u #define OP_ADDN 0x05u #define OP_ADDV 0x06u #define OP_SUBN 0x07u #define OP_SUBV 0x08u #define OP_LINDIRECTV 0x09u #define OP_RINDIRECT 0x0Au #define OP_LINDIRECTN 0x0Bu #define OP_SET 0x0Cu #define OP_RESET 0x0Du #define OP_TOGGLE 0x0Eu #define OP_SETV 0x0Fu #define OP_RESETV 0x10u #define OP_TOGGLEV 0x11u // Control flow (sub-B). 0xFF as an action opcode introduces an IF // expression; the test list is terminated by an inner 0xFF, followed // by a 2-byte LE skip offset that jumps past the IF body when the // test is false. 0xFE is an unconditional relative GOTO (signed 16 // bit), used by the compiler to skip ELSE bodies and to form loops. #define OP_GOTO 0xFEu #define OP_IF 0xFFu // Test command IDs (sub-B). Only valid inside an IF test list. #define TEST_EQUALN 0x01u #define TEST_EQUALV 0x02u #define TEST_LESSN 0x03u #define TEST_LESSV 0x04u #define TEST_GREATERN 0x05u #define TEST_GREATERV 0x06u #define TEST_ISSET 0x07u #define TEST_ISSETV 0x08u #define TEST_HAS 0x09u #define TEST_OBJ_IN_ROOM 0x0Au #define TEST_POSN 0x0Bu #define TEST_CONTROLLER 0x0Cu #define TEST_HAVE_KEY 0x0Du #define TEST_SAID 0x0Eu #define TEST_COMPARE_STRINGS 0x0Fu #define TEST_OBJ_IN_BOX 0x10u #define TEST_CENTER_POSN 0x11u #define TEST_RIGHT_POSN 0x12u #define TEST_MAX 0x13u // Markers inside an IF test list. #define TEST_END_LIST 0xFFu #define TEST_OR_TOGGLE 0xFCu #define TEST_NOT 0xFDu // Side-effecting opcodes wired to host callbacks in sub-D. Each has // a real case in the dispatch switch; the host's callback table // decides what (if anything) actually happens for the call. #define OP_NEW_ROOM 0x12u #define OP_NEW_ROOM_V 0x13u #define OP_LOAD_LOGIC 0x14u #define OP_LOAD_LOGIC_V 0x15u #define OP_CALL 0x16u #define OP_CALL_V 0x17u #define OP_LOAD_PIC 0x18u #define OP_DRAW_PIC 0x19u #define OP_SHOW_PIC 0x1Au #define OP_DISCARD_PIC 0x1Bu #define OP_OVERLAY_PIC 0x1Cu // Object-table opcodes (animate.obj, set.view, ..., release.priority). // These mutate the per-object slots in vm->objects[N]; per-frame // rendering and per-cycle animation tick are handled outside the VM. #define OP_LOAD_VIEW 0x1Eu #define OP_LOAD_VIEW_V 0x1Fu #define OP_DISCARD_VIEW 0x20u #define OP_ANIMATE_OBJ 0x21u #define OP_UNANIMATE_ALL 0x22u #define OP_DRAW 0x23u #define OP_ERASE 0x24u #define OP_POSITION 0x25u #define OP_POSITION_V 0x26u #define OP_GET_POSN 0x27u #define OP_REPOSITION 0x28u #define OP_SET_VIEW 0x29u #define OP_SET_VIEW_V 0x2Au #define OP_SET_LOOP 0x2Bu #define OP_SET_LOOP_V 0x2Cu #define OP_FIX_LOOP 0x2Du #define OP_RELEASE_LOOP 0x2Eu #define OP_SET_CEL 0x2Fu #define OP_SET_CEL_V 0x30u #define OP_LAST_CEL 0x31u #define OP_CURRENT_CEL 0x32u #define OP_CURRENT_LOOP 0x33u #define OP_CURRENT_VIEW 0x34u #define OP_NUMBER_OF_LOOPS 0x35u #define OP_SET_PRIORITY 0x36u #define OP_SET_PRIORITY_V 0x37u #define OP_RELEASE_PRIORITY 0x38u #define OP_GET_PRIORITY 0x39u #define OP_STOP_UPDATE 0x3Au #define OP_START_UPDATE 0x3Bu #define OP_FORCE_UPDATE 0x3Cu #define OP_IGNORE_HORIZON 0x3Du #define OP_OBSERVE_HORIZON 0x3Eu #define OP_SET_HORIZON 0x3Fu #define OP_OBJECT_ON_WATER 0x40u #define OP_OBJECT_ON_LAND 0x41u #define OP_OBJECT_ON_ANYTHING 0x42u #define OP_IGNORE_OBJS 0x43u #define OP_OBSERVE_OBJS 0x44u #define OP_DISTANCE 0x45u // Animation cycling. #define OP_STOP_CYCLING 0x46u #define OP_START_CYCLING 0x47u #define OP_NORMAL_CYCLE 0x48u #define OP_END_OF_LOOP 0x49u #define OP_REVERSE_CYCLE 0x4Au #define OP_REVERSE_LOOP 0x4Bu #define OP_CYCLE_TIME 0x4Cu // Motion. #define OP_STOP_MOTION 0x4Du #define OP_START_MOTION 0x4Eu #define OP_STEP_SIZE 0x4Fu #define OP_STEP_TIME 0x50u #define OP_MOVE_OBJ 0x51u #define OP_MOVE_OBJ_V 0x52u #define OP_FOLLOW_EGO 0x53u #define OP_WANDER 0x54u #define OP_NORMAL_MOTION 0x55u #define OP_SET_DIR 0x56u #define OP_GET_DIR 0x57u // Object collision (most stub no-op; engine doesn't model blocking). #define OP_IGNORE_BLOCKS 0x58u #define OP_OBSERVE_BLOCKS 0x59u #define OP_BLOCK 0x5Au #define OP_UNBLOCK 0x5Bu // Inventory. #define OP_GET 0x5Cu #define OP_GET_V 0x5Du #define OP_DROP 0x5Eu #define OP_PUT 0x5Fu #define OP_PUT_V 0x60u #define OP_GET_ROOM_V 0x61u // Sound (stub for now; satisfies "sound finished" flag immediately). #define OP_LOAD_SOUND 0x62u #define OP_SOUND 0x63u #define OP_STOP_SOUND 0x64u // Text. #define OP_PRINT 0x65u #define OP_PRINT_V 0x66u #define OP_DISPLAY 0x67u #define OP_DISPLAY_V 0x68u #define OP_CLEAR_LINES 0x69u #define OP_TEXT_SCREEN 0x6Au #define OP_GRAPHICS 0x6Bu #define OP_SET_CURSOR_CHAR 0x6Cu #define OP_SET_TEXT_ATTRIBUTE 0x6Du #define OP_SHAKE_SCREEN 0x6Eu #define OP_CONFIGURE_SCREEN 0x6Fu #define OP_STATUS_LINE_ON 0x70u #define OP_STATUS_LINE_OFF 0x71u #define OP_SET_STRING 0x72u #define OP_GET_STRING 0x73u #define OP_WORD_TO_STRING 0x74u #define OP_PARSE 0x75u #define OP_GET_NUM 0x76u // Input. #define OP_PREVENT_INPUT 0x77u #define OP_ACCEPT_INPUT 0x78u #define OP_SET_KEY 0x79u // Picture composition. #define OP_ADD_TO_PIC 0x7Au #define OP_ADD_TO_PIC_V 0x7Bu // Misc. #define OP_STATUS 0x7Cu #define OP_SAVE_GAME 0x7Du #define OP_RESTORE_GAME 0x7Eu #define OP_INIT_DISK 0x7Fu #define OP_RESTART_GAME 0x80u #define OP_SHOW_OBJ 0x81u #define OP_RANDOM 0x82u #define OP_PROGRAM_CONTROL 0x83u #define OP_PLAYER_CONTROL 0x84u #define OP_OBJ_STATUS_V 0x85u #define OP_QUIT 0x86u #define OP_SHOW_MEM 0x87u #define OP_PAUSE 0x88u #define OP_ECHO_LINE 0x89u #define OP_CANCEL_LINE 0x8Au #define OP_INIT_JOY 0x8Bu #define OP_TOGGLE_MONITOR 0x8Cu #define OP_VERSION 0x8Du #define OP_SCRIPT_SIZE 0x8Eu #define OP_SET_GAME_ID 0x8Fu #define OP_LOG 0x90u #define OP_SET_SCAN_START 0x91u #define OP_RESET_SCAN_START 0x92u #define OP_REPOSITION_TO 0x93u #define OP_REPOSITION_TO_V 0x94u #define OP_TRACE_ON 0x95u #define OP_TRACE_INFO 0x96u #define OP_PRINT_AT 0x97u #define OP_PRINT_AT_V 0x98u #define OP_DISCARD_VIEW_V 0x99u #define OP_CLEAR_TEXT_RECT 0x9Au #define OP_SET_UPPER_LEFT 0x9Bu #define OP_SET_MENU 0x9Cu #define OP_SET_MENU_ITEM 0x9Du #define OP_SUBMIT_MENU 0x9Eu #define OP_ENABLE_ITEM 0x9Fu #define OP_DISABLE_ITEM 0xA0u #define OP_MENU_INPUT 0xA1u #define OP_SHOW_OBJ_V 0xA2u #define OP_OPEN_DIALOGUE 0xA3u #define OP_CLOSE_DIALOGUE 0xA4u #define OP_MUL_N 0xA5u #define OP_MUL_V 0xA6u #define OP_DIV_N 0xA7u #define OP_DIV_V 0xA8u #define OP_CLOSE_WINDOW 0xA9u #define OP_SET_SIMPLE 0xAAu // Special vars touched by the engine outside script control. #define ENG_VAR_EGO_DIR 6u #define ENG_VAR_EGO_VIEW 16u // Inventory state lives in vm->vars[88..127] (40 slots) — each slot // stores the room id where item N is currently (255 = ego inventory). // AGI v2 fixes the inventory length to about 40 in most games; we // reserve more (88..255) but cap at 64 items in practice. #define INV_ROOM_INVENTORY 255u // Sentinel value in kActionArgBytes meaning "no v2 opcode at this // index"; the dispatcher halts when it sees this in the table. #define ACTION_UNKNOWN 0xFFu #ifdef JOEYLIB_PLATFORM_IIGS segment "AGIVM"; #endif // ----- Prototypes ----- static bool enterLogic(AgiVmT *vm, uint8_t logicId); static bool evalOneTest(AgiVmT *vm, uint8_t testOp); static bool evalTests(AgiVmT *vm); static bool executeGoto(AgiVmT *vm); static bool executeIf(AgiVmT *vm); static void expandMessage(AgiVmT *vm, const char *src, char *dst, uint16_t dstCap); static const char *fetchMessageOrEmpty(AgiVmT *vm, uint8_t logicId, uint8_t msgId); static void openPrintModal(AgiVmT *vm, const char *expanded); static bool popFrame(AgiVmT *vm); static bool pushFrame(AgiVmT *vm); static bool readByte(AgiVmT *vm, uint8_t *out); static bool readU16(AgiVmT *vm, uint16_t *out); static bool skipTestArgs(AgiVmT *vm, uint8_t testOp); static void writeTextRow(AgiVmT *vm, uint8_t row, uint8_t col, const char *expanded); // Per-action-opcode argument byte counts, indexed by opcode id. The // real handlers (arithmetic / flags / IF / GOTO / new.room) consult // this only as a sanity check; unrecognized opcodes (entries set to // ACTION_UNKNOWN) halt the VM with AGI_VM_HALT_UNKNOWN_OP. Every other // entry is treated as a stub: its argument bytes are consumed and no // side effect is taken. This lets the VM walk arbitrary v2 logic // without halting at every yet-to-be-implemented side-effecting // command (load.pic / animate.obj / sound / ...). static const uint8_t kActionArgBytes[256] = { /* 0x00 */ 0u, 1u, 1u, 2u, 2u, 2u, 2u, 2u, 2u, 2u, 2u, 2u, 1u, 1u, 1u, 1u, /* 0x10 */ 1u, 1u, 1u, 1u, 1u, 1u, 1u, 1u, 1u, 1u, 0u, 1u, 1u, 0u, 1u, 1u, /* 0x20 */ 1u, 1u, 0u, 1u, 1u, 3u, 3u, 3u, 3u, 2u, 2u, 2u, 2u, 1u, 1u, 2u, /* 0x30 */ 2u, 2u, 2u, 2u, 2u, 2u, 2u, 2u, 1u, 2u, 1u, 1u, 1u, 1u, 1u, 1u, /* 0x40 */ 1u, 1u, 1u, 1u, 1u, 3u, 1u, 1u, 1u, 2u, 1u, 2u, 2u, 1u, 1u, 2u, /* 0x50 */ 2u, 5u, 5u, 3u, 1u, 1u, 2u, 2u, 1u, 1u, 4u, 0u, 1u, 1u, 1u, 2u, /* 0x60 */ 2u, 2u, 1u, 2u, 0u, 1u, 1u, 3u, 3u, 3u, 0u, 0u, 1u, 2u, 1u, 3u, /* 0x70 */ 0u, 0u, 2u, 5u, 2u, 1u, 2u, 0u, 0u, 3u, 7u, 7u, 0u, 0u, 0u, 0u, /* 0x80 */ 0u, 1u, 3u, 0u, 0u, 1u, 1u, 0u, 0u, 0u, 0u, 0u, 0u, 0u, 1u, 1u, /* 0x90 */ 1u, 0u, 0u, 3u, 3u, 0u, 3u, 4u, 4u, 1u, 4u, 2u, 1u, 2u, 0u, 1u, /* 0xA0 */ 1u, 0u, 1u, 0u, 0u, 2u, 2u, 2u, 2u, 0u, 1u, 0u, 0u, 0u, 1u, 1u, /* 0xB0 */ 0u, 1u, 0u, 4u, 2u, 0u, 0u, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, /* 0xBA */ ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, /* 0xC0 */ ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, /* 0xC8 */ ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, /* 0xD0 */ ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, /* 0xD8 */ ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, /* 0xE0 */ ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, /* 0xE8 */ ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, /* 0xF0 */ ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, /* 0xF8 */ ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN, ACTION_UNKNOWN }; // Per-test-command argument byte counts, indexed by test opcode id. // 0x0E (said) is variable-length and gets special-cased rather than // indexed via this table. static const uint8_t kTestArgBytes[TEST_MAX] = { /* 0x00 */ 0u, /* 0x01 equaln */ 2u, /* 0x02 equalv */ 2u, /* 0x03 lessn */ 2u, /* 0x04 lessv */ 2u, /* 0x05 greatern */ 2u, /* 0x06 greaterv */ 2u, /* 0x07 isset */ 1u, /* 0x08 issetv */ 1u, /* 0x09 has */ 1u, /* 0x0A obj.in.room */ 2u, /* 0x0B posn */ 5u, /* 0x0C controller */ 1u, /* 0x0D have.key */ 0u, /* 0x0E said */ 0u, /* 0x0F compare.strings */ 2u, /* 0x10 obj.in.box */ 5u, /* 0x11 center.posn */ 5u, /* 0x12 right.posn */ 5u }; // ----- Internal helpers (alphabetical) ----- // Switch the VM's running logic to `logicId`. Used by OP_CALL and // OP_CALL_V after pushFrame has saved the caller's position. Returns // false (and sets haltReason) if the host has no bytecode for the // requested id. static bool enterLogic(AgiVmT *vm, uint8_t logicId) { const uint8_t *bytecode; uint16_t length; if (vm->callbacks.fetchLogic == NULL) { vm->haltReason = AGI_VM_HALT_NO_LOGIC; return false; } length = 0u; bytecode = vm->callbacks.fetchLogic(vm->callbacks.ctx, logicId, &length); if (bytecode == NULL || length == 0u) { vm->haltReason = AGI_VM_HALT_NO_LOGIC; return false; } vm->code = bytecode; vm->codeLength = length; vm->pc = 0u; vm->currentLogicId = logicId; return true; } // Evaluate a single test command, with pc currently positioned at // the first argument byte. Always consumes the test's full argument // list so the caller's stream position stays in sync, even when the // test is stubbed to a constant false. Tests this sub-phase doesn't // implement (objects, parser, controllers, key state) read their // args and return false; the VM walks past the IF body as if the // test evaluated against fresh game state. static bool evalOneTest(AgiVmT *vm, uint8_t testOp) { uint8_t args[5]; uint8_t i; uint8_t argBytes; if (testOp == 0u || testOp >= TEST_MAX) { vm->lastUnknownOp = testOp; vm->haltReason = AGI_VM_HALT_UNKNOWN_OP; return false; } if (testOp == TEST_SAID) { // 1-byte word count, followed by 2 bytes per word. if (!skipTestArgs(vm, testOp)) { return false; } return false; } argBytes = kTestArgBytes[testOp]; for (i = 0u; i < argBytes; i++) { if (!readByte(vm, &args[i])) { return false; } } switch (testOp) { case TEST_EQUALN: return vm->vars[args[0]] == args[1]; case TEST_EQUALV: return vm->vars[args[0]] == vm->vars[args[1]]; case TEST_LESSN: return vm->vars[args[0]] < args[1]; case TEST_LESSV: return vm->vars[args[0]] < vm->vars[args[1]]; case TEST_GREATERN: return vm->vars[args[0]] > args[1]; case TEST_GREATERV: return vm->vars[args[0]] > vm->vars[args[1]]; case TEST_ISSET: return vm->flags[args[0]] != 0u; case TEST_ISSETV: return vm->flags[vm->vars[args[0]]] != 0u; case TEST_HAVE_KEY: if (vm->callbacks.haveKey != NULL) { return vm->callbacks.haveKey(vm->callbacks.ctx); } return false; case TEST_CONTROLLER: { uint8_t c = args[0]; if (c < AGI_MAX_CONTROLLERS && vm->controllerFired[c] != 0u) { vm->controllerFired[c] = 0u; return true; } return false; } case TEST_POSN: { uint8_t n = args[0]; int16_t x1 = (int16_t)args[1]; int16_t y1 = (int16_t)args[2]; int16_t x2 = (int16_t)args[3]; int16_t y2 = (int16_t)args[4]; const AgiObjectT *o; if (n >= AGI_MAX_OBJECTS) return false; o = &vm->objects[n]; return (o->x >= x1 && o->x <= x2 && o->y >= y1 && o->y <= y2); } case TEST_OBJ_IN_BOX: { // Same shape as posn but checks the *entire* cel bbox is // inside the box. Without view dimensions in the VM we // approximate with point containment (== posn). uint8_t n = args[0]; int16_t x1 = (int16_t)args[1]; int16_t y1 = (int16_t)args[2]; int16_t x2 = (int16_t)args[3]; int16_t y2 = (int16_t)args[4]; const AgiObjectT *o; if (n >= AGI_MAX_OBJECTS) return false; o = &vm->objects[n]; return (o->x >= x1 && o->x <= x2 && o->y >= y1 && o->y <= y2); } case TEST_CENTER_POSN: { uint8_t n = args[0]; int16_t x1 = (int16_t)args[1]; int16_t y1 = (int16_t)args[2]; int16_t x2 = (int16_t)args[3]; int16_t y2 = (int16_t)args[4]; const AgiObjectT *o; if (n >= AGI_MAX_OBJECTS) return false; o = &vm->objects[n]; return (o->x >= x1 && o->x <= x2 && o->y >= y1 && o->y <= y2); } case TEST_RIGHT_POSN: { uint8_t n = args[0]; int16_t x1 = (int16_t)args[1]; int16_t y1 = (int16_t)args[2]; int16_t x2 = (int16_t)args[3]; int16_t y2 = (int16_t)args[4]; const AgiObjectT *o; if (n >= AGI_MAX_OBJECTS) return false; o = &vm->objects[n]; return (o->x >= x1 && o->x <= x2 && o->y >= y1 && o->y <= y2); } case TEST_HAS: // Inventory: var[88+arg] is the item room (255 = held). // Without OBJECT-table parsing, default to "not held". return false; case TEST_OBJ_IN_ROOM: // Same: defaults to false until OBJECT support lands. return false; default: return false; } } // Walk the IF test list at pc. Default operator is AND; 0xFC toggles // in/out of an OR group whose result is then ANDed into the running // AND. 0xFD prefixes the next test with NOT. 0xFF terminates the // list. Leaves pc positioned right after the terminator so the // caller can read the 2-byte skip offset. static bool evalTests(AgiVmT *vm) { bool andResult; bool orResult; bool inOrGroup; bool notNext; bool testResult; uint8_t op; andResult = true; orResult = false; inOrGroup = false; notNext = false; for (;;) { if (!readByte(vm, &op)) { return false; } if (op == TEST_END_LIST) { if (inOrGroup) { andResult = (bool)(andResult && orResult); } return andResult; } if (op == TEST_OR_TOGGLE) { if (inOrGroup) { andResult = (bool)(andResult && orResult); inOrGroup = false; } else { orResult = false; inOrGroup = true; } continue; } if (op == TEST_NOT) { notNext = (bool)!notNext; continue; } testResult = evalOneTest(vm, op); if (vm->haltReason != AGI_VM_HALT_NONE) { return false; } if (notNext) { testResult = (bool)!testResult; notNext = false; } if (inOrGroup) { orResult = (bool)(orResult || testResult); } else { andResult = (bool)(andResult && testResult); } } } // Unconditional relative jump. Offset is signed 16-bit; negative // values allow backward jumps for loops. 16-bit arithmetic with // separate positive / negative paths avoids dragging int32 into the // 65816 build. // Substitute AGI v2 message placeholders into an output buffer. // Recognizes %v (decimal var value), %0 (decimal var with // leading zero pad), %s (game string slot), %m (recursive // message lookup in current logic). Unknown sequences are passed // through verbatim. Always NUL-terminates within dstCap. static void expandMessage(AgiVmT *vm, const char *src, char *dst, uint16_t dstCap) { uint16_t out; uint8_t pad; uint8_t varIdx; uint8_t value; uint8_t tmp[4]; int8_t digit; char ch; out = 0u; if (dstCap == 0u) { return; } while (src != NULL && *src != '\0' && out + 1u < dstCap) { ch = *src++; if (ch != '%') { dst[out++] = ch; continue; } if (*src == '\0') { dst[out++] = '%'; break; } { char tag = *src++; uint8_t arg = 0u; uint8_t digitsRead = 0u; while (*src >= '0' && *src <= '9' && digitsRead < 3u) { arg = (uint8_t)(arg * 10u + (uint8_t)(*src - '0')); src++; digitsRead++; } if (digitsRead == 0u) { if (out + 2u < dstCap) { dst[out++] = '%'; dst[out++] = tag; } continue; } switch (tag) { case 'v': case '0': pad = (tag == '0') ? 1u : 0u; varIdx = arg; value = vm->vars[varIdx]; digit = 0; do { tmp[digit++] = (uint8_t)(value % 10u); value = (uint8_t)(value / 10u); } while (value != 0u && digit < 3); while (pad && digit < 3) { tmp[digit++] = 0u; } while (digit > 0 && out + 1u < dstCap) { digit--; dst[out++] = (char)('0' + tmp[digit]); } break; case 's': if (arg < AGI_MAX_STRINGS) { const char *s = vm->strings[arg]; while (*s != '\0' && out + 1u < dstCap) { dst[out++] = *s++; } } break; case 'm': { const char *nested = fetchMessageOrEmpty(vm, vm->currentLogicId, arg); char scratch[AGI_PRINT_MAX_LEN + 1u]; expandMessage(vm, nested, scratch, sizeof(scratch)); { const char *s = scratch; while (*s != '\0' && out + 1u < dstCap) { dst[out++] = *s++; } } break; } case 'g': case 'w': // %g (logic 0 message — global) and %w (parser word) // — fall back to no-op so the script doesn't see // garbage substitutions. break; default: if (out + 2u < dstCap) { dst[out++] = '%'; dst[out++] = tag; } break; } } } dst[out] = '\0'; } static bool executeGoto(AgiVmT *vm) { uint16_t raw; int16_t signedOffset; uint16_t magnitude; if (!readU16(vm, &raw)) { return false; } signedOffset = (int16_t)raw; if (signedOffset >= 0) { magnitude = (uint16_t)signedOffset; if (magnitude > (uint16_t)(vm->codeLength - vm->pc)) { vm->haltReason = AGI_VM_HALT_TRUNCATED; return false; } vm->pc = (uint16_t)(vm->pc + magnitude); } else { magnitude = (uint16_t)(-signedOffset); if (magnitude > vm->pc) { vm->haltReason = AGI_VM_HALT_TRUNCATED; return false; } vm->pc = (uint16_t)(vm->pc - magnitude); } return true; } static bool executeIf(AgiVmT *vm) { bool cond; uint16_t skip; cond = evalTests(vm); if (vm->haltReason != AGI_VM_HALT_NONE) { return false; } if (!readU16(vm, &skip)) { return false; } if (!cond) { if (skip > (uint16_t)(vm->codeLength - vm->pc)) { vm->haltReason = AGI_VM_HALT_TRUNCATED; return false; } vm->pc = (uint16_t)(vm->pc + skip); } return true; } static const char *fetchMessageOrEmpty(AgiVmT *vm, uint8_t logicId, uint8_t msgId) { const char *msg; if (vm->callbacks.fetchMessage == NULL) { return ""; } msg = vm->callbacks.fetchMessage(vm->callbacks.ctx, logicId, msgId); return (msg != NULL) ? msg : ""; } // AGI print() default text-window width: ~30 columns. Sierra's // interpreter word-wraps the message at this width before measuring // the window. Without wrapping a long single-line message (e.g. // KQ3's "Gwydion is a lonely lad...") overflows the screen edge. #define AGI_PRINT_WRAP_COLS 30u static void openPrintModal(AgiVmT *vm, const char *expanded) { uint16_t i; uint16_t out; uint16_t cap; uint16_t lineStart; // index in printModal.message where current line begins uint16_t lastSpace; // index of last space char in current line (0 = none yet) uint16_t col; // visible columns on current line cap = AGI_PRINT_MAX_LEN; if (expanded == NULL) { vm->printModal.message[0] = '\0'; vm->printModal.length = 0u; vm->printModal.active = true; vm->haltReason = AGI_VM_HALT_PRINT_PENDING; return; } // Stream characters from expanded into printModal.message, breaking // at AGI_PRINT_WRAP_COLS by replacing the last space on the line // with '\n'. Preserves explicit '\n' from the source message. out = 0u; lineStart = 0u; lastSpace = 0u; col = 0u; i = 0u; while (expanded[i] != '\0' && out < cap - 1u) { char c = expanded[i++]; if (c == '\n') { vm->printModal.message[out++] = '\n'; lineStart = out; lastSpace = 0u; col = 0u; continue; } vm->printModal.message[out++] = c; if (c == ' ') { lastSpace = (uint16_t)(out - 1u); } col++; if (col >= AGI_PRINT_WRAP_COLS) { if (lastSpace != 0u && lastSpace >= lineStart) { // Replace the last space on this line with '\n' so // the partial word that followed wraps to the next. vm->printModal.message[lastSpace] = '\n'; col = (uint16_t)(out - lastSpace - 1u); lineStart = (uint16_t)(lastSpace + 1u); lastSpace = 0u; } else { // No space to break on: hard-wrap mid-token. if (out < cap - 1u) { vm->printModal.message[out++] = '\n'; } lineStart = out; lastSpace = 0u; col = 0u; } } } vm->printModal.message[out] = '\0'; vm->printModal.length = out; vm->printModal.active = true; vm->haltReason = AGI_VM_HALT_PRINT_PENDING; } // Restore the most recently pushed frame as the active context. // Caller (OP_RETURN at depth > 0) has already confirmed callDepth > 0. static bool popFrame(AgiVmT *vm) { const AgiVmFrameT *f; vm->callDepth--; f = &vm->callStack[vm->callDepth]; vm->code = f->code; vm->codeLength = f->codeLength; vm->pc = f->pc; vm->currentLogicId = f->logicId; return true; } // Save the active context onto the call stack so OP_CALL can switch // to a different logic. Returns false (and halts) if the stack is // already full -- AGI v2 games keep call depth shallow so a small // fixed-size stack is sufficient. static bool pushFrame(AgiVmT *vm) { AgiVmFrameT *f; if (vm->callDepth >= AGI_VM_CALL_STACK_MAX) { vm->haltReason = AGI_VM_HALT_STACK_OVER; return false; } f = &vm->callStack[vm->callDepth]; f->code = vm->code; f->codeLength = vm->codeLength; f->pc = vm->pc; f->logicId = vm->currentLogicId; vm->callDepth++; return true; } static bool readByte(AgiVmT *vm, uint8_t *out) { if (vm->pc >= vm->codeLength) { vm->haltReason = AGI_VM_HALT_TRUNCATED; return false; } *out = vm->code[vm->pc]; vm->pc++; return true; } static bool readU16(AgiVmT *vm, uint16_t *out) { uint8_t lo; uint8_t hi; if (!readByte(vm, &lo)) { return false; } if (!readByte(vm, &hi)) { return false; } *out = (uint16_t)(lo | ((uint16_t)hi << 8)); return true; } // Walk past the argument bytes of a test command without evaluating. // For the variable-length `said` test, the first byte is the word // count and the rest are 2 bytes per word. static bool skipTestArgs(AgiVmT *vm, uint8_t testOp) { uint8_t scratch; uint8_t wordCount; uint16_t toSkip; uint16_t i; if (testOp == TEST_SAID) { if (!readByte(vm, &wordCount)) { return false; } toSkip = (uint16_t)((uint16_t)wordCount * 2u); for (i = 0u; i < toSkip; i++) { if (!readByte(vm, &scratch)) { return false; } } return true; } if (testOp == 0u || testOp >= TEST_MAX) { return false; } toSkip = (uint16_t)kTestArgBytes[testOp]; for (i = 0u; i < toSkip; i++) { if (!readByte(vm, &scratch)) { return false; } } return true; } static void writeTextRow(AgiVmT *vm, uint8_t row, uint8_t col, const char *expanded) { uint16_t i; uint16_t off; if (row >= AGI_TEXT_ROWS || col >= AGI_TEXT_COLS) { return; } if (expanded == NULL) { return; } // Pad text[0..col-1] with spaces. Otherwise the row starts with // '\0' and agiTextRender skips the whole row -- display(r,c,m) // with c>0 produces no visible text. for (i = 0u; i < (uint16_t)col; i++) { if (vm->textRows[row].text[i] == '\0') { vm->textRows[row].text[i] = ' '; } } off = col; i = 0u; while (expanded[i] != '\0' && off < AGI_TEXT_COLS) { if (expanded[i] == '\n') { // newline — clear rest of row, advance to next row col 0 while (off < AGI_TEXT_COLS) { vm->textRows[row].text[off++] = ' '; } vm->textRows[row].text[AGI_TEXT_COLS] = '\0'; row++; off = 0u; if (row >= AGI_TEXT_ROWS) { return; } } else { vm->textRows[row].text[off++] = expanded[i]; } i++; } vm->textRows[row].text[AGI_TEXT_COLS] = '\0'; vm->textRows[row].fg = vm->textFg; vm->textRows[row].bg = vm->textBg; } // ----- Public API (alphabetical) ----- void agiVmAckPrint(AgiVmT *vm) { vm->printModal.active = false; vm->printModal.length = 0u; vm->printModal.message[0] = '\0'; if (vm->haltReason == AGI_VM_HALT_PRINT_PENDING) { vm->haltReason = AGI_VM_HALT_NONE; } } void agiVmDispatchKey(AgiVmT *vm, uint8_t key1, uint8_t key2) { uint8_t i; for (i = 0u; i < (uint8_t)AGI_MAX_KEY_BINDINGS; i++) { AgiKeyBindingT *b; b = &vm->keyBindings[i]; if (b->active == 0u) { continue; } if (b->key1 == key1 && b->key2 == key2) { if (b->controller < AGI_MAX_CONTROLLERS) { vm->controllerFired[b->controller] = 1u; } } } } void agiVmInit(AgiVmT *vm) { uint8_t i; memset(vm, 0, sizeof(*vm)); vm->haltReason = AGI_VM_HALT_NONE; vm->statusLineOn = false; vm->statusLineRow = 0u; vm->textFg = 15u; vm->textBg = 0u; vm->horizon = 36u; // canonical AGI default vm->programControl = false; vm->acceptInput = false; vm->menuOpen = 0xFFu; for (i = 0u; i < (uint8_t)AGI_TEXT_ROWS; i++) { vm->textRows[i].text[0] = '\0'; vm->textRows[i].fg = 15u; vm->textRows[i].bg = 0u; } agiObjResetAll(vm); } bool agiVmLoadLogic(AgiVmT *vm, const uint8_t *resourceBytes, uint16_t resourceLength) { uint16_t bytecodeLen; if (resourceLength < AGI_LOGIC_HDR_BYTES) { return false; } bytecodeLen = (uint16_t)(resourceBytes[0] | ((uint16_t)resourceBytes[1] << 8)); if ((uint16_t)(AGI_LOGIC_HDR_BYTES + bytecodeLen) > resourceLength) { return false; } vm->code = resourceBytes + AGI_LOGIC_HDR_BYTES; vm->codeLength = bytecodeLen; vm->pc = 0u; vm->haltReason = AGI_VM_HALT_NONE; vm->lastUnknownOp = 0u; vm->newRoomId = 0u; vm->currentLogicId = 0u; vm->callDepth = 0u; return true; } void agiVmResetToLogic(AgiVmT *vm, const uint8_t *bytecode, uint16_t bytecodeLength, uint8_t logicId) { vm->code = bytecode; vm->codeLength = bytecodeLength; vm->pc = 0u; vm->haltReason = AGI_VM_HALT_NONE; vm->lastUnknownOp = 0u; vm->newRoomId = 0u; vm->currentLogicId = logicId; vm->callDepth = 0u; } AgiVmHaltE agiVmRun(AgiVmT *vm) { uint8_t op; uint8_t a; uint8_t b; uint8_t c; uint8_t d; uint8_t e; uint8_t f; uint8_t g; while (vm->haltReason == AGI_VM_HALT_NONE) { if (!readByte(vm, &op)) { // readByte sets haltReason = TRUNCATED on out-of-range. return vm->haltReason; } switch (op) { case OP_RETURN: if (vm->callDepth > 0u) { popFrame(vm); } else { vm->haltReason = AGI_VM_HALT_RETURN; } break; case OP_INCREMENT: if (!readByte(vm, &a)) { return vm->haltReason; } // AGI spec: increment clamps at 255. if (vm->vars[a] != 0xFFu) { vm->vars[a] = (uint8_t)(vm->vars[a] + 1u); } break; case OP_DECREMENT: if (!readByte(vm, &a)) { return vm->haltReason; } // AGI spec: decrement clamps at 0 (does NOT wrap). // Sparkle counters like v221..v224 stay at 0 once // assigned 0, which is how the wizard scene suppresses // the logo-sparkle loop until sound 22 ends. if (vm->vars[a] != 0u) { vm->vars[a] = (uint8_t)(vm->vars[a] - 1u); } break; case OP_ASSIGNN: if (!readByte(vm, &a) || !readByte(vm, &b)) { return vm->haltReason; } vm->vars[a] = b; break; case OP_ASSIGNV: if (!readByte(vm, &a) || !readByte(vm, &b)) { return vm->haltReason; } vm->vars[a] = vm->vars[b]; break; case OP_ADDN: if (!readByte(vm, &a) || !readByte(vm, &b)) { return vm->haltReason; } vm->vars[a] = (uint8_t)(vm->vars[a] + b); break; case OP_ADDV: if (!readByte(vm, &a) || !readByte(vm, &b)) { return vm->haltReason; } vm->vars[a] = (uint8_t)(vm->vars[a] + vm->vars[b]); break; case OP_SUBN: if (!readByte(vm, &a) || !readByte(vm, &b)) { return vm->haltReason; } vm->vars[a] = (uint8_t)(vm->vars[a] - b); break; case OP_SUBV: if (!readByte(vm, &a) || !readByte(vm, &b)) { return vm->haltReason; } vm->vars[a] = (uint8_t)(vm->vars[a] - vm->vars[b]); break; case OP_LINDIRECTV: // var[a] = var[var[b]] if (!readByte(vm, &a) || !readByte(vm, &b)) { return vm->haltReason; } vm->vars[a] = vm->vars[vm->vars[b]]; break; case OP_RINDIRECT: // var[var[a]] = var[b] if (!readByte(vm, &a) || !readByte(vm, &b)) { return vm->haltReason; } vm->vars[vm->vars[a]] = vm->vars[b]; break; case OP_LINDIRECTN: // var[var[a]] = b (immediate) if (!readByte(vm, &a) || !readByte(vm, &b)) { return vm->haltReason; } vm->vars[vm->vars[a]] = b; break; case OP_SET: if (!readByte(vm, &a)) { return vm->haltReason; } vm->flags[a] = 1u; break; case OP_RESET: if (!readByte(vm, &a)) { return vm->haltReason; } vm->flags[a] = 0u; break; case OP_TOGGLE: if (!readByte(vm, &a)) { return vm->haltReason; } vm->flags[a] = (uint8_t)(vm->flags[a] ? 0u : 1u); break; case OP_SETV: if (!readByte(vm, &a)) { return vm->haltReason; } vm->flags[vm->vars[a]] = 1u; break; case OP_RESETV: if (!readByte(vm, &a)) { return vm->haltReason; } vm->flags[vm->vars[a]] = 0u; break; case OP_TOGGLEV: if (!readByte(vm, &a)) { return vm->haltReason; } vm->flags[vm->vars[a]] = (uint8_t)(vm->flags[vm->vars[a]] ? 0u : 1u); break; case OP_IF: if (!executeIf(vm)) { return vm->haltReason; } break; case OP_GOTO: if (!executeGoto(vm)) { return vm->haltReason; } break; case OP_NEW_ROOM: if (!readByte(vm, &a)) { return vm->haltReason; } vm->newRoomId = a; vm->haltReason = AGI_VM_HALT_NEW_ROOM; jlLogF("OP_NEW_ROOM -> %u (v11=%u v12=%u)", (unsigned)a, (unsigned)vm->vars[11], (unsigned)vm->vars[12]); jlLogFlush(); break; case OP_NEW_ROOM_V: if (!readByte(vm, &a)) { return vm->haltReason; } vm->newRoomId = vm->vars[a]; vm->haltReason = AGI_VM_HALT_NEW_ROOM; jlLogF("OP_NEW_ROOM_V -> %u (v11=%u v12=%u)", (unsigned)vm->newRoomId, (unsigned)vm->vars[11], (unsigned)vm->vars[12]); jlLogFlush(); break; case OP_LOAD_LOGIC: case OP_LOAD_LOGIC_V: // load.logic just primes the host's cache; the actual // bytecode swap happens at OP_CALL time. We touch the // callback so callers that prefetch can react. if (!readByte(vm, &a)) { return vm->haltReason; } if (vm->callbacks.fetchLogic != NULL) { uint8_t logicId; uint16_t ignore; logicId = (op == OP_LOAD_LOGIC) ? a : vm->vars[a]; ignore = 0u; (void)vm->callbacks.fetchLogic(vm->callbacks.ctx, logicId, &ignore); } break; case OP_CALL: case OP_CALL_V: if (!readByte(vm, &a)) { return vm->haltReason; } { uint8_t logicId; logicId = (op == OP_CALL) ? a : vm->vars[a]; if (!pushFrame(vm)) { return vm->haltReason; } if (!enterLogic(vm, logicId)) { // Roll back the pushed frame so RETURN doesn't // pop garbage state. vm->callDepth--; return vm->haltReason; } } break; case OP_LOAD_PIC: if (!readByte(vm, &a)) { return vm->haltReason; } if (vm->callbacks.loadPic != NULL) { vm->callbacks.loadPic(vm->callbacks.ctx, vm->vars[a]); } break; case OP_DRAW_PIC: if (!readByte(vm, &a)) { return vm->haltReason; } if (vm->callbacks.drawPic != NULL) { vm->callbacks.drawPic(vm->callbacks.ctx, vm->vars[a]); } break; case OP_SHOW_PIC: if (vm->callbacks.showPic != NULL) { vm->callbacks.showPic(vm->callbacks.ctx); } break; case OP_DISCARD_PIC: if (!readByte(vm, &a)) { return vm->haltReason; } if (vm->callbacks.discardPic != NULL) { vm->callbacks.discardPic(vm->callbacks.ctx, vm->vars[a]); } break; case OP_OVERLAY_PIC: if (!readByte(vm, &a)) { return vm->haltReason; } if (vm->callbacks.overlayPic != NULL) { vm->callbacks.overlayPic(vm->callbacks.ctx, vm->vars[a]); } break; // ----- view cache hints (no-op; host loads on demand) ----- case OP_LOAD_VIEW: case OP_LOAD_VIEW_V: case OP_DISCARD_VIEW: case OP_DISCARD_VIEW_V: if (!readByte(vm, &a)) { return vm->haltReason; } break; // ----- object table ----- case OP_ANIMATE_OBJ: if (!readByte(vm, &a)) { return vm->haltReason; } jlLogF("OP_ANIMATE_OBJ %u", (unsigned)a); if (a < AGI_MAX_OBJECTS) { agiObjReset(&vm->objects[a]); vm->objects[a].flags |= AGI_OBJ_FLAG_ANIMATED | AGI_OBJ_FLAG_UPDATING | AGI_OBJ_FLAG_CYCLING; } break; case OP_UNANIMATE_ALL: jlLog("OP_UNANIMATE_ALL"); agiObjResetAll(vm); break; case OP_DRAW: if (!readByte(vm, &a)) { return vm->haltReason; } jlLogF("OP_DRAW %u", (unsigned)a); if (a < AGI_MAX_OBJECTS) { vm->objects[a].flags |= AGI_OBJ_FLAG_DRAWN; } break; case OP_ERASE: if (!readByte(vm, &a)) { return vm->haltReason; } jlLogF("OP_ERASE %u", (unsigned)a); if (a < AGI_MAX_OBJECTS) { vm->objects[a].flags &= (uint8_t)~AGI_OBJ_FLAG_DRAWN; } break; case OP_POSITION: if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c)) { return vm->haltReason; } jlLogF("OP_POSITION %u (%u,%u)", (unsigned)a, (unsigned)b, (unsigned)c); if (a < AGI_MAX_OBJECTS) { vm->objects[a].x = (int16_t)b; vm->objects[a].y = (int16_t)c; } break; case OP_POSITION_V: case OP_REPOSITION_TO_V: if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c)) { return vm->haltReason; } if (a < AGI_MAX_OBJECTS) { vm->objects[a].x = (int16_t)vm->vars[b]; vm->objects[a].y = (int16_t)vm->vars[c]; jlLogF("OP_POSITION_V/RTO_V %u <- v%u/v%u = (%d,%d)", (unsigned)a, (unsigned)b, (unsigned)c, (int)vm->objects[a].x, (int)vm->objects[a].y); } break; case OP_GET_POSN: if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c)) { return vm->haltReason; } if (a < AGI_MAX_OBJECTS) { vm->vars[b] = (uint8_t)vm->objects[a].x; vm->vars[c] = (uint8_t)vm->objects[a].y; } break; case OP_REPOSITION: // reposition(N, vdx, vdy) — apply signed deltas (vars hold int8_t-style) if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c)) { return vm->haltReason; } if (a < AGI_MAX_OBJECTS) { int8_t dx = (int8_t)vm->vars[b]; int8_t dy = (int8_t)vm->vars[c]; vm->objects[a].x = (int16_t)(vm->objects[a].x + dx); vm->objects[a].y = (int16_t)(vm->objects[a].y + dy); } break; case OP_REPOSITION_TO: if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c)) { return vm->haltReason; } jlLogF("OP_REPOSITION_TO %u (%u,%u)", (unsigned)a, (unsigned)b, (unsigned)c); if (a < AGI_MAX_OBJECTS) { vm->objects[a].x = (int16_t)b; vm->objects[a].y = (int16_t)c; } break; case OP_SET_VIEW: if (!readByte(vm, &a) || !readByte(vm, &b)) { return vm->haltReason; } jlLogF("OP_SET_VIEW obj=%u view=%u", (unsigned)a, (unsigned)b); if (a < AGI_MAX_OBJECTS) { vm->objects[a].viewId = b; vm->objects[a].cel = 0u; } break; case OP_SET_VIEW_V: if (!readByte(vm, &a) || !readByte(vm, &b)) { return vm->haltReason; } jlLogF("OP_SET_VIEW_V obj=%u view=v%u (=%u)", (unsigned)a, (unsigned)b, (unsigned)vm->vars[b]); if (a < AGI_MAX_OBJECTS) { vm->objects[a].viewId = vm->vars[b]; vm->objects[a].cel = 0u; } break; case OP_SET_LOOP: if (!readByte(vm, &a) || !readByte(vm, &b)) { return vm->haltReason; } jlLogF("OP_SET_LOOP obj=%u loop=%u", (unsigned)a, (unsigned)b); if (a < AGI_MAX_OBJECTS) { vm->objects[a].loop = b; vm->objects[a].cel = 0u; } break; case OP_SET_LOOP_V: if (!readByte(vm, &a) || !readByte(vm, &b)) { return vm->haltReason; } jlLogF("OP_SET_LOOP_V obj=%u loop=v%u (=%u)", (unsigned)a, (unsigned)b, (unsigned)vm->vars[b]); if (a < AGI_MAX_OBJECTS) { vm->objects[a].loop = vm->vars[b]; vm->objects[a].cel = 0u; } break; case OP_FIX_LOOP: if (!readByte(vm, &a)) { return vm->haltReason; } if (a < AGI_MAX_OBJECTS) { vm->objects[a].flags |= AGI_OBJ_FLAG_LOOP_FIXED; } break; case OP_RELEASE_LOOP: if (!readByte(vm, &a)) { return vm->haltReason; } if (a < AGI_MAX_OBJECTS) { vm->objects[a].flags &= (uint8_t)~AGI_OBJ_FLAG_LOOP_FIXED; } break; case OP_SET_CEL: if (!readByte(vm, &a) || !readByte(vm, &b)) { return vm->haltReason; } if (a < AGI_MAX_OBJECTS) { vm->objects[a].cel = b; vm->objects[a].cycleTick = 0u; } break; case OP_SET_CEL_V: if (!readByte(vm, &a) || !readByte(vm, &b)) { return vm->haltReason; } if (a < AGI_MAX_OBJECTS) { vm->objects[a].cel = vm->vars[b]; vm->objects[a].cycleTick = 0u; } break; case OP_LAST_CEL: if (!readByte(vm, &a) || !readByte(vm, &b)) { return vm->haltReason; } if (a < AGI_MAX_OBJECTS && vm->callbacks.viewCelCount != NULL) { uint8_t c = vm->callbacks.viewCelCount(vm->callbacks.ctx, vm->objects[a].viewId, vm->objects[a].loop); vm->vars[b] = (c > 0u) ? (uint8_t)(c - 1u) : 0u; } else { vm->vars[b] = 0u; } break; case OP_CURRENT_CEL: if (!readByte(vm, &a) || !readByte(vm, &b)) { return vm->haltReason; } vm->vars[b] = (a < AGI_MAX_OBJECTS) ? vm->objects[a].cel : 0u; break; case OP_CURRENT_LOOP: if (!readByte(vm, &a) || !readByte(vm, &b)) { return vm->haltReason; } vm->vars[b] = (a < AGI_MAX_OBJECTS) ? vm->objects[a].loop : 0u; break; case OP_CURRENT_VIEW: if (!readByte(vm, &a) || !readByte(vm, &b)) { return vm->haltReason; } vm->vars[b] = (a < AGI_MAX_OBJECTS) ? vm->objects[a].viewId : 0u; break; case OP_NUMBER_OF_LOOPS: if (!readByte(vm, &a) || !readByte(vm, &b)) { return vm->haltReason; } if (a < AGI_MAX_OBJECTS && vm->callbacks.viewLoopCount != NULL) { uint8_t c = vm->callbacks.viewLoopCount(vm->callbacks.ctx, vm->objects[a].viewId); vm->vars[b] = (c > 0u) ? c : 1u; } else { vm->vars[b] = 1u; } break; case OP_SET_PRIORITY: if (!readByte(vm, &a) || !readByte(vm, &b)) { return vm->haltReason; } if (a < AGI_MAX_OBJECTS) { vm->objects[a].priority = b; vm->objects[a].flags |= AGI_OBJ_FLAG_PRI_FIXED; } break; case OP_SET_PRIORITY_V: if (!readByte(vm, &a) || !readByte(vm, &b)) { return vm->haltReason; } if (a < AGI_MAX_OBJECTS) { vm->objects[a].priority = vm->vars[b]; vm->objects[a].flags |= AGI_OBJ_FLAG_PRI_FIXED; } break; case OP_RELEASE_PRIORITY: if (!readByte(vm, &a)) { return vm->haltReason; } if (a < AGI_MAX_OBJECTS) { vm->objects[a].flags &= (uint8_t)~AGI_OBJ_FLAG_PRI_FIXED; } break; case OP_GET_PRIORITY: if (!readByte(vm, &a) || !readByte(vm, &b)) { return vm->haltReason; } vm->vars[b] = (a < AGI_MAX_OBJECTS) ? vm->objects[a].priority : 0u; break; case OP_STOP_UPDATE: if (!readByte(vm, &a)) { return vm->haltReason; } if (a < AGI_MAX_OBJECTS) { vm->objects[a].flags &= (uint8_t)~AGI_OBJ_FLAG_UPDATING; } break; case OP_START_UPDATE: if (!readByte(vm, &a)) { return vm->haltReason; } if (a < AGI_MAX_OBJECTS) { vm->objects[a].flags |= AGI_OBJ_FLAG_UPDATING; } break; case OP_FORCE_UPDATE: if (!readByte(vm, &a)) { return vm->haltReason; } // Force-redraw hint; we always redraw, so no-op apart from arg consume. break; case OP_IGNORE_HORIZON: if (!readByte(vm, &a)) { return vm->haltReason; } if (a < AGI_MAX_OBJECTS) { vm->objects[a].flags |= AGI_OBJ_FLAG_HORIZON_IGN; } break; case OP_OBSERVE_HORIZON: if (!readByte(vm, &a)) { return vm->haltReason; } if (a < AGI_MAX_OBJECTS) { vm->objects[a].flags &= (uint8_t)~AGI_OBJ_FLAG_HORIZON_IGN; } break; case OP_SET_HORIZON: if (!readByte(vm, &a)) { return vm->haltReason; } vm->horizon = a; break; case OP_OBJECT_ON_WATER: case OP_OBJECT_ON_LAND: case OP_OBJECT_ON_ANYTHING: case OP_IGNORE_BLOCKS: case OP_OBSERVE_BLOCKS: if (!readByte(vm, &a)) { return vm->haltReason; } break; case OP_IGNORE_OBJS: if (!readByte(vm, &a)) { return vm->haltReason; } if (a < AGI_MAX_OBJECTS) { vm->objects[a].flags |= AGI_OBJ_FLAG_OBJS_IGN; } break; case OP_OBSERVE_OBJS: if (!readByte(vm, &a)) { return vm->haltReason; } if (a < AGI_MAX_OBJECTS) { vm->objects[a].flags &= (uint8_t)~AGI_OBJ_FLAG_OBJS_IGN; } break; case OP_DISTANCE: { int16_t dx; int16_t dy; if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c)) { return vm->haltReason; } if (a < AGI_MAX_OBJECTS && b < AGI_MAX_OBJECTS) { dx = (int16_t)(vm->objects[a].x - vm->objects[b].x); dy = (int16_t)(vm->objects[a].y - vm->objects[b].y); if (dx < 0) { dx = (int16_t)(-dx); } if (dy < 0) { dy = (int16_t)(-dy); } vm->vars[c] = (uint8_t)((dx + dy > 255) ? 255 : (dx + dy)); } else { vm->vars[c] = 255u; } break; } case OP_BLOCK: // 4 args: (x1, y1, x2, y2) if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c) || !readByte(vm, &d)) { return vm->haltReason; } break; case OP_UNBLOCK: break; // ----- animation cycling ----- case OP_STOP_CYCLING: if (!readByte(vm, &a)) { return vm->haltReason; } if (a < AGI_MAX_OBJECTS) { vm->objects[a].flags &= (uint8_t)~AGI_OBJ_FLAG_CYCLING; } break; case OP_START_CYCLING: if (!readByte(vm, &a)) { return vm->haltReason; } if (a < AGI_MAX_OBJECTS) { vm->objects[a].flags |= AGI_OBJ_FLAG_CYCLING; } break; case OP_NORMAL_CYCLE: if (!readByte(vm, &a)) { return vm->haltReason; } if (a < AGI_MAX_OBJECTS) { vm->objects[a].cycleMode = AGI_CYCLE_NORMAL; vm->objects[a].endOfLoopFlag = 0u; vm->objects[a].flags |= AGI_OBJ_FLAG_CYCLING; } break; case OP_END_OF_LOOP: if (!readByte(vm, &a) || !readByte(vm, &b)) { return vm->haltReason; } if (a < AGI_MAX_OBJECTS) { vm->objects[a].cycleMode = AGI_CYCLE_END_LOOP; vm->objects[a].endOfLoopFlag = b; vm->objects[a].flags |= AGI_OBJ_FLAG_CYCLING; vm->flags[b] = 0u; } break; case OP_REVERSE_CYCLE: if (!readByte(vm, &a)) { return vm->haltReason; } if (a < AGI_MAX_OBJECTS) { vm->objects[a].cycleMode = AGI_CYCLE_REVERSE; vm->objects[a].endOfLoopFlag = 0u; vm->objects[a].flags |= AGI_OBJ_FLAG_CYCLING; } break; case OP_REVERSE_LOOP: if (!readByte(vm, &a) || !readByte(vm, &b)) { return vm->haltReason; } if (a < AGI_MAX_OBJECTS) { vm->objects[a].cycleMode = AGI_CYCLE_REV_LOOP; vm->objects[a].endOfLoopFlag = b; vm->objects[a].flags |= AGI_OBJ_FLAG_CYCLING; vm->flags[b] = 0u; } break; case OP_CYCLE_TIME: if (!readByte(vm, &a) || !readByte(vm, &b)) { return vm->haltReason; } if (a < AGI_MAX_OBJECTS) { vm->objects[a].cycleTime = vm->vars[b]; if (vm->objects[a].cycleTime == 0u) { vm->objects[a].cycleTime = 1u; } vm->objects[a].cycleTick = 0u; } break; // ----- motion ----- case OP_STOP_MOTION: if (!readByte(vm, &a)) { return vm->haltReason; } if (a < AGI_MAX_OBJECTS) { vm->objects[a].direction = 0u; } break; case OP_START_MOTION: if (!readByte(vm, &a)) { return vm->haltReason; } if (a < AGI_MAX_OBJECTS) { if (a == 0u) { vm->objects[0].direction = vm->vars[ENG_VAR_EGO_DIR]; } else { // Re-allow motion; direction stays whatever it was. } } break; case OP_STEP_SIZE: if (!readByte(vm, &a) || !readByte(vm, &b)) { return vm->haltReason; } if (a < AGI_MAX_OBJECTS) { vm->objects[a].stepSize = vm->vars[b]; if (vm->objects[a].stepSize == 0u) { vm->objects[a].stepSize = 1u; } } break; case OP_STEP_TIME: if (!readByte(vm, &a) || !readByte(vm, &b)) { return vm->haltReason; } if (a < AGI_MAX_OBJECTS) { vm->objects[a].stepTime = vm->vars[b]; if (vm->objects[a].stepTime == 0u) { vm->objects[a].stepTime = 1u; } vm->objects[a].stepTick = 0u; } break; case OP_MOVE_OBJ: // n, x, y, vstep, flag if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c) || !readByte(vm, &d) || !readByte(vm, &e)) { return vm->haltReason; } jlLogF("OP_MOVE_OBJ %u -> (%u,%u) step=%u doneFlag=%u", (unsigned)a, (unsigned)b, (unsigned)c, (unsigned)d, (unsigned)e); if (a < AGI_MAX_OBJECTS) { vm->objects[a].targetX = (int16_t)b; vm->objects[a].targetY = (int16_t)c; vm->objects[a].moveStepBackup = vm->objects[a].stepSize; vm->objects[a].stepSize = (d == 0u) ? vm->objects[a].stepSize : d; vm->objects[a].moveDoneFlag = e; vm->objects[a].motionType = AGI_MOTION_MOVE_OBJ; vm->flags[e] = 0u; } break; case OP_MOVE_OBJ_V: if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c) || !readByte(vm, &d) || !readByte(vm, &e)) { return vm->haltReason; } jlLogF("OP_MOVE_OBJ_V %u -> v%u/v%u=(%u,%u) vstep=v%u=%u doneFlag=%u", (unsigned)a, (unsigned)b, (unsigned)c, (unsigned)vm->vars[b], (unsigned)vm->vars[c], (unsigned)d, (unsigned)vm->vars[d], (unsigned)e); if (a < AGI_MAX_OBJECTS) { vm->objects[a].targetX = (int16_t)vm->vars[b]; vm->objects[a].targetY = (int16_t)vm->vars[c]; vm->objects[a].moveStepBackup = vm->objects[a].stepSize; vm->objects[a].stepSize = (vm->vars[d] == 0u) ? vm->objects[a].stepSize : vm->vars[d]; vm->objects[a].moveDoneFlag = e; vm->objects[a].motionType = AGI_MOTION_MOVE_OBJ; vm->flags[e] = 0u; } break; case OP_FOLLOW_EGO: if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c)) { return vm->haltReason; } if (a < AGI_MAX_OBJECTS) { vm->objects[a].followStep = b; vm->objects[a].followDoneFlag = c; vm->objects[a].motionType = AGI_MOTION_FOLLOW_EGO; } break; case OP_WANDER: if (!readByte(vm, &a)) { return vm->haltReason; } if (a < AGI_MAX_OBJECTS) { vm->objects[a].motionType = AGI_MOTION_WANDER; vm->objects[a].wanderTick = 0u; } break; case OP_NORMAL_MOTION: if (!readByte(vm, &a)) { return vm->haltReason; } if (a < AGI_MAX_OBJECTS) { vm->objects[a].motionType = AGI_MOTION_NORMAL; } break; case OP_SET_DIR: if (!readByte(vm, &a) || !readByte(vm, &b)) { return vm->haltReason; } jlLogF("OP_SET_DIR obj=%u dir=v%u (=%u)", (unsigned)a, (unsigned)b, (unsigned)vm->vars[b]); if (a < AGI_MAX_OBJECTS) { vm->objects[a].direction = vm->vars[b]; if (a == 0u) { vm->vars[ENG_VAR_EGO_DIR] = vm->objects[0].direction; } } break; case OP_GET_DIR: if (!readByte(vm, &a) || !readByte(vm, &b)) { return vm->haltReason; } vm->vars[b] = (a < AGI_MAX_OBJECTS) ? vm->objects[a].direction : 0u; break; // ----- inventory (state only; reads OBJECT data via host later) ----- case OP_GET: case OP_DROP: if (!readByte(vm, &a)) { return vm->haltReason; } break; case OP_GET_V: if (!readByte(vm, &a)) { return vm->haltReason; } break; case OP_PUT: case OP_PUT_V: if (!readByte(vm, &a) || !readByte(vm, &b)) { return vm->haltReason; } break; case OP_GET_ROOM_V: if (!readByte(vm, &a) || !readByte(vm, &b)) { return vm->haltReason; } vm->vars[b] = 0u; break; // ----- sound (no-op; complete callback fires immediately) ----- case OP_LOAD_SOUND: if (!readByte(vm, &a)) { return vm->haltReason; } break; case OP_SOUND: if (!readByte(vm, &a) || !readByte(vm, &b)) { return vm->haltReason; } jlLogF("OP_SOUND id=%u flag=%u (prev playing=%u prevFlag=%u)", (unsigned)a, (unsigned)b, (unsigned)vm->soundPlaying, (unsigned)vm->soundDoneFlag); jlLogFlush(); // AGI v2 sound(N, F): both args are LITERAL. // (No _V variant exists for sound; first arg is // always the sound resource id, second is the // completion flag id.) Don't index through vars. // // If a previous sound is still armed, fire its // done flag now (the script's about to wait on the // new sound; the old one's completion is the new // sound's start, not whenever playback ended). if (vm->soundPlaying) { if (vm->soundDoneFlag != 0u) { vm->flags[vm->soundDoneFlag] = 1u; } vm->flags[9] = 1u; vm->soundPlaying = false; } if (vm->callbacks.playSound != NULL && vm->callbacks.isPlayingSound != NULL) { vm->soundDoneFlag = b; vm->flags[9] = 0u; // ScummVM startSound does setFlagOrVar(_endflag, // false) here -- without it the script's // `while (!flag[b]) wait` loop sees a stale 1 // from a previous sound() and skips waiting. vm->flags[b] = 0u; vm->callbacks.playSound(vm->callbacks.ctx, a); vm->soundPlaying = true; } else { // Legacy fire-immediately when the host can't // report playback state. Keeps non-AGI uses of // this VM (and any test harness without audio) // from blocking forever on a wait-for-sound flag. vm->flags[b] = 1u; vm->flags[9] = 1u; } break; case OP_STOP_SOUND: jlLogF("OP_STOP_SOUND (playing=%u flag=%u)", (unsigned)vm->soundPlaying, (unsigned)vm->soundDoneFlag); jlLogFlush(); if (vm->callbacks.stopSound != NULL) { vm->callbacks.stopSound(vm->callbacks.ctx); } if (vm->soundPlaying) { if (vm->soundDoneFlag != 0u) { vm->flags[vm->soundDoneFlag] = 1u; } vm->flags[9] = 1u; vm->soundPlaying = false; } break; // ----- text ----- case OP_PRINT: { char expanded[AGI_PRINT_MAX_LEN + 1u]; if (!readByte(vm, &a)) { return vm->haltReason; } expandMessage(vm, fetchMessageOrEmpty(vm, vm->currentLogicId, a), expanded, sizeof(expanded)); openPrintModal(vm, expanded); return vm->haltReason; } case OP_PRINT_V: { char expanded[AGI_PRINT_MAX_LEN + 1u]; if (!readByte(vm, &a)) { return vm->haltReason; } expandMessage(vm, fetchMessageOrEmpty(vm, vm->currentLogicId, vm->vars[a]), expanded, sizeof(expanded)); openPrintModal(vm, expanded); return vm->haltReason; } case OP_DISPLAY: { char expanded[AGI_PRINT_MAX_LEN + 1u]; if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c)) { return vm->haltReason; } expandMessage(vm, fetchMessageOrEmpty(vm, vm->currentLogicId, c), expanded, sizeof(expanded)); jlLogF("OP_DISPLAY row=%u col=%u msg=%u text=\"%s\"", (unsigned)a, (unsigned)b, (unsigned)c, expanded); writeTextRow(vm, a, b, expanded); break; } case OP_DISPLAY_V: { char expanded[AGI_PRINT_MAX_LEN + 1u]; if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c)) { return vm->haltReason; } expandMessage(vm, fetchMessageOrEmpty(vm, vm->currentLogicId, vm->vars[c]), expanded, sizeof(expanded)); writeTextRow(vm, vm->vars[a], vm->vars[b], expanded); break; } case OP_CLEAR_LINES: { uint8_t r; if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c)) { return vm->haltReason; } jlLogF("OP_CLEAR_LINES rows=%u..%u bg=%u", (unsigned)a, (unsigned)b, (unsigned)c); if (a >= AGI_TEXT_ROWS) a = AGI_TEXT_ROWS - 1u; if (b >= AGI_TEXT_ROWS) b = AGI_TEXT_ROWS - 1u; for (r = a; r <= b; r++) { vm->textRows[r].text[0] = '\0'; vm->textRows[r].bg = c; } break; } case OP_CLEAR_TEXT_RECT: { uint8_t r; if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c) || !readByte(vm, &d) || !readByte(vm, &e)) { return vm->haltReason; } if (c >= AGI_TEXT_ROWS) c = AGI_TEXT_ROWS - 1u; for (r = a; r <= c; r++) { vm->textRows[r].text[0] = '\0'; vm->textRows[r].bg = e; } break; } case OP_TEXT_SCREEN: case OP_GRAPHICS: break; case OP_SET_CURSOR_CHAR: if (!readByte(vm, &a)) { return vm->haltReason; } break; case OP_SET_TEXT_ATTRIBUTE: if (!readByte(vm, &a) || !readByte(vm, &b)) { return vm->haltReason; } vm->textFg = a; vm->textBg = b; break; case OP_SHAKE_SCREEN: if (!readByte(vm, &a)) { return vm->haltReason; } break; case OP_CONFIGURE_SCREEN: if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c)) { return vm->haltReason; } break; case OP_STATUS_LINE_ON: vm->statusLineOn = true; break; case OP_STATUS_LINE_OFF: vm->statusLineOn = false; break; case OP_SET_STRING: { char expanded[AGI_STRING_LEN + 1u]; uint8_t i; if (!readByte(vm, &a) || !readByte(vm, &b)) { return vm->haltReason; } if (a < AGI_MAX_STRINGS) { expandMessage(vm, fetchMessageOrEmpty(vm, vm->currentLogicId, b), expanded, sizeof(expanded)); for (i = 0u; i < (uint8_t)AGI_STRING_LEN; i++) { vm->strings[a][i] = expanded[i]; if (expanded[i] == '\0') break; } vm->strings[a][AGI_STRING_LEN] = '\0'; } break; } case OP_GET_STRING: // get.string(s, msgId, row, col, max) — parser input; // no parser yet, just consume args. if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c) || !readByte(vm, &d) || !readByte(vm, &e)) { return vm->haltReason; } break; case OP_WORD_TO_STRING: if (!readByte(vm, &a) || !readByte(vm, &b)) { return vm->haltReason; } break; case OP_PARSE: if (!readByte(vm, &a)) { return vm->haltReason; } break; case OP_GET_NUM: if (!readByte(vm, &a) || !readByte(vm, &b)) { return vm->haltReason; } vm->vars[b] = 0u; break; // ----- input ----- case OP_PREVENT_INPUT: vm->acceptInput = false; break; case OP_ACCEPT_INPUT: vm->acceptInput = true; break; case OP_SET_KEY: { uint8_t i; if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c)) { return vm->haltReason; } for (i = 0u; i < (uint8_t)AGI_MAX_KEY_BINDINGS; i++) { if (vm->keyBindings[i].active == 0u) { vm->keyBindings[i].key1 = a; vm->keyBindings[i].key2 = b; vm->keyBindings[i].controller = c; vm->keyBindings[i].active = 1u; break; } } break; } // ----- picture composition ----- case OP_ADD_TO_PIC: if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c) || !readByte(vm, &d) || !readByte(vm, &e) || !readByte(vm, &f) || !readByte(vm, &g)) { return vm->haltReason; } if (vm->callbacks.addToPic != NULL) { vm->callbacks.addToPic(vm->callbacks.ctx, a, b, c, d, e, f, g); } break; case OP_ADD_TO_PIC_V: if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c) || !readByte(vm, &d) || !readByte(vm, &e) || !readByte(vm, &f) || !readByte(vm, &g)) { return vm->haltReason; } if (vm->callbacks.addToPic != NULL) { vm->callbacks.addToPic(vm->callbacks.ctx, vm->vars[a], vm->vars[b], vm->vars[c], vm->vars[d], vm->vars[e], vm->vars[f], vm->vars[g]); } break; // ----- misc state / no-ops ----- case OP_STATUS: break; case OP_SAVE_GAME: case OP_RESTORE_GAME: break; case OP_INIT_DISK: break; case OP_RESTART_GAME: vm->haltReason = AGI_VM_HALT_QUIT; return vm->haltReason; case OP_SHOW_OBJ: if (!readByte(vm, &a)) { return vm->haltReason; } break; case OP_RANDOM: if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c)) { return vm->haltReason; } { uint16_t span; span = (uint16_t)((b > a) ? (b - a + 1u) : 1u); vm->vars[c] = (uint8_t)(a + (uint8_t)(jlRandomRange(span))); } break; case OP_PROGRAM_CONTROL: vm->programControl = true; break; case OP_PLAYER_CONTROL: vm->programControl = false; break; case OP_OBJ_STATUS_V: if (!readByte(vm, &a)) { return vm->haltReason; } break; case OP_QUIT: if (!readByte(vm, &a)) { return vm->haltReason; } vm->haltReason = AGI_VM_HALT_QUIT; return vm->haltReason; case OP_SHOW_MEM: break; case OP_PAUSE: openPrintModal(vm, "Game Paused.\n\nPress ENTER to continue."); return vm->haltReason; case OP_ECHO_LINE: case OP_CANCEL_LINE: break; case OP_INIT_JOY: break; case OP_TOGGLE_MONITOR: break; case OP_VERSION: break; case OP_SCRIPT_SIZE: if (!readByte(vm, &a)) { return vm->haltReason; } break; case OP_SET_GAME_ID: if (!readByte(vm, &a)) { return vm->haltReason; } break; case OP_LOG: if (!readByte(vm, &a)) { return vm->haltReason; } break; case OP_SET_SCAN_START: case OP_RESET_SCAN_START: break; case OP_TRACE_ON: break; case OP_TRACE_INFO: if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c)) { return vm->haltReason; } break; case OP_PRINT_AT: { char expanded[AGI_PRINT_MAX_LEN + 1u]; if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c) || !readByte(vm, &d)) { return vm->haltReason; } expandMessage(vm, fetchMessageOrEmpty(vm, vm->currentLogicId, a), expanded, sizeof(expanded)); openPrintModal(vm, expanded); return vm->haltReason; } case OP_PRINT_AT_V: { char expanded[AGI_PRINT_MAX_LEN + 1u]; if (!readByte(vm, &a) || !readByte(vm, &b) || !readByte(vm, &c) || !readByte(vm, &d)) { return vm->haltReason; } expandMessage(vm, fetchMessageOrEmpty(vm, vm->currentLogicId, vm->vars[a]), expanded, sizeof(expanded)); openPrintModal(vm, expanded); return vm->haltReason; } case OP_SET_UPPER_LEFT: if (!readByte(vm, &a) || !readByte(vm, &b)) { return vm->haltReason; } break; // ----- menus ----- case OP_SET_MENU: if (!readByte(vm, &a)) { return vm->haltReason; } if (vm->menuCount < AGI_MAX_MENUS) { vm->menus[vm->menuCount].nameMsgId = a; vm->menus[vm->menuCount].itemCount = 0u; vm->menuCount++; } break; case OP_SET_MENU_ITEM: if (!readByte(vm, &a) || !readByte(vm, &b)) { return vm->haltReason; } if (vm->menuCount > 0u) { AgiMenuT *m = &vm->menus[vm->menuCount - 1u]; if (m->itemCount < AGI_MAX_MENU_ITEMS) { m->items[m->itemCount].messageId = a; m->items[m->itemCount].controller = b; m->items[m->itemCount].enabled = 1u; m->itemCount++; } } break; case OP_SUBMIT_MENU: break; case OP_ENABLE_ITEM: case OP_DISABLE_ITEM: { uint8_t m; uint8_t i; uint8_t enable; if (!readByte(vm, &a)) { return vm->haltReason; } enable = (op == OP_ENABLE_ITEM) ? 1u : 0u; for (m = 0u; m < vm->menuCount; m++) { for (i = 0u; i < vm->menus[m].itemCount; i++) { if (vm->menus[m].items[i].controller == a) { vm->menus[m].items[i].enabled = enable; } } } break; } case OP_MENU_INPUT: // Real menu UI is not wired; just no-op so scripts don't stall. break; case OP_SHOW_OBJ_V: if (!readByte(vm, &a)) { return vm->haltReason; } break; case OP_OPEN_DIALOGUE: case OP_CLOSE_DIALOGUE: break; case OP_MUL_N: if (!readByte(vm, &a) || !readByte(vm, &b)) { return vm->haltReason; } vm->vars[a] = (uint8_t)(vm->vars[a] * b); break; case OP_MUL_V: if (!readByte(vm, &a) || !readByte(vm, &b)) { return vm->haltReason; } vm->vars[a] = (uint8_t)(vm->vars[a] * vm->vars[b]); break; case OP_DIV_N: if (!readByte(vm, &a) || !readByte(vm, &b)) { return vm->haltReason; } if (b != 0u) { vm->vars[a] = (uint8_t)(vm->vars[a] / b); } break; case OP_DIV_V: if (!readByte(vm, &a) || !readByte(vm, &b)) { return vm->haltReason; } if (vm->vars[b] != 0u) { vm->vars[a] = (uint8_t)(vm->vars[a] / vm->vars[b]); } break; case OP_CLOSE_WINDOW: vm->printModal.active = false; break; case OP_SET_SIMPLE: if (!readByte(vm, &a)) { return vm->haltReason; } break; default: { uint8_t argBytes; uint8_t i; uint8_t scratch; argBytes = kActionArgBytes[op]; if (argBytes == ACTION_UNKNOWN) { vm->lastUnknownOp = op; vm->haltReason = AGI_VM_HALT_UNKNOWN_OP; vm->pc = (uint16_t)(vm->pc - 1u); break; } for (i = 0u; i < argBytes; i++) { if (!readByte(vm, &scratch)) { return vm->haltReason; } } break; } } } return vm->haltReason; } void agiVmSetCallbacks(AgiVmT *vm, const AgiVmCallbacksT *callbacks) { if (callbacks == NULL) { memset(&vm->callbacks, 0, sizeof(vm->callbacks)); } else { vm->callbacks = *callbacks; } } void agiVmTickSeconds(AgiVmT *vm, uint8_t seconds) { uint16_t s; uint16_t m; uint16_t h; uint16_t carry; if (seconds == 0u) { return; } // Sound completion is no longer driven from here -- the per-cycle // poll in agiVmTickAnimation reads callbacks.isPlayingSound and // fires the done flags on the live host->silent edge. Keeps the // VM in lock-step with what the user actually hears. s = (uint16_t)((uint16_t)vm->vars[11] + (uint16_t)seconds); carry = (uint16_t)(s / 60u); vm->vars[11] = (uint8_t)(s % 60u); if (carry == 0u) { return; } m = (uint16_t)((uint16_t)vm->vars[12] + carry); carry = (uint16_t)(m / 60u); vm->vars[12] = (uint8_t)(m % 60u); if (carry == 0u) { return; } h = (uint16_t)((uint16_t)vm->vars[13] + carry); vm->vars[13] = (uint8_t)(h % 24u); }