Optimized a bit better. Examples are a mess of broken crap.

This commit is contained in:
Scott Duensing 2026-05-27 20:22:24 -05:00
commit 2d447f3d95
17 changed files with 8838 additions and 0 deletions

218
AGI-SESSION-HANDOFF.md Normal file
View file

@ -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 `<platform> [game] [room]` and forwards
the room to DOS via dosbox `-c "AGI.EXE . <room>"`. 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.

1873
agi.c Normal file

File diff suppressed because it is too large Load diff

562
agi.h Normal file
View file

@ -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 <stdio.h>
// 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

368
agiObj.c Normal file
View file

@ -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 <stddef.h>
#include <string.h>
#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;
}
}

662
agiPic.c Normal file
View file

@ -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 <stddef.h>
#include <stdlib.h>
#include <string.h>
// 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;
}
}

309
agiRes.c Normal file
View file

@ -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 <stddef.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
// 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;
}

438
agiText.c Normal file
View file

@ -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 <stddef.h>
#include <string.h>
#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++;
}
}
}

340
agiView.c Normal file
View file

@ -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 <stddef.h>
#include <stdlib.h>
#include <string.h>
// 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;
}

2300
agiVm.c Normal file

File diff suppressed because it is too large Load diff

277
scripts/run-agi.sh Executable file
View file

@ -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 <platform> [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 <dos|amiga|atarist|iigs> [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 <<EOF
DOSBox: mounting $work as C:, running '$agi_cmd' for $game.
ESC quits the demo, then 'pause' waits for a keypress in the shell.
EOF
local dosboxArgs=(
-conf "$conf"
-set "mouse_capture=seamless"
)
# CYCLES=max (or any other DOSBox cycles value) overrides the conf's
# period-correct 386SX-16 cap. Use this to A/B test whether perf
# ceilings are coming from the simulated CPU or the code itself.
if [[ -n "${CYCLES:-}" ]]; then
dosboxArgs+=(-set "cpu cycles=$CYCLES")
fi
dosboxArgs+=(
-c "C:"
-c "$agi_cmd"
-c "pause"
--exit "$work"
)
# Avoid `exec`: we need bash to stay alive so the EXIT trap above
# actually runs and removes the staged $work dir. With `exec`,
# dosbox replaces the shell and the trap never fires, leaking
# ~1 MB of game data into /tmp on every launch.
dosbox "${dosboxArgs[@]}"
}
runAtariST() {
local bin=$repo/build/atarist/bin/AGI.PRG
local tos=$support/emutos-512k.img
local work
if [[ ! -f $bin ]]; then
echo "$bin not built. Run 'make -f make/atarist.mk EXAMPLE=agi' first." >&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 <<EOF
Hatari: mounting $work as GEMDOS C:, autostarting AGI.PRG for $game.
EOF
# Run hatari as a child so the EXIT trap removes the staged
# game data dir; `exec` would have hatari replace bash and
# leak $work into /tmp on every launch.
hatari \
--tos "$tos" \
--harddrive "$work" \
--gemdos-drive C \
--auto "C:\\AGI.PRG"
}
runAmiga() {
local bin=$repo/build/amiga/bin/Agi
local kickstart=$support/kickstart.rom
local workbench=$support/workbench.adf
local work
if [[ ! -f $bin ]]; then
echo "$bin not built. Run 'make -f make/amiga.mk EXAMPLE=agi' first." >&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 <<EOF
FS-UAE booting JOEYLIB hard drive ($work) and auto-running Agi for $game.
EOF
fs-uae "${fsargs[@]}"
}
runIigs() {
cat <<EOF >&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

169
scripts/test-agi.sh Executable file
View file

@ -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 <stdlib.h>
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/<name>/ 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

413
tests/disasmAgi.c Normal file
View file

@ -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 <game-directory> <logic-id> [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 <stdio.h>
#include <stdlib.h>
#include <string.h>
#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(" <truncated>)\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(") <truncated skip>\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(<truncated>");
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("<truncated>");
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("<truncated>)");
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 <truncated>\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 <game-directory> <logic-id> [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;
}

191
tests/testAgiPic.c Normal file
View file

@ -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 <game-directory> [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 <stdio.h>
#include <stdlib.h>
#include <string.h>
// 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 <game-directory> [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;
}

284
tests/testAgiPipeline.c Normal file
View file

@ -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 <game-directory> [max-cycles]
#include "agi.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#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 <game-directory> [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;
}

101
tests/testAgiRes.c Normal file
View file

@ -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 <game-directory>
//
// 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 <stdio.h>
#include <stdlib.h>
#include <string.h>
// ----- 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 <game-directory>\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;
}

198
tests/testAgiView.c Normal file
View file

@ -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 <game-directory> [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 <stdio.h>
#include <stdlib.h>
#include <string.h>
#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 <game-directory> [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;
}

135
tests/testAgiVm.c Normal file
View file

@ -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 <game-directory> [logic-id]
#include "agi.h"
#include <stdio.h>
#include <stdlib.h>
// ----- 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 <game-directory> [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;
}