From 2d447f3d95d02af7c7fdddd4ffc646a7e5a5b615 Mon Sep 17 00:00:00 2001 From: Scott Duensing Date: Wed, 27 May 2026 20:22:24 -0500 Subject: [PATCH] Optimized a bit better. Examples are a mess of broken crap. --- AGI-SESSION-HANDOFF.md | 218 ++++ agi.c | 1873 +++++++++++++++++++++++++++++++ agi.h | 562 ++++++++++ agiObj.c | 368 +++++++ agiPic.c | 662 +++++++++++ agiRes.c | 309 ++++++ agiText.c | 438 ++++++++ agiView.c | 340 ++++++ agiVm.c | 2300 +++++++++++++++++++++++++++++++++++++++ scripts/run-agi.sh | 277 +++++ scripts/test-agi.sh | 169 +++ tests/disasmAgi.c | 413 +++++++ tests/testAgiPic.c | 191 ++++ tests/testAgiPipeline.c | 284 +++++ tests/testAgiRes.c | 101 ++ tests/testAgiView.c | 198 ++++ tests/testAgiVm.c | 135 +++ 17 files changed, 8838 insertions(+) create mode 100644 AGI-SESSION-HANDOFF.md create mode 100644 agi.c create mode 100644 agi.h create mode 100644 agiObj.c create mode 100644 agiPic.c create mode 100644 agiRes.c create mode 100644 agiText.c create mode 100644 agiView.c create mode 100644 agiVm.c create mode 100755 scripts/run-agi.sh create mode 100755 scripts/test-agi.sh create mode 100644 tests/disasmAgi.c create mode 100644 tests/testAgiPic.c create mode 100644 tests/testAgiPipeline.c create mode 100644 tests/testAgiRes.c create mode 100644 tests/testAgiView.c create mode 100644 tests/testAgiVm.c diff --git a/AGI-SESSION-HANDOFF.md b/AGI-SESSION-HANDOFF.md new file mode 100644 index 0000000..b2d4485 --- /dev/null +++ b/AGI-SESSION-HANDOFF.md @@ -0,0 +1,218 @@ +# AGI port — session handoff + +This document captures the state of the AGI interpreter port as of +2026-05-15. Hand it to a fresh Claude Code session to pick up where +the previous one left off. + +## Project context + +JoeyLib is a unified C game-dev library targeting Apple IIgs (perf +floor / reference), Amiga, Atari ST, MS-DOS. The AGI port is a +from-scratch reimplementation of Sierra's Adventure Game Interpreter +v2 on top of JoeyLib's surface / draw / input APIs. Written from +public AGI format specs only -- NOT derived from ScummVM / NAGI / +Sarien (those are GPL'd; JoeyLib is not). + +Test data: KQ3 v2, locally installed at +`examples/agi/gamedata/kq3/`. The user owns the game and is not +distributing it; gamedata is gitignored. + +## Current phase + +**Phase 1 sub-D** (callback-driven rendering): verified working. +Logic.0 + room 45 init paint KQ3's title screen on every supported +port. Ego sprite (VIEW 0, Graham) draws on top with priority masking, +WASD/arrow keys move him, ESC quits. + +What's next: **Phase 1 sub-E** (input + animation opcodes). The +title screen does not auto-advance because the VM still stubs ~150 +opcodes including `wait`, `set.menu`, `accept.input`, animation +timers, etc. Real KQ3 progression past the title requires implementing +enough of those to let logic.45 complete its intro and call new.room. + +Phase 2 sub-A onward: parser, NPC actors, sound (NTP / PSG). + +## What works (verified) + +- Resource loader (LOGDIR / PICDIR / VIEWDIR / SNDDIR / VOL.x) +- PIC decoder (visual + priority planes, flood fill, vector draw) +- VIEW decoder (cel parse + RLE blit + priority masking + mirror) +- LOGIC VM: 256 vars, 256 flags, 8-deep call stack + - Real handlers: arithmetic (0x00-0x11), OP_IF (0xFF), OP_GOTO + (0xFE), OP_NEW_ROOM (0x12/0x13), OP_LOAD_LOGIC (0x14/0x15), + OP_CALL (0x16/0x17), OP_LOAD_PIC (0x18), OP_DRAW_PIC (0x19), + OP_SHOW_PIC (0x1A), OP_DISCARD_PIC (0x1B), OP_OVERLAY_PIC (0x1C) + - All ~180 other action opcodes are stubs (skip their arg bytes + via `kActionArgBytes[256]` table) +- Host callbacks: fetchLogic / loadPic / drawPic / showPic / + discardPic / overlayPic +- Per-platform save-under for the ego sprite (no full-stage memcpy + per frame; IIgs uses MVN-based codegen) +- Cross-platform binary works on all 4 ports (`AGI.EXE`, + `AGI.PRG`, `Agi`, IIgs `AGI`) + +## What does NOT work + +- **Title screen never advances.** Stubs the input / timer opcodes + the title uses. Workaround: starting-room override (argv[2]). +- **No NPC actors.** animate.obj / set.cel / set.loop are stubs. +- **No parser.** said() and the word-tokenize opcodes are stubs. +- **No sound.** Phase 3 work. +- **IIgs run path for AGI** is not wired. `scripts/run-agi.sh iigs` + prints an explicit "not yet wired" message describing the missing + disk-packager work (need bigger 2mg volume + case-folded ProDOS + names + game-data argument to `package-disk.sh`). +- **ST / Amiga can't pass argv.** Hatari `--auto` and Amiga + startup-sequence don't forward args, so the starting-room override + only works on DOS. ST / Amiga AGI starts at the title screen. + Fix needs either a config-file stage (`ROOM.TXT` read at startup) + or enough title opcodes for the title to advance naturally. + +## How to test + +```bash +source toolchains/env.sh + +# Host-side parsers + VM (fast iteration, no emulator) +./scripts/test-agi.sh + +# Cross-platform binary in DOSBox +./scripts/run-agi.sh dos # title screen +./scripts/run-agi.sh dos kq3 1 # jump to room 1 +./scripts/run-agi.sh dos kq3 3 # jump to room 3 + +# Other platforms (no argv support yet, always title) +./scripts/run-agi.sh atarist +./scripts/run-agi.sh amiga +./scripts/run-agi.sh iigs # prints "not wired" message +``` + +## Files touched (this session) + +New: + +- `src/core/random.c` — xorshift32 PRNG (jlRandom / jlRandomRange / jlRandomSeed) +- `examples/agi/*.c` — agi.c, agiRes.c, agiPic.c, agiView.c, agiVm.c +- `examples/agi/agi.h` — all AGI types + public API +- `tests/agi/*.c` — testAgiRes / testAgiPic / testAgiView / testAgiVm / testAgiPipeline +- `scripts/test-agi.sh` — host-build runner for all five tests +- `scripts/run-agi.sh` — cross-platform emulator launcher (dos | amiga | atarist | iigs) + +Modified: + +- `include/joey/core.h` — random prototypes +- `include/joey/platform.h` — auto-detect from compiler defines +- `.gitignore` — examples/agi/gamedata/ +- All four make/*.mk — added agi to EXAMPLES list + +## Architectural decisions worth remembering + +### Render loop: save-under, not full stage copy + +The naive per-frame `jlSurfaceCopy(stage, gBackdrop)` is 32 KB of +memcpy + present overhead -- not acceptable on a 2.8 MHz IIgs. The +current code: + +1. `cbShowPic` paints the picture onto the stage ONCE and snapshots + it as `gBackdrop`. Sets `gPicReady = true` and invalidates any + prior ego save-under. +2. Per frame: `jlSpriteRestoreUnder` the last-frame ego rect (puts + clean backdrop bytes back), `jlSpriteSaveUnder` the new ego rect, + then `agiViewDraw` paints the cel with priority masking. +3. Placeholder sprite for save-under is 4x5 tiles = 32x40 stage + pixels, sized to cover any AGI v2 ego cel. Compiled via + `jlSpriteCompile` so save/restore use the platform's compiled + fast path (MVN block-copy on IIgs, etc.) instead of the C + interpreter fallback. + +Per-frame memory traffic: ~1.3 KB save+restore vs ~32 KB full copy. + +### VM throttling + +AGI's native cycle is ~6 Hz. Running logic.0 every frame (60 Hz) is +6800-byte switch dispatcher × 60 = ~400k opcodes/sec for nothing. +`agi.c` now runs runVmCycle once every 10 frames (~6 Hz on 60 Hz +refresh, ~5 Hz on 50 Hz PAL). Input polling, ego movement, render +still run at full refresh -- only the bytecode interp is throttled. + +Startup pumps the VM until `gPicReady` (or 32 cycles) so the user +never sees a flash of COLOR_MISSING before the first room paints. + +### VIEW mirror handling + +Sierra's encoder writes `mirror=1, src=N` on BOTH loops in a mirror +pair, where N is the lower-numbered loop. `agiView.c:resolveMirror` +must only treat a cel as mirrored when `src != current_loop`. KQ3 +view 0 hit this directly: both loop 0 (right) and loop 1 (left) had +`mirror=1, src=0`, and our original code flipped both -- so pressing +left and right both faced left. Fixed. + +### Starting-room override + +`agi.c` argv[2] = starting room number 1..255. When set, +`runVmCycle`'s NEW_ROOM handler substitutes the override for +`vm.newRoomId` on the FIRST NEW_ROOM halt only (one-shot). Lets the +user skip past KQ3's title room (45) into the actual game. Once the +override fires, subsequent transitions go through unchanged. + +`scripts/run-agi.sh` accepts ` [game] [room]` and forwards +the room to DOS via dosbox `-c "AGI.EXE . "`. ST and Amiga +can't pass argv via autostart so their AGI starts at the title. + +### Default game dir is "." not "agidemo" + +`resolveGameDir` defaults to "." (CWD). On platforms whose autostart +can't pass argv (Hatari, AmigaDOS), `run-agi.sh` stages the AGI +binary plus the v2 game data files (LOGDIR / PICDIR / etc.) flat in +a temp directory and points the emulator at it; AGI runs from CWD = +that temp dir and finds the data files relative to "./". + +## Likely next-step work + +The user's natural next ask is "implement enough title opcodes to +let KQ3's title screen auto-advance." That probably means: + +- `wait` (cycle.time gate) +- `input.line` / `accept.input` +- Animation timers: `cycle.time`, `current.loop`, etc. +- `set.menu` / `disable.item` (KQ3 title shows a menu) + +Alternative path: skip the title work and implement enough core +opcodes to make a gameplay room functional. animate.obj, +set.view.v, set.cel, set.loop, position, draw, erase. That gets us +walking around KQ3 room 1 (Manannan's house) with Graham instead +of a static title. + +The user should pick which to prioritize. + +## Memory entries you should know about + +The repo's auto-memory at +`~/.claude/projects/-home-scott-claude-joeylib/memory/` includes +JoeyLib-wide context (CMake quirks, ORCA-C gotchas, perf +constraints) plus `project_joeylib_agi_plan.md` which describes the +overall AGI port plan. All persist into the next session +automatically; no need to reload. + +Specifically relevant to the AGI port: + +- `project_joeylib_agi_plan.md` — overall plan, 3-4 month estimate +- `feedback_orca_c_atoi_segment.md` — don't use atoi in AGI code + (it pushes the IIgs ORCA-C stdio cluster past 64 KB; we use a + manual parseArgU8 instead) +- `project_perf_directive.md` — IIgs is the perf floor; every + other target must match or beat it +- `feedback_iigs_speed.md` — IIgs must be absolute fastest + technically possible; the save-under decision was made under + this constraint. + +## Git / user preferences + +- The user manages git themselves -- do NOT commit / push / branch + on JoeyLib without explicit ask (see memory + `feedback_git.md`). +- Always `source toolchains/env.sh` before any `make` -- the user + has added that to .claude/settings.local.json allow list. +- DJGPP / ORCA / vasm / m68k-atari-mint paths come from env.sh. +- The user does NOT need hand-holding -- they're an expert C dev. + Skip excessive explanation; describe what was done and move on. diff --git a/agi.c b/agi.c new file mode 100644 index 0000000..ebd1300 --- /dev/null +++ b/agi.c @@ -0,0 +1,1873 @@ +// JoeyLib AGI interpreter, Phase 2 host. The VM in agiVm.c owns all +// game state (vars, flags, objects, text, menus); the host renders +// the VM's snapshot every frame and bridges VM callbacks to the +// platform (resource I/O, picture decoding, text rendering, key +// dispatch). One VM cycle runs every VM_FRAMES_PER_CYCLE display +// frames; per-real-second the host pumps agiVmTickSeconds so the +// engine clock advances and per-second logic.0 branches fire. +// +// Argv layout: +// argv[1] = game directory (defaults to ".") +// argv[2] = starting room (optional; 1..255 overrides logic.0's +// hardcoded new.room target so you can skip past a title +// screen the VM can't yet advance through. 0 means "use +// whatever logic.0 picks", same as omitting the argument.) + +#include "agi.h" + +#include "joey/audio.h" +#include "joey/core.h" +#include "joey/debug.h" +#include "joey/draw.h" +#include "joey/input.h" +#include "joey/palette.h" +#include "joey/present.h" +#include "joey/sprite.h" +#include "joey/surface.h" +#include "surfaceInternal.h" + +#include +#include +#include + + +#define AGI_LOGIC_HDR_BYTES 2u +// Border fill color (rows above/below the AGI picture region and any +// area not painted by show.pic). AGI canonical: black (palette 0). +#define COLOR_MISSING 0u +#define CYCLE_HALT_GUARD 16u +#define EGO_START_X 80 +#define EGO_START_Y 140 +#define EGO_STEP_PX 2 + +// AGI v2 EGA layout on a 320x200 display: pic occupies y=0..167 +// (160x168 doubled horizontally to 320 wide), and y=168..199 is the +// 4-text-row band below the picture. Text rows always use y = row*8, +// so pic at y=0 keeps the in-picture rows (0..20) visually aligned +// with the text rendered into them. Status line, when on, overlays +// the picture at its assigned row -- that's Sierra's canonical +// behaviour. +// Canonical AGI: 1 jiffy = 50ms, VM cycle dispatch every v10 +// jiffies. KQ3 sets v10=1 -> 20 cycles/sec; default is 2 -> +// 10 cycles/sec. We drive the cycle off wall-clock so the +// rate stays correct regardless of host frame rate. +#define AGI_JIFFY_MS 50u +// Renders are wall-clock paced too. AGI canonical runs at ~20 Hz +// (one VM cycle = one rendered frame in Sierra's interpreter); we +// render slightly above that for smoother sparkle/cel cycling but +// not the full 70 Hz VBL rate, which on emulated 386 in DOSBox +// burns enough CPU on backdrop-restore + cel redraw that audio +// refill misses its 92 ms half-buffer deadline. +#define AGI_RENDER_PERIOD_MS 33u +#define STARTUP_CYCLE_LIMIT 32u +// Per Sierra KQ3 v2 EGA reference: pic starts 8 pixels down from +// the top of the 320x200 display (one 8-pixel text-row band reserved +// for the status line). Pic occupies y=8..175. Text rows use the +// row*8 absolute Y -- no pic offset added -- so they deliberately +// overlap pic art where the script wants captions inside the picture. +#define PIC_DEST_Y 8 + +// (Per-object save-under removed: we now restore each object's prev +// rect by memcpy from the gBackdrop surface, sized to the actual +// cel bbox at draw time. That handles arbitrary cel sizes without +// any per-object backup buffers and is faster on chunky platforms.) + +// Canonical AGI ego loop indices. +#define LOOP_RIGHT 0 +#define LOOP_LEFT 1 +#define LOOP_DOWN 2 +#define LOOP_UP 3 + +// Encryption key applied per-message in the AGI v2 message section. +// Same string in every supported game; XOR cycles every 11 bytes. +#define MSG_KEY "Avis Durgan" +#define MSG_KEY_LEN 11u + +// Largest decrypted message we'll surface to the VM. Game scripts +// stay well under this (most under 80 chars); the cap protects the +// scratch buffer from a corrupt resource. +#define MSG_BUFFER_BYTES 256u + + +// ----- Types ----- + +typedef struct { + uint8_t *raw; + uint16_t rawLength; + const uint8_t *bytecode; + uint16_t bytecodeLength; +} LogicCacheSlotT; + +typedef struct { + AgiViewT view; + bool loaded; +} ViewCacheSlotT; + + +// ----- Prototypes ----- + +static void agiSoundReset(void); +static uint32_t agiSynthFill(void *ctx, int8_t *dst, uint32_t count); +static void agiTickAdvance(void); +static void agiVoiceTick(uint8_t voice); +static void psgRecompute(uint8_t chipCh); +static void psgWriteData(uint8_t val); +static void cbAddToPic(void *ctx, uint8_t viewId, uint8_t loop, uint8_t cel, + uint8_t x, uint8_t y, uint8_t pri, uint8_t margin); +static void cbDiscardPic(void *ctx, uint8_t picId); +static void cbDrawPic(void *ctx, uint8_t picId); +static const uint8_t *cbFetchLogic(void *ctx, uint8_t logicId, uint16_t *outLength); +static const char *cbFetchMessage(void *ctx, uint8_t logicId, uint8_t msgId); +static bool cbHaveKey(void *ctx); +static bool cbIsPlayingSound(void *ctx); +static void cbLoadPic(void *ctx, uint8_t picId); +static void cbOverlayPic(void *ctx, uint8_t picId); +static void cbPlaySound(void *ctx, uint8_t soundId); +static void cbShowPic(void *ctx); +static uint32_t cbSoundDuration(void *ctx, uint8_t soundId); +static void cbStopSound(void *ctx); +static uint8_t cbViewCelCount(void *ctx, uint8_t viewId, uint8_t loopId); +static uint8_t cbViewLoopCount(void *ctx, uint8_t viewId); +static bool decodePicInto(uint8_t picId, bool clearFirst); +static void dispatchAllPressedKeys(void); +static void drawObjects(jlSurfaceT *stage); +static const AgiViewT *fetchView(uint8_t viewId); +static void invalidateObjectSaves(void); +static uint8_t loopForDirection(uint8_t direction, uint8_t loopCount); +static uint8_t parseArgU8(const char *s); +static void releaseLogicCache(void); +static void releaseViewCache(void); +static void renderFrame(jlSurfaceT *stage); +static const char *resolveGameDir(int argc, char **argv); +static uint8_t resolveStartingRoom(int argc, char **argv); +static void runVmCycle(void); +static void updateEgoFromInput(void); + + +// ----- Module state ----- + +static AgiGameT gGame; +static AgiPicT gPic; +static jlSurfaceT *gBackdrop; +static jlSurfaceT *gStage; +static AgiVmT gVm; +static LogicCacheSlotT gLogicCache[AGI_MAX_RESOURCES]; +static ViewCacheSlotT gViewCache[AGI_MAX_RESOURCES]; +static bool gPicReady; +static uint8_t gOverrideRoom; +static bool gOverrideRoomActive; +static bool gAnyKeyHit; +// Per-object previously-drawn stage rect. After each draw we record +// the cel's exact stage bbox here; next frame's pass 1 restores that +// rect from gBackdrop, then pass 2 redraws. valid=false means the +// object wasn't drawn last frame (skip restore). +typedef struct { + int16_t x; + int16_t y; + int16_t w; + int16_t h; + bool valid; +} ObjPrevRectT; + +static ObjPrevRectT gObjPrev[AGI_MAX_OBJECTS]; +// Per-text-row "had content last frame" flag. We restore each row's +// stripe of the backdrop at the start of every frame if it was +// dirtied last frame, so the old text disappears when the VM clears +// it (clear.lines) or moves to a different row. +static bool gTextRowDirty[AGI_TEXT_ROWS]; +// One static scratch for the most recently decrypted message. The +// fetchMessage callback contract guarantees its return pointer is +// only valid until the next call -- the VM's expandMessage copies +// the bytes it needs before calling fetchMessage again. +static char gMessageScratch[MSG_BUFFER_BYTES]; + +// SN76489 PSG clock divisor base. AGI v2 uses the TI PSG's tone +// generator: tone_hz = 111860 / divisor (3.579545 MHz / 32). +#define PSG_CLOCK_HZ 111860u + +// AGI sound is software-synthesized at 22050 Hz mono then pushed +// through the existing Sound Blaster auto-init DMA path via the +// jlAudioPlaySfxStream API. Three independent squarewave voices +// (16-bit phase, 8-bit amplitude) sum into the output stream; AGI +// tick logic advances every 22050/60 = 367 output samples, matching +// ScummVM's PCjr/Tandy sound model (which is also how Sierra's own +// SB drivers shipped the AGI sound after 1988). +// +// This is the "software synth to PCM" model. Pros: works on any +// SB-equipped DOS box, no hardware-IRQ headaches, sample-accurate +// note timing because we're rendering audio at 22050 Hz with the +// AGI ticks embedded. Cons: requires SB-compat audio (true for +// every target since 1989). The PCjr/Tandy native PSG path (cheaper, +// older) is a separate problem we can revisit later. +// +// SND v2 layout (per channel): +// bytes 0..7 : 4 channel offsets (LE16 each) +// per channel : sequence of 5-byte events. Bytes 0..1 = duration +// LE16 in 1/60s units, terminator 0xFFFF; bytes 2/3 +// = raw SN76489 latch+data pair encoding the 10-bit +// frequency divisor (low 4 bits in byte 3, high 6 +// bits in byte 2); byte 4 = 4-bit attenuation +// (0 = loudest, 15 = silent). +// freq_hz = 111860 / divisor (PSG clock 3.579545 MHz / 32). +// +// Sample rate must match the SB DMA setup (see MIX_RATE in +// src/port/dos/audio.c). 22050 keeps 256-sample stream chunks at +// ~12 ms each, well over an AGI tick. +typedef struct { + uint16_t chOffset; // cursor in this channel's event stream + uint16_t eventFramesRem; // ticks until current event ends + bool done; // channel hit 0xFFFF terminator +} AgiVoiceSchedT; + +// Per-SN76489-channel state. The chip has 3 tone channels + 1 noise +// channel. Each event in the SND stream is 3 raw chip-command bytes +// (b4=atten, b3=latch-or-noise-control, b2=data) that may target +// any chip channel, not necessarily the AGI channel that owns the +// stream. We dispatch the bytes through psgWriteData (mirrors +// ScummVM's writeData) and let the chip-state machine update the +// right register. The synth then reads each tone channel's derived +// (amplitude, phaseInc) and produces samples. +typedef enum { + PSG_GEN_TONE = 0, + PSG_GEN_NOISE_PERIOD = 1, + PSG_GEN_NOISE_WHITE = 2 +} PsgGenE; + +typedef struct { + uint16_t freqCount; // 10-bit tone period; noise uses small values per ScummVM + uint8_t attenuation; // 0=loud, 15=silent + uint8_t genType; // PsgGenE + // Derived cache (recomputed when freqCount/attenuation change): + uint16_t phaseInc; + int8_t amplitude; + // Synth state: + uint16_t phase; +} PsgChannelT; + +// AGI v2 SND has 4 sub-channels: 3 tone + 1 noise. The "AGI +// channel" indexes into the event-stream offsets table at the +// start of the file; it does NOT correspond 1:1 to the chip's 4 +// channels. Each AGI channel ticks an event stream and fires raw +// chip writes; the chip state is shared across all AGI channels. +#define AGI_SOUND_CHANNELS 4u +#define AGI_SOUND_TONE_VOICES 3u +#define AGI_SOUND_NOISE_CHANNEL 3u +#define AGI_SOUND_END_MARKER 0xFFFFu +// AGI sound tick rate. Sierra's PCjr/Tandy SOUND.DRV ran at 120 Hz +// (vs ScummVM's 60 Hz). All script-driven timing (v11 seconds +// counter, animation cycling, motion stepping) is derived from +// this same tick rate, NOT from wall clock -- so doubling the +// tick rate makes both the music AND v11 advance 2x faster, which +// keeps script-internal wait conditions consistent. +#define AGI_SOUND_TICKS_HZ 120u +#define AGI_SYNTH_SAMPLE_RATE 22050u +#define AGI_SYNTH_SAMPLES_PER_TICK (AGI_SYNTH_SAMPLE_RATE / AGI_SOUND_TICKS_HZ) + +// SN76489 attenuation -> int8 amplitude lookup. 2 dB per step +// (so atten=1 is 0.794x atten=0, atten=2 is 0.794x atten=1, etc.). +// Linear (15-atten)/15 mapping makes mid-volume notes far too loud +// and turns fade-outs into harsh cliffs; this table matches +// ScummVM's int16 PSG table scaled down to our 40-peak headroom +// for safe 3-voice sums into int8 PCM. +static const int8_t kAgiAttenAmp[16] = { + 40, 32, 25, 20, 16, 13, 10, 8, + 6, 5, 4, 3, 3, 2, 2, 0 +}; + +// All synth + scheduler state runs entirely on the main thread now +// (the stream fill callback is invoked from halAudioFrameTick, not +// from any IRQ). No critical sections needed. +static uint8_t *gSoundBytes; +static uint16_t gSoundLength; +static AgiVoiceSchedT gVoiceSched[AGI_SOUND_CHANNELS]; +static PsgChannelT gPsg[4]; // 3 tone + 1 noise +static uint8_t gPsgReg; // last register selected by a LATCH or ATTEN byte +static bool gSoundActive; +// AGI ticks are driven from wall-clock ms, NOT from samples +// produced. The previous countdown-per-sample approach assumed +// the synth's call rate matched SB DMA's drain rate (22050/sec). +// In practice DOSBox's SB Pro emulation at cycles=2200 drains +// the DMA buffer roughly 2.1x faster than the requested time +// constant (or fires refill IRQs at half the block-size cadence, +// or some combination -- empirically observed 47-50 kHz +// production rate when we asked for 22050). Pitch is unaffected +// because phaseInc still matches the actual DMA rate, but tempo +// (ticks/sec) was running 2.1x fast. Wall-clock tick gating +// locks tempo to 60 Hz exactly regardless of DMA quirks. +#define AGI_TICK_PERIOD_US (1000000u / AGI_SOUND_TICKS_HZ) // 8333 us at 120 Hz +// Tick counter advanced by agiTickAdvance() from the host loop. +// All AGI script-driven timing keys off this, NOT off wall clock, +// so changing AGI_SOUND_TICKS_HZ uniformly speeds up music + v11 +// + animation + script waits together. v11 advances every 60 AGI +// ticks (= 1 "AGI sec"); at AGI_SOUND_TICKS_HZ=120 that's twice +// per wall second, matching Sierra's PCjr/Tandy game-tick model. +static uint32_t gAgiTickAccumUs; +static uint32_t gAgiTickLastWallMs; +static uint32_t gAgiV11SubTickAccum; // counts AGI ticks toward next v11++ + + +#ifdef JOEYLIB_PLATFORM_IIGS +segment "AGIMAIN"; +#endif + +// ----- Internal helpers (alphabetical) ----- + +static void cbAddToPic(void *ctx, uint8_t viewId, uint8_t loop, uint8_t cel, + uint8_t x, uint8_t y, uint8_t pri, uint8_t margin) { + const AgiViewT *view; + + (void)ctx; + view = fetchView(viewId); + if (view == NULL) { + return; + } + // Mutate the in-memory pic buffer only. The companion show.pic + // call (which always follows an add.to.pic batch in canonical + // AGI logic) is what blits the updated buffer to the stage and + // republishes the backdrop. Per-add.to.pic blits would compound + // into 100s of ms of redundant pixel work on slow targets. + agiPicAddView(&gPic, view, loop, cel, (int16_t)x, (int16_t)y, pri, margin); +} + + +static void cbDiscardPic(void *ctx, uint8_t picId) { + (void)ctx; + (void)picId; +} + + +static void cbDrawPic(void *ctx, uint8_t picId) { + uint8_t i; + + (void)ctx; + jlLogF("cbDrawPic id=%u v11=%u", (unsigned)picId, (unsigned)gVm.vars[11]); + // Dump active objects so we can see whether scene leftovers + // (e.g. sparkle objects from previous scene) are still flagged + // animated/drawn at scene-change time. + for (i = 0u; i < (uint8_t)AGI_MAX_OBJECTS; i++) { + const AgiObjectT *o = &gVm.objects[i]; + if (o->flags != 0u) { + jlLogF(" obj[%u] flags=0x%02x view=%u loop=%u cel=%u x=%d y=%d", + (unsigned)i, (unsigned)o->flags, + (unsigned)o->viewId, (unsigned)o->loop, (unsigned)o->cel, + (int)o->x, (int)o->y); + } + } + // Dump set flags so we can compare flag state across scene + // transitions and identify which flag gates a wrong-path branch. + { + uint16_t fi; + char line[128]; + size_t used; + line[0] = '\0'; + used = 0u; + for (fi = 0u; fi < AGI_VM_NUM_FLAGS; fi++) { + if (gVm.flags[fi] != 0u) { + int n = snprintf(line + used, sizeof(line) - used, "%u ", (unsigned)fi); + if (n < 0 || (size_t)n >= sizeof(line) - used) { + jlLogF(" flagsSet=%s...(trunc)", line); + line[0] = '\0'; + used = 0u; + } else { + used += (size_t)n; + } + } + } + jlLogF(" flagsSet=%s", line); + } + jlLogFlush(); + decodePicInto(picId, true); +} + + +static const uint8_t *cbFetchLogic(void *ctx, uint8_t logicId, uint16_t *outLength) { + LogicCacheSlotT *slot; + uint8_t *raw; + uint16_t rawLength; + uint16_t bcLen; + + (void)ctx; + slot = &gLogicCache[logicId]; + if (slot->bytecode != NULL) { + *outLength = slot->bytecodeLength; + return slot->bytecode; + } + if (logicId >= gGame.resCount[AGI_RES_LOGIC]) { + return NULL; + } + raw = agiResLoad(&gGame, AGI_RES_LOGIC, (uint16_t)logicId, &rawLength); + if (raw == NULL || rawLength < AGI_LOGIC_HDR_BYTES) { + if (raw != NULL) { + free(raw); + } + return NULL; + } + bcLen = (uint16_t)(raw[0] | ((uint16_t)raw[1] << 8)); + if ((uint16_t)(AGI_LOGIC_HDR_BYTES + bcLen) > rawLength) { + free(raw); + return NULL; + } + slot->raw = raw; + slot->rawLength = rawLength; + slot->bytecode = raw + AGI_LOGIC_HDR_BYTES; + slot->bytecodeLength = bcLen; + *outLength = bcLen; + return slot->bytecode; +} + + +// Look up message `msgId` in logic `logicId`'s message table, decrypt +// it via the cycling "Avis Durgan" XOR, write the result to the +// shared scratch buffer, and return a pointer to it. Returns NULL +// on missing message; returns "" for an empty slot. +// +// AGI v2 quirks the spec requires: +// 1. The 2-byte offsets stored in the table are relative to byte 1 +// of the message section (i.e. they exclude the count byte), so +// the actual byte position is stored_offset + 1. +// 2. The XOR key cycles continuously across the whole strings +// block, not per-message. Key index for byte K = (K - strings +// start byte) mod 11. +static const char *cbFetchMessage(void *ctx, uint8_t logicId, uint8_t msgId) { + LogicCacheSlotT *slot; + const uint8_t *raw; + const uint8_t *msgSection; + uint16_t msgSectionOff; + uint16_t rawLength; + uint16_t msgCount; + uint16_t msgOff; + uint16_t stringsStartOff; + uint16_t k; + uint16_t i; + + (void)ctx; + slot = &gLogicCache[logicId]; + if (slot->raw == NULL) { + uint16_t ignore = 0u; + if (cbFetchLogic(NULL, logicId, &ignore) == NULL) { + return NULL; + } + } + raw = slot->raw; + rawLength = slot->rawLength; + msgSectionOff = (uint16_t)(AGI_LOGIC_HDR_BYTES + slot->bytecodeLength); + if (msgSectionOff >= rawLength) { + return NULL; + } + msgSection = raw + msgSectionOff; + msgCount = (uint16_t)msgSection[0]; + if (msgId == 0u || msgId > msgCount) { + return NULL; + } + // Find the lowest non-zero stored offset across all message + // slots. That's where the encrypted strings block begins. + stringsStartOff = 0xFFFFu; + for (k = 1u; k <= msgCount; k++) { + uint16_t off; + uint16_t tOff = (uint16_t)(3u + (uint16_t)((k - 1u) * 2u)); + if ((uint16_t)(msgSectionOff + tOff + 1u) >= rawLength) { + continue; + } + off = (uint16_t)(msgSection[tOff] | ((uint16_t)msgSection[tOff + 1u] << 8)); + if (off != 0u && off < stringsStartOff) { + stringsStartOff = off; + } + } + if (stringsStartOff == 0xFFFFu) { + gMessageScratch[0] = '\0'; + return gMessageScratch; + } + { + uint16_t tableOff; + uint16_t msgStart; + uint16_t keyBase; + + tableOff = (uint16_t)(3u + (uint16_t)((msgId - 1u) * 2u)); + if ((uint16_t)(msgSectionOff + tableOff + 1u) >= rawLength) { + return NULL; + } + msgOff = (uint16_t)(msgSection[tableOff] | ((uint16_t)msgSection[tableOff + 1u] << 8)); + if (msgOff == 0u) { + gMessageScratch[0] = '\0'; + return gMessageScratch; + } + // actual byte = section_off + stored_offset + 1. + msgStart = (uint16_t)(msgSectionOff + msgOff + 1u); + if (msgStart >= rawLength) { + return NULL; + } + // continuous key index = (msgOff - stringsStartOff) at byte 0 + // of this message, then increments per byte. + keyBase = (uint16_t)(msgOff - stringsStartOff); + i = 0u; + while (msgStart + i < rawLength && i < (uint16_t)(MSG_BUFFER_BYTES - 1u)) { + uint8_t enc; + uint8_t dec; + + enc = raw[msgStart + i]; + dec = (uint8_t)(enc ^ (uint8_t)MSG_KEY[(keyBase + i) % MSG_KEY_LEN]); + if (dec == 0u) { + break; + } + gMessageScratch[i] = (char)dec; + i++; + } + gMessageScratch[i] = '\0'; + } + return gMessageScratch; +} + + +static bool cbHaveKey(void *ctx) { + bool hit; + + (void)ctx; + hit = gAnyKeyHit; + gAnyKeyHit = false; + return hit; +} + + +static void cbLoadPic(void *ctx, uint8_t picId) { + (void)ctx; + (void)picId; +} + + +static void cbOverlayPic(void *ctx, uint8_t picId) { + (void)ctx; + jlLogF("cbOverlayPic id=%u v11=%u", (unsigned)picId, (unsigned)gVm.vars[11]); + decodePicInto(picId, false); +} + + +static void cbShowPic(void *ctx) { + (void)ctx; + jlLogF("cbShowPic v11=%u", (unsigned)gVm.vars[11]); + if (gStage == NULL) { + return; + } + // Clear the WHOLE stage first so the rows above/below the AGI + // picture region (y=0..15 and y=184..199) don't retain pixels + // from the previous scene. Without this, text drawn during the + // logo phase at rows 22..24 ends up captured by the backdrop + // copy below and gets restored over every subsequent frame -- + // so clear.lines never visually erases anything that sat in + // the border bands. + jlSurfaceClear(gStage, COLOR_MISSING); + agiPicBlit(&gPic, gStage, PIC_DEST_Y); + if (gBackdrop != NULL) { + jlSurfaceCopy(gBackdrop, gStage); + } + gPicReady = true; + // The stage just got repainted; any prior save-under refers to + // pre-paint pixels and must NOT be restored next frame (it would + // leak the old picture over the new one). + invalidateObjectSaves(); +} + + +// Recompute derived (phaseInc, amplitude) for chip channel +// `chipCh` from its current freqCount + attenuation + genType. +// Called whenever the chip state for a tone channel changes; +// noise channels aren't synthesized and skip recompute. +static void psgRecompute(uint8_t chipCh) { + PsgChannelT *p; + uint32_t freqHz; + + if (chipCh >= 3u) { + return; + } + p = &gPsg[chipCh]; + if (p->genType != PSG_GEN_TONE || p->freqCount == 0u || p->attenuation >= 15u) { + p->phaseInc = 0u; + p->amplitude = 0; + return; + } + freqHz = (uint32_t)PSG_CLOCK_HZ / (uint32_t)p->freqCount; + p->phaseInc = (uint16_t)((freqHz * 65536u) / AGI_SYNTH_SAMPLE_RATE); + p->amplitude = kAgiAttenAmp[p->attenuation]; +} + + +// SN76489 byte dispatcher, byte-for-byte equivalent to ScummVM's +// SoundGenPCJr::writeData. The chip has 4 channels (3 tone + 1 +// noise) and a single "last register" latch used by DATA bytes +// to know which channel to update. +// +// Byte forms (per the SN76489 datasheet): +// 1rrr1xxx (atten): bits 6-5 = chip channel, bits 3-0 = atten. +// Updates _reg and the channel's attenuation. +// 11100xxx (noise): noise channel (3); bit 2 = white/period, +// bits 1-0 = period code. _reg NOT updated -- +// the DATA byte that follows still targets +// whatever channel was last selected by a tone +// latch or atten command. +// 1rrr0xxx (tone latch): bits 6-5 = chip channel, bits 3-0 = +// LOW 4 bits of freqCount. Sets _reg, fully +// replaces low nibble, marks gen as tone. +// 0xxxxxxx (data): ORs (val & 0x3F) << 4 into freqCount of +// whichever channel _reg points to (the high +// 6 bits of the 10-bit divisor). The OR is +// key: an event without a fresh tone latch +// keeps its previous low nibble, so two +// events can share a low-nibble setup and a +// noise-control byte in between leaves the +// tone channel's freqCount mostly intact. +static void psgWriteData(uint8_t val) { + uint8_t reg; + + if ((val & 0x90u) == 0x90u) { + gPsgReg = (uint8_t)((val >> 5) & 0x03u); + gPsg[gPsgReg].attenuation = (uint8_t)(val & 0x0Fu); + psgRecompute(gPsgReg); + } else if ((val & 0xF0u) == 0xE0u) { + gPsg[3].genType = (val & 0x04u) ? PSG_GEN_NOISE_WHITE : PSG_GEN_NOISE_PERIOD; + switch (val & 0x03u) { + case 0u: gPsg[3].freqCount = 32u; break; + case 1u: gPsg[3].freqCount = 64u; break; + case 2u: gPsg[3].freqCount = 128u; break; + default: gPsg[3].freqCount = (uint16_t)(gPsg[2].freqCount * 2u); break; + } + // No _reg update; no synth recompute (noise not synthesized). + } else if (val & 0x80u) { + reg = (uint8_t)((val >> 5) & 0x03u); + gPsgReg = reg; + gPsg[reg].freqCount = (uint16_t)(val & 0x0Fu); + gPsg[reg].genType = PSG_GEN_TONE; + psgRecompute(reg); + } else { + gPsg[gPsgReg].freqCount |= (uint16_t)((val & 0x3Fu) << 4); + psgRecompute(gPsgReg); + } +} + + +// Silence every channel and free the owned SND buffer. Called from +// main thread; stops the SFX-stream slot first so the synth +// callback can't be invoked again against a freed buffer. +static void agiSoundReset(void) { + uint8_t v; + + jlAudioStopSfx(0u); + for (v = 0u; v < AGI_SOUND_CHANNELS; v++) { + gVoiceSched[v].chOffset = 0u; + gVoiceSched[v].eventFramesRem = 0u; + gVoiceSched[v].done = true; + } + for (v = 0u; v < 4u; v++) { + gPsg[v].freqCount = 0u; + gPsg[v].attenuation = 15u; // silent + gPsg[v].genType = PSG_GEN_TONE; + gPsg[v].phaseInc = 0u; + gPsg[v].amplitude = 0; + gPsg[v].phase = 0u; + } + gPsgReg = 0u; + gSoundActive = false; + gAgiTickAccumUs = 0u; + gAgiTickLastWallMs = 0u; + gAgiV11SubTickAccum = 0u; + if (gSoundBytes != NULL) { + free(gSoundBytes); + gSoundBytes = NULL; + } + gSoundLength = 0u; +} + + +// Advance one AGI channel by one tick. When the channel's +// duration counter rolls over, parse the 5-byte event header +// (dur LE16 + 3 chip-command bytes) and feed the 3 chip bytes +// through psgWriteData in ScummVM's order: byte 4 (atten), byte +// 3 (latch or noise control), byte 2 (data). The chip state is +// shared across all AGI channels, so an event in AGI channel N +// can update chip-channel state for any chip register, including +// the noise channel and other tone channels' attenuation. +// +// dur=0 events fire-then-skip in the same tick (AGI uses them for +// legato note transitions). Inner-loop guard caps iterations so +// a malformed channel can't lock the synth. +static void agiVoiceTick(uint8_t v) { + AgiVoiceSchedT *sch; + uint16_t dur; + uint8_t guard; + + sch = &gVoiceSched[v]; + if (sch->done) { + return; + } + guard = 16u; + while (sch->eventFramesRem == 0u && guard != 0u) { + guard--; + if ((uint32_t)sch->chOffset + 4u >= (uint32_t)gSoundLength) { + sch->done = true; + return; + } + dur = (uint16_t)(gSoundBytes[sch->chOffset] | + ((uint16_t)gSoundBytes[sch->chOffset + 1u] << 8)); + if (dur == AGI_SOUND_END_MARKER) { + sch->done = true; + return; + } + // ScummVM order: attenuation byte first, then latch, then + // data. Critical because the noise-control byte (0xE0-0xEF) + // does NOT update _reg, so the data byte that follows must + // target whatever channel the atten byte selected. + psgWriteData(gSoundBytes[sch->chOffset + 4u]); + psgWriteData(gSoundBytes[sch->chOffset + 3u]); + psgWriteData(gSoundBytes[sch->chOffset + 2u]); + sch->chOffset = (uint16_t)(sch->chOffset + 5u); + sch->eventFramesRem = dur; + // Light per-event trace (no flush; buffered). Useful when + // diagnosing whether a "weird" note is from the SND data + // itself or from our chip emulator. + jlLogF(" agiV%u @%u dur=%u b234=%02x %02x %02x | psg ch0/1/2/N freq=%u/%u/%u/%u atten=%u/%u/%u/%u _reg=%u", + (unsigned)v, (unsigned)(sch->chOffset - 5u), (unsigned)dur, + (unsigned)gSoundBytes[sch->chOffset - 3u], + (unsigned)gSoundBytes[sch->chOffset - 2u], + (unsigned)gSoundBytes[sch->chOffset - 1u], + (unsigned)gPsg[0].freqCount, (unsigned)gPsg[1].freqCount, + (unsigned)gPsg[2].freqCount, (unsigned)gPsg[3].freqCount, + (unsigned)gPsg[0].attenuation, (unsigned)gPsg[1].attenuation, + (unsigned)gPsg[2].attenuation, (unsigned)gPsg[3].attenuation, + (unsigned)gPsgReg); + } + if (sch->eventFramesRem != 0u) { + sch->eventFramesRem = (uint16_t)(sch->eventFramesRem - 1u); + } +} + + +// Host-loop tick generator. Drives ALL AGI-time-sensitive things +// (music event advancement, v11 seconds counter increment) off the +// same wall-clock-paced tick stream. Sierra's interpreter does the +// same: one tick = one PSG event step AND one 60th of an "AGI +// second", so v11 stays in lockstep with the music regardless of +// what AGI_SOUND_TICKS_HZ is set to. Called once per host frame. +static void agiTickAdvance(void) { + static uint32_t gLastObjDumpMs = 0; + uint32_t nowMs = jlMillisElapsed(); + uint32_t deltaMs; + uint8_t v; + + // Periodic dump of currently-drawn objects so we can see what's + // visible at any moment without having to log per-opcode. + if (nowMs - gLastObjDumpMs >= 2000u || gLastObjDumpMs == 0u) { + uint8_t i; + jlLogF("--- obj dump @wallMs=%u v11=%u v36=%u v221=%u v222=%u v223=%u v224=%u v225=%u v226=%u ---", + (unsigned)nowMs, (unsigned)gVm.vars[11], + (unsigned)gVm.vars[36], + (unsigned)gVm.vars[221], (unsigned)gVm.vars[222], + (unsigned)gVm.vars[223], (unsigned)gVm.vars[224], + (unsigned)gVm.vars[225], (unsigned)gVm.vars[226]); + for (i = 0u; i < (uint8_t)AGI_MAX_OBJECTS; i++) { + const AgiObjectT *o = &gVm.objects[i]; + if ((o->flags & (AGI_OBJ_FLAG_ANIMATED | AGI_OBJ_FLAG_DRAWN)) + == (AGI_OBJ_FLAG_ANIMATED | AGI_OBJ_FLAG_DRAWN)) { + jlLogF(" drawn obj[%u] view=%u loop=%u cel=%u x=%d y=%d flags=0x%02x", + (unsigned)i, (unsigned)o->viewId, (unsigned)o->loop, + (unsigned)o->cel, (int)o->x, (int)o->y, + (unsigned)o->flags); + } + } + gLastObjDumpMs = nowMs; + } + + if (gAgiTickLastWallMs == 0u) { + gAgiTickLastWallMs = nowMs; + // Prime one tick so events load immediately on song start. + gAgiTickAccumUs = AGI_TICK_PERIOD_US; + return; + } + deltaMs = nowMs - gAgiTickLastWallMs; + gAgiTickLastWallMs = nowMs; + gAgiTickAccumUs = (uint32_t)(gAgiTickAccumUs + deltaMs * 1000u); + + while (gAgiTickAccumUs >= AGI_TICK_PERIOD_US) { + gAgiTickAccumUs -= AGI_TICK_PERIOD_US; + + // Advance music if any. + if (gSoundActive) { + bool allDone; + for (v = 0u; v < AGI_SOUND_CHANNELS; v++) { + agiVoiceTick(v); + } + allDone = true; + for (v = 0u; v < AGI_SOUND_TONE_VOICES; v++) { + if (!gVoiceSched[v].done) { + allDone = false; + break; + } + } + if (allDone) { + gSoundActive = false; + jlLogF("synth EOS wallMs=%u v0done=%u v1done=%u v2done=%u v3done=%u", + (unsigned)nowMs, + (unsigned)gVoiceSched[0].done, + (unsigned)gVoiceSched[1].done, + (unsigned)gVoiceSched[2].done, + (unsigned)gVoiceSched[3].done); + jlLogFlush(); + } + } + + // v11 advances every 60 AGI ticks (= 1 AGI sec). At + // AGI_SOUND_TICKS_HZ=120 this is 2 v11 ticks per wall + // sec, which is what the KQ3 script expects (Sierra + // game-tick rate, not wall clock). + gAgiV11SubTickAccum++; + if (gAgiV11SubTickAccum >= 60u) { + gAgiV11SubTickAccum = 0u; + agiVmTickSeconds(&gVm, 1u); + } + } +} + + +// Stream-fill callback for the SB DMA path. Pure sample generator +// now -- all tick advancement is done by agiTickAdvance() from the +// host loop. The synth just reads the latest chip state cached by +// psgRecompute and emits signed 8-bit PCM. +static uint32_t agiSynthFill(void *ctx, int8_t *dst, uint32_t count) { + uint32_t i; + uint8_t v; + int sum; + int8_t voiceSample; + + (void)ctx; + if (!gSoundActive) { + return 0u; + } + for (i = 0u; i < count; i++) { + sum = 0; + for (v = 0u; v < AGI_SOUND_TONE_VOICES; v++) { + voiceSample = (gPsg[v].phase & 0x8000u) + ? gPsg[v].amplitude + : (int8_t)(-gPsg[v].amplitude); + sum += voiceSample; + gPsg[v].phase = (uint16_t)(gPsg[v].phase + gPsg[v].phaseInc); + } + if (sum > 127) { sum = 127; } + if (sum < -128) { sum = -128; } + dst[i] = (int8_t)sum; + } + return count; +} + + +static void cbPlaySound(void *ctx, uint8_t soundId) { + uint8_t *bytes; + uint16_t length; + uint8_t v; + uint16_t off; + + (void)ctx; + agiSoundReset(); + if ((uint16_t)soundId >= gGame.resCount[AGI_RES_SOUND]) { + return; + } + bytes = agiResLoad(&gGame, AGI_RES_SOUND, (uint16_t)soundId, &length); + if (bytes == NULL) { + return; + } + if (length < 8u) { + free(bytes); + return; + } + gSoundBytes = bytes; + gSoundLength = length; + jlLogF("cbPlaySound id=%u len=%u offs=%u/%u/%u/%u wallMs=%u", + (unsigned)soundId, (unsigned)length, + (unsigned)(bytes[0] | (bytes[1] << 8)), + (unsigned)(bytes[2] | (bytes[3] << 8)), + (unsigned)(bytes[4] | (bytes[5] << 8)), + (unsigned)(bytes[6] | (bytes[7] << 8)), + (unsigned)jlMillisElapsed()); + jlLogFlush(); + for (v = 0u; v < AGI_SOUND_CHANNELS; v++) { + off = (uint16_t)(bytes[v * 2u] | ((uint16_t)bytes[v * 2u + 1u] << 8)); + gVoiceSched[v].chOffset = off; + gVoiceSched[v].eventFramesRem = 0u; + gVoiceSched[v].done = (off == 0u) || ((uint32_t)off + 4u >= (uint32_t)length); + } + // Chip state was zeroed by agiSoundReset above; nothing more + // to init here. First agiVoiceTick will fire the events that + // configure freqCount / attenuation / genType. + // Wall-clock-driven tick scheduler: cleared in agiSoundReset + // above; agiSynthFill's first invocation primes the timestamp. + gSoundActive = true; + // Push the synth into SFX slot 0 of the SB DMA mixer at the + // exact output rate so no resampling happens between the synth + // and the chip. + jlAudioPlaySfxStream(0u, agiSynthFill, NULL, AGI_SYNTH_SAMPLE_RATE); +} + + +// VM polls this each cycle to detect natural sound completion. +// True while any tone voice still has events left; false once +// every voice has run off its 0xFFFF terminator (or stopSound +// was called). +static bool cbIsPlayingSound(void *ctx) { + (void)ctx; + return gSoundActive; +} + + +static void cbStopSound(void *ctx) { + (void)ctx; + jlLog("cbStopSound"); + jlLogFlush(); + agiSoundReset(); +} + + +// View metadata callbacks for the VM's cycle / last.cel / +// number.of.loops opcodes. fetchView walks the view-cache, faulting +// the view in from VOL on a cold hit, so the first call after a +// view is referenced may decode the resource. Returns 0 if the +// view cannot be loaded; the VM treats 0 as "still loading" (no +// premature end.of.loop fire) and the host renderer's own clamps +// keep the screen from drawing garbage cels. +static uint8_t cbViewCelCount(void *ctx, uint8_t viewId, uint8_t loopId) { + const AgiViewT *view; + + (void)ctx; + view = fetchView(viewId); + if (view == NULL || loopId >= AGI_MAX_LOOPS_PER_VIEW) { + return 0u; + } + return view->loops[loopId].celCount; +} + + +static uint8_t cbViewLoopCount(void *ctx, uint8_t viewId) { + const AgiViewT *view; + + (void)ctx; + view = fetchView(viewId); + if (view == NULL) { + return 0u; + } + return view->loopCount; +} + + +// (Pre-render synthesizeSnd path was replaced by the streaming +// generator agiSoundFill above; see that for the SND event format +// and PSG synthesis details.) +#if 0 +static bool synthesizeSnd_removed(const uint8_t *bytes, uint16_t length, uint8_t **outPcm, uint32_t *outLen) { + uint32_t maxTicks; + uint32_t durSamples; + uint8_t *pcm; + uint8_t ch; + + if (length < 8u) { + return false; + } + maxTicks = 0u; + for (ch = 0u; ch < 4u; ch++) { + uint16_t off; + uint16_t i; + uint32_t ticks; + + off = (uint16_t)(bytes[ch * 2u] | ((uint16_t)bytes[ch * 2u + 1u] << 8)); + if (off >= length) { + continue; + } + ticks = 0u; + i = off; + while ((uint32_t)i + 4u < (uint32_t)length) { + uint16_t dur; + + dur = (uint16_t)(bytes[i] | ((uint16_t)bytes[i + 1u] << 8)); + if (dur == 0xFFFFu) { + break; + } + ticks = (uint32_t)(ticks + dur); + i = (uint16_t)(i + 5u); + } + if (ticks > maxTicks) { + maxTicks = ticks; + } + } + if (maxTicks == 0u) { + return false; + } + durSamples = (maxTicks * (uint32_t)SOUND_SAMPLE_RATE) / 60u; + if (durSamples == 0u) { + return false; + } + // audioSfxOverlayMix uses a 16.16 fixed-point position counter; + // input samples beyond 2^16 - 1 cause pos to wrap and the slot + // to loop the buffer forever instead of completing. Truncate + // here so longer sounds at least play their opening bars. + if (durSamples > SOUND_MAX_SAMPLES) { + durSamples = SOUND_MAX_SAMPLES; + } + pcm = (uint8_t *)malloc(durSamples); + if (pcm == NULL) { + return false; + } + memset(pcm, 0, durSamples); // signed silence = 0 + + for (ch = 0u; ch < SOUND_SYNTH_CHANNELS; ch++) { + uint16_t off; + uint16_t i; + uint32_t sampleIdx; + // 16.16 phase accumulator: 0..0xFFFF = one square-wave + // cycle. Stepping by phaseStep per output sample yields + // sub-sample precision so 717 Hz, 466 Hz, etc. stay at the + // correct frequency instead of quantizing to the nearest + // integer half-period (which scrambled the melody at + // lower sample rates). + uint32_t phaseAcc; + + off = (uint16_t)(bytes[ch * 2u] | ((uint16_t)bytes[ch * 2u + 1u] << 8)); + if (off >= length) { + continue; + } + sampleIdx = 0u; + phaseAcc = 0u; + i = off; + while ((uint32_t)i + 4u < (uint32_t)length && sampleIdx < durSamples) { + uint16_t dur; + uint16_t divisor; + uint8_t atten; + int8_t amp; + uint32_t durSamps; + uint32_t phaseStep; + uint32_t s; + + dur = (uint16_t)(bytes[i] | ((uint16_t)bytes[i + 1u] << 8)); + if (dur == 0xFFFFu) { + break; + } + divisor = (uint16_t)(((bytes[i + 2u] & 0x3Fu) << 4) | (bytes[i + 3u] & 0x0Fu)); + atten = (uint8_t)(bytes[i + 4u] & 0x0Fu); + amp = kAgiSoundAmp[atten]; + durSamps = ((uint32_t)dur * (uint32_t)SOUND_SAMPLE_RATE) / 60u; + if (divisor == 0u || amp == 0) { + // Silent event -- just advance sampleIdx without + // touching pcm (already zeroed) or stepping phase. + if (sampleIdx + durSamps > durSamples) { + sampleIdx = durSamples; + } else { + sampleIdx = sampleIdx + durSamps; + } + i = (uint16_t)(i + 5u); + continue; + } + // freq_hz = PSG_CLOCK_HZ / divisor. + // phaseStep (16.16, one cycle = 0x10000) per output sample + // = (freq_hz << 16) / SOUND_SAMPLE_RATE + // = (PSG_CLOCK_HZ << 16) / (divisor * SOUND_SAMPLE_RATE) + phaseStep = ((uint32_t)PSG_CLOCK_HZ << 16) / ((uint32_t)divisor * SOUND_SAMPLE_RATE); + for (s = 0u; s < durSamps && sampleIdx < durSamples; s++, sampleIdx++) { + int8_t sval; + int16_t mixed; + + sval = (phaseAcc & 0x8000u) ? (int8_t)(-amp) : amp; + mixed = (int16_t)((int8_t)pcm[sampleIdx]) + sval; + if (mixed > 127) { mixed = 127; } + if (mixed < -128) { mixed = -128; } + pcm[sampleIdx] = (uint8_t)mixed; + phaseAcc = (uint32_t)((phaseAcc + phaseStep) & 0xFFFFu); + } + i = (uint16_t)(i + 5u); + } + } + + *outPcm = pcm; + *outLen = durSamples; + return true; +} + + +static void cbPlaySound(void *ctx, uint8_t soundId) { + uint8_t *bytes; + uint16_t length; + uint8_t *pcm; + uint32_t pcmLen; + + (void)ctx; + if (!gAudioReady) { + return; + } + // Cache hit: same sound replayed -- just restart the slot, + // skip the file load + synth (instant restart, no freeze). + if (gSoundPcmValid && gSoundPcm != NULL && gSoundPcmId == soundId) { + jlAudioStopSfx(0u); + jlAudioPlaySfx(0u, gSoundPcm, gSoundPcmLen, (uint16_t)SOUND_SAMPLE_RATE); + return; + } + // Cache miss: tear down prior buffer, synth a new one. + if (gSoundPcm != NULL) { + jlAudioStopSfx(0u); + free(gSoundPcm); + gSoundPcm = NULL; + gSoundPcmLen = 0u; + } + gSoundPcmValid = false; + if ((uint16_t)soundId >= gGame.resCount[AGI_RES_SOUND]) { + return; + } + bytes = agiResLoad(&gGame, AGI_RES_SOUND, (uint16_t)soundId, &length); + if (bytes == NULL) { + return; + } + pcm = NULL; + pcmLen = 0u; + if (synthesizeSnd(bytes, length, &pcm, &pcmLen)) { + gSoundPcm = pcm; + gSoundPcmLen = pcmLen; + gSoundPcmId = soundId; + gSoundPcmValid = true; + jlAudioPlaySfx(0u, gSoundPcm, gSoundPcmLen, (uint16_t)SOUND_SAMPLE_RATE); + } + free(bytes); +} + + +static void cbStopSound(void *ctx) { + (void)ctx; + if (!gAudioReady) { + return; + } + // Stop the slot but keep the PCM cached -- a quick stop/start + // (very common in AGI v2 state machines) then replays without + // a re-synth freeze. + jlAudioStopSfx(0u); +} +#endif + + +// Parse an AGI v2 SND resource and return its playback duration in +// milliseconds (max across the 4 PSG channels). Used to defer the +// sound-done flag firing until the music would naturally end -- +// drives KQ3's title state-1 / state-3 shortcut which advances the +// countdown to 2 sec once flag 9 (sound finished) is set. +// +// SND v2 layout: +// bytes 0..7 : 4 channel offsets (2-byte LE each) +// per channel : sequence of 5-byte events (duration LE16, freq LE16, +// attenuation), terminated by duration = 0xFFFF. +// Duration units are 1/60 sec (NTSC vblank ticks). +static uint32_t cbSoundDuration(void *ctx, uint8_t soundId) { + uint8_t *bytes; + uint16_t length; + uint32_t maxTicks; + uint8_t ch; + + (void)ctx; + if ((uint16_t)soundId >= gGame.resCount[AGI_RES_SOUND]) { + return 0u; + } + bytes = agiResLoad(&gGame, AGI_RES_SOUND, (uint16_t)soundId, &length); + if (bytes == NULL) { + return 0u; + } + if (length < 8u) { + free(bytes); + return 0u; + } + maxTicks = 0u; + for (ch = 0u; ch < 4u; ch++) { + uint16_t off; + uint16_t i; + uint32_t ticks; + + off = (uint16_t)(bytes[ch * 2u] | ((uint16_t)bytes[ch * 2u + 1u] << 8)); + ticks = 0u; + i = off; + while ((uint32_t)i + 4u < (uint32_t)length) { + uint16_t dur; + + dur = (uint16_t)(bytes[i] | ((uint16_t)bytes[i + 1u] << 8)); + if (dur == 0xFFFFu) { + break; + } + ticks = (uint32_t)(ticks + dur); + i = (uint16_t)(i + 5u); + } + if (ticks > maxTicks) { + maxTicks = ticks; + } + } + free(bytes); + // 60 ticks / sec -> ms = ticks * 1000 / 60 = ticks * 50 / 3 + return (maxTicks * 1000u) / 60u; +} + + +static bool decodePicInto(uint8_t picId, bool clearFirst) { + uint8_t *bytes; + uint16_t length; + bool ok; + + if ((uint16_t)picId >= gGame.resCount[AGI_RES_PIC]) { + return false; + } + bytes = agiResLoad(&gGame, AGI_RES_PIC, (uint16_t)picId, &length); + if (bytes == NULL) { + return false; + } + if (clearFirst) { + agiPicClear(&gPic); + } + ok = agiPicDecode(&gPic, bytes, length); + free(bytes); + return ok; +} + + +// Walk every key the JoeyLib enum knows about; for each one that +// went down this frame, look it up against vm->keyBindings via +// agiVmDispatchKey. Function keys map to the AGI extended scancode +// pair (0, 59..68); printable ASCII keys map to (asciiCode, 0). +static void dispatchAllPressedKeys(void) { + static const uint8_t fnScancodes[10] = { 59, 60, 61, 62, 63, 64, 65, 66, 67, 68 }; + static const jlKeyE fnKeys[10] = { + KEY_F1, KEY_F2, KEY_F3, KEY_F4, KEY_F5, + KEY_F6, KEY_F7, KEY_F8, KEY_F9, KEY_F10 + }; + uint8_t k; + uint8_t i; + + if (jlKeyPressed(KEY_RETURN)) { + agiVmDispatchKey(&gVm, 13u, 0u); + gAnyKeyHit = true; + } + if (jlKeyPressed(KEY_SPACE)) { + agiVmDispatchKey(&gVm, 32u, 0u); + gAnyKeyHit = true; + } + if (jlKeyPressed(KEY_TAB)) { + agiVmDispatchKey(&gVm, 9u, 0u); + gAnyKeyHit = true; + } + if (jlKeyPressed(KEY_BACKSPACE)) { + agiVmDispatchKey(&gVm, 8u, 0u); + gAnyKeyHit = true; + } + for (k = 0u; k < 26u; k++) { + if (jlKeyPressed((jlKeyE)((uint8_t)KEY_A + k))) { + agiVmDispatchKey(&gVm, (uint8_t)('a' + k), 0u); + gAnyKeyHit = true; + } + } + for (k = 0u; k < 10u; k++) { + if (jlKeyPressed((jlKeyE)((uint8_t)KEY_0 + k))) { + agiVmDispatchKey(&gVm, (uint8_t)('0' + k), 0u); + gAnyKeyHit = true; + } + } + for (i = 0u; i < 10u; i++) { + if (jlKeyPressed(fnKeys[i])) { + agiVmDispatchKey(&gVm, 0u, fnScancodes[i]); + gAnyKeyHit = true; + } + } + if (jlJoystickConnected(JOYSTICK_0)) { + if (jlJoyPressed(JOYSTICK_0, JOY_BUTTON_0) || jlJoyPressed(JOYSTICK_0, JOY_BUTTON_1)) { + agiVmDispatchKey(&gVm, 13u, 0u); // joystick fire == ENTER + gAnyKeyHit = true; + } + } +} + + +// Restore the exact stage rect `(x, y, w, h)` from the gBackdrop +// surface. Chunky-fast: a single memcpy per row from backdrop pixels +// to stage pixels. Bypassing jlDrawPixel keeps per-frame restore +// cost ~1KB memcpy per object instead of per-pixel function calls. +// Planar (s->pixels NULL) is uncommon at this point; fall back to +// jlSurfaceCopy of the whole surface (slower but correct). +static void restoreRectFromBackdrop(jlSurfaceT *stage, int16_t x, int16_t y, int16_t w, int16_t h) { + int16_t row; + int16_t byteX; + int16_t byteW; + int16_t endY; + + if (stage == NULL || gBackdrop == NULL || w <= 0 || h <= 0) { + return; + } + if (x < 0) { w += x; x = 0; } + if (y < 0) { h += y; y = 0; } + if (x >= (int16_t)SURFACE_WIDTH || y >= (int16_t)SURFACE_HEIGHT) { return; } + if (x + w > (int16_t)SURFACE_WIDTH) { w = (int16_t)SURFACE_WIDTH - x; } + if (y + h > (int16_t)SURFACE_HEIGHT) { h = (int16_t)SURFACE_HEIGHT - y; } + if (w <= 0 || h <= 0) { return; } + + if (stage->pixels == NULL || gBackdrop->pixels == NULL) { + // Planar fallback -- restore from backdrop via full copy. + // Wasteful but rare in current ports (Amiga only). + jlSurfaceCopy(stage, gBackdrop); + return; + } + // Round to byte boundaries (4bpp packed: byte index = x/2). + // Both x and w come from cel boundaries already aligned to 2px + // (pixel doubling) so this is exact. + byteX = (int16_t)(x >> 1); + byteW = (int16_t)((w + 1) >> 1); + endY = (int16_t)(y + h); + for (row = y; row < endY; row++) { + memcpy(&stage->pixels[row * (int16_t)SURFACE_BYTES_PER_ROW + byteX], + &gBackdrop->pixels[row * (int16_t)SURFACE_BYTES_PER_ROW + byteX], + (size_t)byteW); + } + surfaceMarkDirtyRect(stage, x, y, w, h); +} + + +// Walk vm->objects[] and (re)draw every animated+drawn object. Per +// frame: +// pass 1: restore the prev-frame rect of every previously-drawn +// obj from the backdrop. Erases the prev draw without +// needing per-object save buffers. +// pass 2: for each visible obj, compute its cel's stage bbox, +// draw the cel, and record the bbox as its new prev rect. +// +// Two passes are required so an obj's restore can't accidentally +// erase another obj's draw -- all restores happen before any draws. +static void drawObjects(jlSurfaceT *stage) { + uint8_t i; + + // Pass 1: restore prev-frame rect from backdrop for every obj + // that was drawn last frame. + for (i = 0u; i < (uint8_t)AGI_MAX_OBJECTS; i++) { + if (gObjPrev[i].valid) { + restoreRectFromBackdrop(stage, gObjPrev[i].x, gObjPrev[i].y, + gObjPrev[i].w, gObjPrev[i].h); + gObjPrev[i].valid = false; + } + } + + // Pass 2: draw current frame's visible objects. + for (i = 0u; i < (uint8_t)AGI_MAX_OBJECTS; i++) { + const AgiObjectT *obj; + const AgiViewT *view; + uint8_t loop; + uint8_t cel; + uint8_t actorPri; + int16_t stageX; + int16_t stageY; + int16_t stageW; + int16_t stageH; + + obj = &gVm.objects[i]; + if ((obj->flags & AGI_OBJ_FLAG_ANIMATED) == 0u) { + continue; + } + if ((obj->flags & AGI_OBJ_FLAG_DRAWN) == 0u) { + continue; + } + view = fetchView(obj->viewId); + if (view == NULL || view->loopCount == 0u) { + continue; + } + loop = obj->loop; + // Auto-loop from direction only applies to MOVING objects. + // A stopped obj keeps whatever loop the script (e.g. + // set.loop) assigned -- otherwise KQ3's title-screen wizard + // eyes (obj 0, view=65 loop=1, direction=0) get clobbered + // to loop 0 (the body view) by the stopped-default branch. + if ((obj->flags & AGI_OBJ_FLAG_LOOP_FIXED) == 0u && + obj->direction != 0u) { + uint8_t autoLoop = loopForDirection(obj->direction, view->loopCount); + if (autoLoop < view->loopCount) { + loop = autoLoop; + } + } + if (loop >= view->loopCount) { + loop = 0u; + } + cel = obj->cel; + if (cel >= view->loops[loop].celCount) { + cel = 0u; + } + if ((obj->flags & AGI_OBJ_FLAG_PRI_FIXED) != 0u && obj->priority >= AGI_PRIORITY_BG) { + actorPri = obj->priority; + } else { + actorPri = agiActorPriorityForY(obj->y); + } + agiViewDraw(view, loop, cel, + (int16_t)obj->x, (int16_t)obj->y, + actorPri, gPic.priority, + stage, PIC_DEST_Y); + // Record the cel's exact stage bbox so next frame's restore + // covers only that region (works for cels of any size, fixes + // the trailing-pixel artifact wider/taller cels suffered with + // the previous fixed-size save-under). + { + uint8_t cw = view->loops[loop].cels[cel].width; + uint8_t ch = view->loops[loop].cels[cel].height; + stageX = (int16_t)(obj->x * 2); + stageY = (int16_t)(PIC_DEST_Y + obj->y - (int16_t)ch + 1); + stageW = (int16_t)(cw * 2); + stageH = (int16_t)ch; + gObjPrev[i].x = stageX; + gObjPrev[i].y = stageY; + gObjPrev[i].w = stageW; + gObjPrev[i].h = stageH; + gObjPrev[i].valid = true; + } + } +} + + +// Invalidate every object's prev-rect entry. Called when the picture +// changes (show.pic / add.to.pic) so we don't restore stale pixels +// over the new backdrop on the next frame. +static void invalidateObjectSaves(void) { + uint8_t i; + + for (i = 0u; i < (uint8_t)AGI_MAX_OBJECTS; i++) { + gObjPrev[i].valid = false; + } +} + + +static const AgiViewT *fetchView(uint8_t viewId) { + ViewCacheSlotT *slot; + uint8_t *bytes; + uint16_t length; + + slot = &gViewCache[viewId]; + if (slot->loaded) { + return &slot->view; + } + if (viewId >= gGame.resCount[AGI_RES_VIEW]) { + return NULL; + } + bytes = agiResLoad(&gGame, AGI_RES_VIEW, (uint16_t)viewId, &length); + if (bytes == NULL) { + return NULL; + } + if (!agiViewParse(&slot->view, bytes, length)) { + return NULL; + } + slot->loaded = true; + return &slot->view; +} + + +// AGI's canonical loop assignment from direction (idx 0 = right): +// right=0, left=1, down=2, up=3 +// Diagonal directions reuse the dominant axis loop. +static uint8_t loopForDirection(uint8_t direction, uint8_t loopCount) { + uint8_t loop; + + if (loopCount <= 1u) { + return 0u; + } + switch (direction) { + case 1u: loop = 3u; break; // N + case 2u: loop = 0u; break; // NE -> right + case 3u: loop = 0u; break; // E + case 4u: loop = 0u; break; // SE -> right + case 5u: loop = 2u; break; // S + case 6u: loop = 1u; break; // SW -> left + case 7u: loop = 1u; break; // W + case 8u: loop = 1u; break; // NW -> left + default: loop = 2u; break; // stopped -> down + } + if (loop >= loopCount) { + return 0u; + } + return loop; +} + + +static uint8_t parseArgU8(const char *s) { + uint16_t val; + uint8_t digit; + + if (s == NULL) { + return 0u; + } + val = 0u; + while (*s != '\0') { + if (*s < '0' || *s > '9') { + return 0u; + } + digit = (uint8_t)(*s - '0'); + if (val > 25u || (val == 25u && digit > 5u)) { + return 0u; + } + val = (uint16_t)(val * 10u + digit); + s++; + } + return (uint8_t)val; +} + + +static void releaseLogicCache(void) { + uint16_t i; + + for (i = 0u; i < AGI_MAX_RESOURCES; i++) { + if (gLogicCache[i].raw != NULL) { + free(gLogicCache[i].raw); + gLogicCache[i].raw = NULL; + gLogicCache[i].bytecode = NULL; + } + } +} + + +static void releaseViewCache(void) { + uint16_t i; + + for (i = 0u; i < AGI_MAX_RESOURCES; i++) { + if (gViewCache[i].loaded) { + agiViewFree(&gViewCache[i].view); + gViewCache[i].loaded = false; + } + } +} + + +static void renderFrame(jlSurfaceT *stage) { + uint8_t r; + + if (!gPicReady) { + jlStagePresent(); + return; + } + // Step 1: restore text rows that had content last frame so a VM + // that cleared them this frame (clear.lines / row reassign) sees + // them disappear from the stage. + for (r = 0u; r < (uint8_t)AGI_TEXT_ROWS; r++) { + if (gTextRowDirty[r]) { + restoreRectFromBackdrop(stage, 0, (int16_t)((int16_t)r * 8), + (int16_t)SURFACE_WIDTH, 8); + gTextRowDirty[r] = false; + } + } + // Step 2: restore + draw animated/drawn objects. + drawObjects(stage); + // Step 3: draw current text rows on top. Mark dirty as we go so + // step 1 of the next frame knows to wipe them. + { + bool statusOn = gVm.statusLineOn; + uint8_t statusRow = gVm.statusLineRow; + + if (statusOn) { + gTextRowDirty[statusRow] = true; + } + for (r = 0u; r < (uint8_t)AGI_TEXT_ROWS; r++) { + if (gVm.textRows[r].text[0] != '\0') { + gTextRowDirty[r] = true; + } + } + } + agiTextRender(&gVm, stage); + jlStagePresent(); +} + + +static const char *resolveGameDir(int argc, char **argv) { + if (argc > 1 && argv[1] != NULL && argv[1][0] != '\0') { + return argv[1]; + } + return "."; +} + + +static uint8_t resolveStartingRoom(int argc, char **argv) { + if (argc <= 2 || argv[2] == NULL) { + return 0u; + } + return parseArgU8(argv[2]); +} + + +static void runVmCycle(void) { + AgiVmHaltE reason; + const uint8_t *logic0; + uint16_t length; + uint8_t prevRoom; + uint8_t i; + uint8_t guard; + + // Block the cycle entirely while a print() modal is up; the + // host's input loop dismisses it on the next ack key. + if (gVm.printModal.active) { + return; + } + + guard = CYCLE_HALT_GUARD; + for (;;) { + if (guard == 0u) { + return; + } + guard--; + reason = agiVmRun(&gVm); + if (reason == AGI_VM_HALT_PRINT_PENDING) { + return; + } + if (reason == AGI_VM_HALT_QUIT) { + return; + } + if (reason == AGI_VM_HALT_RETURN) { + agiVmTickAnimation(&gVm); + gVm.flags[5] = 0u; + length = 0u; + logic0 = cbFetchLogic(NULL, 0u, &length); + if (logic0 != NULL) { + agiVmResetToLogic(&gVm, logic0, length, 0u); + } + return; + } + if (reason == AGI_VM_HALT_NEW_ROOM) { + prevRoom = gVm.vars[0]; + if (gOverrideRoomActive) { + gVm.vars[0] = gOverrideRoom; + gOverrideRoomActive = false; + } else { + gVm.vars[0] = gVm.newRoomId; + } + gVm.vars[1] = prevRoom; + for (i = 0u; i < 32u; i++) { + gVm.flags[i] = 0u; + } + gVm.flags[5] = 1u; + // Clear the object table on room change so leftover NPCs + // don't bleed into the new scene. Ego (object 0) stays + // animated/drawn; the new room's init re-runs animate.obj + // with the same setup if it wants ego on screen. + for (i = 1u; i < (uint8_t)AGI_MAX_OBJECTS; i++) { + agiObjReset(&gVm.objects[i]); + } + // Clear text rows. Sierra's new.room expects a fresh text + // band -- without this, v226=3 credits from LOGIC.45 stay + // visible after the room.46 transition. + for (i = 0u; i < (uint8_t)AGI_TEXT_ROWS; i++) { + gVm.textRows[i].text[0] = '\0'; + } + length = 0u; + logic0 = cbFetchLogic(NULL, 0u, &length); + if (logic0 != NULL) { + agiVmResetToLogic(&gVm, logic0, length, 0u); + } + continue; + } + // Unknown opcode, truncated stream, missing logic, stack + // overflow: stop cycling this frame and let the host render + // what we have. + return; + } +} + + +// Player-control input mapping for ego (when programControl is false). +// Sets var 6 (ego direction) so the engine's motion tick moves the +// player normally. Diagonals not bound; AGI canonical games rarely +// use them via keyboard. +static void updateEgoFromInput(void) { + uint8_t newDir; + + if (gVm.programControl) { + return; + } + newDir = 0u; + if (jlKeyDown(KEY_UP)) { newDir = 1u; } + if (jlKeyDown(KEY_RIGHT)) { newDir = 3u; } + if (jlKeyDown(KEY_DOWN)) { newDir = 5u; } + if (jlKeyDown(KEY_LEFT)) { newDir = 7u; } + if (newDir != 0u || jlKeyPressed(KEY_UP) || jlKeyPressed(KEY_DOWN) || jlKeyPressed(KEY_LEFT) || jlKeyPressed(KEY_RIGHT)) { + gVm.objects[0].direction = newDir; + gVm.vars[6] = newDir; + } else if (!jlKeyDown(KEY_UP) && !jlKeyDown(KEY_DOWN) && !jlKeyDown(KEY_LEFT) && !jlKeyDown(KEY_RIGHT)) { + gVm.objects[0].direction = 0u; + gVm.vars[6] = 0u; + } +} + + +#ifdef JOEYLIB_PLATFORM_IIGS +segment ""; +#endif + +// ----- Entry point ----- + +int main(int argc, char **argv) { + jlConfigT cfg; + AgiVmCallbacksT cb; + const char *gameDir; + const uint8_t *logic0; + uint16_t length; + uint8_t startingRoom; + bool picOk; + bool vmReady; + + cfg.codegenBytes = 0u; // no sprites needed -- backdrop-restore path is direct memcpy + cfg.maxSurfaces = 4u; // stage + backdrop (2 used) + // AGI sound is software-synthesized PSG squarewaves pushed + // through the Sound Blaster auto-init DMA path (see agiSynthFill + // and src/port/dos/audio.c). The audio pool covers the 4 KB + // DMA buffer + libxmp-lite SMIX state. + cfg.audioBytes = 32u * 1024u; + if (!jlInit(&cfg)) { + return 1; + } + + jlLogReset(); + jlLogF("AGI TRACE build=%s %s", __DATE__, __TIME__); + jlLogFlush(); + gStage = jlStageGet(); + jlPaletteSet(gStage, 0u, kAgiPalette); + jlRandomSeed(0xA61DA61Du); + + // Bring up the SB DMA + mixer. AGI music feeds the SFX stream + // path inside jlAudioFrameTick, which depends on a successful + // init here. + (void)jlAudioInit(); + + { + uint8_t i; + for (i = 0u; i < (uint8_t)AGI_MAX_OBJECTS; i++) { + gObjPrev[i].valid = false; + } + } + + gameDir = resolveGameDir(argc, argv); + startingRoom = resolveStartingRoom(argc, argv); + gOverrideRoom = startingRoom; + gOverrideRoomActive = (startingRoom != 0u); + picOk = false; + vmReady = false; + gBackdrop = NULL; + gPicReady = false; + gAnyKeyHit = false; + + if (agiResOpen(&gGame, gameDir)) { + picOk = agiPicAlloc(&gPic); + if (picOk) { + agiPicClear(&gPic); + gBackdrop = jlSurfaceCreate(); + + agiVmInit(&gVm); + cb.fetchLogic = cbFetchLogic; + cb.loadPic = cbLoadPic; + cb.drawPic = cbDrawPic; + cb.showPic = cbShowPic; + cb.discardPic = cbDiscardPic; + cb.overlayPic = cbOverlayPic; + cb.haveKey = cbHaveKey; + cb.fetchMessage = cbFetchMessage; + cb.addToPic = cbAddToPic; + cb.soundDuration = cbSoundDuration; + cb.playSound = cbPlaySound; + cb.stopSound = cbStopSound; + cb.isPlayingSound = cbIsPlayingSound; + cb.viewCelCount = cbViewCelCount; + cb.viewLoopCount = cbViewLoopCount; + cb.ctx = NULL; + agiVmSetCallbacks(&gVm, &cb); + + length = 0u; + logic0 = cbFetchLogic(NULL, 0u, &length); + if (logic0 != NULL) { + agiVmResetToLogic(&gVm, logic0, length, 0u); + vmReady = true; + } + } + } + + jlSurfaceClear(gStage, COLOR_MISSING); + if (gBackdrop != NULL) { + jlSurfaceClear(gBackdrop, COLOR_MISSING); + } + + if (vmReady) { + uint8_t pump; + + for (pump = 0u; pump < STARTUP_CYCLE_LIMIT; pump++) { + if (gPicReady) { + break; + } + runVmCycle(); + } + } + + { + uint32_t lastVmCycleMs = 0u; + uint32_t lastRenderMs = 0u; + + for (;;) { + jlInputPoll(); + if (jlKeyPressed(KEY_ESCAPE)) { + break; + } + gAnyKeyHit = false; + dispatchAllPressedKeys(); + + // Pump the AGI tick generator EVERY frame, even with a + // print modal up. This drives music event playback AND + // the v11 seconds counter -- both keyed to the same + // AGI tick rate so script timing stays consistent with + // music tempo. Pausing this would freeze the AGI clock. + agiTickAdvance(); + + // If a print() modal is up, any key dismisses it and resumes + // the VM next cycle. Don't run the VM or update inputs while + // the modal is alive. + if (gVm.printModal.active) { + if (gAnyKeyHit) { + agiVmAckPrint(&gVm); + } + } else { + uint32_t nowMs = jlMillisElapsed(); + uint8_t delayJiffies; + uint32_t cyclePeriodMs; + + updateEgoFromInput(); + delayJiffies = gVm.vars[10]; + if (delayJiffies == 0u) { delayJiffies = 1u; } + cyclePeriodMs = (uint32_t)delayJiffies * (uint32_t)AGI_JIFFY_MS; + if (vmReady && (nowMs - lastVmCycleMs) >= cyclePeriodMs) { + runVmCycle(); + lastVmCycleMs = nowMs; + } + if (gVm.haltReason == AGI_VM_HALT_QUIT) { + break; + } + } + + // Wall-clock-paced render. Audio refill still runs every + // loop iteration so DMA half-buffers get serviced even + // when we're skipping the visual update. + { + uint32_t nowMs2 = jlMillisElapsed(); + if (nowMs2 - lastRenderMs >= AGI_RENDER_PERIOD_MS) { + renderFrame(gStage); + lastRenderMs = nowMs2; + } else { + jlStagePresent(); + } + } + // Pump the SB DMA mixer. Refills any half-buffer the + // ISR flagged for refill since last call, which in turn + // pulls the next chunk from agiSynthFill (when music + // is armed) or libxmp + SFX overlay (when not). + jlAudioFrameTick(); + jlWaitVBL(); + } + } + + agiSoundReset(); + jlAudioShutdown(); + releaseLogicCache(); + releaseViewCache(); + if (gBackdrop != NULL) { + jlSurfaceDestroy(gBackdrop); + } + if (picOk) { + agiPicFree(&gPic); + } + agiResClose(&gGame); + jlShutdown(); + return 0; +} diff --git a/agi.h b/agi.h new file mode 100644 index 0000000..c0aa459 --- /dev/null +++ b/agi.h @@ -0,0 +1,562 @@ +// JoeyLib AGI interpreter (Sierra Adventure Game Interpreter, v2). +// +// Reimplements the engine that ran Sierra's first wave of graphic +// adventures (KQ1-3, SQ1-2, PQ1, LSL1, Manhunter 1-2) on top of +// JoeyLib's surface/draw/input APIs. v2 format only for Phase 0; v3 +// (LZW-compressed) lands in a later phase. +// +// This is a from-scratch implementation written against the public +// AGI format specification, not derived from any GPL'd reference. + +#ifndef AGI_H +#define AGI_H + +#include "joey/types.h" +#include "joey/surface.h" +#include + + +// AGI resource directories are sized by the on-disk file length. The +// maximum index any single game references is well under 256 for the +// supported game set; oversize directories are rejected at load. +#define AGI_MAX_RESOURCES 256 + +// AGI VOL.x files are numbered 0..15 (4-bit field in the directory +// entry). One open FILE * per volume kept resident for the life of +// the game session. +#define AGI_MAX_VOLUMES 16 + +// AGI's native rendering surface. Visual and priority screens are +// each 160 wide; horizontal pixel doubling produces the 320-wide +// display. The vertical 168 leaves room above and below for the +// status line and command prompt in a 200-line display. +#define AGI_PIC_WIDTH 160 +#define AGI_PIC_HEIGHT 168 +#define AGI_PIC_PIXELS (AGI_PIC_WIDTH * AGI_PIC_HEIGHT) + +// Flood-fill spread tests: a fill spreads through pixels matching the +// background color and stops at anything else. Visual default bg is +// 15 (white), priority default bg is 4 (red). +#define AGI_VISUAL_BG 15u +#define AGI_PRIORITY_BG 4u + +// AGI 16-color CGA-derived palette in JoeyLib's $0RGB format (4-bit +// per channel). Entry 6 keeps the classic CGA "brown" tweak (R=A, +// G=5, B=0) so the muddy-yellow-on-CGA-monitors look matches the +// original render rather than reading as pure yellow-green. +extern const uint16_t kAgiPalette[16]; + +// Resource directory entry: which VOL.x file holds the resource and +// where in that file it starts. A 0xFF marker in volume means the +// directory slot is empty (game does not define this resource ID). +typedef struct { + uint8_t volume; + uint32_t offset; +} AgiResEntryT; + + +typedef enum { + AGI_RES_LOGIC = 0, + AGI_RES_PIC = 1, + AGI_RES_VIEW = 2, + AGI_RES_SOUND = 3, + AGI_RES_COUNT = 4 +} AgiResTypeE; + + +// Open-game state: directory metadata + handles to every VOL.x file. +// Loading and freeing individual resource blobs is per-call so the +// working-set footprint stays small enough for stock IIgs / Amiga. +typedef struct { + FILE *volFiles[AGI_MAX_VOLUMES]; + uint16_t resCount[AGI_RES_COUNT]; + AgiResEntryT resDir[AGI_RES_COUNT][AGI_MAX_RESOURCES]; +} AgiGameT; + + +// Decoded PIC working buffers. Both planes are 160x168 chunky 8bpp; +// only the low nibble (4 bits) of each byte carries actual color +// information. Buffers are heap-allocated so the binary's BSS +// footprint stays under what the IIgs stock heap can satisfy in +// one shot. +typedef struct { + uint8_t *visual; + uint8_t *priority; +} AgiPicT; + + +// VIEW resources hold one or more animation loops; each loop is a +// sequence of cels (frames). The parser walks the published format +// once and records pointers into the raw resource buffer so per-cel +// access is O(1) without re-parsing. +#define AGI_MAX_LOOPS_PER_VIEW 16u +#define AGI_MAX_CELS_PER_LOOP 32u + +typedef struct { + uint8_t width; + uint8_t height; + uint8_t transparentColor; + uint8_t mirrored; + uint8_t mirrorSourceLoop; + const uint8_t *rleData; +} AgiCelInfoT; + +typedef struct { + uint8_t celCount; + AgiCelInfoT cels[AGI_MAX_CELS_PER_LOOP]; +} AgiLoopInfoT; + +typedef struct { + uint8_t *raw; + uint16_t rawLength; + uint8_t loopCount; + AgiLoopInfoT loops[AGI_MAX_LOOPS_PER_VIEW]; +} AgiViewT; + + +// AGI priority for an actor based on its feet (bottom-edge) Y. +// 0..47 is the sky band (priority 4); 48..167 is ground divided into +// 10 bands at priority 5..14 (12 pixels each). Priority 15 is "always +// on top" and isn't reached by the actor's natural Y. +uint8_t agiActorPriorityForY(int16_t y); + + +// LOGIC VM state. AGI's interpreter is a stack-of-logics bytecode +// VM with 256 byte-sized variables and 256 single-bit flags. +#define AGI_VM_NUM_VARS 256u +#define AGI_VM_NUM_FLAGS 256u + +typedef enum { + AGI_VM_HALT_NONE = 0, + AGI_VM_HALT_RETURN = 1, + AGI_VM_HALT_UNKNOWN_OP = 2, + AGI_VM_HALT_TRUNCATED = 3, + AGI_VM_HALT_NEW_ROOM = 4, + AGI_VM_HALT_NO_LOGIC = 5, + AGI_VM_HALT_STACK_OVER = 6, + AGI_VM_HALT_PRINT_PENDING = 7, // print() popup waiting for ack + AGI_VM_HALT_QUIT = 8 // game logic asked to exit +} AgiVmHaltE; + +#define AGI_VM_CALL_STACK_MAX 8u + +typedef struct { + const uint8_t *code; + uint16_t codeLength; + uint16_t pc; + uint8_t logicId; +} AgiVmFrameT; + + +// ----- Animated objects ----- +// +// AGI v2 supports up to 16 simultaneous animated objects ("screen +// objects"). Object 0 is the player/ego; the rest are NPCs, props, +// and incidental animations driven by the game logic. The state +// here mirrors the per-object fields the game scripts manipulate +// via animate.obj / set.view / set.cel / position / set.dir / etc. +#define AGI_MAX_OBJECTS 16u + +#define AGI_OBJ_FLAG_ANIMATED 0x01u // animate.obj called +#define AGI_OBJ_FLAG_DRAWN 0x02u // draw() called, sprite is on-screen +#define AGI_OBJ_FLAG_CYCLING 0x04u // start.cycling, advances cel +#define AGI_OBJ_FLAG_UPDATING 0x08u // start.update, render allowed +#define AGI_OBJ_FLAG_LOOP_FIXED 0x10u // fix.loop disables auto-loop choice +#define AGI_OBJ_FLAG_PRI_FIXED 0x20u // set.priority overrides Y-band priority +#define AGI_OBJ_FLAG_HORIZON_IGN 0x40u // ignore.horizon +#define AGI_OBJ_FLAG_OBJS_IGN 0x80u // ignore.objs (collision) + +typedef enum { + AGI_CYCLE_NORMAL = 0, // 0->1->2..->last->0->... + AGI_CYCLE_END_LOOP = 1, // play once forward, stop, set flag + AGI_CYCLE_REVERSE = 2, // last->..->1->0->last->... + AGI_CYCLE_REV_LOOP = 3 // play once backward, stop, set flag +} AgiCycleE; + +typedef enum { + AGI_MOTION_NORMAL = 0, // moves on direction (vN+6 for ego) + AGI_MOTION_WANDER = 1, // random direction every few cycles + AGI_MOTION_FOLLOW_EGO = 2, // chases obj 0 + AGI_MOTION_MOVE_OBJ = 3 // moving toward fixed (x,y) +} AgiMotionE; + +typedef struct { + uint8_t viewId; + uint8_t loop; + uint8_t cel; + uint8_t priority; // 4..15; high nibble masking + int16_t x; // baseline (bottom-left) AGI x + int16_t y; // baseline (bottom) AGI y + int16_t prevX; + int16_t prevY; + uint8_t prevLoop; + uint8_t prevCel; + uint8_t prevView; + uint8_t direction; // 0=stop, 1..8 (up=1, clockwise) + uint8_t stepSize; // pixels per step + uint8_t stepTime; // VM cycles between steps + uint8_t stepTick; // counter toward stepTime + uint8_t cycleTime; // VM cycles between cel advances + uint8_t cycleTick; // counter toward cycleTime + AgiCycleE cycleMode; + uint8_t endOfLoopFlag; // flag id to set when end.of.loop fires + AgiMotionE motionType; + int16_t targetX; // for move.obj + int16_t targetY; + uint8_t moveStepBackup; // step size before move.obj started + uint8_t moveDoneFlag; // flag id set on move.obj completion + uint8_t wanderTick; // wander direction-change countdown + uint8_t followStep; // step.size override for follow.ego + uint8_t followDoneFlag; + uint8_t flags; // AGI_OBJ_FLAG_* +} AgiObjectT; + + +// ----- Text plane / status line ----- +// +// AGI's text plane is 40 columns x 25 rows of 8x8 character cells. +// Status line (row 0) is reserved for game-managed status text; +// display() writes anywhere by row/col; print() pops a modal window. +#define AGI_TEXT_COLS 40u +#define AGI_TEXT_ROWS 25u + +typedef struct { + char text[AGI_TEXT_COLS + 1u]; // NUL-terminated + uint8_t fg; + uint8_t bg; +} AgiTextLineT; + +#define AGI_PRINT_MAX_LEN 240u + +typedef struct { + bool active; // print modal on screen + uint16_t length; + char message[AGI_PRINT_MAX_LEN + 1u]; +} AgiPrintModalT; + + +// ----- Controllers ----- +// +// set.key binds a (key1, key2) pair to a controller id 0..49. When +// the host dispatches a key matching a binding, the controller's +// "fired this cycle" bit is set. The IF test controller(N) consumes +// the bit so the binding only fires once per press. +#define AGI_MAX_CONTROLLERS 50u +#define AGI_MAX_KEY_BINDINGS 64u + +typedef struct { + uint8_t key1; // ASCII or extended scancode (low byte) + uint8_t key2; // extended high byte (0 for plain ASCII) + uint8_t controller; + uint8_t active; +} AgiKeyBindingT; + + +// ----- Menus ----- +// +// set.menu / set.menu.item populate a 2D menu from message ids; the +// game can later disable individual items. menu.input pops it up; +// the user's choice fires the corresponding controller. Stored as +// state so menu.input can render and return a controller id. +#define AGI_MAX_MENUS 16u +#define AGI_MAX_MENU_ITEMS 16u + +typedef struct { + uint8_t messageId; // logic 0 message id + uint8_t controller; // fired when item selected + uint8_t enabled; +} AgiMenuItemT; + +typedef struct { + uint8_t nameMsgId; + uint8_t itemCount; + AgiMenuItemT items[AGI_MAX_MENU_ITEMS]; +} AgiMenuT; + + +// ----- Strings ----- +// +// set.string / get.string / word.to.string maintain a small pool of +// up to 24 game-defined strings. Each is 40 chars max (one screen row). +#define AGI_MAX_STRINGS 24u +#define AGI_STRING_LEN 40u + + +// Host callbacks invoked from the VM for side-effecting opcodes the +// host owns. The host owns the logic-resource cache and screen +// surfaces; the VM never touches either directly. fetchLogic returns +// the post-header bytecode pointer and its length (the 2-byte LE +// logic header has already been consumed by the host). NULL on +// missing logic causes the VM to halt with AGI_VM_HALT_NO_LOGIC. +// +// haveKey returns true if a keystroke is buffered, consuming it so +// the next call returns false until another key arrives. Used by +// the AGI test command have.key. +// +// fetchMessage returns the *decrypted* bytes of message `msgId` +// from logic `logicId`, or NULL if the message is missing. The +// returned pointer is valid until the next fetchMessage call (host +// may use a single shared scratch buffer). Used by display, print, +// print.at and friends. +typedef struct { + const uint8_t *(*fetchLogic)(void *ctx, uint8_t logicId, uint16_t *outBytecodeLength); + void (*loadPic)(void *ctx, uint8_t picId); + void (*drawPic)(void *ctx, uint8_t picId); + void (*showPic)(void *ctx); + void (*discardPic)(void *ctx, uint8_t picId); + void (*overlayPic)(void *ctx, uint8_t picId); + bool (*haveKey)(void *ctx); + const char *(*fetchMessage)(void *ctx, uint8_t logicId, uint8_t msgId); + void (*addToPic)(void *ctx, uint8_t viewId, uint8_t loop, uint8_t cel, + uint8_t x, uint8_t y, uint8_t pri, uint8_t margin); + // Return the playback duration of sound `soundId` in milliseconds, + // or 0 if the sound is missing/empty. Used only as a fallback + // duration cap when the host can't reliably report playback end; + // when isPlayingSound is wired, the VM ignores this and trusts + // the host's live state instead. + uint32_t (*soundDuration)(void *ctx, uint8_t soundId); + // Start playback of sound `soundId`. Any currently-playing AGI + // sound is replaced. The VM polls isPlayingSound each tick and + // sets the sound-done flag on the true->false transition. + void (*playSound)(void *ctx, uint8_t soundId); + // Stop playback immediately. Called by OP_STOP_SOUND (and any + // path that preempts the current sound). + void (*stopSound)(void *ctx); + // Live host-side playback state. Returns true while audio output + // is active for the currently-armed sound, false once the host + // has run all SND events to completion (or stopSound was called). + // Source of truth for sound completion: matches what the user + // actually hears, so the title sequence advances exactly when + // the music ends. + bool (*isPlayingSound)(void *ctx); + // View metadata for accurate cycling. AGI's cycle/loop opcodes + // (cycle.normal, end.of.loop, last.cel, number.of.loops...) need + // to know the cel count of the current view+loop and the loop + // count of the current view. Without these, end.of.loop fires + // its flag only at the AGI_MAX_CELS_PER_LOOP boundary -- and a + // sparkle that visually has 8 cels but is treated as 32 won't + // signal its end until 4x the intended time, breaking title + // sequences that arm end.of.loop expecting a tight cycle. + // + // Returns 0 if the view/loop is unloaded; callers treat 0 as + // "still running" so a not-yet-loaded view doesn't terminate + // early. Returns the loop count (>=1) or cel count (>=1) when + // the view is available. + uint8_t (*viewLoopCount)(void *ctx, uint8_t viewId); + uint8_t (*viewCelCount)(void *ctx, uint8_t viewId, uint8_t loopId); + void *ctx; +} AgiVmCallbacksT; + +typedef struct { + // ----- core state ----- + uint8_t vars[AGI_VM_NUM_VARS]; + uint8_t flags[AGI_VM_NUM_FLAGS]; + const uint8_t *code; + uint16_t codeLength; + uint16_t pc; + AgiVmHaltE haltReason; + uint8_t lastUnknownOp; + uint8_t newRoomId; + uint8_t currentLogicId; + uint8_t callDepth; + AgiVmFrameT callStack[AGI_VM_CALL_STACK_MAX]; + AgiVmCallbacksT callbacks; + + // ----- objects ----- + AgiObjectT objects[AGI_MAX_OBJECTS]; + + // ----- text plane ----- + AgiTextLineT textRows[AGI_TEXT_ROWS]; + uint8_t textFg; + uint8_t textBg; + bool statusLineOn; + uint8_t statusLineRow; // usually 0 + uint8_t horizon; // any obj y < horizon stops + bool programControl; // true => program controls ego (false = player) + bool acceptInput; // accept.input; toggled by accept/prevent + AgiPrintModalT printModal; + + // ----- controllers + key bindings ----- + AgiKeyBindingT keyBindings[AGI_MAX_KEY_BINDINGS]; + uint8_t controllerFired[AGI_MAX_CONTROLLERS]; + + // ----- menus ----- + AgiMenuT menus[AGI_MAX_MENUS]; + uint8_t menuCount; + uint8_t menuOpen; // 0xFF = not open + + // ----- strings ----- + char strings[AGI_MAX_STRINGS][AGI_STRING_LEN + 1u]; + + // ----- sound playback state ----- + // The VM polls callbacks.isPlayingSound() each tick; when it + // transitions from true to false, the sound-done flags fire. + // soundPlaying tracks whether we are currently waiting on an + // armed sound; soundDoneFlag is the per-sound user flag from + // OP_SOUND's second arg (0 = no user flag, just engine flag 9). + bool soundPlaying; + uint8_t soundDoneFlag; +} AgiVmT; + + +// ----- Resource loader (agiRes.c) ----- + +// Close all volume files; safe to call after a failed open. +void agiResClose(AgiGameT *game); + +// Allocate and return a resource's raw bytes. Caller frees with +// free(). Returns NULL on any failure (slot empty, signature bad, +// I/O error, out of memory). On success *outLength receives the +// resource payload length in bytes. +uint8_t *agiResLoad(const AgiGameT *game, AgiResTypeE type, uint16_t index, uint16_t *outLength); + +// Open an AGI v2 game directory. gameDir is a relative or absolute +// path to a directory containing LOGDIR/PICDIR/VIEWDIR/SNDDIR plus +// VOL.0..VOL.N. Returns true if every directory file and at least +// one VOL.* file opened. Partial-success on missing optional +// volumes; agiResLoad reports the missing-volume case per resource. +bool agiResOpen(AgiGameT *game, const char *gameDir); + + +// ----- Picture decoder (agiPic.c) ----- + +// Allocate the two 160x168 working buffers. Caller must agiPicFree +// before exit. Returns false if either malloc fails. +bool agiPicAlloc(AgiPicT *pic); + +// Pixel-doubled blit of the visual plane onto the stage starting at +// (0, destY). 160 source columns become 320 stage columns; the rows +// copy 1:1 so the output occupies (0, destY) - (319, destY+167). +void agiPicBlit(const AgiPicT *pic, jlSurfaceT *stage, int16_t destY); + +// Reset both planes to their AGI default backgrounds (15 / 4). +void agiPicClear(AgiPicT *pic); + +// Decode an AGI v2 PIC resource into the working buffers. The buffers +// must already be cleared (agiPicClear) and allocated. Returns false +// if the byte stream is malformed (truncated argument, unknown +// opcode); on failure the buffers are left in a partial state. +bool agiPicDecode(AgiPicT *pic, const uint8_t *data, uint16_t length); + +void agiPicFree(AgiPicT *pic); + + +// ----- View decoder (agiView.c) ----- + +// Parse an AGI VIEW resource. Takes ownership of `rawBytes` -- they +// must remain valid until agiViewFree (the view's cel pointers index +// into them). Returns false on malformed data; on failure the buffer +// is freed and `view` is left zeroed. +bool agiViewParse(AgiViewT *view, uint8_t *rawBytes, uint16_t length); + +void agiViewFree(AgiViewT *view); + +// Draw cel (loopIdx, celIdx) of `view` at AGI coordinate (x, y), +// where (x, y) is the bottom-left corner of the cel in 160x168 AGI +// space. Pixels are masked per-pixel by the priority plane: actor +// pixels are skipped where the picture priority exceeds actorPri. +// The blit is pixel-doubled horizontally onto the stage at +// (0, destY) -- pass the same destY used by agiPicBlit. +void agiViewDraw(const AgiViewT *view, uint8_t loopIdx, uint8_t celIdx, + int16_t x, int16_t y, uint8_t actorPri, + const uint8_t *picPriority, + jlSurfaceT *stage, int16_t destY); + + +// ----- LOGIC VM (agiVm.c) ----- + +// Reset every variable and flag to 0 and clear the halt state. +void agiVmInit(AgiVmT *vm); + +// Point the VM at a logic resource's bytecode segment. AGI v2 logic +// resources start with a 2-byte little-endian length for the bytecode +// section followed by the bytecode itself (messages live past it). +// Returns false if the buffer is too short to contain a header. +bool agiVmLoadLogic(AgiVmT *vm, const uint8_t *resourceBytes, uint16_t resourceLength); + +// Reset the VM's program counter to 0 against an already-loaded +// bytecode pointer. Used by the host after handling a halt (room +// transition, frame end) to re-run a logic from the top without +// re-parsing its resource header. +void agiVmResetToLogic(AgiVmT *vm, const uint8_t *bytecode, uint16_t bytecodeLength, uint8_t logicId); + +// Run until the VM hits any halt condition (return, unknown opcode, +// truncated stream, or pending room transition). Returns the halt +// reason; details live in vm->lastUnknownOp / vm->newRoomId / vm->pc. +AgiVmHaltE agiVmRun(AgiVmT *vm); + +// Install host callbacks. May be called before or after agiVmInit; +// pass NULL for any callback the host doesn't implement (the VM will +// treat that opcode as a no-op stub). +void agiVmSetCallbacks(AgiVmT *vm, const AgiVmCallbacksT *callbacks); + +// Advance the AGI engine clock by `seconds` wall-clock seconds. Bumps +// v11 (seconds 0..59), v12 (minutes 0..59) and v13 (hours 0..23) with +// proper roll-over. Game logic uses these to detect per-second ticks +// (KQ3's logic.0 fires a per-second update branch when v11 changes, +// which sets flag 45 -- the gate the title countdown is waiting on). +// Safe to call with seconds=0 (no-op) and with any size up to UINT8_MAX. +void agiVmTickSeconds(AgiVmT *vm, uint8_t seconds); + +// Per-VM-cycle ticker. Called by the host once per game-loop cycle +// (~6 Hz, the AGI interpreter cadence). Advances every animated +// object's cel cycling and motion. Honors per-object stop.cycling / +// stop.motion / stop.update flags. Sets end.of.loop / move.obj.done +// flags as those events fire. +void agiVmTickAnimation(AgiVmT *vm); + +// Notify the VM that the host received key `keyCode` (low 7 bits = +// ASCII; high byte = extended scancode for arrow keys / Fn). Walks +// the key-binding table; if `keyCode` matches a set.key binding, +// the corresponding controller fires. Also dispatches to any open +// menu navigation. Called by the host once per pressed key. +void agiVmDispatchKey(AgiVmT *vm, uint8_t key1, uint8_t key2); + +// Acknowledge an open print() modal so the VM can resume. Called by +// the host when the user presses ENTER / SPACE / etc. while +// vm->printModal.active is true. Clears the modal and the +// AGI_VM_HALT_PRINT_PENDING halt reason. +void agiVmAckPrint(AgiVmT *vm); + + +// ----- Object helpers (agiObj.c) ----- + +// Reset every object slot to the default empty state. Called by +// agiVmInit and again on every NEW_ROOM transition (matches AGI's +// canonical room-change reset). +void agiObjResetAll(AgiVmT *vm); + +// Single-object reset (animate.obj reuses this). +void agiObjReset(AgiObjectT *obj); + + +// ----- Text helpers (agiText.c) ----- + +// Fetch the host font surface (built lazily on first call). Returned +// surface contains 96 ASCII glyphs (codes 32..127) laid out 16 wide +// by 6 tall. Caller must NOT free. +const jlSurfaceT *agiTextFontSurface(void); + +// asciiMap suitable for jlDrawText against the font surface above. +const uint16_t *agiTextAsciiMap(void); + +// Render the VM's text plane (status line + display rows + open +// print modal) onto `stage`. Called by host after the picture + +// objects are drawn. +void agiTextRender(const AgiVmT *vm, jlSurfaceT *stage); + + +// ----- PIC compositing (agiPic.c addendum) ----- + +// Bake a VIEW cel into the PIC visual + priority planes (the +// implementation of add.to.pic). priColor 4..15 sets the priority +// pixels; priColor == 0 means "use the priority of the actor's Y +// band" (AGI default). margin 0..3 reserves a control-line band at +// the cel's bottom so add.to.pic art can suggest a walkable edge; +// margin 4 means "no margin". +void agiPicAddView(AgiPicT *pic, const AgiViewT *view, + uint8_t loop, uint8_t cel, + int16_t x, int16_t y, + uint8_t priColor, uint8_t margin); + +#endif diff --git a/agiObj.c b/agiObj.c new file mode 100644 index 0000000..2401df9 --- /dev/null +++ b/agiObj.c @@ -0,0 +1,368 @@ +// AGI v2 animated-object table: state lifecycle + per-cycle ticks. +// +// The VM stores an AgiObjectT[16] inside AgiVmT; opcode handlers in +// agiVm.c mutate fields directly (set.view / position / step.size / +// etc.) and the host renders from the table each frame. This file +// owns the housekeeping the dispatcher would otherwise duplicate +// across many opcodes: +// +// * reset on construction and on NEW_ROOM +// * per-cycle cel cycling honoring start/stop.cycling, end.of.loop +// * per-cycle motion: normal / wander / follow.ego / move.obj +// +// Every algorithm here is a re-implementation against the published +// AGI v2 actor-cycle / actor-motion spec; no third-party source is +// referenced. + +#include "agi.h" + +#include "joey/core.h" +#include "joey/debug.h" + +#include +#include + + +#ifdef JOEYLIB_PLATFORM_IIGS +segment "AGIOBJ"; +#endif + + +// AGI direction encoding. 0 means "no motion this cycle"; 1..8 +// circle clockwise starting at north. +// 1=N 2=NE 3=E 4=SE +// 5=S 6=SW 7=W 8=NW +#define AGI_DIR_NONE 0u +#define AGI_DIR_N 1u +#define AGI_DIR_NE 2u +#define AGI_DIR_E 3u +#define AGI_DIR_SE 4u +#define AGI_DIR_S 5u +#define AGI_DIR_SW 6u +#define AGI_DIR_W 7u +#define AGI_DIR_NW 8u +#define AGI_DIR_COUNT 9u + +#define WANDER_TICKS_MIN 4u +#define WANDER_TICKS_MAX 24u +#define MOVE_REACH_PX 2 + + +// dx, dy per direction (subscript 0 unused; AGI directions are 1..8). +static const int8_t kDirDx[AGI_DIR_COUNT] = { 0, 0, +1, +1, +1, 0, -1, -1, -1 }; +static const int8_t kDirDy[AGI_DIR_COUNT] = { 0, -1, -1, 0, +1, +1, +1, 0, -1 }; + + +// ----- Prototypes ----- + +static void advanceCycle(AgiVmT *vm, uint8_t objId); +static void advanceMotion(AgiVmT *vm, uint8_t objId); +static uint8_t celCount(const AgiVmT *vm, const AgiObjectT *obj); +static uint8_t directionToward(int16_t fromX, int16_t fromY, int16_t toX, int16_t toY); + + +// ----- Internal helpers (alphabetical) ----- + +static void advanceCycle(AgiVmT *vm, uint8_t objId) { + AgiObjectT *obj; + uint8_t cels; + uint8_t next; + + obj = &vm->objects[objId]; + if ((obj->flags & AGI_OBJ_FLAG_ANIMATED) == 0u) { + return; + } + if ((obj->flags & AGI_OBJ_FLAG_CYCLING) == 0u) { + return; + } + // Erased objects don't cycle. KQ3 LOGIC.45 sparkle pattern depends + // on this: erase(1..4) at wizard-scene start leaves their CYCLING + // flag set (LOGIC.45 never explicitly stops it), but Sierra's + // interpreter freezes the cel until the next draw(). Without this + // gate, end.of.loop fires for invisible objs, sets flag 221..224, + // and the sparkle loop kicks back in on the wizard pic. + if ((obj->flags & AGI_OBJ_FLAG_DRAWN) == 0u) { + return; + } + // stop.update freezes the obj's displayed cel until start.update. + // Canonical AGI cycles cels internally regardless, but with no + // display refresh the visual stays put. Since our host renderer + // always paints from obj->cel, the easiest faithful approximation + // is to also stop advancing cel while !UPDATING -- otherwise + // poses cycle visibly (e.g. Manannan's body in room 46, which is + // stop.update'd at init and only released to start.update at a + // specific state, would otherwise wave its hand continuously). + if ((obj->flags & AGI_OBJ_FLAG_UPDATING) == 0u) { + return; + } + if (obj->cycleTime == 0u) { + return; + } + obj->cycleTick++; + if (obj->cycleTick < obj->cycleTime) { + return; + } + obj->cycleTick = 0u; + + cels = celCount(vm, obj); + if (cels <= 1u) { + return; + } + + switch (obj->cycleMode) { + case AGI_CYCLE_NORMAL: + next = (uint8_t)((obj->cel + 1u) % cels); + obj->cel = next; + break; + case AGI_CYCLE_REVERSE: + obj->cel = (obj->cel == 0u) ? (uint8_t)(cels - 1u) : (uint8_t)(obj->cel - 1u); + break; + case AGI_CYCLE_END_LOOP: + if (obj->cel + 1u >= cels) { + // reached last frame, fire end-of-loop + obj->flags &= (uint8_t)~AGI_OBJ_FLAG_CYCLING; + if (obj->endOfLoopFlag != 0u) { + vm->flags[obj->endOfLoopFlag] = 1u; + } + } else { + obj->cel++; + } + break; + case AGI_CYCLE_REV_LOOP: + if (obj->cel == 0u) { + obj->flags &= (uint8_t)~AGI_OBJ_FLAG_CYCLING; + if (obj->endOfLoopFlag != 0u) { + vm->flags[obj->endOfLoopFlag] = 1u; + } + } else { + obj->cel--; + } + break; + default: + break; + } +} + + +static void advanceMotion(AgiVmT *vm, uint8_t objId) { + AgiObjectT *obj; + int16_t newX; + int16_t newY; + uint8_t dir; + int8_t dx; + int8_t dy; + + obj = &vm->objects[objId]; + if ((obj->flags & AGI_OBJ_FLAG_ANIMATED) == 0u) { + return; + } + if ((obj->flags & AGI_OBJ_FLAG_DRAWN) == 0u) { + return; + } + if (obj->stepSize == 0u) { + return; + } + if (obj->stepTime == 0u) { + return; + } + obj->stepTick++; + if (obj->stepTick < obj->stepTime) { + return; + } + obj->stepTick = 0u; + + switch (obj->motionType) { + case AGI_MOTION_WANDER: + if (obj->wanderTick == 0u) { + obj->direction = (uint8_t)(jlRandomRange((uint16_t)AGI_DIR_COUNT)); + obj->wanderTick = (uint8_t)(WANDER_TICKS_MIN + jlRandomRange((uint16_t)(WANDER_TICKS_MAX - WANDER_TICKS_MIN))); + } else { + obj->wanderTick--; + } + break; + case AGI_MOTION_FOLLOW_EGO: + obj->direction = directionToward(obj->x, obj->y, vm->objects[0].x, vm->objects[0].y); + break; + case AGI_MOTION_MOVE_OBJ: { + int16_t reachDx; + int16_t reachDy; + + reachDx = (int16_t)(obj->targetX - obj->x); + reachDy = (int16_t)(obj->targetY - obj->y); + if (reachDx < 0) { + reachDx = (int16_t)(-reachDx); + } + if (reachDy < 0) { + reachDy = (int16_t)(-reachDy); + } + if (reachDx <= MOVE_REACH_PX && reachDy <= MOVE_REACH_PX) { + obj->x = obj->targetX; + obj->y = obj->targetY; + obj->direction = AGI_DIR_NONE; + obj->motionType = AGI_MOTION_NORMAL; + obj->stepSize = obj->moveStepBackup; + if (obj->moveDoneFlag != 0u) { + vm->flags[obj->moveDoneFlag] = 1u; + } + return; + } + obj->direction = directionToward(obj->x, obj->y, obj->targetX, obj->targetY); + break; + } + case AGI_MOTION_NORMAL: + default: + // Direction is whatever the script (or var 6 for ego) set. + break; + } + + dir = obj->direction; + if (dir == AGI_DIR_NONE || dir >= AGI_DIR_COUNT) { + return; + } + dx = kDirDx[dir]; + dy = kDirDy[dir]; + newX = (int16_t)(obj->x + (int16_t)((int16_t)dx * (int16_t)obj->stepSize)); + newY = (int16_t)(obj->y + (int16_t)((int16_t)dy * (int16_t)obj->stepSize)); + + // Clamp to AGI's canonical 160x168 picture window. The horizon + // applies only to objects observing it. + if (newX < 0) { + newX = 0; + } + if (newX > (int16_t)(AGI_PIC_WIDTH - 1)) { + newX = (int16_t)(AGI_PIC_WIDTH - 1); + } + if (newY > (int16_t)(AGI_PIC_HEIGHT - 1)) { + newY = (int16_t)(AGI_PIC_HEIGHT - 1); + } + if ((obj->flags & AGI_OBJ_FLAG_HORIZON_IGN) == 0u && newY < (int16_t)vm->horizon) { + newY = (int16_t)vm->horizon; + } + if (newY < 0) { + newY = 0; + } + obj->x = newX; + obj->y = newY; +} + + +// Without a view-id-to-loop-cel-count map in the VM, fall back to an +// Asks the host for the actual cel count of obj's view+loop via +// the viewCelCount callback. Falls back to AGI_MAX_CELS_PER_LOOP +// only when no callback is wired (legacy host) -- but cycling +// is broken in that mode because end.of.loop won't fire at the +// correct cel. A 0 return from the callback means "view not +// loaded yet"; we report 0 and the caller's `cels <= 1u` guard +// pauses cycling until the load completes. +static uint8_t celCount(const AgiVmT *vm, const AgiObjectT *obj) { + if (vm->callbacks.viewCelCount != NULL) { + return vm->callbacks.viewCelCount(vm->callbacks.ctx, obj->viewId, obj->loop); + } + return AGI_MAX_CELS_PER_LOOP; +} + + +// 8-direction snap by quadrant + axis-dominance. +static uint8_t directionToward(int16_t fromX, int16_t fromY, int16_t toX, int16_t toY) { + int16_t dx; + int16_t dy; + int16_t ax; + int16_t ay; + + dx = (int16_t)(toX - fromX); + dy = (int16_t)(toY - fromY); + if (dx == 0 && dy == 0) { + return AGI_DIR_NONE; + } + ax = (dx < 0) ? (int16_t)(-dx) : dx; + ay = (dy < 0) ? (int16_t)(-dy) : dy; + if (ax > ay * 2) { + return (dx > 0) ? AGI_DIR_E : AGI_DIR_W; + } + if (ay > ax * 2) { + return (dy > 0) ? AGI_DIR_S : AGI_DIR_N; + } + if (dx > 0) { + return (dy > 0) ? AGI_DIR_SE : AGI_DIR_NE; + } + return (dy > 0) ? AGI_DIR_SW : AGI_DIR_NW; +} + + +// ----- Public API (alphabetical) ----- + +void agiObjReset(AgiObjectT *obj) { + memset(obj, 0, sizeof(*obj)); + obj->stepSize = 1u; + obj->stepTime = 1u; + obj->cycleTime = 1u; + obj->priority = 0u; // 0 = "auto-Y-band" (canonical AGI) + obj->direction = AGI_DIR_NONE; + obj->cycleMode = AGI_CYCLE_NORMAL; + obj->motionType = AGI_MOTION_NORMAL; + obj->endOfLoopFlag = 0u; + obj->moveDoneFlag = 0u; + obj->followDoneFlag = 0u; +} + + +void agiObjResetAll(AgiVmT *vm) { + uint8_t i; + + for (i = 0u; i < (uint8_t)AGI_MAX_OBJECTS; i++) { + agiObjReset(&vm->objects[i]); + } +} + + +// Called by VM core after each VM cycle returns to the host (or by +// the host directly each game-loop cycle). Walks the object table +// and advances cycling + motion. Also polls the host for sound +// completion so the sound-done flag fires on the natural-end of +// playback rather than a pre-computed deadline. +void agiVmTickAnimation(AgiVmT *vm) { + uint8_t i; + + // Sound poll: fire done-flags on the host's playback->silent + // edge. Runs every VM cycle so OP_IF wait loops see the flag + // within one cycle of actual audio end. + if (vm->soundPlaying && vm->callbacks.isPlayingSound != NULL) { + if (!vm->callbacks.isPlayingSound(vm->callbacks.ctx)) { + jlLogF("sound-done flag fires: flag=%u (v11=%u)", + (unsigned)vm->soundDoneFlag, (unsigned)vm->vars[11]); + jlLogFlush(); + if (vm->soundDoneFlag != 0u) { + vm->flags[vm->soundDoneFlag] = 1u; + } + vm->flags[9] = 1u; + vm->soundPlaying = false; + } + } + + for (i = 0u; i < (uint8_t)AGI_MAX_OBJECTS; i++) { + AgiObjectT *obj; + + obj = &vm->objects[i]; + if ((obj->flags & AGI_OBJ_FLAG_ANIMATED) == 0u) { + continue; + } + // Snapshot current state so the host renderer can restore the + // last frame's pixels under each obj before re-drawing this + // frame. Done before the tick so prevX/prevY reflect what was + // actually painted last frame. + obj->prevX = obj->x; + obj->prevY = obj->y; + obj->prevLoop = obj->loop; + obj->prevCel = obj->cel; + obj->prevView = obj->viewId; + + advanceCycle(vm, i); + advanceMotion(vm, i); + } + // Mirror ego state into AGI's well-known engine vars so game + // scripts that probe v6 (ego dir) / current.view / get.posn + // see the right values without a per-opcode getter call. + if ((vm->objects[0].flags & AGI_OBJ_FLAG_ANIMATED) != 0u) { + vm->vars[6] = vm->objects[0].direction; + } +} diff --git a/agiPic.c b/agiPic.c new file mode 100644 index 0000000..518ca3c --- /dev/null +++ b/agiPic.c @@ -0,0 +1,662 @@ +// AGI v2 PIC (picture / background) decoder. +// +// Paints to two private 160x168 chunky 8bpp buffers (visual + priority). +// The blit step doubles each AGI source column into two stage columns +// so the natural 160x168 internal coordinate system can be used by the +// drawing primitives, and the 320-wide stage receives the canonical +// AGI look. +// +// All algorithms here -- line interpolation, corner drawing, scanline +// flood fill, pen plotting, opcode dispatch -- are re-implementations +// written against the published AGI v2 picture-stream specification. +// No third-party source is referenced. + +#include "agi.h" + +#include "joey/draw.h" +#include "surfaceInternal.h" + +#include +#include +#include + + +// PIC opcodes. Anything < 0xF0 is an argument byte; >= 0xF0 ends the +// current opcode's argument list. +#define OP_FIRST 0xF0u +#define OP_SET_PIC_COLOR 0xF0u +#define OP_DISABLE_PIC 0xF1u +#define OP_SET_PRI_COLOR 0xF2u +#define OP_DISABLE_PRI 0xF3u +#define OP_Y_CORNER 0xF4u +#define OP_X_CORNER 0xF5u +#define OP_ABS_LINE 0xF6u +#define OP_REL_LINE 0xF7u +#define OP_FILL 0xF8u +#define OP_SET_PEN 0xF9u +#define OP_PLOT_PEN 0xFAu +#define OP_END 0xFFu + +// Pen control byte bits (set via OP_SET_PEN, consumed by OP_PLOT_PEN). +#define PEN_PATTERN_BIT 0x20u + +// Scanline-fill seed stack. Each fill is reentrant-free, so a single +// static stack serves both the visual and priority passes. The bound +// is a safe over-estimate of typical PIC fill complexity; KQ3 PICs +// don't approach it. +#define FILL_STACK_MAX 512 + + +typedef struct { + uint8_t visualEnabled; + uint8_t priorityEnabled; + uint8_t visualColor; + uint8_t priorityColor; + uint8_t penPattern; +} PicStateT; + + +// AGI 16-color CGA-derived palette. Entry 6's (R=A, G=5, B=0) keeps the +// classic CGA "brown fix" -- pure (R=A, G=A, B=0) would read as a vivid +// yellow-green on the host, which is wrong for AGI's intended look. +const uint16_t kAgiPalette[16] = { + 0x000u, 0x00Au, 0x0A0u, 0x0AAu, + 0xA00u, 0xA0Au, 0xA50u, 0xAAAu, + 0x555u, 0x55Fu, 0x5F5u, 0x5FFu, + 0xF55u, 0xF5Fu, 0xFF5u, 0xFFFu +}; + + +// ----- Prototypes ----- + +static void drawCorner(AgiPicT *pic, const PicStateT *state, const uint8_t *data, uint16_t length, uint16_t *pos, bool yFirst); +static void drawLine(AgiPicT *pic, const PicStateT *state, int16_t x0, int16_t y0, int16_t x1, int16_t y1); +static void fillPlane(uint8_t *plane, int16_t x, int16_t y, uint8_t bgValue, uint8_t newValue); +static bool isOpcode(uint8_t byte); +static void opAbsLine(AgiPicT *pic, const PicStateT *state, const uint8_t *data, uint16_t length, uint16_t *pos); +static void opFill(AgiPicT *pic, const PicStateT *state, const uint8_t *data, uint16_t length, uint16_t *pos); +static void opPlotPen(AgiPicT *pic, const PicStateT *state, const uint8_t *data, uint16_t length, uint16_t *pos); +static void opRelLine(AgiPicT *pic, const PicStateT *state, const uint8_t *data, uint16_t length, uint16_t *pos); +static void plot(AgiPicT *pic, const PicStateT *state, int16_t x, int16_t y); + + +#ifdef JOEYLIB_PLATFORM_IIGS +segment "AGIPIC"; +#endif + +// ----- Internal helpers (alphabetical) ----- + +static void drawCorner(AgiPicT *pic, const PicStateT *state, const uint8_t *data, uint16_t length, uint16_t *pos, bool yFirst) { + uint16_t p; + int16_t curX; + int16_t curY; + bool readY; + + p = *pos; + if (p + 1 >= length) { + *pos = length; + return; + } + curX = (int16_t)data[p++]; + curY = (int16_t)data[p++]; + plot(pic, state, curX, curY); + + readY = yFirst; + while (p < length && !isOpcode(data[p])) { + if (readY) { + int16_t newY = (int16_t)data[p++]; + drawLine(pic, state, curX, curY, curX, newY); + curY = newY; + } else { + int16_t newX = (int16_t)data[p++]; + drawLine(pic, state, curX, curY, newX, curY); + curX = newX; + } + readY = !readY; + } + *pos = p; +} + + +// Documented AGI line algorithm. Major axis steps in unit increments; +// the minor axis is linearly interpolated with round-half-away-from- +// zero. All math stays in 16-bit -- AGI coordinates are 8-bit so the +// worst product (dy * i = 167 * 167 = 27889) fits in int16, and the +// 65816 build avoids ORCA-C's long-arithmetic helpers entirely. +static void drawLine(AgiPicT *pic, const PicStateT *state, int16_t x0, int16_t y0, int16_t x1, int16_t y1) { + int16_t dx; + int16_t dy; + int16_t absDx; + int16_t absDy; + int16_t steps; + int16_t i; + int16_t half; + int16_t px; + int16_t py; + int16_t sx; + int16_t sy; + + dx = (int16_t)(x1 - x0); + dy = (int16_t)(y1 - y0); + if (dx == 0 && dy == 0) { + plot(pic, state, x0, y0); + return; + } + + absDx = (int16_t)((dx >= 0) ? dx : -dx); + absDy = (int16_t)((dy >= 0) ? dy : -dy); + sx = (int16_t)((dx >= 0) ? 1 : -1); + sy = (int16_t)((dy >= 0) ? 1 : -1); + + if (absDx >= absDy) { + steps = absDx; + half = (int16_t)(steps / 2); + for (i = 0; i <= steps; i++) { + px = (int16_t)(x0 + sx * i); + py = (int16_t)(y0 + (dy * i + sy * half) / steps); + plot(pic, state, px, py); + } + } else { + steps = absDy; + half = (int16_t)(steps / 2); + for (i = 0; i <= steps; i++) { + py = (int16_t)(y0 + sy * i); + px = (int16_t)(x0 + (dx * i + sx * half) / steps); + plot(pic, state, px, py); + } + } +} + + +// Scanline flood fill on one plane. Replaces every cell of value +// `bgValue` 4-connected to (x, y) with `newValue`. The seed stack is +// bounded; runs that don't fit are silently dropped, which mirrors +// the original interpreter's behavior under pathological PIC inputs. +static void fillPlane(uint8_t *plane, int16_t x, int16_t y, uint8_t bgValue, uint8_t newValue) { + static int16_t stackX[FILL_STACK_MAX]; + static int16_t stackY[FILL_STACK_MAX]; + uint16_t sp; + int16_t cx; + int16_t cy; + int16_t left; + int16_t right; + int16_t scanX; + bool aboveOpen; + bool belowOpen; + + if (x < 0 || x >= AGI_PIC_WIDTH || y < 0 || y >= AGI_PIC_HEIGHT) { + return; + } + if (plane[y * AGI_PIC_WIDTH + x] != bgValue) { + return; + } + if (bgValue == newValue) { + return; + } + + sp = 0; + stackX[sp] = x; + stackY[sp] = y; + sp++; + + while (sp > 0u) { + sp--; + cx = stackX[sp]; + cy = stackY[sp]; + + if (plane[cy * AGI_PIC_WIDTH + cx] != bgValue) { + continue; + } + + // Expand the current row to the bg run's full extent. + left = cx; + while (left > 0 && plane[cy * AGI_PIC_WIDTH + (left - 1)] == bgValue) { + left--; + } + right = cx; + while (right < (int16_t)(AGI_PIC_WIDTH - 1) && plane[cy * AGI_PIC_WIDTH + (right + 1)] == bgValue) { + right++; + } + for (scanX = left; scanX <= right; scanX++) { + plane[cy * AGI_PIC_WIDTH + scanX] = newValue; + } + + // Push one seed per bg run on the row above. + if (cy > 0) { + aboveOpen = false; + for (scanX = left; scanX <= right; scanX++) { + if (plane[(cy - 1) * AGI_PIC_WIDTH + scanX] == bgValue) { + if (!aboveOpen) { + if (sp < FILL_STACK_MAX) { + stackX[sp] = scanX; + stackY[sp] = (int16_t)(cy - 1); + sp++; + } + aboveOpen = true; + } + } else { + aboveOpen = false; + } + } + } + // And one seed per bg run on the row below. + if (cy < (int16_t)(AGI_PIC_HEIGHT - 1)) { + belowOpen = false; + for (scanX = left; scanX <= right; scanX++) { + if (plane[(cy + 1) * AGI_PIC_WIDTH + scanX] == bgValue) { + if (!belowOpen) { + if (sp < FILL_STACK_MAX) { + stackX[sp] = scanX; + stackY[sp] = (int16_t)(cy + 1); + sp++; + } + belowOpen = true; + } + } else { + belowOpen = false; + } + } + } + } +} + + +static bool isOpcode(uint8_t byte) { + return byte >= OP_FIRST; +} + + +static void opAbsLine(AgiPicT *pic, const PicStateT *state, const uint8_t *data, uint16_t length, uint16_t *pos) { + uint16_t p; + int16_t prevX; + int16_t prevY; + int16_t curX; + int16_t curY; + bool have; + + p = *pos; + have = false; + prevX = 0; + prevY = 0; + while (p + 1 < length && !isOpcode(data[p])) { + curX = (int16_t)data[p++]; + curY = (int16_t)data[p++]; + if (!have) { + plot(pic, state, curX, curY); + have = true; + } else { + drawLine(pic, state, prevX, prevY, curX, curY); + } + prevX = curX; + prevY = curY; + } + *pos = p; +} + + +static void opFill(AgiPicT *pic, const PicStateT *state, const uint8_t *data, uint16_t length, uint16_t *pos) { + uint16_t p; + int16_t x; + int16_t y; + + p = *pos; + while (p + 1 < length && !isOpcode(data[p])) { + x = (int16_t)data[p++]; + y = (int16_t)data[p++]; + if (state->visualEnabled) { + fillPlane(pic->visual, x, y, AGI_VISUAL_BG, state->visualColor); + } + if (state->priorityEnabled) { + fillPlane(pic->priority, x, y, AGI_PRIORITY_BG, state->priorityColor); + } + } + *pos = p; +} + + +// Plot-with-pen. With pen patterning off, each iteration consumes an +// X,Y pair and plots a single pixel. With patterning on, each +// iteration consumes a texture byte first (we discard it -- proper +// patterned brushes are deferred to a later pass). Size and shape +// are likewise stubbed to single-pixel; KQ3 PICs use pens sparingly +// and dropping the brush footprint just thins occasional grass / sky +// dithering. +static void opPlotPen(AgiPicT *pic, const PicStateT *state, const uint8_t *data, uint16_t length, uint16_t *pos) { + uint16_t p; + int16_t x; + int16_t y; + + p = *pos; + while (p < length && !isOpcode(data[p])) { + if (state->penPattern) { + // Skip the texture-selector byte. + p++; + if (p >= length || isOpcode(data[p])) { + break; + } + } + if (p + 1 >= length) { + break; + } + x = (int16_t)data[p++]; + y = (int16_t)data[p++]; + plot(pic, state, x, y); + } + *pos = p; +} + + +static void opRelLine(AgiPicT *pic, const PicStateT *state, const uint8_t *data, uint16_t length, uint16_t *pos) { + uint16_t p; + int16_t curX; + int16_t curY; + int16_t newX; + int16_t newY; + uint8_t disp; + int16_t dx; + int16_t dy; + + p = *pos; + if (p + 1 >= length) { + *pos = p; + return; + } + curX = (int16_t)data[p++]; + curY = (int16_t)data[p++]; + plot(pic, state, curX, curY); + + while (p < length && !isOpcode(data[p])) { + disp = data[p++]; + // High nibble = X disp (bit 7 sign, bits 4-6 magnitude 0..7). + // Low nibble = Y disp (bit 3 sign, bits 0-2 magnitude 0..7). + dx = (int16_t)((disp >> 4) & 0x07u); + if (disp & 0x80u) { + dx = (int16_t)-dx; + } + dy = (int16_t)(disp & 0x07u); + if (disp & 0x08u) { + dy = (int16_t)-dy; + } + newX = (int16_t)(curX + dx); + newY = (int16_t)(curY + dy); + drawLine(pic, state, curX, curY, newX, newY); + curX = newX; + curY = newY; + } + *pos = p; +} + + +static void plot(AgiPicT *pic, const PicStateT *state, int16_t x, int16_t y) { + uint16_t idx; + + if (x < 0 || x >= (int16_t)AGI_PIC_WIDTH) { + return; + } + if (y < 0 || y >= (int16_t)AGI_PIC_HEIGHT) { + return; + } + idx = (uint16_t)(y * (int16_t)AGI_PIC_WIDTH + x); + if (state->visualEnabled) { + pic->visual[idx] = state->visualColor; + } + if (state->priorityEnabled) { + pic->priority[idx] = state->priorityColor; + } +} + + +// ----- Public API (alphabetical) ----- + +bool agiPicAlloc(AgiPicT *pic) { + pic->visual = (uint8_t *)malloc(AGI_PIC_PIXELS); + pic->priority = (uint8_t *)malloc(AGI_PIC_PIXELS); + if (pic->visual == NULL || pic->priority == NULL) { + agiPicFree(pic); + return false; + } + return true; +} + + +void agiPicBlit(const AgiPicT *pic, jlSurfaceT *stage, int16_t destY) { + int16_t sy; + int16_t sx; + int16_t dx; + uint8_t color; + + // Fast path: chunky shadow exists -> write packed bytes direct, + // mark the whole region dirty once at the end. ~30x faster than + // calling jlDrawPixel 53,760 times (each does bounds + 1-pixel + // dirty mark). Required for state-2 / state-3 title transitions + // to render under a second on period-correct DOSBox CPU cycles. + if (stage != NULL && stage->pixels != NULL) { + const uint8_t *src = pic->visual; + uint8_t *dst; + int16_t rowOff; + + for (sy = 0; sy < (int16_t)AGI_PIC_HEIGHT; sy++) { + rowOff = (int16_t)((int16_t)(destY + sy) * (int16_t)SURFACE_BYTES_PER_ROW); + dst = &stage->pixels[rowOff]; + for (sx = 0; sx < (int16_t)AGI_PIC_WIDTH; sx++) { + color = (uint8_t)(src[sy * AGI_PIC_WIDTH + sx] & 0x0Fu); + // Pixel-doubled: each source pixel = 1 dst byte + // (both nibbles = same color). + dst[sx] = (uint8_t)((color << 4) | color); + } + } + surfaceMarkDirtyRect(stage, 0, destY, + (int16_t)SURFACE_WIDTH, (int16_t)AGI_PIC_HEIGHT); + return; + } + + // Planar fallback (s->pixels NULL: Phase-9 Amiga). Goes through + // jlDrawPixel so the port's c2p / plane-sync path stays correct. + for (sy = 0; sy < (int16_t)AGI_PIC_HEIGHT; sy++) { + for (sx = 0; sx < (int16_t)AGI_PIC_WIDTH; sx++) { + color = pic->visual[sy * AGI_PIC_WIDTH + sx]; + dx = (int16_t)(sx << 1); + jlDrawPixel(stage, dx, (int16_t)(destY + sy), color); + jlDrawPixel(stage, (int16_t)(dx + 1), (int16_t)(destY + sy), color); + } + } +} + + +void agiPicClear(AgiPicT *pic) { + if (pic->visual != NULL) { + memset(pic->visual, AGI_VISUAL_BG, AGI_PIC_PIXELS); + } + if (pic->priority != NULL) { + memset(pic->priority, AGI_PRIORITY_BG, AGI_PIC_PIXELS); + } +} + + +bool agiPicDecode(AgiPicT *pic, const uint8_t *data, uint16_t length) { + PicStateT state; + uint16_t pos; + uint8_t op; + + state.visualEnabled = 0u; + state.priorityEnabled = 0u; + state.visualColor = 0u; + state.priorityColor = 0u; + state.penPattern = 0u; + + pos = 0u; + while (pos < length) { + op = data[pos++]; + switch (op) { + case OP_SET_PIC_COLOR: + if (pos >= length) { + return false; + } + state.visualColor = data[pos++]; + state.visualEnabled = 1u; + break; + case OP_DISABLE_PIC: + state.visualEnabled = 0u; + break; + case OP_SET_PRI_COLOR: + if (pos >= length) { + return false; + } + state.priorityColor = data[pos++]; + state.priorityEnabled = 1u; + break; + case OP_DISABLE_PRI: + state.priorityEnabled = 0u; + break; + case OP_Y_CORNER: + drawCorner(pic, &state, data, length, &pos, true); + break; + case OP_X_CORNER: + drawCorner(pic, &state, data, length, &pos, false); + break; + case OP_ABS_LINE: + opAbsLine(pic, &state, data, length, &pos); + break; + case OP_REL_LINE: + opRelLine(pic, &state, data, length, &pos); + break; + case OP_FILL: + opFill(pic, &state, data, length, &pos); + break; + case OP_SET_PEN: + if (pos >= length) { + return false; + } + state.penPattern = (uint8_t)((data[pos++] & PEN_PATTERN_BIT) ? 1u : 0u); + break; + case OP_PLOT_PEN: + opPlotPen(pic, &state, data, length, &pos); + break; + case OP_END: + return true; + default: + // Unknown opcode -- stop cleanly rather than misparsing. + return false; + } + } + return true; +} + + +// Bake a single VIEW cel into the visual + priority planes (the +// implementation of add.to.pic). Pixels outside the picture window +// or set to the cel's transparent color are skipped. priColor in +// 4..15 overwrites the priority pixels where the visual is opaque; +// 0 preserves the existing priority. RLE format matches agiViewDraw +// (per-row 0-byte EOR marker after a variable run). +void agiPicAddView(AgiPicT *pic, const AgiViewT *view, + uint8_t loop, uint8_t cel, + int16_t baseX, int16_t baseY, + uint8_t priColor, uint8_t margin) { + const AgiCelInfoT *info; + const AgiCelInfoT *resolved; + const uint8_t *rle; + uint8_t actualLoop; + int16_t width; + int16_t height; + uint8_t trans; + bool mirrored; + int16_t topY; + int16_t cy; + int16_t cx; + int16_t drawCx; + int16_t agiX; + int16_t agiY; + uint8_t rleByte; + uint8_t color; + uint8_t runLen; + uint8_t r; + + (void)margin; + if (view == NULL || pic == NULL || pic->visual == NULL) { + return; + } + if (loop >= view->loopCount) { + return; + } + if (cel >= view->loops[loop].celCount) { + return; + } + info = &view->loops[loop].cels[cel]; + actualLoop = loop; + mirrored = false; + if (info->mirrored != 0u) { + if (info->mirrorSourceLoop != loop && info->mirrorSourceLoop < view->loopCount + && cel < view->loops[info->mirrorSourceLoop].celCount) { + actualLoop = info->mirrorSourceLoop; + mirrored = true; + } + } + resolved = &view->loops[actualLoop].cels[cel]; + + width = (int16_t)resolved->width; + height = (int16_t)resolved->height; + trans = resolved->transparentColor; + rle = resolved->rleData; + topY = (int16_t)(baseY - height + 1); + + for (cy = 0; cy < height; cy++) { + cx = 0; + for (;;) { + rleByte = *rle++; + if (rleByte == 0u) { + // End-of-row marker. + break; + } + color = (uint8_t)(rleByte >> 4); + runLen = (uint8_t)(rleByte & 0x0Fu); + for (r = 0u; r < runLen; r++) { + if (cx >= width) { + break; + } + if (color != trans) { + drawCx = mirrored ? (int16_t)(width - 1 - cx) : cx; + agiX = (int16_t)(baseX + drawCx); + agiY = (int16_t)(topY + cy); + if (agiX >= 0 && agiX < (int16_t)AGI_PIC_WIDTH && agiY >= 0 && agiY < (int16_t)AGI_PIC_HEIGHT) { + int16_t idx = (int16_t)(agiY * (int16_t)AGI_PIC_WIDTH + agiX); + bool paint = true; + + // AGI v2 priority masking: when priColor is a + // real priority value (>= AGI_PRIORITY_BG), + // skip the pixel if the existing priority is + // strictly higher -- the new art is behind + // existing higher-priority art. This is how + // KQ3's title "III" ends up behind the KQ + // logo (III priColor=4 = background, KQ logo + // in PIC 45 sits at a higher band). + if (priColor >= AGI_PRIORITY_BG && priColor < 16u && pic->priority != NULL) { + if (pic->priority[idx] > priColor) { + paint = false; + } + } + if (paint) { + pic->visual[idx] = color; + if (priColor >= AGI_PRIORITY_BG && priColor < 16u && pic->priority != NULL) { + pic->priority[idx] = priColor; + } + } + } + } + cx++; + } + } + } +} + + +void agiPicFree(AgiPicT *pic) { + if (pic->visual != NULL) { + free(pic->visual); + pic->visual = NULL; + } + if (pic->priority != NULL) { + free(pic->priority); + pic->priority = NULL; + } +} diff --git a/agiRes.c b/agiRes.c new file mode 100644 index 0000000..78ac34b --- /dev/null +++ b/agiRes.c @@ -0,0 +1,309 @@ +// AGI v2 resource loader. +// +// Parses LOGDIR/PICDIR/VIEWDIR/SNDDIR into in-RAM index tables, keeps +// every VOL.x file open for the session, and exposes a single +// resource-by-id load primitive that hands back a freshly-allocated +// payload buffer. +// +// AGI v2 directory entry format (3 bytes, big-endian within the byte +// stream): byte0 high nibble is the volume number, the 20-bit value +// formed from (byte0 & 0x0F):byte1:byte2 is the file offset. All-FF +// means the slot is empty (no resource with that ID). +// +// VOL.x record header (5 bytes at the directory's offset): 0x12 0x34 +// signature, then the volume number, then a little-endian 16-bit +// payload length. v2 payloads are stored raw; v3 sets bit 7 of the +// volume byte to flag LZW compression (not handled here). + +#include "agi.h" + +#include +#include +#include +#include + + +// AGI directory files are flat arrays of 3-byte entries. The maximum +// directory length the loader will accept is bounded by AGI_MAX_- +// RESOURCES; larger files are reported as bad rather than truncated. +#define AGI_DIR_ENTRY_BYTES 3 + +// VOL.x resource records start with a 5-byte header. +#define AGI_VOL_HEADER_BYTES 5 +#define AGI_VOL_SIG_0 0x12 +#define AGI_VOL_SIG_1 0x34 +#define AGI_VOL_COMPRESSED 0x80 +#define AGI_VOL_VOLUME_MASK 0x7F + +// Max directory + path string size. AGI filenames are 7 chars max; +// gameDir is application-controlled. 256 leaves comfortable margin. +#define AGI_PATH_MAX 256 + +// Empty-slot sentinel: must be >= AGI_MAX_VOLUMES so agiResLoad +// rejects empty entries. The on-disk empty marker is 0xFFFFFF (high +// nibble 0xF = volume 15); 15 is a legitimate volume number, so we +// don't reuse it as the sentinel. +#define AGI_DIR_EMPTY_VOLUME 0xFF + + +// IIgs 64 KB code-bank rule: park this TU in its own load segment +// so it doesn't pile onto _ROOT (which still holds main() and the +// ORCA-C startup). Cross-platform builds ignore this pragma. +#ifdef JOEYLIB_PLATFORM_IIGS +segment "AGIRES"; +#endif + + +// ----- Prototypes ----- + +static bool buildPath(char *out, size_t outSize, const char *dir, const char *file); +static bool buildVolPath(char *out, size_t outSize, const char *dir, uint8_t volNum); +static bool loadDirectory(AgiGameT *game, AgiResTypeE type, const char *gameDir, const char *fileName); +static bool openVolumes(AgiGameT *game, const char *gameDir); +static const char *resTypeFileName(AgiResTypeE type); + + +// ----- Internal helpers (alphabetical) ----- + +static bool buildPath(char *out, size_t outSize, const char *dir, const char *file) { + size_t dirLen; + size_t fileLen; + + dirLen = strlen(dir); + fileLen = strlen(file); + if (dirLen + 1 + fileLen + 1 > outSize) { + return false; + } + memcpy(out, dir, dirLen); + out[dirLen] = '/'; + memcpy(out + dirLen + 1, file, fileLen); + out[dirLen + 1 + fileLen] = '\0'; + return true; +} + + +static bool buildVolPath(char *out, size_t outSize, const char *dir, uint8_t volNum) { + size_t dirLen; + char *p; + + dirLen = strlen(dir); + // "/VOL." + up to 2 digits + NUL = 8 chars max. + if (dirLen + 8 > outSize) { + return false; + } + memcpy(out, dir, dirLen); + p = out + dirLen; + *p++ = '/'; + *p++ = 'V'; + *p++ = 'O'; + *p++ = 'L'; + *p++ = '.'; + if (volNum >= 10u) { + *p++ = (char)('0' + (volNum / 10u)); + *p++ = (char)('0' + (volNum % 10u)); + } else { + *p++ = (char)('0' + volNum); + } + *p = '\0'; + return true; +} + + +static bool loadDirectory(AgiGameT *game, AgiResTypeE type, const char *gameDir, const char *fileName) { + char path[AGI_PATH_MAX]; + FILE *fp; + long fileSize; + uint16_t entryCount; + uint16_t i; + uint8_t buf[AGI_DIR_ENTRY_BYTES]; + uint8_t volNibble; + uint32_t offset; + + if (!buildPath(path, AGI_PATH_MAX, gameDir, fileName)) { + return false; + } + + fp = fopen(path, "rb"); + if (fp == NULL) { + return false; + } + + if (fseek(fp, 0L, SEEK_END) != 0) { + fclose(fp); + return false; + } + fileSize = ftell(fp); + if (fileSize < 0 || fileSize > (long)(AGI_MAX_RESOURCES * AGI_DIR_ENTRY_BYTES)) { + fclose(fp); + return false; + } + if ((fileSize % AGI_DIR_ENTRY_BYTES) != 0) { + fclose(fp); + return false; + } + if (fseek(fp, 0L, SEEK_SET) != 0) { + fclose(fp); + return false; + } + + entryCount = (uint16_t)(fileSize / AGI_DIR_ENTRY_BYTES); + for (i = 0; i < entryCount; i++) { + if (fread(buf, 1, AGI_DIR_ENTRY_BYTES, fp) != AGI_DIR_ENTRY_BYTES) { + fclose(fp); + return false; + } + + volNibble = (uint8_t)(buf[0] >> 4); + // 20-bit offset, cast to uint32_t before shift so ORCA-C's + // 16-bit int doesn't truncate the (& 0x0F) << 16 term. + offset = ((uint32_t)(buf[0] & 0x0F) << 16) | ((uint32_t)buf[1] << 8) | (uint32_t)buf[2]; + + if (buf[0] == 0xFF && buf[1] == 0xFF && buf[2] == 0xFF) { + game->resDir[type][i].volume = AGI_DIR_EMPTY_VOLUME; + game->resDir[type][i].offset = 0u; + } else { + game->resDir[type][i].volume = volNibble; + game->resDir[type][i].offset = offset; + } + } + fclose(fp); + + game->resCount[type] = entryCount; + return true; +} + + +static bool openVolumes(AgiGameT *game, const char *gameDir) { + char path[AGI_PATH_MAX]; + uint8_t v; + bool anyOpened; + + anyOpened = false; + for (v = 0; v < AGI_MAX_VOLUMES; v++) { + if (!buildVolPath(path, AGI_PATH_MAX, gameDir, v)) { + return false; + } + game->volFiles[v] = fopen(path, "rb"); + if (game->volFiles[v] != NULL) { + anyOpened = true; + } + } + return anyOpened; +} + + +static const char *resTypeFileName(AgiResTypeE type) { + switch (type) { + case AGI_RES_LOGIC: return "LOGDIR"; + case AGI_RES_PIC: return "PICDIR"; + case AGI_RES_VIEW: return "VIEWDIR"; + case AGI_RES_SOUND: return "SNDDIR"; + default: return NULL; + } +} + + +// ----- Public API (alphabetical) ----- + +void agiResClose(AgiGameT *game) { + uint8_t v; + uint8_t t; + + for (v = 0; v < AGI_MAX_VOLUMES; v++) { + if (game->volFiles[v] != NULL) { + fclose(game->volFiles[v]); + game->volFiles[v] = NULL; + } + } + for (t = 0; t < AGI_RES_COUNT; t++) { + game->resCount[t] = 0u; + } +} + + +uint8_t *agiResLoad(const AgiGameT *game, AgiResTypeE type, uint16_t index, uint16_t *outLength) { + const AgiResEntryT *entry; + FILE *fp; + uint8_t header[AGI_VOL_HEADER_BYTES]; + uint8_t *payload; + uint16_t length; + uint8_t flagsAndVolume; + + if (outLength != NULL) { + *outLength = 0u; + } + if (type >= AGI_RES_COUNT) { + return NULL; + } + if (index >= game->resCount[type]) { + return NULL; + } + + entry = &game->resDir[type][index]; + if (entry->volume >= AGI_MAX_VOLUMES) { + return NULL; + } + fp = game->volFiles[entry->volume]; + if (fp == NULL) { + return NULL; + } + + if (fseek(fp, (long)entry->offset, SEEK_SET) != 0) { + return NULL; + } + if (fread(header, 1, AGI_VOL_HEADER_BYTES, fp) != AGI_VOL_HEADER_BYTES) { + return NULL; + } + if (header[0] != AGI_VOL_SIG_0 || header[1] != AGI_VOL_SIG_1) { + return NULL; + } + + flagsAndVolume = header[2]; + if (flagsAndVolume & AGI_VOL_COMPRESSED) { + // v3 LZW-compressed resource. Phase 2 work; not handled yet. + return NULL; + } + if ((flagsAndVolume & AGI_VOL_VOLUME_MASK) != entry->volume) { + return NULL; + } + + length = (uint16_t)(((uint16_t)header[4] << 8) | (uint16_t)header[3]); + if (length == 0u) { + return NULL; + } + + payload = (uint8_t *)malloc(length); + if (payload == NULL) { + return NULL; + } + if (fread(payload, 1, length, fp) != length) { + free(payload); + return NULL; + } + + if (outLength != NULL) { + *outLength = length; + } + return payload; +} + + +bool agiResOpen(AgiGameT *game, const char *gameDir) { + uint8_t t; + + memset(game, 0, sizeof(*game)); + + for (t = 0; t < AGI_RES_COUNT; t++) { + if (!loadDirectory(game, (AgiResTypeE)t, gameDir, resTypeFileName((AgiResTypeE)t))) { + agiResClose(game); + return false; + } + } + + if (!openVolumes(game, gameDir)) { + agiResClose(game); + return false; + } + + return true; +} diff --git a/agiText.c b/agiText.c new file mode 100644 index 0000000..8621cc5 --- /dev/null +++ b/agiText.c @@ -0,0 +1,438 @@ +// AGI v2 text plane: status line, display() rows, print() modal. +// +// Renders directly to the stage with an embedded 8x8 ASCII bitmap +// font; bypasses jlDrawText so glyph foreground/background can vary +// per-call without rebuilding a font surface for every color combo. +// +// Layout matches AGI's 40x25 text grid: +// Row 0 : status line (when statusLineOn) +// Rows 1..21 : picture (handled by agiPicBlit, not here) +// Rows 22..24 : message overlay area +// +// The print() modal centers a window over the picture region with +// a 1-character border and waits for user ack (HALT_PRINT_PENDING). + +#include "agi.h" + +#include "joey/draw.h" +#include "joey/surface.h" +#include "surfaceInternal.h" + +#include +#include + + +#ifdef JOEYLIB_PLATFORM_IIGS +segment "AGITXT"; +#endif + + +#define GLYPH_W 8u +#define GLYPH_H 8u +#define FIRST_CHAR 0x20u // space +#define LAST_CHAR 0x7Eu // tilde +#define GLYPH_COUNT (LAST_CHAR - FIRST_CHAR + 1u) + +#define STAGE_W SURFACE_WIDTH +#define STAGE_H SURFACE_HEIGHT + + +// ----- Prototypes ----- + +static void drawChar(jlSurfaceT *stage, int16_t x, int16_t y, char ch, uint8_t fg, uint8_t bg); +static void drawCharTransparent(jlSurfaceT *stage, int16_t x, int16_t y, char ch, uint8_t fg); +static void drawString(jlSurfaceT *stage, int16_t x, int16_t y, const char *s, uint8_t fg, uint8_t bg); +static void drawWindow(jlSurfaceT *stage, int16_t x, int16_t y, uint8_t cols, uint8_t rows, uint8_t bg, uint8_t border); +static void fillRow(jlSurfaceT *stage, uint8_t row, uint8_t bg); + + +// 8x8 bitmap font for printable ASCII (0x20 = space .. 0x7E = tilde). +// Each glyph is 8 bytes; bit 7 is the leftmost pixel of each row. +// Lower-case letters map to upper-case via the ASCII normalizer in +// drawChar so the table only needs the upper half. +// +// Hand-pixeled to be readable rather than authentic. Each row is +// commented with the visual pattern (X = on, . = off). +static const uint8_t kFont[GLYPH_COUNT][GLYPH_H] = { + /* 0x20 ' ' */ {0,0,0,0,0,0,0,0}, + /* 0x21 '!' */ {0x18,0x18,0x18,0x18,0x18,0x00,0x18,0x00}, + /* 0x22 '"' */ {0x6C,0x6C,0x48,0x00,0x00,0x00,0x00,0x00}, + /* 0x23 '#' */ {0x36,0x36,0x7F,0x36,0x7F,0x36,0x36,0x00}, + /* 0x24 '$' */ {0x18,0x3E,0x60,0x3C,0x06,0x7C,0x18,0x00}, + /* 0x25 '%' */ {0x66,0x66,0x0C,0x18,0x30,0x66,0x66,0x00}, + /* 0x26 '&' */ {0x38,0x6C,0x38,0x76,0xDC,0xCC,0x76,0x00}, + /* 0x27 '\''*/ {0x18,0x18,0x10,0x00,0x00,0x00,0x00,0x00}, + /* 0x28 '(' */ {0x0C,0x18,0x30,0x30,0x30,0x18,0x0C,0x00}, + /* 0x29 ')' */ {0x30,0x18,0x0C,0x0C,0x0C,0x18,0x30,0x00}, + /* 0x2A '*' */ {0x00,0x66,0x3C,0xFF,0x3C,0x66,0x00,0x00}, + /* 0x2B '+' */ {0x00,0x18,0x18,0x7E,0x18,0x18,0x00,0x00}, + /* 0x2C ',' */ {0x00,0x00,0x00,0x00,0x00,0x18,0x18,0x30}, + /* 0x2D '-' */ {0x00,0x00,0x00,0x7E,0x00,0x00,0x00,0x00}, + /* 0x2E '.' */ {0x00,0x00,0x00,0x00,0x00,0x18,0x18,0x00}, + /* 0x2F '/' */ {0x06,0x0C,0x18,0x30,0x60,0xC0,0x80,0x00}, + /* 0x30 '0' */ {0x3C,0x66,0x6E,0x76,0x66,0x66,0x3C,0x00}, + /* 0x31 '1' */ {0x18,0x38,0x18,0x18,0x18,0x18,0x7E,0x00}, + /* 0x32 '2' */ {0x3C,0x66,0x06,0x1C,0x30,0x66,0x7E,0x00}, + /* 0x33 '3' */ {0x3C,0x66,0x06,0x1C,0x06,0x66,0x3C,0x00}, + /* 0x34 '4' */ {0x0C,0x1C,0x3C,0x6C,0x7E,0x0C,0x0C,0x00}, + /* 0x35 '5' */ {0x7E,0x60,0x7C,0x06,0x06,0x66,0x3C,0x00}, + /* 0x36 '6' */ {0x1C,0x30,0x60,0x7C,0x66,0x66,0x3C,0x00}, + /* 0x37 '7' */ {0x7E,0x66,0x0C,0x18,0x18,0x18,0x18,0x00}, + /* 0x38 '8' */ {0x3C,0x66,0x66,0x3C,0x66,0x66,0x3C,0x00}, + /* 0x39 '9' */ {0x3C,0x66,0x66,0x3E,0x06,0x0C,0x38,0x00}, + /* 0x3A ':' */ {0x00,0x18,0x18,0x00,0x00,0x18,0x18,0x00}, + /* 0x3B ';' */ {0x00,0x18,0x18,0x00,0x00,0x18,0x18,0x30}, + /* 0x3C '<' */ {0x0C,0x18,0x30,0x60,0x30,0x18,0x0C,0x00}, + /* 0x3D '=' */ {0x00,0x00,0x7E,0x00,0x7E,0x00,0x00,0x00}, + /* 0x3E '>' */ {0x30,0x18,0x0C,0x06,0x0C,0x18,0x30,0x00}, + /* 0x3F '?' */ {0x3C,0x66,0x06,0x0C,0x18,0x00,0x18,0x00}, + /* 0x40 '@' */ {0x3C,0x66,0x6E,0x6E,0x60,0x62,0x3C,0x00}, + /* 0x41 'A' */ {0x18,0x3C,0x66,0x66,0x7E,0x66,0x66,0x00}, + /* 0x42 'B' */ {0x7C,0x66,0x66,0x7C,0x66,0x66,0x7C,0x00}, + /* 0x43 'C' */ {0x3C,0x66,0x60,0x60,0x60,0x66,0x3C,0x00}, + /* 0x44 'D' */ {0x78,0x6C,0x66,0x66,0x66,0x6C,0x78,0x00}, + /* 0x45 'E' */ {0x7E,0x60,0x60,0x78,0x60,0x60,0x7E,0x00}, + /* 0x46 'F' */ {0x7E,0x60,0x60,0x78,0x60,0x60,0x60,0x00}, + /* 0x47 'G' */ {0x3C,0x66,0x60,0x6E,0x66,0x66,0x3C,0x00}, + /* 0x48 'H' */ {0x66,0x66,0x66,0x7E,0x66,0x66,0x66,0x00}, + /* 0x49 'I' */ {0x3C,0x18,0x18,0x18,0x18,0x18,0x3C,0x00}, + /* 0x4A 'J' */ {0x1E,0x0C,0x0C,0x0C,0x0C,0x6C,0x38,0x00}, + /* 0x4B 'K' */ {0x66,0x6C,0x78,0x70,0x78,0x6C,0x66,0x00}, + /* 0x4C 'L' */ {0x60,0x60,0x60,0x60,0x60,0x60,0x7E,0x00}, + /* 0x4D 'M' */ {0x63,0x77,0x7F,0x6B,0x63,0x63,0x63,0x00}, + /* 0x4E 'N' */ {0x66,0x76,0x7E,0x7E,0x6E,0x66,0x66,0x00}, + /* 0x4F 'O' */ {0x3C,0x66,0x66,0x66,0x66,0x66,0x3C,0x00}, + /* 0x50 'P' */ {0x7C,0x66,0x66,0x7C,0x60,0x60,0x60,0x00}, + /* 0x51 'Q' */ {0x3C,0x66,0x66,0x66,0x6A,0x6C,0x36,0x00}, + /* 0x52 'R' */ {0x7C,0x66,0x66,0x7C,0x6C,0x66,0x66,0x00}, + /* 0x53 'S' */ {0x3C,0x66,0x60,0x3C,0x06,0x66,0x3C,0x00}, + /* 0x54 'T' */ {0x7E,0x18,0x18,0x18,0x18,0x18,0x18,0x00}, + /* 0x55 'U' */ {0x66,0x66,0x66,0x66,0x66,0x66,0x3C,0x00}, + /* 0x56 'V' */ {0x66,0x66,0x66,0x66,0x66,0x3C,0x18,0x00}, + /* 0x57 'W' */ {0x63,0x63,0x63,0x6B,0x7F,0x77,0x63,0x00}, + /* 0x58 'X' */ {0x66,0x66,0x3C,0x18,0x3C,0x66,0x66,0x00}, + /* 0x59 'Y' */ {0x66,0x66,0x66,0x3C,0x18,0x18,0x18,0x00}, + /* 0x5A 'Z' */ {0x7E,0x06,0x0C,0x18,0x30,0x60,0x7E,0x00}, + /* 0x5B '[' */ {0x3C,0x30,0x30,0x30,0x30,0x30,0x3C,0x00}, + /* 0x5C '\\'*/ {0xC0,0x60,0x30,0x18,0x0C,0x06,0x02,0x00}, + /* 0x5D ']' */ {0x3C,0x0C,0x0C,0x0C,0x0C,0x0C,0x3C,0x00}, + /* 0x5E '^' */ {0x18,0x3C,0x66,0x00,0x00,0x00,0x00,0x00}, + /* 0x5F '_' */ {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xFF}, + /* 0x60 '`' */ {0x30,0x18,0x0C,0x00,0x00,0x00,0x00,0x00}, + /* 0x61 'a' */ {0x00,0x00,0x3C,0x06,0x3E,0x66,0x3E,0x00}, + /* 0x62 'b' */ {0x60,0x60,0x7C,0x66,0x66,0x66,0x7C,0x00}, + /* 0x63 'c' */ {0x00,0x00,0x3C,0x66,0x60,0x66,0x3C,0x00}, + /* 0x64 'd' */ {0x06,0x06,0x3E,0x66,0x66,0x66,0x3E,0x00}, + /* 0x65 'e' */ {0x00,0x00,0x3C,0x66,0x7E,0x60,0x3C,0x00}, + /* 0x66 'f' */ {0x1C,0x36,0x30,0x78,0x30,0x30,0x30,0x00}, + /* 0x67 'g' */ {0x00,0x00,0x3E,0x66,0x66,0x3E,0x06,0x3C}, + /* 0x68 'h' */ {0x60,0x60,0x7C,0x66,0x66,0x66,0x66,0x00}, + /* 0x69 'i' */ {0x18,0x00,0x38,0x18,0x18,0x18,0x3C,0x00}, + /* 0x6A 'j' */ {0x06,0x00,0x06,0x06,0x06,0x06,0x66,0x3C}, + /* 0x6B 'k' */ {0x60,0x60,0x66,0x6C,0x78,0x6C,0x66,0x00}, + /* 0x6C 'l' */ {0x38,0x18,0x18,0x18,0x18,0x18,0x3C,0x00}, + /* 0x6D 'm' */ {0x00,0x00,0x66,0x7F,0x7F,0x6B,0x63,0x00}, + /* 0x6E 'n' */ {0x00,0x00,0x7C,0x66,0x66,0x66,0x66,0x00}, + /* 0x6F 'o' */ {0x00,0x00,0x3C,0x66,0x66,0x66,0x3C,0x00}, + /* 0x70 'p' */ {0x00,0x00,0x7C,0x66,0x66,0x7C,0x60,0x60}, + /* 0x71 'q' */ {0x00,0x00,0x3E,0x66,0x66,0x3E,0x06,0x06}, + /* 0x72 'r' */ {0x00,0x00,0x7C,0x66,0x60,0x60,0x60,0x00}, + /* 0x73 's' */ {0x00,0x00,0x3E,0x60,0x3C,0x06,0x7C,0x00}, + /* 0x74 't' */ {0x18,0x18,0x7E,0x18,0x18,0x18,0x0E,0x00}, + /* 0x75 'u' */ {0x00,0x00,0x66,0x66,0x66,0x66,0x3E,0x00}, + /* 0x76 'v' */ {0x00,0x00,0x66,0x66,0x66,0x3C,0x18,0x00}, + /* 0x77 'w' */ {0x00,0x00,0x63,0x6B,0x7F,0x7F,0x36,0x00}, + /* 0x78 'x' */ {0x00,0x00,0x66,0x3C,0x18,0x3C,0x66,0x00}, + /* 0x79 'y' */ {0x00,0x00,0x66,0x66,0x66,0x3E,0x06,0x3C}, + /* 0x7A 'z' */ {0x00,0x00,0x7E,0x0C,0x18,0x30,0x7E,0x00}, + /* 0x7B '{' */ {0x0E,0x18,0x18,0x70,0x18,0x18,0x0E,0x00}, + /* 0x7C '|' */ {0x18,0x18,0x18,0x18,0x18,0x18,0x18,0x00}, + /* 0x7D '}' */ {0x70,0x18,0x18,0x0E,0x18,0x18,0x70,0x00}, + /* 0x7E '~' */ {0x76,0xDC,0x00,0x00,0x00,0x00,0x00,0x00} +}; + + +// ----- Internal helpers (alphabetical) ----- + +static void drawChar(jlSurfaceT *stage, int16_t x, int16_t y, char ch, uint8_t fg, uint8_t bg) { + const uint8_t *glyph; + uint8_t row; + uint8_t col; + uint8_t bits; + uint8_t c; + uint8_t *stagePixels; + + c = (uint8_t)ch; + if (c < FIRST_CHAR || c > LAST_CHAR) { + c = FIRST_CHAR; // unknown -> space + } + glyph = kFont[c - FIRST_CHAR]; + + // Chunky fast path: each glyph row is 8 bits == 4 stage bytes + // (each byte = 2 pixels in 4bpp packed). Build each byte by + // checking 2 bits at a time. Bypasses jlDrawPixel's per-pixel + // overhead which dominates text rendering on slow targets. + stagePixels = (stage != NULL) ? stage->pixels : NULL; + if (stagePixels != NULL && x >= 0 && y >= 0 && + x + (int16_t)GLYPH_W <= (int16_t)SURFACE_WIDTH && + y + (int16_t)GLYPH_H <= (int16_t)SURFACE_HEIGHT && + (x & 1) == 0) { + uint16_t rowOff; + uint16_t byteX; + uint8_t bytePair; + uint8_t left; + uint8_t right; + + byteX = (uint16_t)(x >> 1); + for (row = 0u; row < GLYPH_H; row++) { + bits = glyph[row]; + rowOff = (uint16_t)((y + row) * SURFACE_BYTES_PER_ROW + byteX); + for (col = 0u; col < GLYPH_W; col += 2u) { + left = (uint8_t)((bits & (uint8_t)(0x80u >> col)) ? fg : bg); + right = (uint8_t)((bits & (uint8_t)(0x80u >> (col + 1u))) ? fg : bg); + bytePair = (uint8_t)((left << 4) | right); + stagePixels[rowOff + (col >> 1)] = bytePair; + } + } + surfaceMarkDirtyRect(stage, x, y, (int16_t)GLYPH_W, (int16_t)GLYPH_H); + return; + } + + // Planar / out-of-bounds slow fallback. + for (row = 0u; row < GLYPH_H; row++) { + bits = glyph[row]; + for (col = 0u; col < GLYPH_W; col++) { + uint8_t pixel; + + pixel = ((bits >> (uint8_t)(7u - col)) & 0x01u) ? fg : bg; + jlDrawPixel(stage, (int16_t)(x + (int16_t)col), (int16_t)(y + (int16_t)row), pixel); + } + } +} + + +// Draw a char with transparent background: only foreground bits are +// written, the surface pixels underneath show through. Chunky fast +// path reads each stage byte, modifies set nibbles, writes back. +static void drawCharTransparent(jlSurfaceT *stage, int16_t x, int16_t y, char ch, uint8_t fg) { + const uint8_t *glyph; + uint8_t row; + uint8_t col; + uint8_t bits; + uint8_t c; + uint8_t *stagePixels; + + c = (uint8_t)ch; + if (c < FIRST_CHAR || c > LAST_CHAR) { + return; // unknown -> nothing to draw (transparent) + } + glyph = kFont[c - FIRST_CHAR]; + stagePixels = (stage != NULL) ? stage->pixels : NULL; + if (stagePixels != NULL && x >= 0 && y >= 0 && + x + (int16_t)GLYPH_W <= (int16_t)SURFACE_WIDTH && + y + (int16_t)GLYPH_H <= (int16_t)SURFACE_HEIGHT && + (x & 1) == 0) { + uint16_t rowOff; + uint16_t byteX; + uint8_t existing; + uint8_t leftMask; + uint8_t rightMask; + uint8_t nibbleFg; + + byteX = (uint16_t)(x >> 1); + nibbleFg = (uint8_t)(fg & 0x0Fu); + for (row = 0u; row < GLYPH_H; row++) { + bits = glyph[row]; + if (bits == 0u) { continue; } // entire row transparent + rowOff = (uint16_t)((y + row) * SURFACE_BYTES_PER_ROW + byteX); + for (col = 0u; col < GLYPH_W; col += 2u) { + leftMask = (uint8_t)(bits & (uint8_t)(0x80u >> col)); + rightMask = (uint8_t)(bits & (uint8_t)(0x80u >> (col + 1u))); + if (leftMask == 0u && rightMask == 0u) { continue; } + existing = stagePixels[rowOff + (col >> 1)]; + if (leftMask) { existing = (uint8_t)((existing & 0x0Fu) | (nibbleFg << 4)); } + if (rightMask) { existing = (uint8_t)((existing & 0xF0u) | nibbleFg); } + stagePixels[rowOff + (col >> 1)] = existing; + } + } + surfaceMarkDirtyRect(stage, x, y, (int16_t)GLYPH_W, (int16_t)GLYPH_H); + return; + } + + // Slow fallback (planar or out-of-bounds). + for (row = 0u; row < GLYPH_H; row++) { + bits = glyph[row]; + for (col = 0u; col < GLYPH_W; col++) { + if (bits & (uint8_t)(0x80u >> col)) { + jlDrawPixel(stage, (int16_t)(x + (int16_t)col), (int16_t)(y + (int16_t)row), fg); + } + } + } +} + + +static void drawString(jlSurfaceT *stage, int16_t x, int16_t y, const char *s, uint8_t fg, uint8_t bg) { + int16_t cur; + + if (s == NULL) { + return; + } + cur = x; + while (*s != '\0') { + if (cur >= (int16_t)STAGE_W) { + return; + } + drawChar(stage, cur, y, *s, fg, bg); + cur = (int16_t)(cur + (int16_t)GLYPH_W); + s++; + } +} + + +static void drawWindow(jlSurfaceT *stage, int16_t x, int16_t y, uint8_t cols, uint8_t rows, uint8_t bg, uint8_t border) { + int16_t w; + int16_t h; + + w = (int16_t)((int16_t)cols * (int16_t)GLYPH_W); + h = (int16_t)((int16_t)rows * (int16_t)GLYPH_H); + jlFillRect(stage, x, y, (uint16_t)(w + 4), (uint16_t)(h + 4), border); + jlFillRect(stage, (int16_t)(x + 2), (int16_t)(y + 2), (uint16_t)w, (uint16_t)h, bg); +} + + +static void fillRow(jlSurfaceT *stage, uint8_t row, uint8_t bg) { + jlFillRect(stage, 0, (int16_t)((int16_t)row * (int16_t)GLYPH_H), (uint16_t)STAGE_W, (uint16_t)GLYPH_H, bg); +} + + +// ----- Public API (alphabetical) ----- + +const uint16_t *agiTextAsciiMap(void) { + // Not used in this impl (we render direct), but expose so callers + // who want to use jlDrawText against agiTextFontSurface have the + // standard 16x6 layout map. Built lazily on first call. + static uint16_t map[256]; + static bool built; + uint16_t i; + + if (built) { + return map; + } + for (i = 0u; i < 256u; i++) { + if (i < FIRST_CHAR || i > LAST_CHAR) { + map[i] = (uint16_t)0xFFFFu; + } else { + uint8_t off; + + off = (uint8_t)(i - FIRST_CHAR); + map[i] = (uint16_t)(((uint16_t)(off / 16u) << 8) | (uint16_t)(off % 16u)); + } + } + built = true; + return map; +} + + +const jlSurfaceT *agiTextFontSurface(void) { + // No surface built in this impl (drawChar walks raw bits direct + // to the stage). Reserved for callers that prefer jlDrawText. + return NULL; +} + + +void agiTextRender(const AgiVmT *vm, jlSurfaceT *stage) { + uint8_t r; + + if (vm->statusLineOn) { + fillRow(stage, vm->statusLineRow, 15u); // white background + if (vm->textRows[vm->statusLineRow].text[0] != '\0') { + drawString(stage, 0, (int16_t)((int16_t)vm->statusLineRow * (int16_t)GLYPH_H), + vm->textRows[vm->statusLineRow].text, 0u, 15u); + } + } + // display() rows: walk every row that has content. Glyphs draw + // transparently (no bg fill) so any PIC art behind shows through + // where glyph pixels are 0. Filling the whole row's bg would + // obscure picture elements that overlap the text band (e.g. + // KQ3's "King's Quest III" title art). + for (r = 0u; r < AGI_TEXT_ROWS; r++) { + const char *p; + int16_t x; + int16_t y; + uint8_t fg; + + if (r == vm->statusLineRow && vm->statusLineOn) { + continue; // status line handled above + } + if (vm->textRows[r].text[0] == '\0') { + continue; + } + y = (int16_t)((int16_t)r * (int16_t)GLYPH_H); + x = 0; + fg = vm->textRows[r].fg; + p = vm->textRows[r].text; + while (*p != '\0' && x < (int16_t)STAGE_W) { + if (*p != ' ') { + drawCharTransparent(stage, x, y, *p, fg); + } + x = (int16_t)(x + (int16_t)GLYPH_W); + p++; + } + } + + if (vm->printModal.active) { + const char *p; + int16_t lineW; + int16_t maxW; + int16_t lineCount; + int16_t winX; + int16_t winY; + int16_t lineH; + int16_t textY; + int16_t curX; + + // Measure: width = longest line in chars; height = lines. + lineW = 0; + maxW = 1; + lineCount = 1; + p = vm->printModal.message; + while (*p != '\0') { + if (*p == '\n') { + if (lineW > maxW) { + maxW = lineW; + } + lineW = 0; + lineCount++; + } else { + lineW++; + } + p++; + } + if (lineW > maxW) { + maxW = lineW; + } + if (maxW > (int16_t)AGI_TEXT_COLS) { + maxW = (int16_t)AGI_TEXT_COLS; + } + lineH = (int16_t)GLYPH_H; + winX = (int16_t)((STAGE_W - (maxW * (int16_t)GLYPH_W + 4)) / 2); + winY = (int16_t)((STAGE_H - (lineCount * lineH + 4)) / 2); + if (winX < 0) { winX = 0; } + if (winY < 0) { winY = 0; } + drawWindow(stage, winX, winY, (uint8_t)maxW, (uint8_t)lineCount, 15u, 4u); + + textY = (int16_t)(winY + 2); + curX = (int16_t)(winX + 2); + p = vm->printModal.message; + while (*p != '\0') { + if (*p == '\n') { + textY = (int16_t)(textY + lineH); + curX = (int16_t)(winX + 2); + } else { + drawChar(stage, curX, textY, *p, 0u, 15u); + curX = (int16_t)(curX + (int16_t)GLYPH_W); + } + p++; + } + } +} diff --git a/agiView.c b/agiView.c new file mode 100644 index 0000000..31cee78 --- /dev/null +++ b/agiView.c @@ -0,0 +1,340 @@ +// AGI v2 VIEW (sprite/animation) decoder. +// +// Parses an in-RAM VIEW resource into a structured loop/cel table +// once at load time, then renders any cel against the priority plane +// for per-pixel actor-vs-picture masking. Re-implements the public +// AGI VIEW byte layout from scratch. +// +// Pixel-doubled blit goes straight to the stage so animation cycles +// don't require an intermediate back-buffer. The 160x168 AGI surface +// maps onto stage columns (2 * agiX, 2 * agiX + 1) at the same destY +// agiPicBlit uses. + +#include "agi.h" + +#include "joey/draw.h" +#include "surfaceInternal.h" + +#include +#include +#include + + +// VIEW header byte offsets (publicly documented v2 layout). +#define AGI_VIEW_HDR_LOOP_COUNT 2u +#define AGI_VIEW_HDR_LOOP_TABLE 5u + +// Cel header bits (publicly documented v2 layout). +#define AGI_CEL_TRANSPARENT_MASK 0x0Fu +#define AGI_CEL_MIRROR_FLAG 0x80u +#define AGI_CEL_MIRROR_SRC_SHIFT 4u +#define AGI_CEL_MIRROR_SRC_MASK 0x07u + +// Priority bands. Y 0..47 is sky (priority 4); Y 48..167 splits into +// 10 ground bands of 12 pixels each at priorities 5..14. Priority 15 +// is "always on top" and reserved for HUD/overlay; an actor's natural +// Y never reaches it. +#define AGI_PRI_SKY_TOP 48 +#define AGI_PRI_GROUND_FIRST 5u +#define AGI_PRI_GROUND_BAND_PX 12 + + +#ifdef JOEYLIB_PLATFORM_IIGS +segment "AGIVIEW"; +#endif + + +// ----- Prototypes ----- + +static bool parseLoop(AgiLoopInfoT *loop, const uint8_t *loopStart, uint16_t maxBytes); +static const AgiCelInfoT *resolveMirror(const AgiViewT *view, uint8_t loopIdx, uint8_t celIdx, bool *outMirrored); + + +// ----- Internal helpers (alphabetical) ----- + +static bool parseLoop(AgiLoopInfoT *loop, const uint8_t *loopStart, uint16_t maxBytes) { + uint8_t celCount; + uint8_t i; + uint16_t celOffset; + const uint8_t *celStart; + uint8_t flags; + + if (maxBytes < 1u) { + return false; + } + celCount = loopStart[0]; + if ((uint16_t)celCount > AGI_MAX_CELS_PER_LOOP) { + return false; + } + if (maxBytes < (uint16_t)(1u + (uint16_t)celCount * 2u)) { + return false; + } + + loop->celCount = celCount; + for (i = 0u; i < celCount; i++) { + celOffset = (uint16_t)(loopStart[1u + (uint16_t)i * 2u] | + ((uint16_t)loopStart[2u + (uint16_t)i * 2u] << 8)); + if ((uint16_t)(celOffset + 3u) > maxBytes) { + return false; + } + celStart = loopStart + celOffset; + flags = celStart[2]; + loop->cels[i].width = celStart[0]; + loop->cels[i].height = celStart[1]; + loop->cels[i].transparentColor = (uint8_t)(flags & AGI_CEL_TRANSPARENT_MASK); + loop->cels[i].mirrored = (uint8_t)((flags & AGI_CEL_MIRROR_FLAG) ? 1u : 0u); + loop->cels[i].mirrorSourceLoop = (uint8_t)((flags >> AGI_CEL_MIRROR_SRC_SHIFT) & AGI_CEL_MIRROR_SRC_MASK); + loop->cels[i].rleData = celStart + 3; + } + return true; +} + + +// If the cel is a mirror, swap to the source loop's same-index cel and +// flag the caller to flip the RLE walk horizontally. Mirror chains +// don't recurse in valid AGI data; we don't follow more than one hop. +// +// Sierra's encoder marks every cel in BOTH halves of a mirror pair +// with the mirror flag, with the source-loop field pointing to the +// lower-numbered loop. The cel is only an actual mirror when the +// source-loop field points somewhere ELSE; when source == this loop, +// this IS the source data and must be drawn unflipped (otherwise both +// loops in a pair render mirrored and e.g. Graham faces left whether +// the player presses left or right). +static const AgiCelInfoT *resolveMirror(const AgiViewT *view, uint8_t loopIdx, uint8_t celIdx, bool *outMirrored) { + const AgiCelInfoT *cel; + uint8_t sourceLoop; + + if (loopIdx >= view->loopCount) { + return NULL; + } + if (celIdx >= view->loops[loopIdx].celCount) { + return NULL; + } + cel = &view->loops[loopIdx].cels[celIdx]; + *outMirrored = false; + if (cel->mirrored) { + sourceLoop = cel->mirrorSourceLoop; + if (sourceLoop != loopIdx && sourceLoop < view->loopCount && celIdx < view->loops[sourceLoop].celCount) { + cel = &view->loops[sourceLoop].cels[celIdx]; + *outMirrored = true; + } + } + return cel; +} + + +// ----- Public API (alphabetical) ----- + +uint8_t agiActorPriorityForY(int16_t y) { + int16_t capped; + int16_t band; + + if (y < AGI_PRI_SKY_TOP) { + return 4u; + } + capped = (y > (int16_t)(AGI_PIC_HEIGHT - 1)) ? (int16_t)(AGI_PIC_HEIGHT - 1) : y; + band = (int16_t)(AGI_PRI_GROUND_FIRST + (capped - AGI_PRI_SKY_TOP) / AGI_PRI_GROUND_BAND_PX); + if (band > 14) { + band = 14; + } + return (uint8_t)band; +} + + +#ifdef JOEYLIB_PLATFORM_IIGS +segment "AGIDRAW"; +#endif + +void agiViewDraw(const AgiViewT *view, uint8_t loopIdx, uint8_t celIdx, + int16_t x, int16_t y, uint8_t actorPri, + const uint8_t *picPriority, + jlSurfaceT *stage, int16_t destY) { + const AgiCelInfoT *cel; + const uint8_t *rle; + bool mirrored; + int16_t width; + int16_t height; + uint8_t transparent; + int16_t topY; + int16_t cy; + int16_t cx; + int16_t drawCx; + int16_t agiX; + int16_t agiY; + int16_t stageX; + int16_t stageY; + uint8_t rleByte; + uint8_t color; + uint8_t packed; + uint8_t runLen; + uint8_t r; + uint8_t picPri; + uint8_t *stagePixels; + + cel = resolveMirror(view, loopIdx, celIdx, &mirrored); + if (cel == NULL) { + return; + } + width = (int16_t)cel->width; + height = (int16_t)cel->height; + transparent = cel->transparentColor; + rle = cel->rleData; + topY = (int16_t)(y - height + 1); + stagePixels = (stage != NULL) ? stage->pixels : NULL; + + // Per-pixel chunky fast path: pixel-doubled output means each + // source pixel maps to exactly one stage byte (both nibbles = + // same color). We can skip jlDrawPixel and write the byte + // direct, marking the cel bbox dirty once at the end. ~80x + // faster on DOSBox @ 386SX-equivalent CPU cycles. + if (stagePixels != NULL) { + int16_t minStageX = (int16_t)(x << 1); + int16_t maxStageX = (int16_t)((x + width) << 1); + int16_t minStageY = (int16_t)(destY + topY); + int16_t maxStageY = (int16_t)(destY + topY + height); + const uint8_t *priRow; + uint8_t *stageRow; + + for (cy = 0; cy < height; cy++) { + agiY = (int16_t)(topY + cy); + // Hoist per-row work out of the per-pixel inner loop. + // For rows entirely outside the pic clip we still have to + // walk the RLE so the byte pointer ends in the right place + // for subsequent rows. + if (agiY < 0 || agiY >= (int16_t)AGI_PIC_HEIGHT) { + priRow = NULL; + stageRow = NULL; + } else { + priRow = picPriority + (int32_t)agiY * (int32_t)AGI_PIC_WIDTH; + stageRow = stagePixels + + (int32_t)(destY + agiY) * (int32_t)SURFACE_BYTES_PER_ROW; + } + cx = 0; + for (;;) { + rleByte = *rle++; + if (rleByte == 0u) { break; } + color = (uint8_t)(rleByte >> 4); + runLen = (uint8_t)(rleByte & 0x0Fu); + if (color == transparent) { + cx = (int16_t)(cx + runLen); + if (cx > width) { cx = width; } + continue; + } + if (priRow == NULL) { + cx = (int16_t)(cx + runLen); + if (cx > width) { cx = width; } + continue; + } + packed = (uint8_t)((color << 4) | color); + for (r = 0u; r < runLen; r++) { + if (cx >= width) { break; } + drawCx = mirrored ? (int16_t)(width - 1 - cx) : cx; + agiX = (int16_t)(x + drawCx); + if (agiX >= 0 && agiX < (int16_t)AGI_PIC_WIDTH) { + if (priRow[agiX] <= actorPri) { + stageRow[agiX] = packed; + } + } + cx++; + } + } + } + // Dirty-rect tracking at the cel level only: we know the + // bbox a priori (the cel's stage rect), and per-pixel tight + // bounds aren't worth the inner-loop cost on a 386. Clip to + // the stage so the surface marker doesn't reject the whole + // rect for an off-screen edge. + if (minStageX < 0) { minStageX = 0; } + if (minStageY < 0) { minStageY = 0; } + if (maxStageX > (int16_t)SURFACE_WIDTH) { maxStageX = (int16_t)SURFACE_WIDTH; } + if (maxStageY > (int16_t)SURFACE_HEIGHT) { maxStageY = (int16_t)SURFACE_HEIGHT; } + if (maxStageX > minStageX && maxStageY > minStageY) { + surfaceMarkDirtyRect(stage, minStageX, minStageY, + (int16_t)(maxStageX - minStageX), + (int16_t)(maxStageY - minStageY)); + } + return; + } + + // Planar fallback (s->pixels NULL: Amiga Phase 9). Goes through + // jlDrawPixel so the port's c2p / plane-sync path stays correct. + for (cy = 0; cy < height; cy++) { + cx = 0; + for (;;) { + rleByte = *rle++; + if (rleByte == 0u) { break; } + color = (uint8_t)(rleByte >> 4); + runLen = (uint8_t)(rleByte & 0x0Fu); + for (r = 0u; r < runLen; r++) { + if (cx >= width) { break; } + if (color != transparent) { + drawCx = mirrored ? (int16_t)(width - 1 - cx) : cx; + agiX = (int16_t)(x + drawCx); + agiY = (int16_t)(topY + cy); + if (agiX >= 0 && agiX < (int16_t)AGI_PIC_WIDTH && agiY >= 0 && agiY < (int16_t)AGI_PIC_HEIGHT) { + picPri = picPriority[agiY * (int16_t)AGI_PIC_WIDTH + agiX]; + if (picPri <= actorPri) { + stageX = (int16_t)(agiX << 1); + stageY = (int16_t)(destY + agiY); + jlDrawPixel(stage, stageX, stageY, color); + jlDrawPixel(stage, (int16_t)(stageX + 1), stageY, color); + } + } + } + cx++; + } + } + } +} + + +void agiViewFree(AgiViewT *view) { + if (view->raw != NULL) { + free(view->raw); + view->raw = NULL; + } + view->rawLength = 0u; + view->loopCount = 0u; +} + + +bool agiViewParse(AgiViewT *view, uint8_t *rawBytes, uint16_t length) { + uint8_t loopCount; + uint8_t i; + uint16_t loopOffset; + + memset(view, 0, sizeof(*view)); + if (length < AGI_VIEW_HDR_LOOP_TABLE) { + free(rawBytes); + return false; + } + + loopCount = rawBytes[AGI_VIEW_HDR_LOOP_COUNT]; + if ((uint16_t)loopCount > AGI_MAX_LOOPS_PER_VIEW) { + free(rawBytes); + return false; + } + if ((uint16_t)(AGI_VIEW_HDR_LOOP_TABLE + (uint16_t)loopCount * 2u) > length) { + free(rawBytes); + return false; + } + + view->raw = rawBytes; + view->rawLength = length; + view->loopCount = loopCount; + + for (i = 0u; i < loopCount; i++) { + loopOffset = (uint16_t)(rawBytes[AGI_VIEW_HDR_LOOP_TABLE + (uint16_t)i * 2u] | + ((uint16_t)rawBytes[AGI_VIEW_HDR_LOOP_TABLE + 1u + (uint16_t)i * 2u] << 8)); + if (loopOffset >= length) { + agiViewFree(view); + return false; + } + if (!parseLoop(&view->loops[i], rawBytes + loopOffset, (uint16_t)(length - loopOffset))) { + agiViewFree(view); + return false; + } + } + return true; +} diff --git a/agiVm.c b/agiVm.c new file mode 100644 index 0000000..5a4fe1e --- /dev/null +++ b/agiVm.c @@ -0,0 +1,2300 @@ +// 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); +} diff --git a/scripts/run-agi.sh b/scripts/run-agi.sh new file mode 100755 index 0000000..442a390 --- /dev/null +++ b/scripts/run-agi.sh @@ -0,0 +1,277 @@ +#!/usr/bin/env bash +# Launch the AGI interpreter in the named platform's emulator with a +# staged AGI v2 game directory. The game's data files are copied flat +# alongside the binary so AGI's CWD-relative default ("./LOGDIR" etc.) +# works on platforms whose autostart can't pass argv. +# +# scripts/run-agi.sh [game] [room] +# platform : dos | amiga | atarist | iigs +# game : subdirectory under examples/agi/gamedata/ (default kq3) +# room : starting room number 1..255. Skips logic.0's hardcoded +# title-room target and jumps straight to the named room. +# Only forwarded to DOS today -- the Atari ST / Amiga +# autostart paths can't pass argv to the binary; their +# AGI starts at the title screen until we wire a stage- +# time config-file fallback or implement enough title +# opcodes for the title to advance on its own. +# +# Required files in the game directory (others are ignored): +# LOGDIR PICDIR VIEWDIR SNDDIR VOL.0 [VOL.1 ...] [OBJECT] [WORDS.TOK] +# +# IIgs is not yet wired (needs the disk packager extended to a larger +# 2mg volume plus case-folded ProDOS file names); the script reports +# what's missing rather than launching. + +set -euo pipefail + +if [[ $# -lt 1 || $# -gt 3 ]]; then + echo "usage: $0 [game-name] [starting-room]" >&2 + exit 2 +fi + +platform=$1 +game=${2:-kq3} +room=${3:-0} + +if ! [[ $room =~ ^[0-9]+$ ]] || (( room > 255 )); then + echo "starting-room must be 0..255 (got '$room')" >&2 + exit 2 +fi + +repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +gamedata=$repo/examples/agi/gamedata/$game +support=$repo/toolchains/emulators/support + +if [[ ! -d $gamedata ]]; then + echo "$gamedata: not an AGI v2 game directory" >&2 + if [[ -d $repo/examples/agi/gamedata ]]; then + echo "available games:" >&2 + find "$repo/examples/agi/gamedata" -maxdepth 1 -mindepth 1 -type d -printf ' %f\n' >&2 + fi + exit 1 +fi +if [[ ! -f $gamedata/LOGDIR ]]; then + echo "$gamedata: no LOGDIR -- not an AGI v2 game directory" >&2 + exit 1 +fi + + +# stageGameData WORKDIR +# Copies the subset of files AGI v2 reads (DIR files, VOL.x, OBJECT, +# WORDS.TOK) into WORKDIR. Leaves Sierra's original AGI binary, OVL +# graphics drivers, and platform-specific launchers behind so a flat +# stage doesn't clash with our binary's name on case-insensitive +# filesystems (e.g. Amiga FFS: "AGI" vs our "Agi"). +stageGameData() { + local destDir=$1 + local f + + for f in LOGDIR PICDIR VIEWDIR SNDDIR OBJECT WORDS.TOK; do + if [[ -f $gamedata/$f ]]; then + cp "$gamedata/$f" "$destDir/$f" + fi + done + for f in "$gamedata"/VOL.*; do + [[ -f $f ]] && cp "$f" "$destDir/$(basename "$f")" + done +} + + +runDos() { + local bin=$repo/build/dos/bin/AGI.EXE + local conf=$repo/scripts/dosbox-386sx16.conf + local work + + if [[ ! -f $bin ]]; then + echo "$bin not built. Run 'make -f make/dos.mk EXAMPLE=agi' first." >&2 + exit 1 + fi + + # Sweep any leftover staging dirs from previous runs that + # didn't reach their trap (DOSBox window close, SIGKILL, etc.). + # We're about to make a new one anyway; freshly leftover dirs + # serve no purpose and just leak ~1 MB each. + rm -rf /tmp/joeylib-agi-dos.* 2>/dev/null || true + rm -f /tmp/joeylib-agi-dos-last.log 2>/dev/null || true + + work=$(mktemp -d -t joeylib-agi-dos.XXXXXX) + # Cleanup trap: copy DJGPP's diagnostic log out (DOS uppercases + # so the file lands as JOEYLOG.TXT, not joeylog.txt), then nuke + # the staging dir. Hook EXIT plus the common interrupt signals + # so the cleanup fires even when the user closes the DOSBox + # window or Ctrl-Cs the terminal -- both bypass plain EXIT in + # some bash configurations. + cleanupDos() { + local log + for log in "$work/JOEYLOG.TXT" "$work/joeylog.txt"; do + if [[ -f $log ]]; then + cp "$log" /tmp/joeylib-agi-dos-last.log + break + fi + done + rm -rf "$work" + } + trap cleanupDos EXIT INT TERM HUP + + cp "$bin" "$work/AGI.EXE" + stageGameData "$work" + + # AGI.EXE reads game data from its CWD (we mount $work as C: and + # cd there first), so argv[1] is "." -- the starting-room override + # lives in argv[2]. Skip the argv when room=0 so the binary + # exercises its default code path. + local agi_cmd="AGI.EXE" + if (( room != 0 )); then + agi_cmd="AGI.EXE . $room" + fi + + cat <&2 + exit 1 + fi + if [[ ! -f $tos ]]; then + echo "TOS ROM missing: $tos" >&2 + echo "Run ./toolchains/install.sh (EmuTOS should have been staged)." >&2 + exit 1 + fi + + rm -rf /tmp/joeylib-agi-atarist.* 2>/dev/null || true + work=$(mktemp -d -t joeylib-agi-atarist.XXXXXX) + trap 'rm -rf "$work"' EXIT INT TERM HUP + + cp "$bin" "$work/AGI.PRG" + stageGameData "$work" + + cat <&2 + exit 1 + fi + + rm -rf /tmp/joeylib-agi-amiga.* 2>/dev/null || true + work=$(mktemp -d -t joeylib-agi-amiga.XXXXXX) + trap 'rm -rf "$work"' EXIT INT TERM HUP + + mkdir -p "$work/s" + cp "$bin" "$work/Agi" + stageGameData "$work" + # ':' prefix anchors to volume root so AmigaDOS doesn't search C:. + echo ":Agi" > "$work/s/startup-sequence" + + local fsargs=( + --amiga_model=A500 + --fast_memory=2048 + --hard_drive_0="$work" + --hard_drive_0_label=JOEYLIB + --automatic_input_grab=0 + --middle_click_ungrab=1 + --mouse_integration=1 + --floppy_drive_speed=800 + ) + if [[ -f $kickstart ]]; then + fsargs+=(--kickstart_file="$kickstart") + fi + + if [[ -f $workbench ]]; then + # If a Workbench floppy is staged it boots from DF0: instead of + # the HD, so the startup-sequence never fires. Skip Workbench + # for AGI to keep the launch one-shot; user can still launch + # manually with run-amiga.sh if they want the GUI. + echo "(skipping workbench.adf so the JOEYLIB HD boots Agi directly)" + fi + + cat <&2 +IIgs AGI run is not wired up yet. + + The existing IIgs disk packager (toolchains/iigs/package-disk.sh) builds + joey.2mg from build/iigs/bin/* only; it doesn't accept arbitrary game + data, the disk size is fixed at 800K (KQ3's ~1.5 MB of resources won't + fit), and ProDOS case-folding for game-data file names has not been + audited. + + To wire this up the package-disk.sh tool needs a larger volume option + and an "extra data" path argument, and run-iigs.sh needs an AGI mode + that points GSplus at the resulting 2mg. Tracked separately. + +Workaround for now: build with 'make -f make/iigs.mk EXAMPLE=agi' to +prove the IIgs link still works, but launch via run-iigs.sh and pick +JOEYLIB:AGI in Finder -- it will fail to find LOGDIR (no game data on +disk), which is the expected current state. +EOF + exit 1 +} + + +case "$platform" in + dos) runDos ;; + atarist) runAtariST ;; + amiga) runAmiga ;; + iigs) runIigs ;; + *) + echo "$platform: unknown platform (expected dos, amiga, atarist, or iigs)" >&2 + exit 2 + ;; +esac diff --git a/scripts/test-agi.sh b/scripts/test-agi.sh new file mode 100755 index 0000000..9551c9c --- /dev/null +++ b/scripts/test-agi.sh @@ -0,0 +1,169 @@ +#!/usr/bin/env bash +# Host-side regression tests for the AGI port. +# +# Compiles tests/agi/testAgiRes.c + examples/agi/agiRes.c with the +# host gcc and runs it against every game directory in +# examples/agi/gamedata/. Each game is exercised in isolation; the +# script aggregates pass/fail counts and exits non-zero if any test +# fails. +# +# Why a host build instead of running the emulator binaries: the AGI +# resource loader is plain stdio C with no platform dependencies, so +# the parsing logic is identical on every JoeyLib target. Validating +# it on the build host means each iteration takes a second instead +# of the 15-30s of an emulator boot, and reproducible failures don't +# depend on capturing video / log output through DOSBox or MAME. +# +# Usage: +# scripts/test-agi.sh # test every subdir of gamedata/ +# scripts/test-agi.sh kq3 agidemo # test only the named subdirs + +set -euo pipefail + +repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +gamedata=$repo/examples/agi/gamedata +build=$repo/build/tests +testRes=$build/testAgiRes +testPic=$build/testAgiPic +testView=$build/testAgiView +testVm=$build/testAgiVm +testPipeline=$build/testAgiPipeline + +mkdir -p "$build" + +# Shared host compile flags. agiPic.c calls jlDrawPixel in the blit +# step, which the host test never invokes; we stub it as an empty +# function via the link-line shim below so the host build can resolve +# the symbol without dragging in the JoeyLib runtime. +# Pick a JoeyLib platform for the host build. None of the platform- +# specific code paths are exercised by the host test (it only touches +# the AGI parsers, never the JoeyLib HAL), but joey/platform.h #errors +# unless exactly one is defined. DOS is the closest fit for a Linux +# host (chunky, little-endian, x86 lineage). +hostCflags=(-std=c99 -Wall -Wextra -Werror -O2 -DJOEYLIB_PLATFORM_DOS -I "$repo/include" -I "$repo/examples/agi" -I "$repo/src/core") +stubSrc=$build/hostStubs.c +cat > "$stubSrc" <<'EOF' +#include "joey/draw.h" +#include "joey/core.h" +#include "surfaceInternal.h" +#include +void jlDrawPixel(jlSurfaceT *s, int16_t x, int16_t y, uint8_t c) { (void)s; (void)x; (void)y; (void)c; } +void jlFillRect(jlSurfaceT *s, int16_t x, int16_t y, uint16_t w, uint16_t h, uint8_t c) { (void)s; (void)x; (void)y; (void)w; (void)h; (void)c; } +uint32_t jlRandom(void) { return (uint32_t)rand(); } +uint16_t jlRandomRange(uint16_t bound) { return (bound == 0u) ? 0u : (uint16_t)((unsigned)rand() % bound); } +void jlRandomSeed(uint32_t seed) { srand((unsigned)seed); } +void surfaceMarkDirtyRect(const jlSurfaceT *s, int16_t x, int16_t y, int16_t w, int16_t h) { (void)s; (void)x; (void)y; (void)w; (void)h; } +void surfaceMarkDirtyAll(const jlSurfaceT *s) { (void)s; } +EOF + +echo "Building host tests..." +gcc "${hostCflags[@]}" \ + "$repo/tests/agi/testAgiRes.c" \ + "$repo/examples/agi/agiRes.c" \ + -o "$testRes" + +gcc "${hostCflags[@]}" \ + "$repo/tests/agi/testAgiPic.c" \ + "$repo/examples/agi/agiRes.c" \ + "$repo/examples/agi/agiPic.c" \ + "$stubSrc" \ + -o "$testPic" + +gcc "${hostCflags[@]}" \ + "$repo/tests/agi/testAgiView.c" \ + "$repo/examples/agi/agiRes.c" \ + "$repo/examples/agi/agiPic.c" \ + "$repo/examples/agi/agiView.c" \ + "$stubSrc" \ + -o "$testView" + +gcc "${hostCflags[@]}" \ + "$repo/tests/agi/testAgiVm.c" \ + "$repo/examples/agi/agiRes.c" \ + "$repo/examples/agi/agiVm.c" \ + "$repo/examples/agi/agiObj.c" \ + "$stubSrc" \ + -o "$testVm" + +gcc "${hostCflags[@]}" \ + "$repo/tests/agi/testAgiPipeline.c" \ + "$repo/examples/agi/agiRes.c" \ + "$repo/examples/agi/agiVm.c" \ + "$repo/examples/agi/agiObj.c" \ + "$stubSrc" \ + -o "$testPipeline" + +# Pick the game directories to exercise. With no args, every +# subdirectory of examples/agi/gamedata that contains LOGDIR is a +# candidate (skips stray README files etc). +if [[ $# -gt 0 ]]; then + games=("$@") +else + games=() + if [[ -d $gamedata ]]; then + for dir in "$gamedata"/*/; do + [[ -d $dir ]] || continue + name=$(basename "$dir") + if [[ -f $dir/LOGDIR ]]; then + games+=("$name") + fi + done + fi +fi + +if [[ ${#games[@]} -eq 0 ]]; then + echo + echo "No AGI game directories found under $gamedata" + echo "Drop an AGI v2 game in examples/agi/gamedata// and rerun." + echo "See examples/agi/gamedata/README.md for the required files." + exit 0 +fi + +passed=0 +failed=0 +echo +for game in "${games[@]}"; do + dir=$gamedata/$game + echo "===== $game =====" + if [[ ! -d $dir ]]; then + echo " FAIL: $dir does not exist" + failed=$((failed + 1)) + continue + fi + + gameOk=1 + echo "--- resource loader ---" + if ! "$testRes" "$dir"; then + gameOk=0 + fi + echo "--- PIC decoder ---" + if ! "$testPic" "$dir"; then + gameOk=0 + fi + echo "--- VIEW decoder ---" + if ! "$testView" "$dir"; then + gameOk=0 + fi + echo "--- LOGIC VM (logic.0) ---" + if ! "$testVm" "$dir"; then + gameOk=0 + fi + echo "--- VM pipeline (callbacks + room transitions) ---" + if ! "$testPipeline" "$dir"; then + gameOk=0 + fi + + if [[ $gameOk -eq 1 ]]; then + passed=$((passed + 1)) + else + failed=$((failed + 1)) + fi + echo +done + +total=$((passed + failed)) +echo "===== summary: $passed/$total passed =====" +if [[ $failed -gt 0 ]]; then + exit 1 +fi +exit 0 diff --git a/tests/disasmAgi.c b/tests/disasmAgi.c new file mode 100644 index 0000000..32fdfe2 --- /dev/null +++ b/tests/disasmAgi.c @@ -0,0 +1,413 @@ +// AGI v2 LOGIC disassembler. +// +// Walks one LOGIC resource and prints the bytecode in a human-readable +// form, with names for action opcodes, test commands, common variables +// and flags. Intended as a debugging aid while filling out the VM: +// dump KQ3's logic.0 + logic.45 to identify which stub opcodes are +// gating title-screen progression. +// +// Usage: +// disasmAgi [end-pc] +// +// `end-pc` caps the walk at a given offset; useful for spelunking +// through huge logic.0 without dumping the whole thing. + +#include "agi.h" + +#include +#include +#include + + +#define AGI_LOGIC_HDR_BYTES 2u +#define ACTION_UNKNOWN 0xFFu +#define MAX_TEST 0x12u + + +// ----- Prototypes ----- + +static const char *actionName(uint8_t op); +static uint16_t disasmAction(const uint8_t *code, uint16_t pc, uint16_t length, uint8_t op); +static uint16_t disasmIf(const uint8_t *code, uint16_t pc, uint16_t length); +static uint16_t disasmTest(const uint8_t *code, uint16_t pc, uint16_t length); +static const char *testName(uint8_t op); +static int walk(const uint8_t *code, uint16_t length, uint16_t endPc); + + +// AGI v2 action-opcode names. Indexed by opcode id; NULL means "no +// such opcode" (matches the VM's ACTION_UNKNOWN sentinel). +static const char *kActionNames[256] = { + /* 0x00 */ "return", "increment", "decrement", "assignn", + /* 0x04 */ "assignv", "addn", "addv", "subn", + /* 0x08 */ "subv", "lindirectv", "rindirect", "lindirectn", + /* 0x0C */ "set", "reset", "toggle", "setv", + /* 0x10 */ "resetv", "togglev", "new.room", "new.room.v", + /* 0x14 */ "load.logic", "load.logic.v", "call", "call.v", + /* 0x18 */ "load.pic", "draw.pic", "show.pic", "discard.pic", + /* 0x1C */ "overlay.pic", "show.pri.screen", "load.view", "load.view.v", + /* 0x20 */ "discard.view", "animate.obj", "unanimate.all", "draw", + /* 0x24 */ "erase", "position", "position.v", "get.posn", + /* 0x28 */ "reposition", "set.view", "set.view.v", "set.loop", + /* 0x2C */ "set.loop.v", "fix.loop", "release.loop", "set.cel", + /* 0x30 */ "set.cel.v", "last.cel", "current.cel", "current.loop", + /* 0x34 */ "current.view", "number.of.loops", "set.priority", "set.priority.v", + /* 0x38 */ "release.priority", "get.priority", "stop.update", "start.update", + /* 0x3C */ "force.update", "ignore.horizon", "observe.horizon", "set.horizon", + /* 0x40 */ "object.on.water", "object.on.land", "object.on.anything", "ignore.objs", + /* 0x44 */ "observe.objs", "distance", "stop.cycling", "start.cycling", + /* 0x48 */ "normal.cycle", "end.of.loop", "reverse.cycle", "reverse.loop", + /* 0x4C */ "cycle.time", "stop.motion", "start.motion", "step.size", + /* 0x50 */ "step.time", "move.obj", "move.obj.v", "follow.ego", + /* 0x54 */ "wander", "normal.motion", "set.dir", "get.dir", + /* 0x58 */ "ignore.blocks", "observe.blocks", "block", "unblock", + /* 0x5C */ "get", "get.v", "drop", "put", + /* 0x60 */ "put.v", "get.room.v", "load.sound", "sound", + /* 0x64 */ "stop.sound", "print", "print.v", "display", + /* 0x68 */ "display.v", "clear.lines", "text.screen", "graphics", + /* 0x6C */ "set.cursor.char", "set.text.attribute", "shake.screen", "configure.screen", + /* 0x70 */ "status.line.on", "status.line.off", "set.string", "get.string", + /* 0x74 */ "word.to.string", "parse", "get.num", "prevent.input", + /* 0x78 */ "accept.input", "set.key", "add.to.pic", "add.to.pic.v", + /* 0x7C */ "status", "save.game", "restore.game", "init.disk", + /* 0x80 */ "restart.game", "show.obj", "random", "program.control", + /* 0x84 */ "player.control", "obj.status.v", "quit", "show.mem", + /* 0x88 */ "pause", "echo.line", "cancel.line", "init.joy", + /* 0x8C */ "toggle.monitor", "version", "script.size", "set.game.id", + /* 0x90 */ "log", "set.scan.start", "reset.scan.start", "reposition.to", + /* 0x94 */ "reposition.to.v", "trace.on", "trace.info", "print.at", + /* 0x98 */ "print.at.v", "discard.view.v", "clear.text.rect", "set.upper.left", + /* 0x9C */ "set.menu", "set.menu.item", "submit.menu", "enable.item", + /* 0xA0 */ "disable.item", "menu.input", "show.obj.v", "open.dialogue", + /* 0xA4 */ "close.dialogue", "mul.n", "mul.v", "div.n", + /* 0xA8 */ "div.v", "close.window", "set.simple", "push.script", + /* 0xAC */ "pop.script", "hold.key", "set.pri.base", "discard.sound", + /* 0xB0 */ "hide.mouse", "allow.menu", "show.mouse", "fence.mouse", + /* 0xB4 */ "mouse.posn", "release.key", "adj.ego.move.to.x.y", + /* 0xB7..0xFD */ NULL + // 0xFE = goto, 0xFF = if -- handled separately +}; + + +// AGI v2 action arg byte counts (matches kActionArgBytes in agiVm.c). +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 +}; + + +static const char *kTestNames[MAX_TEST + 1u] = { + /* 0x00 */ NULL, + /* 0x01 */ "equaln", + /* 0x02 */ "equalv", + /* 0x03 */ "lessn", + /* 0x04 */ "lessv", + /* 0x05 */ "greatern", + /* 0x06 */ "greaterv", + /* 0x07 */ "isset", + /* 0x08 */ "issetv", + /* 0x09 */ "has", + /* 0x0A */ "obj.in.room", + /* 0x0B */ "posn", + /* 0x0C */ "controller", + /* 0x0D */ "have.key", + /* 0x0E */ "said", + /* 0x0F */ "compare.strings", + /* 0x10 */ "obj.in.box", + /* 0x11 */ "center.posn", + /* 0x12 */ "right.posn" +}; + + +static const uint8_t kTestArgBytes[MAX_TEST + 1u] = { + 0u, 2u, 2u, 2u, 2u, 2u, 2u, 1u, 1u, 1u, 2u, 5u, 1u, 0u, 0u /*said*/, 2u, 5u, 5u, 5u +}; + + +// ----- Helpers (alphabetical) ----- + +static const char *actionName(uint8_t op) { + if (op == 0xFFu) { + return "if"; + } + if (op == 0xFEu) { + return "goto"; + } + if (kActionNames[op] != NULL) { + return kActionNames[op]; + } + return "???"; +} + + +static uint16_t disasmAction(const uint8_t *code, uint16_t pc, uint16_t length, uint8_t op) { + uint8_t args; + uint8_t i; + uint16_t here; + + here = (uint16_t)(pc - 1u); + args = kActionArgBytes[op]; + if (args == ACTION_UNKNOWN) { + printf(" %04X: %02X ???\n", (unsigned)here, (unsigned)op); + return length; + } + if ((uint32_t)pc + (uint32_t)args > (uint32_t)length) { + printf(" %04X: %02X truncated args\n", (unsigned)here, (unsigned)op); + return length; + } + printf(" %04X: %s", (unsigned)here, actionName(op)); + if (args == 0u) { + printf("()\n"); + } else { + printf("("); + for (i = 0u; i < args; i++) { + if (i > 0u) { + printf(", "); + } + printf("%u", (unsigned)code[pc + i]); + } + printf(")\n"); + } + return (uint16_t)(pc + args); +} + + +static uint16_t disasmIf(const uint8_t *code, uint16_t pc, uint16_t length) { + uint16_t here; + uint8_t testOp; + bool first; + bool notNext; + bool inOr; + uint16_t skip; + uint16_t target; + + here = (uint16_t)(pc - 1u); + printf(" %04X: if (", (unsigned)here); + first = true; + notNext = false; + inOr = false; + for (;;) { + if (pc >= length) { + printf(" )\n"); + return length; + } + testOp = code[pc]; + pc = (uint16_t)(pc + 1u); + if (testOp == 0xFFu) { + // End of test list. + break; + } + if (testOp == 0xFCu) { + if (inOr) { + printf(")"); + inOr = false; + } else { + if (!first) { + printf(" && "); + } + printf("("); + inOr = true; + first = true; + } + continue; + } + if (testOp == 0xFDu) { + notNext = true; + continue; + } + if (!first) { + printf(inOr ? " || " : " && "); + } + first = false; + if (notNext) { + printf("!"); + notNext = false; + } + pc = disasmTest(code, pc, length); + } + if (inOr) { + printf(")"); + } + if ((uint32_t)pc + 2u > (uint32_t)length) { + printf(") \n"); + return length; + } + skip = (uint16_t)(code[pc] | ((uint16_t)code[pc + 1u] << 8)); + target = (uint16_t)(pc + 2u + skip); + printf(") -> %04X\n", (unsigned)target); + return (uint16_t)(pc + 2u); +} + + +static uint16_t disasmTest(const uint8_t *code, uint16_t pc, uint16_t length) { + uint8_t args; + uint8_t i; + uint8_t testOp; + uint8_t wordCount; + uint16_t word; + + testOp = code[pc - 1u]; + if (testOp == 0u || testOp > MAX_TEST) { + printf("???_test_0x%02X(", (unsigned)testOp); + return pc; + } + if (testOp == 0x0Eu) { + // said(word, word, ...) + if (pc >= length) { + printf("said("); + return length; + } + wordCount = code[pc]; + pc = (uint16_t)(pc + 1u); + printf("said("); + for (i = 0u; i < wordCount; i++) { + if (i > 0u) { + printf(", "); + } + if ((uint32_t)pc + 2u > (uint32_t)length) { + printf(""); + return length; + } + word = (uint16_t)(code[pc] | ((uint16_t)code[pc + 1u] << 8)); + printf("w%u", (unsigned)word); + pc = (uint16_t)(pc + 2u); + } + printf(")"); + return pc; + } + args = kTestArgBytes[testOp]; + printf("%s(", testName(testOp)); + if ((uint32_t)pc + (uint32_t)args > (uint32_t)length) { + printf(")"); + return length; + } + for (i = 0u; i < args; i++) { + if (i > 0u) { + printf(", "); + } + printf("%u", (unsigned)code[pc + i]); + } + printf(")"); + return (uint16_t)(pc + args); +} + + +static const char *testName(uint8_t op) { + if (op > MAX_TEST || kTestNames[op] == NULL) { + return "???"; + } + return kTestNames[op]; +} + + +static int walk(const uint8_t *code, uint16_t length, uint16_t endPc) { + uint16_t pc; + uint8_t op; + int16_t gotoOff; + uint16_t target; + + pc = 0u; + while (pc < length && pc < endPc) { + op = code[pc]; + pc = (uint16_t)(pc + 1u); + if (op == 0xFFu) { + pc = disasmIf(code, pc, length); + continue; + } + if (op == 0xFEu) { + if ((uint32_t)pc + 2u > (uint32_t)length) { + printf(" %04X: goto \n", (unsigned)(pc - 1u)); + return 1; + } + gotoOff = (int16_t)(code[pc] | ((uint16_t)code[pc + 1u] << 8)); + target = (uint16_t)((int32_t)pc + 2 + (int32_t)gotoOff); + printf(" %04X: goto %04X\n", (unsigned)(pc - 1u), (unsigned)target); + pc = (uint16_t)(pc + 2u); + continue; + } + pc = disasmAction(code, pc, length, op); + if (op == 0x00u) { + // return -- end of logic. Keep walking past it; some + // logics have unreachable handlers below the top-level + // return that callers jump into via goto. + } + } + return 0; +} + + +// ----- main ----- + +int main(int argc, char **argv) { + AgiGameT game; + uint16_t logicId; + uint8_t *bytes; + uint16_t length; + uint16_t bytecodeLen; + uint16_t endPc; + int ret; + + if (argc < 3 || argc > 4) { + fprintf(stderr, "usage: %s [end-pc]\n", argv[0]); + return 2; + } + if (!agiResOpen(&game, argv[1])) { + fprintf(stderr, "FAIL: agiResOpen rejected '%s'\n", argv[1]); + return 1; + } + logicId = (uint16_t)atoi(argv[2]); + if (logicId >= game.resCount[AGI_RES_LOGIC]) { + fprintf(stderr, "FAIL: no LOGIC at id %u\n", (unsigned)logicId); + agiResClose(&game); + return 1; + } + bytes = agiResLoad(&game, AGI_RES_LOGIC, logicId, &length); + if (bytes == NULL) { + fprintf(stderr, "FAIL: LOGIC %u failed to load\n", (unsigned)logicId); + agiResClose(&game); + return 1; + } + if (length < AGI_LOGIC_HDR_BYTES) { + fprintf(stderr, "FAIL: LOGIC %u truncated header\n", (unsigned)logicId); + free(bytes); + agiResClose(&game); + return 1; + } + bytecodeLen = (uint16_t)(bytes[0] | ((uint16_t)bytes[1] << 8)); + if ((uint32_t)bytecodeLen + AGI_LOGIC_HDR_BYTES > (uint32_t)length) { + fprintf(stderr, "FAIL: LOGIC %u bytecode length exceeds resource\n", (unsigned)logicId); + free(bytes); + agiResClose(&game); + return 1; + } + endPc = (argc == 4) ? (uint16_t)atoi(argv[3]) : bytecodeLen; + if (endPc > bytecodeLen) { + endPc = bytecodeLen; + } + + printf("LOGIC %u: %u bytes resource, %u bytes bytecode\n", + (unsigned)logicId, (unsigned)length, (unsigned)bytecodeLen); + ret = walk(bytes + AGI_LOGIC_HDR_BYTES, bytecodeLen, endPc); + free(bytes); + agiResClose(&game); + return ret; +} diff --git a/tests/testAgiPic.c b/tests/testAgiPic.c new file mode 100644 index 0000000..433a5d2 --- /dev/null +++ b/tests/testAgiPic.c @@ -0,0 +1,191 @@ +// Host-side test for examples/agi/agiPic.c. +// +// Loads a single PIC resource from a game directory, decodes it, and +// writes two image files: a color PPM for the visual plane (palette +// expanded to 8-bit RGB) and a grayscale PGM for the priority plane. +// Also reports the byte count and pixel coverage so a regression in +// the decoder shows up as a numeric diff. +// +// Usage: +// testAgiPic [pic-id] +// +// The default pic-id is the first non-empty entry in PICDIR; KQ3 has +// id 0 unused so the loader hops forward until it finds one. +// +// Exit codes: +// 0 decoded; outputs written +// 1 game directory missing, PIC slot empty, or decode failed +// 2 invalid command line + +#include "agi.h" + +#include +#include +#include + + +// 4-bit channel -> 8-bit channel scaling: x * 17 maps 0..15 to 0..255 +// without bias (15 * 17 = 255 exactly). +#define CHANNEL_SCALE 17 + + +// ----- Prototypes ----- + +static int16_t firstNonEmptyPic(const AgiGameT *game); +static bool writePgm(const char *path, const uint8_t *plane); +static bool writePpm(const char *path, const uint8_t *plane); + + +// ----- Helpers (alphabetical) ----- + +static int16_t firstNonEmptyPic(const AgiGameT *game) { + uint16_t count; + uint16_t i; + + count = game->resCount[AGI_RES_PIC]; + for (i = 0u; i < count; i++) { + if (game->resDir[AGI_RES_PIC][i].volume < AGI_MAX_VOLUMES) { + return (int16_t)i; + } + } + return -1; +} + + +static bool writePgm(const char *path, const uint8_t *plane) { + FILE *fp; + uint32_t i; + uint8_t gray; + + fp = fopen(path, "wb"); + if (fp == NULL) { + return false; + } + fprintf(fp, "P5\n%d %d\n255\n", AGI_PIC_WIDTH, AGI_PIC_HEIGHT); + for (i = 0u; i < (uint32_t)AGI_PIC_PIXELS; i++) { + gray = (uint8_t)((plane[i] & 0x0Fu) * CHANNEL_SCALE); + fputc(gray, fp); + } + fclose(fp); + return true; +} + + +static bool writePpm(const char *path, const uint8_t *plane) { + FILE *fp; + uint32_t i; + uint16_t rgb; + uint8_t r; + uint8_t g; + uint8_t b; + + fp = fopen(path, "wb"); + if (fp == NULL) { + return false; + } + fprintf(fp, "P6\n%d %d\n255\n", AGI_PIC_WIDTH, AGI_PIC_HEIGHT); + for (i = 0u; i < (uint32_t)AGI_PIC_PIXELS; i++) { + rgb = kAgiPalette[plane[i] & 0x0Fu]; + r = (uint8_t)(((rgb >> 8) & 0x0Fu) * CHANNEL_SCALE); + g = (uint8_t)(((rgb >> 4) & 0x0Fu) * CHANNEL_SCALE); + b = (uint8_t)((rgb & 0x0Fu) * CHANNEL_SCALE); + fputc(r, fp); + fputc(g, fp); + fputc(b, fp); + } + fclose(fp); + return true; +} + + +// ----- main ----- + +int main(int argc, char **argv) { + AgiGameT game; + AgiPicT pic; + int16_t picId; + uint8_t *bytes; + uint16_t length; + uint32_t nonBgVisual; + uint32_t nonBgPriority; + uint32_t i; + char visPath[256]; + char priPath[256]; + + if (argc < 2 || argc > 3) { + fprintf(stderr, "usage: %s [pic-id]\n", argv[0]); + return 2; + } + + if (!agiResOpen(&game, argv[1])) { + fprintf(stderr, "FAIL: agiResOpen rejected '%s'\n", argv[1]); + return 1; + } + + if (argc == 3) { + picId = (int16_t)atoi(argv[2]); + } else { + picId = firstNonEmptyPic(&game); + } + if (picId < 0 || (uint16_t)picId >= game.resCount[AGI_RES_PIC]) { + fprintf(stderr, "FAIL: no PIC at id %d\n", (int)picId); + agiResClose(&game); + return 1; + } + + bytes = agiResLoad(&game, AGI_RES_PIC, (uint16_t)picId, &length); + if (bytes == NULL) { + fprintf(stderr, "FAIL: PIC %d failed to load\n", (int)picId); + agiResClose(&game); + return 1; + } + + if (!agiPicAlloc(&pic)) { + fprintf(stderr, "FAIL: agiPicAlloc out of memory\n"); + free(bytes); + agiResClose(&game); + return 1; + } + agiPicClear(&pic); + + if (!agiPicDecode(&pic, bytes, length)) { + fprintf(stderr, "FAIL: PIC %d decode rejected (malformed stream?)\n", (int)picId); + agiPicFree(&pic); + free(bytes); + agiResClose(&game); + return 1; + } + + nonBgVisual = 0u; + nonBgPriority = 0u; + for (i = 0u; i < (uint32_t)AGI_PIC_PIXELS; i++) { + if (pic.visual[i] != AGI_VISUAL_BG) { + nonBgVisual++; + } + if (pic.priority[i] != AGI_PRIORITY_BG) { + nonBgPriority++; + } + } + + snprintf(visPath, sizeof(visPath), "build/tests/pic%d_visual.ppm", (int)picId); + snprintf(priPath, sizeof(priPath), "build/tests/pic%d_priority.pgm", (int)picId); + if (!writePpm(visPath, pic.visual) || !writePgm(priPath, pic.priority)) { + fprintf(stderr, "FAIL: could not write output images\n"); + agiPicFree(&pic); + free(bytes); + agiResClose(&game); + return 1; + } + + printf("PIC %d: %u bytes, visual %u/%u px painted, priority %u/%u px painted\n", + (int)picId, (unsigned)length, + (unsigned)nonBgVisual, (unsigned)AGI_PIC_PIXELS, + (unsigned)nonBgPriority, (unsigned)AGI_PIC_PIXELS); + printf(" wrote %s\n", visPath); + printf(" wrote %s\n", priPath); + + agiPicFree(&pic); + free(bytes); + agiResClose(&game); + return 0; +} diff --git a/tests/testAgiPipeline.c b/tests/testAgiPipeline.c new file mode 100644 index 0000000..9645614 --- /dev/null +++ b/tests/testAgiPipeline.c @@ -0,0 +1,284 @@ +// Host-side AGI pipeline test (Phase 1 sub-D). +// +// Wires up a real logic-resource cache + trace callbacks and runs +// the VM in a simulated game-cycle loop: +// * agiVmRun() +// * NEW_ROOM -> update var[0]/var[1], reset flags, restart logic.0 +// * RETURN -> count one cycle, restart logic.0 +// * UNKNOWN_OP -> print and exit +// Capped at MAX_CYCLES so a logic that always returns without +// painting can't loop forever. +// +// What this lets us verify before touching any emulator: +// - logic.0's prologue + IF branching against fresh game state +// - room transition handling (new.room(N) loads logic N) +// - room logic execution (load.pic/draw.pic/show.pic callbacks) +// - call.logic / RETURN frame management +// +// Usage: +// testAgiPipeline [max-cycles] + +#include "agi.h" + +#include +#include +#include + + +#define MAX_CYCLES_DEFAULT 20u +#define AGI_LOGIC_HDR_BYTES 2u + + +typedef struct { + uint8_t *raw; + uint16_t rawLength; + const uint8_t *bytecode; + uint16_t bytecodeLength; +} CachedLogicT; + + +static AgiGameT gGame; +static CachedLogicT gLogicCache[AGI_MAX_RESOURCES]; +static uint32_t gTraceLoadPic; +static uint32_t gTraceDrawPic; +static uint32_t gTraceShowPic; +static uint32_t gTraceDiscardPic; +static uint32_t gTraceOverlayPic; +static uint32_t gTraceFetchLogic; + + +static void cbDiscardPic(void *ctx, uint8_t picId); +static void cbDrawPic(void *ctx, uint8_t picId); +static const uint8_t *cbFetchLogic(void *ctx, uint8_t logicId, uint16_t *outLength); +static void cbLoadPic(void *ctx, uint8_t picId); +static void cbOverlayPic(void *ctx, uint8_t picId); +static void cbShowPic(void *ctx); +static const char *haltName(AgiVmHaltE r); +static void releaseCache(void); + + +static void cbDiscardPic(void *ctx, uint8_t picId) { + (void)ctx; + gTraceDiscardPic++; + printf(" [host] discardPic(%u)\n", (unsigned)picId); +} + + +static void cbDrawPic(void *ctx, uint8_t picId) { + (void)ctx; + gTraceDrawPic++; + printf(" [host] drawPic(%u)\n", (unsigned)picId); +} + + +static const uint8_t *cbFetchLogic(void *ctx, uint8_t logicId, uint16_t *outLength) { + CachedLogicT *slot; + uint8_t *raw; + uint16_t rawLength; + uint16_t bcLen; + + (void)ctx; + slot = &gLogicCache[logicId]; + if (slot->bytecode != NULL) { + *outLength = slot->bytecodeLength; + return slot->bytecode; + } + + if (logicId >= gGame.resCount[AGI_RES_LOGIC]) { + return NULL; + } + raw = agiResLoad(&gGame, AGI_RES_LOGIC, (uint16_t)logicId, &rawLength); + if (raw == NULL || rawLength < AGI_LOGIC_HDR_BYTES) { + if (raw != NULL) { + free(raw); + } + return NULL; + } + bcLen = (uint16_t)(raw[0] | ((uint16_t)raw[1] << 8)); + if ((uint16_t)(AGI_LOGIC_HDR_BYTES + bcLen) > rawLength) { + free(raw); + return NULL; + } + slot->raw = raw; + slot->rawLength = rawLength; + slot->bytecode = raw + AGI_LOGIC_HDR_BYTES; + slot->bytecodeLength = bcLen; + gTraceFetchLogic++; + printf(" [host] fetchLogic(%u) -> %u bytes (first load)\n", + (unsigned)logicId, (unsigned)bcLen); + + *outLength = bcLen; + return slot->bytecode; +} + + +static void cbLoadPic(void *ctx, uint8_t picId) { + (void)ctx; + gTraceLoadPic++; + printf(" [host] loadPic(%u)\n", (unsigned)picId); +} + + +static void cbOverlayPic(void *ctx, uint8_t picId) { + (void)ctx; + gTraceOverlayPic++; + printf(" [host] overlayPic(%u)\n", (unsigned)picId); +} + + +static void cbShowPic(void *ctx) { + (void)ctx; + gTraceShowPic++; + printf(" [host] showPic\n"); +} + + +static const char *haltName(AgiVmHaltE r) { + switch (r) { + case AGI_VM_HALT_NONE: return "NONE"; + case AGI_VM_HALT_RETURN: return "RETURN"; + case AGI_VM_HALT_UNKNOWN_OP: return "UNKNOWN_OP"; + case AGI_VM_HALT_TRUNCATED: return "TRUNCATED"; + case AGI_VM_HALT_NEW_ROOM: return "NEW_ROOM"; + case AGI_VM_HALT_NO_LOGIC: return "NO_LOGIC"; + case AGI_VM_HALT_STACK_OVER: return "STACK_OVER"; + default: return "?"; + } +} + + +static void releaseCache(void) { + uint16_t i; + + for (i = 0u; i < AGI_MAX_RESOURCES; i++) { + if (gLogicCache[i].raw != NULL) { + free(gLogicCache[i].raw); + gLogicCache[i].raw = NULL; + } + } +} + + +int main(int argc, char **argv) { + AgiVmT vm; + AgiVmCallbacksT cb; + uint16_t length; + const uint8_t *logic0; + uint32_t maxCycles; + uint32_t cycles; + AgiVmHaltE reason; + uint8_t prevRoom; + uint8_t i; + + if (argc < 2 || argc > 3) { + fprintf(stderr, "usage: %s [max-cycles]\n", argv[0]); + return 2; + } + maxCycles = (argc == 3) ? (uint32_t)atoi(argv[2]) : (uint32_t)MAX_CYCLES_DEFAULT; + if (maxCycles == 0u) { + maxCycles = (uint32_t)MAX_CYCLES_DEFAULT; + } + + if (!agiResOpen(&gGame, argv[1])) { + fprintf(stderr, "FAIL: agiResOpen rejected '%s'\n", argv[1]); + return 1; + } + memset(gLogicCache, 0, sizeof(gLogicCache)); + + cb.fetchLogic = cbFetchLogic; + cb.loadPic = cbLoadPic; + cb.drawPic = cbDrawPic; + cb.showPic = cbShowPic; + cb.discardPic = cbDiscardPic; + cb.overlayPic = cbOverlayPic; + cb.haveKey = NULL; + cb.fetchMessage = NULL; + cb.addToPic = NULL; + cb.soundDuration = NULL; + cb.playSound = NULL; + cb.stopSound = NULL; + cb.ctx = NULL; + + agiVmInit(&vm); + agiVmSetCallbacks(&vm, &cb); + + length = 0u; + logic0 = cbFetchLogic(NULL, 0u, &length); + if (logic0 == NULL) { + fprintf(stderr, "FAIL: could not load logic.0\n"); + releaseCache(); + agiResClose(&gGame); + return 1; + } + agiVmResetToLogic(&vm, logic0, length, 0u); + + cycles = 0u; + for (;;) { + if (cycles >= maxCycles) { + printf(" (capped at %u cycles)\n", (unsigned)maxCycles); + break; + } + cycles++; + // Simulate one wall-clock second per cycle so per-second + // game logic (countdowns, state machines, animation cadence) + // advances under the host test without waiting in real time. + // KQ3's title state machine in particular is gated entirely + // on var 11 changing; without this tick logic.45's countdowns + // never fire and the title appears to stall at 20+ cycles. + agiVmTickSeconds(&vm, 1u); + printf(" cycle %u: pc=%u/%u logic=%u callDepth=%u\n", + (unsigned)cycles, (unsigned)vm.pc, (unsigned)vm.codeLength, + (unsigned)vm.currentLogicId, (unsigned)vm.callDepth); + + reason = agiVmRun(&vm); + + printf(" halt=%s at pc=%u logic=%u", + haltName(reason), (unsigned)vm.pc, (unsigned)vm.currentLogicId); + if (reason == AGI_VM_HALT_UNKNOWN_OP) { + printf(" opcode=0x%02X", (unsigned)vm.lastUnknownOp); + } + if (reason == AGI_VM_HALT_NEW_ROOM) { + printf(" newRoom=%u", (unsigned)vm.newRoomId); + } + printf("\n"); + + if (reason == AGI_VM_HALT_NEW_ROOM) { + prevRoom = vm.vars[0]; + vm.vars[0] = vm.newRoomId; + vm.vars[1] = prevRoom; + // Reset most flags, then set flag 5 ("new room") to + // signal the room logic that its init branch should + // fire. In the real interpreter the engine also resets + // a handful of cycle vars (input, signal, etc.) but for + // the sub-D pipeline test the new-room flag is the only + // one room logic actually checks. + for (i = 0u; i < 32u; i++) { + vm.flags[i] = 0u; + } + vm.flags[5] = 1u; + agiVmResetToLogic(&vm, logic0, length, 0u); + continue; + } + if (reason == AGI_VM_HALT_RETURN) { + // End-of-cycle housekeeping the real interpreter would + // perform: clear flag 5 (new.room) so subsequent cycles + // don't re-run the room-init branch. + vm.flags[5] = 0u; + agiVmResetToLogic(&vm, logic0, length, 0u); + continue; + } + break; + } + + printf("===== trace =====\n"); + printf(" fetchLogic: %u (first-loads only)\n", (unsigned)gTraceFetchLogic); + printf(" loadPic: %u\n", (unsigned)gTraceLoadPic); + printf(" drawPic: %u\n", (unsigned)gTraceDrawPic); + printf(" showPic: %u\n", (unsigned)gTraceShowPic); + printf(" discardPic: %u\n", (unsigned)gTraceDiscardPic); + printf(" overlayPic: %u\n", (unsigned)gTraceOverlayPic); + + releaseCache(); + agiResClose(&gGame); + return 0; +} diff --git a/tests/testAgiRes.c b/tests/testAgiRes.c new file mode 100644 index 0000000..e04b118 --- /dev/null +++ b/tests/testAgiRes.c @@ -0,0 +1,101 @@ +// Host-side regression test for examples/agi/agiRes.c. +// +// The AGI resource loader is plain stdio C with no JoeyLib runtime +// dependencies, so we can compile and exercise it on the build host +// without touching any emulator. This keeps the Phase 0 iteration +// loop fast: edit agiRes.c, rerun scripts/test-agi.sh, get a pass +// or fail against KQ3 (or any AGI v2 directory) in under a second. +// +// Usage: testAgiRes +// +// Exit codes: +// 0 resource directories parsed and at least one resource per +// type loaded successfully (or the type was empty) +// 1 game directory missing or malformed +// 2 invalid command line + +#include "agi.h" + +#include +#include +#include + + +// ----- Prototypes ----- + +static const char *resTypeName(AgiResTypeE type); +static int testFirstResource(AgiGameT *game, AgiResTypeE type); + + +// ----- Helpers (alphabetical) ----- + +static const char *resTypeName(AgiResTypeE type) { + switch (type) { + case AGI_RES_LOGIC: return "LOGIC"; + case AGI_RES_PIC: return "PIC "; + case AGI_RES_VIEW: return "VIEW "; + case AGI_RES_SOUND: return "SOUND"; + default: return "?????"; + } +} + + +// Locate the first non-empty resource of `type`, load it, print its +// length. Returns 0 on success or empty type, 1 on load failure. +static int testFirstResource(AgiGameT *game, AgiResTypeE type) { + uint16_t i; + uint16_t length; + uint8_t *payload; + uint16_t count; + + count = game->resCount[type]; + for (i = 0; i < count; i++) { + if (game->resDir[type][i].volume >= AGI_MAX_VOLUMES) { + continue; + } + payload = agiResLoad(game, type, i, &length); + if (payload == NULL) { + fprintf(stderr, "FAIL: %s id %u failed to load\n", resTypeName(type), (unsigned)i); + return 1; + } + printf(" %s: %u resources, first non-empty id %u is %u bytes\n", + resTypeName(type), (unsigned)count, (unsigned)i, (unsigned)length); + free(payload); + return 0; + } + printf(" %s: %u directory slots, all empty\n", resTypeName(type), (unsigned)count); + return 0; +} + + +// ----- main ----- + +int main(int argc, char **argv) { + AgiGameT game; + uint8_t t; + int rc; + + if (argc != 2) { + fprintf(stderr, "usage: %s \n", argv[0]); + return 2; + } + + printf("Opening AGI game directory: %s\n", argv[1]); + if (!agiResOpen(&game, argv[1])) { + fprintf(stderr, "FAIL: agiResOpen rejected '%s' (missing files? wrong format?)\n", argv[1]); + return 1; + } + + rc = 0; + for (t = 0; t < AGI_RES_COUNT; t++) { + if (testFirstResource(&game, (AgiResTypeE)t) != 0) { + rc = 1; + } + } + + agiResClose(&game); + if (rc == 0) { + printf("OK\n"); + } + return rc; +} diff --git a/tests/testAgiView.c b/tests/testAgiView.c new file mode 100644 index 0000000..6971fa1 --- /dev/null +++ b/tests/testAgiView.c @@ -0,0 +1,198 @@ +// Host-side test for examples/agi/agiView.c. +// +// Loads a single VIEW resource, parses it, dumps the loop/cel +// structure, and writes cel 0 of every loop as a PPM image. The +// transparent color is rendered as bright magenta so the cel's +// silhouette stands out from the actual painted pixels. +// +// Usage: +// testAgiView [view-id] +// +// Exit codes: +// 0 parsed; outputs written +// 1 game directory missing, view slot empty, or parse failed +// 2 invalid command line + +#include "agi.h" + +#include +#include +#include + + +#define CHANNEL_SCALE 17u + +// Pseudo-color used for "transparent" pixels in the output image. +#define TRANS_R 0xFFu +#define TRANS_G 0x00u +#define TRANS_B 0xFFu + + +// ----- Prototypes ----- + +static int16_t firstNonEmptyView(const AgiGameT *game); +static bool writeCelPpm(const char *path, const AgiCelInfoT *cel); + + +// ----- Helpers (alphabetical) ----- + +static int16_t firstNonEmptyView(const AgiGameT *game) { + uint16_t count; + uint16_t i; + + count = game->resCount[AGI_RES_VIEW]; + for (i = 0u; i < count; i++) { + if (game->resDir[AGI_RES_VIEW][i].volume < AGI_MAX_VOLUMES) { + return (int16_t)i; + } + } + return -1; +} + + +// Walk the cel's RLE data, paint into a chunky 8bpp buffer, then +// emit a PPM with transparent pixels mapped to magenta. Same RLE +// rules as agiViewDraw: each byte = (color << 4) | runLen, runLen == 0 +// ends the row. +static bool writeCelPpm(const char *path, const AgiCelInfoT *cel) { + FILE *fp; + uint8_t *buf; + uint16_t w; + uint16_t h; + uint16_t cx; + uint16_t cy; + uint16_t i; + const uint8_t *rle; + uint8_t rleByte; + uint8_t color; + uint8_t runLen; + uint8_t r; + uint8_t transparent; + uint16_t rgb; + + w = (uint16_t)cel->width; + h = (uint16_t)cel->height; + if (w == 0u || h == 0u) { + return false; + } + + buf = (uint8_t *)malloc((size_t)w * (size_t)h); + if (buf == NULL) { + return false; + } + transparent = cel->transparentColor; + memset(buf, transparent, (size_t)w * (size_t)h); + + rle = cel->rleData; + for (cy = 0u; cy < h; cy++) { + cx = 0u; + for (;;) { + rleByte = *rle++; + if (rleByte == 0u) { + break; + } + color = (uint8_t)(rleByte >> 4); + runLen = (uint8_t)(rleByte & 0x0Fu); + for (r = 0u; r < runLen; r++) { + if (cx >= w) { + break; + } + buf[cy * w + cx] = color; + cx++; + } + } + } + + fp = fopen(path, "wb"); + if (fp == NULL) { + free(buf); + return false; + } + fprintf(fp, "P6\n%u %u\n255\n", (unsigned)w, (unsigned)h); + for (i = 0u; i < (uint16_t)(w * h); i++) { + if (buf[i] == transparent) { + fputc(TRANS_R, fp); + fputc(TRANS_G, fp); + fputc(TRANS_B, fp); + } else { + rgb = kAgiPalette[buf[i] & 0x0Fu]; + fputc((int)(((rgb >> 8) & 0x0Fu) * CHANNEL_SCALE), fp); + fputc((int)(((rgb >> 4) & 0x0Fu) * CHANNEL_SCALE), fp); + fputc((int)((rgb & 0x0Fu) * CHANNEL_SCALE), fp); + } + } + fclose(fp); + free(buf); + return true; +} + + +// ----- main ----- + +int main(int argc, char **argv) { + AgiGameT game; + AgiViewT view; + int16_t viewId; + uint8_t *bytes; + uint16_t length; + uint8_t li; + uint8_t ci; + const AgiCelInfoT *cel; + char path[256]; + + if (argc < 2 || argc > 3) { + fprintf(stderr, "usage: %s [view-id]\n", argv[0]); + return 2; + } + + if (!agiResOpen(&game, argv[1])) { + fprintf(stderr, "FAIL: agiResOpen rejected '%s'\n", argv[1]); + return 1; + } + + if (argc == 3) { + viewId = (int16_t)atoi(argv[2]); + } else { + viewId = firstNonEmptyView(&game); + } + if (viewId < 0 || (uint16_t)viewId >= game.resCount[AGI_RES_VIEW]) { + fprintf(stderr, "FAIL: no VIEW at id %d\n", (int)viewId); + agiResClose(&game); + return 1; + } + + bytes = agiResLoad(&game, AGI_RES_VIEW, (uint16_t)viewId, &length); + if (bytes == NULL) { + fprintf(stderr, "FAIL: VIEW %d failed to load\n", (int)viewId); + agiResClose(&game); + return 1; + } + + if (!agiViewParse(&view, bytes, length)) { + fprintf(stderr, "FAIL: VIEW %d parse rejected\n", (int)viewId); + agiResClose(&game); + return 1; + } + + printf("VIEW %d: %u bytes, %u loops\n", (int)viewId, (unsigned)length, (unsigned)view.loopCount); + for (li = 0u; li < view.loopCount; li++) { + printf(" loop %u: %u cels\n", (unsigned)li, (unsigned)view.loops[li].celCount); + for (ci = 0u; ci < view.loops[li].celCount; ci++) { + cel = &view.loops[li].cels[ci]; + printf(" cel %u: %ux%u trans=%u mirror=%u (src loop %u)\n", + (unsigned)ci, (unsigned)cel->width, (unsigned)cel->height, + (unsigned)cel->transparentColor, + (unsigned)cel->mirrored, (unsigned)cel->mirrorSourceLoop); + } + if (view.loops[li].celCount > 0u) { + snprintf(path, sizeof(path), "build/tests/view%d_loop%u_cel0.ppm", (int)viewId, (unsigned)li); + if (!writeCelPpm(path, &view.loops[li].cels[0])) { + fprintf(stderr, " warn: could not write %s\n", path); + } + } + } + + agiViewFree(&view); + agiResClose(&game); + return 0; +} diff --git a/tests/testAgiVm.c b/tests/testAgiVm.c new file mode 100644 index 0000000..3507cd0 --- /dev/null +++ b/tests/testAgiVm.c @@ -0,0 +1,135 @@ +// Host-side test for examples/agi/agiVm.c (Phase 1 sub-A). +// +// Loads a LOGIC resource (default 0), runs the VM until it halts, +// and prints the halt reason plus any non-zero vars/flags. Since +// only the arithmetic and flag opcodes (0x00-0x11) are implemented +// this pass, expectation is one of: +// - HALT_RETURN at offset N (logic was entirely sub-A opcodes) +// - HALT_UNKNOWN_OP 0xXX at offset N (hit IF / new.room / etc.) +// - HALT_TRUNCATED at offset N (malformed resource) +// +// Usage: +// testAgiVm [logic-id] + +#include "agi.h" + +#include +#include + + +// ----- Prototypes ----- + +static const char *haltName(AgiVmHaltE r); +static void printNonZeroState(const AgiVmT *vm); + + +// ----- Helpers (alphabetical) ----- + +static const char *haltName(AgiVmHaltE r) { + switch (r) { + case AGI_VM_HALT_NONE: return "NONE"; + case AGI_VM_HALT_RETURN: return "RETURN"; + case AGI_VM_HALT_UNKNOWN_OP: return "UNKNOWN_OP"; + case AGI_VM_HALT_TRUNCATED: return "TRUNCATED"; + case AGI_VM_HALT_NEW_ROOM: return "NEW_ROOM"; + default: return "?"; + } +} + + +static void printNonZeroState(const AgiVmT *vm) { + uint16_t i; + uint16_t nonZeroVars; + uint16_t nonZeroFlags; + + nonZeroVars = 0u; + nonZeroFlags = 0u; + for (i = 0u; i < AGI_VM_NUM_VARS; i++) { + if (vm->vars[i] != 0u) { + if (nonZeroVars == 0u) { + printf(" vars:"); + } + printf(" v%u=%u", (unsigned)i, (unsigned)vm->vars[i]); + nonZeroVars++; + } + } + if (nonZeroVars > 0u) { + printf("\n"); + } + for (i = 0u; i < AGI_VM_NUM_FLAGS; i++) { + if (vm->flags[i] != 0u) { + if (nonZeroFlags == 0u) { + printf(" flags:"); + } + printf(" f%u", (unsigned)i); + nonZeroFlags++; + } + } + if (nonZeroFlags > 0u) { + printf("\n"); + } +} + + +// ----- main ----- + +int main(int argc, char **argv) { + AgiGameT game; + AgiVmT vm; + uint16_t logicId; + uint8_t *bytes; + uint16_t length; + AgiVmHaltE reason; + + if (argc < 2 || argc > 3) { + fprintf(stderr, "usage: %s [logic-id]\n", argv[0]); + return 2; + } + + if (!agiResOpen(&game, argv[1])) { + fprintf(stderr, "FAIL: agiResOpen rejected '%s'\n", argv[1]); + return 1; + } + + logicId = (argc == 3) ? (uint16_t)atoi(argv[2]) : 0u; + if (logicId >= game.resCount[AGI_RES_LOGIC]) { + fprintf(stderr, "FAIL: no LOGIC at id %u\n", (unsigned)logicId); + agiResClose(&game); + return 1; + } + + bytes = agiResLoad(&game, AGI_RES_LOGIC, logicId, &length); + if (bytes == NULL) { + fprintf(stderr, "FAIL: LOGIC %u failed to load\n", (unsigned)logicId); + agiResClose(&game); + return 1; + } + + agiVmInit(&vm); + if (!agiVmLoadLogic(&vm, bytes, length)) { + fprintf(stderr, "FAIL: agiVmLoadLogic rejected LOGIC %u (truncated header?)\n", (unsigned)logicId); + free(bytes); + agiResClose(&game); + return 1; + } + + printf("LOGIC %u: %u bytes resource, %u bytes bytecode\n", + (unsigned)logicId, (unsigned)length, (unsigned)vm.codeLength); + + reason = agiVmRun(&vm); + + printf(" halt=%s at pc=%u/%u", + haltName(reason), (unsigned)vm.pc, (unsigned)vm.codeLength); + if (reason == AGI_VM_HALT_UNKNOWN_OP) { + printf(" opcode=0x%02X", (unsigned)vm.lastUnknownOp); + } + if (reason == AGI_VM_HALT_NEW_ROOM) { + printf(" newRoom=%u", (unsigned)vm.newRoomId); + } + printf("\n"); + printNonZeroState(&vm); + + free(bytes); + agiResClose(&game); + return 0; +}