commit 2d447f3d95d02af7c7fdddd4ffc646a7e5a5b615 Author: Scott Duensing Date: Wed May 27 20:22:24 2026 -0500 Optimized a bit better. Examples are a mess of broken crap. 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; +}