joeylib2/examples/agi/agi.c

1873 lines
66 KiB
C

// JoeyLib AGI interpreter, Phase 2 host. The VM in agiVm.c owns all
// game state (vars, flags, objects, text, menus); the host renders
// the VM's snapshot every frame and bridges VM callbacks to the
// platform (resource I/O, picture decoding, text rendering, key
// dispatch). One VM cycle runs every VM_FRAMES_PER_CYCLE display
// frames; per-real-second the host pumps agiVmTickSeconds so the
// engine clock advances and per-second logic.0 branches fire.
//
// Argv layout:
// argv[1] = game directory (defaults to ".")
// argv[2] = starting room (optional; 1..255 overrides logic.0's
// hardcoded new.room target so you can skip past a title
// screen the VM can't yet advance through. 0 means "use
// whatever logic.0 picks", same as omitting the argument.)
#include "agi.h"
#include "joey/audio.h"
#include "joey/core.h"
#include "joey/debug.h"
#include "joey/draw.h"
#include "joey/input.h"
#include "joey/palette.h"
#include "joey/present.h"
#include "joey/sprite.h"
#include "joey/surface.h"
#include "surfaceInternal.h"
#include <stddef.h>
#include <stdlib.h>
#include <string.h>
#define AGI_LOGIC_HDR_BYTES 2u
// Border fill color (rows above/below the AGI picture region and any
// area not painted by show.pic). AGI canonical: black (palette 0).
#define COLOR_MISSING 0u
#define CYCLE_HALT_GUARD 16u
#define EGO_START_X 80
#define EGO_START_Y 140
#define EGO_STEP_PX 2
// AGI v2 EGA layout on a 320x200 display: pic occupies y=0..167
// (160x168 doubled horizontally to 320 wide), and y=168..199 is the
// 4-text-row band below the picture. Text rows always use y = row*8,
// so pic at y=0 keeps the in-picture rows (0..20) visually aligned
// with the text rendered into them. Status line, when on, overlays
// the picture at its assigned row -- that's Sierra's canonical
// behaviour.
// Canonical AGI: 1 jiffy = 50ms, VM cycle dispatch every v10
// jiffies. KQ3 sets v10=1 -> 20 cycles/sec; default is 2 ->
// 10 cycles/sec. We drive the cycle off wall-clock so the
// rate stays correct regardless of host frame rate.
#define AGI_JIFFY_MS 50u
// Renders are wall-clock paced too. AGI canonical runs at ~20 Hz
// (one VM cycle = one rendered frame in Sierra's interpreter); we
// render slightly above that for smoother sparkle/cel cycling but
// not the full 70 Hz VBL rate, which on emulated 386 in DOSBox
// burns enough CPU on backdrop-restore + cel redraw that audio
// refill misses its 92 ms half-buffer deadline.
#define AGI_RENDER_PERIOD_MS 33u
#define STARTUP_CYCLE_LIMIT 32u
// Per Sierra KQ3 v2 EGA reference: pic starts 8 pixels down from
// the top of the 320x200 display (one 8-pixel text-row band reserved
// for the status line). Pic occupies y=8..175. Text rows use the
// row*8 absolute Y -- no pic offset added -- so they deliberately
// overlap pic art where the script wants captions inside the picture.
#define PIC_DEST_Y 8
// (Per-object save-under removed: we now restore each object's prev
// rect by memcpy from the gBackdrop surface, sized to the actual
// cel bbox at draw time. That handles arbitrary cel sizes without
// any per-object backup buffers and is faster on chunky platforms.)
// Canonical AGI ego loop indices.
#define LOOP_RIGHT 0
#define LOOP_LEFT 1
#define LOOP_DOWN 2
#define LOOP_UP 3
// Encryption key applied per-message in the AGI v2 message section.
// Same string in every supported game; XOR cycles every 11 bytes.
#define MSG_KEY "Avis Durgan"
#define MSG_KEY_LEN 11u
// Largest decrypted message we'll surface to the VM. Game scripts
// stay well under this (most under 80 chars); the cap protects the
// scratch buffer from a corrupt resource.
#define MSG_BUFFER_BYTES 256u
// ----- Types -----
typedef struct {
uint8_t *raw;
uint16_t rawLength;
const uint8_t *bytecode;
uint16_t bytecodeLength;
} LogicCacheSlotT;
typedef struct {
AgiViewT view;
bool loaded;
} ViewCacheSlotT;
// ----- Prototypes -----
static void agiSoundReset(void);
static uint32_t agiSynthFill(void *ctx, int8_t *dst, uint32_t count);
static void agiTickAdvance(void);
static void agiVoiceTick(uint8_t voice);
static void psgRecompute(uint8_t chipCh);
static void psgWriteData(uint8_t val);
static void cbAddToPic(void *ctx, uint8_t viewId, uint8_t loop, uint8_t cel,
uint8_t x, uint8_t y, uint8_t pri, uint8_t margin);
static void cbDiscardPic(void *ctx, uint8_t picId);
static void cbDrawPic(void *ctx, uint8_t picId);
static const uint8_t *cbFetchLogic(void *ctx, uint8_t logicId, uint16_t *outLength);
static const char *cbFetchMessage(void *ctx, uint8_t logicId, uint8_t msgId);
static bool cbHaveKey(void *ctx);
static bool cbIsPlayingSound(void *ctx);
static void cbLoadPic(void *ctx, uint8_t picId);
static void cbOverlayPic(void *ctx, uint8_t picId);
static void cbPlaySound(void *ctx, uint8_t soundId);
static void cbShowPic(void *ctx);
static uint32_t cbSoundDuration(void *ctx, uint8_t soundId);
static void cbStopSound(void *ctx);
static uint8_t cbViewCelCount(void *ctx, uint8_t viewId, uint8_t loopId);
static uint8_t cbViewLoopCount(void *ctx, uint8_t viewId);
static bool decodePicInto(uint8_t picId, bool clearFirst);
static void dispatchAllPressedKeys(void);
static void drawObjects(jlSurfaceT *stage);
static const AgiViewT *fetchView(uint8_t viewId);
static void invalidateObjectSaves(void);
static uint8_t loopForDirection(uint8_t direction, uint8_t loopCount);
static uint8_t parseArgU8(const char *s);
static void releaseLogicCache(void);
static void releaseViewCache(void);
static void renderFrame(jlSurfaceT *stage);
static const char *resolveGameDir(int argc, char **argv);
static uint8_t resolveStartingRoom(int argc, char **argv);
static void runVmCycle(void);
static void updateEgoFromInput(void);
// ----- Module state -----
static AgiGameT gGame;
static AgiPicT gPic;
static jlSurfaceT *gBackdrop;
static jlSurfaceT *gStage;
static AgiVmT gVm;
static LogicCacheSlotT gLogicCache[AGI_MAX_RESOURCES];
static ViewCacheSlotT gViewCache[AGI_MAX_RESOURCES];
static bool gPicReady;
static uint8_t gOverrideRoom;
static bool gOverrideRoomActive;
static bool gAnyKeyHit;
// Per-object previously-drawn stage rect. After each draw we record
// the cel's exact stage bbox here; next frame's pass 1 restores that
// rect from gBackdrop, then pass 2 redraws. valid=false means the
// object wasn't drawn last frame (skip restore).
typedef struct {
int16_t x;
int16_t y;
int16_t w;
int16_t h;
bool valid;
} ObjPrevRectT;
static ObjPrevRectT gObjPrev[AGI_MAX_OBJECTS];
// Per-text-row "had content last frame" flag. We restore each row's
// stripe of the backdrop at the start of every frame if it was
// dirtied last frame, so the old text disappears when the VM clears
// it (clear.lines) or moves to a different row.
static bool gTextRowDirty[AGI_TEXT_ROWS];
// One static scratch for the most recently decrypted message. The
// fetchMessage callback contract guarantees its return pointer is
// only valid until the next call -- the VM's expandMessage copies
// the bytes it needs before calling fetchMessage again.
static char gMessageScratch[MSG_BUFFER_BYTES];
// SN76489 PSG clock divisor base. AGI v2 uses the TI PSG's tone
// generator: tone_hz = 111860 / divisor (3.579545 MHz / 32).
#define PSG_CLOCK_HZ 111860u
// AGI sound is software-synthesized at 22050 Hz mono then pushed
// through the existing Sound Blaster auto-init DMA path via the
// jlAudioPlaySfxStream API. Three independent squarewave voices
// (16-bit phase, 8-bit amplitude) sum into the output stream; AGI
// tick logic advances every 22050/60 = 367 output samples, matching
// ScummVM's PCjr/Tandy sound model (which is also how Sierra's own
// SB drivers shipped the AGI sound after 1988).
//
// This is the "software synth to PCM" model. Pros: works on any
// SB-equipped DOS box, no hardware-IRQ headaches, sample-accurate
// note timing because we're rendering audio at 22050 Hz with the
// AGI ticks embedded. Cons: requires SB-compat audio (true for
// every target since 1989). The PCjr/Tandy native PSG path (cheaper,
// older) is a separate problem we can revisit later.
//
// SND v2 layout (per channel):
// bytes 0..7 : 4 channel offsets (LE16 each)
// per channel : sequence of 5-byte events. Bytes 0..1 = duration
// LE16 in 1/60s units, terminator 0xFFFF; bytes 2/3
// = raw SN76489 latch+data pair encoding the 10-bit
// frequency divisor (low 4 bits in byte 3, high 6
// bits in byte 2); byte 4 = 4-bit attenuation
// (0 = loudest, 15 = silent).
// freq_hz = 111860 / divisor (PSG clock 3.579545 MHz / 32).
//
// Sample rate must match the SB DMA setup (see MIX_RATE in
// src/port/dos/audio.c). 22050 keeps 256-sample stream chunks at
// ~12 ms each, well over an AGI tick.
typedef struct {
uint16_t chOffset; // cursor in this channel's event stream
uint16_t eventFramesRem; // ticks until current event ends
bool done; // channel hit 0xFFFF terminator
} AgiVoiceSchedT;
// Per-SN76489-channel state. The chip has 3 tone channels + 1 noise
// channel. Each event in the SND stream is 3 raw chip-command bytes
// (b4=atten, b3=latch-or-noise-control, b2=data) that may target
// any chip channel, not necessarily the AGI channel that owns the
// stream. We dispatch the bytes through psgWriteData (mirrors
// ScummVM's writeData) and let the chip-state machine update the
// right register. The synth then reads each tone channel's derived
// (amplitude, phaseInc) and produces samples.
typedef enum {
PSG_GEN_TONE = 0,
PSG_GEN_NOISE_PERIOD = 1,
PSG_GEN_NOISE_WHITE = 2
} PsgGenE;
typedef struct {
uint16_t freqCount; // 10-bit tone period; noise uses small values per ScummVM
uint8_t attenuation; // 0=loud, 15=silent
uint8_t genType; // PsgGenE
// Derived cache (recomputed when freqCount/attenuation change):
uint16_t phaseInc;
int8_t amplitude;
// Synth state:
uint16_t phase;
} PsgChannelT;
// AGI v2 SND has 4 sub-channels: 3 tone + 1 noise. The "AGI
// channel" indexes into the event-stream offsets table at the
// start of the file; it does NOT correspond 1:1 to the chip's 4
// channels. Each AGI channel ticks an event stream and fires raw
// chip writes; the chip state is shared across all AGI channels.
#define AGI_SOUND_CHANNELS 4u
#define AGI_SOUND_TONE_VOICES 3u
#define AGI_SOUND_NOISE_CHANNEL 3u
#define AGI_SOUND_END_MARKER 0xFFFFu
// AGI sound tick rate. Sierra's PCjr/Tandy SOUND.DRV ran at 120 Hz
// (vs ScummVM's 60 Hz). All script-driven timing (v11 seconds
// counter, animation cycling, motion stepping) is derived from
// this same tick rate, NOT from wall clock -- so doubling the
// tick rate makes both the music AND v11 advance 2x faster, which
// keeps script-internal wait conditions consistent.
#define AGI_SOUND_TICKS_HZ 120u
#define AGI_SYNTH_SAMPLE_RATE 22050u
#define AGI_SYNTH_SAMPLES_PER_TICK (AGI_SYNTH_SAMPLE_RATE / AGI_SOUND_TICKS_HZ)
// SN76489 attenuation -> int8 amplitude lookup. 2 dB per step
// (so atten=1 is 0.794x atten=0, atten=2 is 0.794x atten=1, etc.).
// Linear (15-atten)/15 mapping makes mid-volume notes far too loud
// and turns fade-outs into harsh cliffs; this table matches
// ScummVM's int16 PSG table scaled down to our 40-peak headroom
// for safe 3-voice sums into int8 PCM.
static const int8_t kAgiAttenAmp[16] = {
40, 32, 25, 20, 16, 13, 10, 8,
6, 5, 4, 3, 3, 2, 2, 0
};
// All synth + scheduler state runs entirely on the main thread now
// (the stream fill callback is invoked from halAudioFrameTick, not
// from any IRQ). No critical sections needed.
static uint8_t *gSoundBytes;
static uint16_t gSoundLength;
static AgiVoiceSchedT gVoiceSched[AGI_SOUND_CHANNELS];
static PsgChannelT gPsg[4]; // 3 tone + 1 noise
static uint8_t gPsgReg; // last register selected by a LATCH or ATTEN byte
static bool gSoundActive;
// AGI ticks are driven from wall-clock ms, NOT from samples
// produced. The previous countdown-per-sample approach assumed
// the synth's call rate matched SB DMA's drain rate (22050/sec).
// In practice DOSBox's SB Pro emulation at cycles=2200 drains
// the DMA buffer roughly 2.1x faster than the requested time
// constant (or fires refill IRQs at half the block-size cadence,
// or some combination -- empirically observed 47-50 kHz
// production rate when we asked for 22050). Pitch is unaffected
// because phaseInc still matches the actual DMA rate, but tempo
// (ticks/sec) was running 2.1x fast. Wall-clock tick gating
// locks tempo to 60 Hz exactly regardless of DMA quirks.
#define AGI_TICK_PERIOD_US (1000000u / AGI_SOUND_TICKS_HZ) // 8333 us at 120 Hz
// Tick counter advanced by agiTickAdvance() from the host loop.
// All AGI script-driven timing keys off this, NOT off wall clock,
// so changing AGI_SOUND_TICKS_HZ uniformly speeds up music + v11
// + animation + script waits together. v11 advances every 60 AGI
// ticks (= 1 "AGI sec"); at AGI_SOUND_TICKS_HZ=120 that's twice
// per wall second, matching Sierra's PCjr/Tandy game-tick model.
static uint32_t gAgiTickAccumUs;
static uint32_t gAgiTickLastWallMs;
static uint32_t gAgiV11SubTickAccum; // counts AGI ticks toward next v11++
#ifdef JOEYLIB_PLATFORM_IIGS
segment "AGIMAIN";
#endif
// ----- Internal helpers (alphabetical) -----
static void cbAddToPic(void *ctx, uint8_t viewId, uint8_t loop, uint8_t cel,
uint8_t x, uint8_t y, uint8_t pri, uint8_t margin) {
const AgiViewT *view;
(void)ctx;
view = fetchView(viewId);
if (view == NULL) {
return;
}
// Mutate the in-memory pic buffer only. The companion show.pic
// call (which always follows an add.to.pic batch in canonical
// AGI logic) is what blits the updated buffer to the stage and
// republishes the backdrop. Per-add.to.pic blits would compound
// into 100s of ms of redundant pixel work on slow targets.
agiPicAddView(&gPic, view, loop, cel, (int16_t)x, (int16_t)y, pri, margin);
}
static void cbDiscardPic(void *ctx, uint8_t picId) {
(void)ctx;
(void)picId;
}
static void cbDrawPic(void *ctx, uint8_t picId) {
uint8_t i;
(void)ctx;
jlLogF("cbDrawPic id=%u v11=%u", (unsigned)picId, (unsigned)gVm.vars[11]);
// Dump active objects so we can see whether scene leftovers
// (e.g. sparkle objects from previous scene) are still flagged
// animated/drawn at scene-change time.
for (i = 0u; i < (uint8_t)AGI_MAX_OBJECTS; i++) {
const AgiObjectT *o = &gVm.objects[i];
if (o->flags != 0u) {
jlLogF(" obj[%u] flags=0x%02x view=%u loop=%u cel=%u x=%d y=%d",
(unsigned)i, (unsigned)o->flags,
(unsigned)o->viewId, (unsigned)o->loop, (unsigned)o->cel,
(int)o->x, (int)o->y);
}
}
// Dump set flags so we can compare flag state across scene
// transitions and identify which flag gates a wrong-path branch.
{
uint16_t fi;
char line[128];
size_t used;
line[0] = '\0';
used = 0u;
for (fi = 0u; fi < AGI_VM_NUM_FLAGS; fi++) {
if (gVm.flags[fi] != 0u) {
int n = snprintf(line + used, sizeof(line) - used, "%u ", (unsigned)fi);
if (n < 0 || (size_t)n >= sizeof(line) - used) {
jlLogF(" flagsSet=%s...(trunc)", line);
line[0] = '\0';
used = 0u;
} else {
used += (size_t)n;
}
}
}
jlLogF(" flagsSet=%s", line);
}
jlLogFlush();
decodePicInto(picId, true);
}
static const uint8_t *cbFetchLogic(void *ctx, uint8_t logicId, uint16_t *outLength) {
LogicCacheSlotT *slot;
uint8_t *raw;
uint16_t rawLength;
uint16_t bcLen;
(void)ctx;
slot = &gLogicCache[logicId];
if (slot->bytecode != NULL) {
*outLength = slot->bytecodeLength;
return slot->bytecode;
}
if (logicId >= gGame.resCount[AGI_RES_LOGIC]) {
return NULL;
}
raw = agiResLoad(&gGame, AGI_RES_LOGIC, (uint16_t)logicId, &rawLength);
if (raw == NULL || rawLength < AGI_LOGIC_HDR_BYTES) {
if (raw != NULL) {
free(raw);
}
return NULL;
}
bcLen = (uint16_t)(raw[0] | ((uint16_t)raw[1] << 8));
if ((uint16_t)(AGI_LOGIC_HDR_BYTES + bcLen) > rawLength) {
free(raw);
return NULL;
}
slot->raw = raw;
slot->rawLength = rawLength;
slot->bytecode = raw + AGI_LOGIC_HDR_BYTES;
slot->bytecodeLength = bcLen;
*outLength = bcLen;
return slot->bytecode;
}
// Look up message `msgId` in logic `logicId`'s message table, decrypt
// it via the cycling "Avis Durgan" XOR, write the result to the
// shared scratch buffer, and return a pointer to it. Returns NULL
// on missing message; returns "" for an empty slot.
//
// AGI v2 quirks the spec requires:
// 1. The 2-byte offsets stored in the table are relative to byte 1
// of the message section (i.e. they exclude the count byte), so
// the actual byte position is stored_offset + 1.
// 2. The XOR key cycles continuously across the whole strings
// block, not per-message. Key index for byte K = (K - strings
// start byte) mod 11.
static const char *cbFetchMessage(void *ctx, uint8_t logicId, uint8_t msgId) {
LogicCacheSlotT *slot;
const uint8_t *raw;
const uint8_t *msgSection;
uint16_t msgSectionOff;
uint16_t rawLength;
uint16_t msgCount;
uint16_t msgOff;
uint16_t stringsStartOff;
uint16_t k;
uint16_t i;
(void)ctx;
slot = &gLogicCache[logicId];
if (slot->raw == NULL) {
uint16_t ignore = 0u;
if (cbFetchLogic(NULL, logicId, &ignore) == NULL) {
return NULL;
}
}
raw = slot->raw;
rawLength = slot->rawLength;
msgSectionOff = (uint16_t)(AGI_LOGIC_HDR_BYTES + slot->bytecodeLength);
if (msgSectionOff >= rawLength) {
return NULL;
}
msgSection = raw + msgSectionOff;
msgCount = (uint16_t)msgSection[0];
if (msgId == 0u || msgId > msgCount) {
return NULL;
}
// Find the lowest non-zero stored offset across all message
// slots. That's where the encrypted strings block begins.
stringsStartOff = 0xFFFFu;
for (k = 1u; k <= msgCount; k++) {
uint16_t off;
uint16_t tOff = (uint16_t)(3u + (uint16_t)((k - 1u) * 2u));
if ((uint16_t)(msgSectionOff + tOff + 1u) >= rawLength) {
continue;
}
off = (uint16_t)(msgSection[tOff] | ((uint16_t)msgSection[tOff + 1u] << 8));
if (off != 0u && off < stringsStartOff) {
stringsStartOff = off;
}
}
if (stringsStartOff == 0xFFFFu) {
gMessageScratch[0] = '\0';
return gMessageScratch;
}
{
uint16_t tableOff;
uint16_t msgStart;
uint16_t keyBase;
tableOff = (uint16_t)(3u + (uint16_t)((msgId - 1u) * 2u));
if ((uint16_t)(msgSectionOff + tableOff + 1u) >= rawLength) {
return NULL;
}
msgOff = (uint16_t)(msgSection[tableOff] | ((uint16_t)msgSection[tableOff + 1u] << 8));
if (msgOff == 0u) {
gMessageScratch[0] = '\0';
return gMessageScratch;
}
// actual byte = section_off + stored_offset + 1.
msgStart = (uint16_t)(msgSectionOff + msgOff + 1u);
if (msgStart >= rawLength) {
return NULL;
}
// continuous key index = (msgOff - stringsStartOff) at byte 0
// of this message, then increments per byte.
keyBase = (uint16_t)(msgOff - stringsStartOff);
i = 0u;
while (msgStart + i < rawLength && i < (uint16_t)(MSG_BUFFER_BYTES - 1u)) {
uint8_t enc;
uint8_t dec;
enc = raw[msgStart + i];
dec = (uint8_t)(enc ^ (uint8_t)MSG_KEY[(keyBase + i) % MSG_KEY_LEN]);
if (dec == 0u) {
break;
}
gMessageScratch[i] = (char)dec;
i++;
}
gMessageScratch[i] = '\0';
}
return gMessageScratch;
}
static bool cbHaveKey(void *ctx) {
bool hit;
(void)ctx;
hit = gAnyKeyHit;
gAnyKeyHit = false;
return hit;
}
static void cbLoadPic(void *ctx, uint8_t picId) {
(void)ctx;
(void)picId;
}
static void cbOverlayPic(void *ctx, uint8_t picId) {
(void)ctx;
jlLogF("cbOverlayPic id=%u v11=%u", (unsigned)picId, (unsigned)gVm.vars[11]);
decodePicInto(picId, false);
}
static void cbShowPic(void *ctx) {
(void)ctx;
jlLogF("cbShowPic v11=%u", (unsigned)gVm.vars[11]);
if (gStage == NULL) {
return;
}
// Clear the WHOLE stage first so the rows above/below the AGI
// picture region (y=0..15 and y=184..199) don't retain pixels
// from the previous scene. Without this, text drawn during the
// logo phase at rows 22..24 ends up captured by the backdrop
// copy below and gets restored over every subsequent frame --
// so clear.lines never visually erases anything that sat in
// the border bands.
jlSurfaceClear(gStage, COLOR_MISSING);
agiPicBlit(&gPic, gStage, PIC_DEST_Y);
if (gBackdrop != NULL) {
jlSurfaceCopy(gBackdrop, gStage);
}
gPicReady = true;
// The stage just got repainted; any prior save-under refers to
// pre-paint pixels and must NOT be restored next frame (it would
// leak the old picture over the new one).
invalidateObjectSaves();
}
// Recompute derived (phaseInc, amplitude) for chip channel
// `chipCh` from its current freqCount + attenuation + genType.
// Called whenever the chip state for a tone channel changes;
// noise channels aren't synthesized and skip recompute.
static void psgRecompute(uint8_t chipCh) {
PsgChannelT *p;
uint32_t freqHz;
if (chipCh >= 3u) {
return;
}
p = &gPsg[chipCh];
if (p->genType != PSG_GEN_TONE || p->freqCount == 0u || p->attenuation >= 15u) {
p->phaseInc = 0u;
p->amplitude = 0;
return;
}
freqHz = (uint32_t)PSG_CLOCK_HZ / (uint32_t)p->freqCount;
p->phaseInc = (uint16_t)((freqHz * 65536u) / AGI_SYNTH_SAMPLE_RATE);
p->amplitude = kAgiAttenAmp[p->attenuation];
}
// SN76489 byte dispatcher, byte-for-byte equivalent to ScummVM's
// SoundGenPCJr::writeData. The chip has 4 channels (3 tone + 1
// noise) and a single "last register" latch used by DATA bytes
// to know which channel to update.
//
// Byte forms (per the SN76489 datasheet):
// 1rrr1xxx (atten): bits 6-5 = chip channel, bits 3-0 = atten.
// Updates _reg and the channel's attenuation.
// 11100xxx (noise): noise channel (3); bit 2 = white/period,
// bits 1-0 = period code. _reg NOT updated --
// the DATA byte that follows still targets
// whatever channel was last selected by a tone
// latch or atten command.
// 1rrr0xxx (tone latch): bits 6-5 = chip channel, bits 3-0 =
// LOW 4 bits of freqCount. Sets _reg, fully
// replaces low nibble, marks gen as tone.
// 0xxxxxxx (data): ORs (val & 0x3F) << 4 into freqCount of
// whichever channel _reg points to (the high
// 6 bits of the 10-bit divisor). The OR is
// key: an event without a fresh tone latch
// keeps its previous low nibble, so two
// events can share a low-nibble setup and a
// noise-control byte in between leaves the
// tone channel's freqCount mostly intact.
static void psgWriteData(uint8_t val) {
uint8_t reg;
if ((val & 0x90u) == 0x90u) {
gPsgReg = (uint8_t)((val >> 5) & 0x03u);
gPsg[gPsgReg].attenuation = (uint8_t)(val & 0x0Fu);
psgRecompute(gPsgReg);
} else if ((val & 0xF0u) == 0xE0u) {
gPsg[3].genType = (val & 0x04u) ? PSG_GEN_NOISE_WHITE : PSG_GEN_NOISE_PERIOD;
switch (val & 0x03u) {
case 0u: gPsg[3].freqCount = 32u; break;
case 1u: gPsg[3].freqCount = 64u; break;
case 2u: gPsg[3].freqCount = 128u; break;
default: gPsg[3].freqCount = (uint16_t)(gPsg[2].freqCount * 2u); break;
}
// No _reg update; no synth recompute (noise not synthesized).
} else if (val & 0x80u) {
reg = (uint8_t)((val >> 5) & 0x03u);
gPsgReg = reg;
gPsg[reg].freqCount = (uint16_t)(val & 0x0Fu);
gPsg[reg].genType = PSG_GEN_TONE;
psgRecompute(reg);
} else {
gPsg[gPsgReg].freqCount |= (uint16_t)((val & 0x3Fu) << 4);
psgRecompute(gPsgReg);
}
}
// Silence every channel and free the owned SND buffer. Called from
// main thread; stops the SFX-stream slot first so the synth
// callback can't be invoked again against a freed buffer.
static void agiSoundReset(void) {
uint8_t v;
jlAudioStopSfx(0u);
for (v = 0u; v < AGI_SOUND_CHANNELS; v++) {
gVoiceSched[v].chOffset = 0u;
gVoiceSched[v].eventFramesRem = 0u;
gVoiceSched[v].done = true;
}
for (v = 0u; v < 4u; v++) {
gPsg[v].freqCount = 0u;
gPsg[v].attenuation = 15u; // silent
gPsg[v].genType = PSG_GEN_TONE;
gPsg[v].phaseInc = 0u;
gPsg[v].amplitude = 0;
gPsg[v].phase = 0u;
}
gPsgReg = 0u;
gSoundActive = false;
gAgiTickAccumUs = 0u;
gAgiTickLastWallMs = 0u;
gAgiV11SubTickAccum = 0u;
if (gSoundBytes != NULL) {
free(gSoundBytes);
gSoundBytes = NULL;
}
gSoundLength = 0u;
}
// Advance one AGI channel by one tick. When the channel's
// duration counter rolls over, parse the 5-byte event header
// (dur LE16 + 3 chip-command bytes) and feed the 3 chip bytes
// through psgWriteData in ScummVM's order: byte 4 (atten), byte
// 3 (latch or noise control), byte 2 (data). The chip state is
// shared across all AGI channels, so an event in AGI channel N
// can update chip-channel state for any chip register, including
// the noise channel and other tone channels' attenuation.
//
// dur=0 events fire-then-skip in the same tick (AGI uses them for
// legato note transitions). Inner-loop guard caps iterations so
// a malformed channel can't lock the synth.
static void agiVoiceTick(uint8_t v) {
AgiVoiceSchedT *sch;
uint16_t dur;
uint8_t guard;
sch = &gVoiceSched[v];
if (sch->done) {
return;
}
guard = 16u;
while (sch->eventFramesRem == 0u && guard != 0u) {
guard--;
if ((uint32_t)sch->chOffset + 4u >= (uint32_t)gSoundLength) {
sch->done = true;
return;
}
dur = (uint16_t)(gSoundBytes[sch->chOffset] |
((uint16_t)gSoundBytes[sch->chOffset + 1u] << 8));
if (dur == AGI_SOUND_END_MARKER) {
sch->done = true;
return;
}
// ScummVM order: attenuation byte first, then latch, then
// data. Critical because the noise-control byte (0xE0-0xEF)
// does NOT update _reg, so the data byte that follows must
// target whatever channel the atten byte selected.
psgWriteData(gSoundBytes[sch->chOffset + 4u]);
psgWriteData(gSoundBytes[sch->chOffset + 3u]);
psgWriteData(gSoundBytes[sch->chOffset + 2u]);
sch->chOffset = (uint16_t)(sch->chOffset + 5u);
sch->eventFramesRem = dur;
// Light per-event trace (no flush; buffered). Useful when
// diagnosing whether a "weird" note is from the SND data
// itself or from our chip emulator.
jlLogF(" agiV%u @%u dur=%u b234=%02x %02x %02x | psg ch0/1/2/N freq=%u/%u/%u/%u atten=%u/%u/%u/%u _reg=%u",
(unsigned)v, (unsigned)(sch->chOffset - 5u), (unsigned)dur,
(unsigned)gSoundBytes[sch->chOffset - 3u],
(unsigned)gSoundBytes[sch->chOffset - 2u],
(unsigned)gSoundBytes[sch->chOffset - 1u],
(unsigned)gPsg[0].freqCount, (unsigned)gPsg[1].freqCount,
(unsigned)gPsg[2].freqCount, (unsigned)gPsg[3].freqCount,
(unsigned)gPsg[0].attenuation, (unsigned)gPsg[1].attenuation,
(unsigned)gPsg[2].attenuation, (unsigned)gPsg[3].attenuation,
(unsigned)gPsgReg);
}
if (sch->eventFramesRem != 0u) {
sch->eventFramesRem = (uint16_t)(sch->eventFramesRem - 1u);
}
}
// Host-loop tick generator. Drives ALL AGI-time-sensitive things
// (music event advancement, v11 seconds counter increment) off the
// same wall-clock-paced tick stream. Sierra's interpreter does the
// same: one tick = one PSG event step AND one 60th of an "AGI
// second", so v11 stays in lockstep with the music regardless of
// what AGI_SOUND_TICKS_HZ is set to. Called once per host frame.
static void agiTickAdvance(void) {
static uint32_t gLastObjDumpMs = 0;
uint32_t nowMs = jlMillisElapsed();
uint32_t deltaMs;
uint8_t v;
// Periodic dump of currently-drawn objects so we can see what's
// visible at any moment without having to log per-opcode.
if (nowMs - gLastObjDumpMs >= 2000u || gLastObjDumpMs == 0u) {
uint8_t i;
jlLogF("--- obj dump @wallMs=%u v11=%u v36=%u v221=%u v222=%u v223=%u v224=%u v225=%u v226=%u ---",
(unsigned)nowMs, (unsigned)gVm.vars[11],
(unsigned)gVm.vars[36],
(unsigned)gVm.vars[221], (unsigned)gVm.vars[222],
(unsigned)gVm.vars[223], (unsigned)gVm.vars[224],
(unsigned)gVm.vars[225], (unsigned)gVm.vars[226]);
for (i = 0u; i < (uint8_t)AGI_MAX_OBJECTS; i++) {
const AgiObjectT *o = &gVm.objects[i];
if ((o->flags & (AGI_OBJ_FLAG_ANIMATED | AGI_OBJ_FLAG_DRAWN))
== (AGI_OBJ_FLAG_ANIMATED | AGI_OBJ_FLAG_DRAWN)) {
jlLogF(" drawn obj[%u] view=%u loop=%u cel=%u x=%d y=%d flags=0x%02x",
(unsigned)i, (unsigned)o->viewId, (unsigned)o->loop,
(unsigned)o->cel, (int)o->x, (int)o->y,
(unsigned)o->flags);
}
}
gLastObjDumpMs = nowMs;
}
if (gAgiTickLastWallMs == 0u) {
gAgiTickLastWallMs = nowMs;
// Prime one tick so events load immediately on song start.
gAgiTickAccumUs = AGI_TICK_PERIOD_US;
return;
}
deltaMs = nowMs - gAgiTickLastWallMs;
gAgiTickLastWallMs = nowMs;
gAgiTickAccumUs = (uint32_t)(gAgiTickAccumUs + deltaMs * 1000u);
while (gAgiTickAccumUs >= AGI_TICK_PERIOD_US) {
gAgiTickAccumUs -= AGI_TICK_PERIOD_US;
// Advance music if any.
if (gSoundActive) {
bool allDone;
for (v = 0u; v < AGI_SOUND_CHANNELS; v++) {
agiVoiceTick(v);
}
allDone = true;
for (v = 0u; v < AGI_SOUND_TONE_VOICES; v++) {
if (!gVoiceSched[v].done) {
allDone = false;
break;
}
}
if (allDone) {
gSoundActive = false;
jlLogF("synth EOS wallMs=%u v0done=%u v1done=%u v2done=%u v3done=%u",
(unsigned)nowMs,
(unsigned)gVoiceSched[0].done,
(unsigned)gVoiceSched[1].done,
(unsigned)gVoiceSched[2].done,
(unsigned)gVoiceSched[3].done);
jlLogFlush();
}
}
// v11 advances every 60 AGI ticks (= 1 AGI sec). At
// AGI_SOUND_TICKS_HZ=120 this is 2 v11 ticks per wall
// sec, which is what the KQ3 script expects (Sierra
// game-tick rate, not wall clock).
gAgiV11SubTickAccum++;
if (gAgiV11SubTickAccum >= 60u) {
gAgiV11SubTickAccum = 0u;
agiVmTickSeconds(&gVm, 1u);
}
}
}
// Stream-fill callback for the SB DMA path. Pure sample generator
// now -- all tick advancement is done by agiTickAdvance() from the
// host loop. The synth just reads the latest chip state cached by
// psgRecompute and emits signed 8-bit PCM.
static uint32_t agiSynthFill(void *ctx, int8_t *dst, uint32_t count) {
uint32_t i;
uint8_t v;
int sum;
int8_t voiceSample;
(void)ctx;
if (!gSoundActive) {
return 0u;
}
for (i = 0u; i < count; i++) {
sum = 0;
for (v = 0u; v < AGI_SOUND_TONE_VOICES; v++) {
voiceSample = (gPsg[v].phase & 0x8000u)
? gPsg[v].amplitude
: (int8_t)(-gPsg[v].amplitude);
sum += voiceSample;
gPsg[v].phase = (uint16_t)(gPsg[v].phase + gPsg[v].phaseInc);
}
if (sum > 127) { sum = 127; }
if (sum < -128) { sum = -128; }
dst[i] = (int8_t)sum;
}
return count;
}
static void cbPlaySound(void *ctx, uint8_t soundId) {
uint8_t *bytes;
uint16_t length;
uint8_t v;
uint16_t off;
(void)ctx;
agiSoundReset();
if ((uint16_t)soundId >= gGame.resCount[AGI_RES_SOUND]) {
return;
}
bytes = agiResLoad(&gGame, AGI_RES_SOUND, (uint16_t)soundId, &length);
if (bytes == NULL) {
return;
}
if (length < 8u) {
free(bytes);
return;
}
gSoundBytes = bytes;
gSoundLength = length;
jlLogF("cbPlaySound id=%u len=%u offs=%u/%u/%u/%u wallMs=%u",
(unsigned)soundId, (unsigned)length,
(unsigned)(bytes[0] | (bytes[1] << 8)),
(unsigned)(bytes[2] | (bytes[3] << 8)),
(unsigned)(bytes[4] | (bytes[5] << 8)),
(unsigned)(bytes[6] | (bytes[7] << 8)),
(unsigned)jlMillisElapsed());
jlLogFlush();
for (v = 0u; v < AGI_SOUND_CHANNELS; v++) {
off = (uint16_t)(bytes[v * 2u] | ((uint16_t)bytes[v * 2u + 1u] << 8));
gVoiceSched[v].chOffset = off;
gVoiceSched[v].eventFramesRem = 0u;
gVoiceSched[v].done = (off == 0u) || ((uint32_t)off + 4u >= (uint32_t)length);
}
// Chip state was zeroed by agiSoundReset above; nothing more
// to init here. First agiVoiceTick will fire the events that
// configure freqCount / attenuation / genType.
// Wall-clock-driven tick scheduler: cleared in agiSoundReset
// above; agiSynthFill's first invocation primes the timestamp.
gSoundActive = true;
// Push the synth into SFX slot 0 of the SB DMA mixer at the
// exact output rate so no resampling happens between the synth
// and the chip.
jlAudioPlaySfxStream(0u, agiSynthFill, NULL, AGI_SYNTH_SAMPLE_RATE);
}
// VM polls this each cycle to detect natural sound completion.
// True while any tone voice still has events left; false once
// every voice has run off its 0xFFFF terminator (or stopSound
// was called).
static bool cbIsPlayingSound(void *ctx) {
(void)ctx;
return gSoundActive;
}
static void cbStopSound(void *ctx) {
(void)ctx;
jlLog("cbStopSound");
jlLogFlush();
agiSoundReset();
}
// View metadata callbacks for the VM's cycle / last.cel /
// number.of.loops opcodes. fetchView walks the view-cache, faulting
// the view in from VOL on a cold hit, so the first call after a
// view is referenced may decode the resource. Returns 0 if the
// view cannot be loaded; the VM treats 0 as "still loading" (no
// premature end.of.loop fire) and the host renderer's own clamps
// keep the screen from drawing garbage cels.
static uint8_t cbViewCelCount(void *ctx, uint8_t viewId, uint8_t loopId) {
const AgiViewT *view;
(void)ctx;
view = fetchView(viewId);
if (view == NULL || loopId >= AGI_MAX_LOOPS_PER_VIEW) {
return 0u;
}
return view->loops[loopId].celCount;
}
static uint8_t cbViewLoopCount(void *ctx, uint8_t viewId) {
const AgiViewT *view;
(void)ctx;
view = fetchView(viewId);
if (view == NULL) {
return 0u;
}
return view->loopCount;
}
// (Pre-render synthesizeSnd path was replaced by the streaming
// generator agiSoundFill above; see that for the SND event format
// and PSG synthesis details.)
#if 0
static bool synthesizeSnd_removed(const uint8_t *bytes, uint16_t length, uint8_t **outPcm, uint32_t *outLen) {
uint32_t maxTicks;
uint32_t durSamples;
uint8_t *pcm;
uint8_t ch;
if (length < 8u) {
return false;
}
maxTicks = 0u;
for (ch = 0u; ch < 4u; ch++) {
uint16_t off;
uint16_t i;
uint32_t ticks;
off = (uint16_t)(bytes[ch * 2u] | ((uint16_t)bytes[ch * 2u + 1u] << 8));
if (off >= length) {
continue;
}
ticks = 0u;
i = off;
while ((uint32_t)i + 4u < (uint32_t)length) {
uint16_t dur;
dur = (uint16_t)(bytes[i] | ((uint16_t)bytes[i + 1u] << 8));
if (dur == 0xFFFFu) {
break;
}
ticks = (uint32_t)(ticks + dur);
i = (uint16_t)(i + 5u);
}
if (ticks > maxTicks) {
maxTicks = ticks;
}
}
if (maxTicks == 0u) {
return false;
}
durSamples = (maxTicks * (uint32_t)SOUND_SAMPLE_RATE) / 60u;
if (durSamples == 0u) {
return false;
}
// audioSfxOverlayMix uses a 16.16 fixed-point position counter;
// input samples beyond 2^16 - 1 cause pos to wrap and the slot
// to loop the buffer forever instead of completing. Truncate
// here so longer sounds at least play their opening bars.
if (durSamples > SOUND_MAX_SAMPLES) {
durSamples = SOUND_MAX_SAMPLES;
}
pcm = (uint8_t *)malloc(durSamples);
if (pcm == NULL) {
return false;
}
memset(pcm, 0, durSamples); // signed silence = 0
for (ch = 0u; ch < SOUND_SYNTH_CHANNELS; ch++) {
uint16_t off;
uint16_t i;
uint32_t sampleIdx;
// 16.16 phase accumulator: 0..0xFFFF = one square-wave
// cycle. Stepping by phaseStep per output sample yields
// sub-sample precision so 717 Hz, 466 Hz, etc. stay at the
// correct frequency instead of quantizing to the nearest
// integer half-period (which scrambled the melody at
// lower sample rates).
uint32_t phaseAcc;
off = (uint16_t)(bytes[ch * 2u] | ((uint16_t)bytes[ch * 2u + 1u] << 8));
if (off >= length) {
continue;
}
sampleIdx = 0u;
phaseAcc = 0u;
i = off;
while ((uint32_t)i + 4u < (uint32_t)length && sampleIdx < durSamples) {
uint16_t dur;
uint16_t divisor;
uint8_t atten;
int8_t amp;
uint32_t durSamps;
uint32_t phaseStep;
uint32_t s;
dur = (uint16_t)(bytes[i] | ((uint16_t)bytes[i + 1u] << 8));
if (dur == 0xFFFFu) {
break;
}
divisor = (uint16_t)(((bytes[i + 2u] & 0x3Fu) << 4) | (bytes[i + 3u] & 0x0Fu));
atten = (uint8_t)(bytes[i + 4u] & 0x0Fu);
amp = kAgiSoundAmp[atten];
durSamps = ((uint32_t)dur * (uint32_t)SOUND_SAMPLE_RATE) / 60u;
if (divisor == 0u || amp == 0) {
// Silent event -- just advance sampleIdx without
// touching pcm (already zeroed) or stepping phase.
if (sampleIdx + durSamps > durSamples) {
sampleIdx = durSamples;
} else {
sampleIdx = sampleIdx + durSamps;
}
i = (uint16_t)(i + 5u);
continue;
}
// freq_hz = PSG_CLOCK_HZ / divisor.
// phaseStep (16.16, one cycle = 0x10000) per output sample
// = (freq_hz << 16) / SOUND_SAMPLE_RATE
// = (PSG_CLOCK_HZ << 16) / (divisor * SOUND_SAMPLE_RATE)
phaseStep = ((uint32_t)PSG_CLOCK_HZ << 16) / ((uint32_t)divisor * SOUND_SAMPLE_RATE);
for (s = 0u; s < durSamps && sampleIdx < durSamples; s++, sampleIdx++) {
int8_t sval;
int16_t mixed;
sval = (phaseAcc & 0x8000u) ? (int8_t)(-amp) : amp;
mixed = (int16_t)((int8_t)pcm[sampleIdx]) + sval;
if (mixed > 127) { mixed = 127; }
if (mixed < -128) { mixed = -128; }
pcm[sampleIdx] = (uint8_t)mixed;
phaseAcc = (uint32_t)((phaseAcc + phaseStep) & 0xFFFFu);
}
i = (uint16_t)(i + 5u);
}
}
*outPcm = pcm;
*outLen = durSamples;
return true;
}
static void cbPlaySound(void *ctx, uint8_t soundId) {
uint8_t *bytes;
uint16_t length;
uint8_t *pcm;
uint32_t pcmLen;
(void)ctx;
if (!gAudioReady) {
return;
}
// Cache hit: same sound replayed -- just restart the slot,
// skip the file load + synth (instant restart, no freeze).
if (gSoundPcmValid && gSoundPcm != NULL && gSoundPcmId == soundId) {
jlAudioStopSfx(0u);
jlAudioPlaySfx(0u, gSoundPcm, gSoundPcmLen, (uint16_t)SOUND_SAMPLE_RATE);
return;
}
// Cache miss: tear down prior buffer, synth a new one.
if (gSoundPcm != NULL) {
jlAudioStopSfx(0u);
free(gSoundPcm);
gSoundPcm = NULL;
gSoundPcmLen = 0u;
}
gSoundPcmValid = false;
if ((uint16_t)soundId >= gGame.resCount[AGI_RES_SOUND]) {
return;
}
bytes = agiResLoad(&gGame, AGI_RES_SOUND, (uint16_t)soundId, &length);
if (bytes == NULL) {
return;
}
pcm = NULL;
pcmLen = 0u;
if (synthesizeSnd(bytes, length, &pcm, &pcmLen)) {
gSoundPcm = pcm;
gSoundPcmLen = pcmLen;
gSoundPcmId = soundId;
gSoundPcmValid = true;
jlAudioPlaySfx(0u, gSoundPcm, gSoundPcmLen, (uint16_t)SOUND_SAMPLE_RATE);
}
free(bytes);
}
static void cbStopSound(void *ctx) {
(void)ctx;
if (!gAudioReady) {
return;
}
// Stop the slot but keep the PCM cached -- a quick stop/start
// (very common in AGI v2 state machines) then replays without
// a re-synth freeze.
jlAudioStopSfx(0u);
}
#endif
// Parse an AGI v2 SND resource and return its playback duration in
// milliseconds (max across the 4 PSG channels). Used to defer the
// sound-done flag firing until the music would naturally end --
// drives KQ3's title state-1 / state-3 shortcut which advances the
// countdown to 2 sec once flag 9 (sound finished) is set.
//
// SND v2 layout:
// bytes 0..7 : 4 channel offsets (2-byte LE each)
// per channel : sequence of 5-byte events (duration LE16, freq LE16,
// attenuation), terminated by duration = 0xFFFF.
// Duration units are 1/60 sec (NTSC vblank ticks).
static uint32_t cbSoundDuration(void *ctx, uint8_t soundId) {
uint8_t *bytes;
uint16_t length;
uint32_t maxTicks;
uint8_t ch;
(void)ctx;
if ((uint16_t)soundId >= gGame.resCount[AGI_RES_SOUND]) {
return 0u;
}
bytes = agiResLoad(&gGame, AGI_RES_SOUND, (uint16_t)soundId, &length);
if (bytes == NULL) {
return 0u;
}
if (length < 8u) {
free(bytes);
return 0u;
}
maxTicks = 0u;
for (ch = 0u; ch < 4u; ch++) {
uint16_t off;
uint16_t i;
uint32_t ticks;
off = (uint16_t)(bytes[ch * 2u] | ((uint16_t)bytes[ch * 2u + 1u] << 8));
ticks = 0u;
i = off;
while ((uint32_t)i + 4u < (uint32_t)length) {
uint16_t dur;
dur = (uint16_t)(bytes[i] | ((uint16_t)bytes[i + 1u] << 8));
if (dur == 0xFFFFu) {
break;
}
ticks = (uint32_t)(ticks + dur);
i = (uint16_t)(i + 5u);
}
if (ticks > maxTicks) {
maxTicks = ticks;
}
}
free(bytes);
// 60 ticks / sec -> ms = ticks * 1000 / 60 = ticks * 50 / 3
return (maxTicks * 1000u) / 60u;
}
static bool decodePicInto(uint8_t picId, bool clearFirst) {
uint8_t *bytes;
uint16_t length;
bool ok;
if ((uint16_t)picId >= gGame.resCount[AGI_RES_PIC]) {
return false;
}
bytes = agiResLoad(&gGame, AGI_RES_PIC, (uint16_t)picId, &length);
if (bytes == NULL) {
return false;
}
if (clearFirst) {
agiPicClear(&gPic);
}
ok = agiPicDecode(&gPic, bytes, length);
free(bytes);
return ok;
}
// Walk every key the JoeyLib enum knows about; for each one that
// went down this frame, look it up against vm->keyBindings via
// agiVmDispatchKey. Function keys map to the AGI extended scancode
// pair (0, 59..68); printable ASCII keys map to (asciiCode, 0).
static void dispatchAllPressedKeys(void) {
static const uint8_t fnScancodes[10] = { 59, 60, 61, 62, 63, 64, 65, 66, 67, 68 };
static const jlKeyE fnKeys[10] = {
KEY_F1, KEY_F2, KEY_F3, KEY_F4, KEY_F5,
KEY_F6, KEY_F7, KEY_F8, KEY_F9, KEY_F10
};
uint8_t k;
uint8_t i;
if (jlKeyPressed(KEY_RETURN)) {
agiVmDispatchKey(&gVm, 13u, 0u);
gAnyKeyHit = true;
}
if (jlKeyPressed(KEY_SPACE)) {
agiVmDispatchKey(&gVm, 32u, 0u);
gAnyKeyHit = true;
}
if (jlKeyPressed(KEY_TAB)) {
agiVmDispatchKey(&gVm, 9u, 0u);
gAnyKeyHit = true;
}
if (jlKeyPressed(KEY_BACKSPACE)) {
agiVmDispatchKey(&gVm, 8u, 0u);
gAnyKeyHit = true;
}
for (k = 0u; k < 26u; k++) {
if (jlKeyPressed((jlKeyE)((uint8_t)KEY_A + k))) {
agiVmDispatchKey(&gVm, (uint8_t)('a' + k), 0u);
gAnyKeyHit = true;
}
}
for (k = 0u; k < 10u; k++) {
if (jlKeyPressed((jlKeyE)((uint8_t)KEY_0 + k))) {
agiVmDispatchKey(&gVm, (uint8_t)('0' + k), 0u);
gAnyKeyHit = true;
}
}
for (i = 0u; i < 10u; i++) {
if (jlKeyPressed(fnKeys[i])) {
agiVmDispatchKey(&gVm, 0u, fnScancodes[i]);
gAnyKeyHit = true;
}
}
if (jlJoystickConnected(JOYSTICK_0)) {
if (jlJoyPressed(JOYSTICK_0, JOY_BUTTON_0) || jlJoyPressed(JOYSTICK_0, JOY_BUTTON_1)) {
agiVmDispatchKey(&gVm, 13u, 0u); // joystick fire == ENTER
gAnyKeyHit = true;
}
}
}
// Restore the exact stage rect `(x, y, w, h)` from the gBackdrop
// surface. Chunky-fast: a single memcpy per row from backdrop pixels
// to stage pixels. Bypassing jlDrawPixel keeps per-frame restore
// cost ~1KB memcpy per object instead of per-pixel function calls.
// Planar (s->pixels NULL) is uncommon at this point; fall back to
// jlSurfaceCopy of the whole surface (slower but correct).
static void restoreRectFromBackdrop(jlSurfaceT *stage, int16_t x, int16_t y, int16_t w, int16_t h) {
int16_t row;
int16_t byteX;
int16_t byteW;
int16_t endY;
if (stage == NULL || gBackdrop == NULL || w <= 0 || h <= 0) {
return;
}
if (x < 0) { w += x; x = 0; }
if (y < 0) { h += y; y = 0; }
if (x >= (int16_t)SURFACE_WIDTH || y >= (int16_t)SURFACE_HEIGHT) { return; }
if (x + w > (int16_t)SURFACE_WIDTH) { w = (int16_t)SURFACE_WIDTH - x; }
if (y + h > (int16_t)SURFACE_HEIGHT) { h = (int16_t)SURFACE_HEIGHT - y; }
if (w <= 0 || h <= 0) { return; }
if (stage->pixels == NULL || gBackdrop->pixels == NULL) {
// Planar fallback -- restore from backdrop via full copy.
// Wasteful but rare in current ports (Amiga only).
jlSurfaceCopy(stage, gBackdrop);
return;
}
// Round to byte boundaries (4bpp packed: byte index = x/2).
// Both x and w come from cel boundaries already aligned to 2px
// (pixel doubling) so this is exact.
byteX = (int16_t)(x >> 1);
byteW = (int16_t)((w + 1) >> 1);
endY = (int16_t)(y + h);
for (row = y; row < endY; row++) {
memcpy(&stage->pixels[row * (int16_t)SURFACE_BYTES_PER_ROW + byteX],
&gBackdrop->pixels[row * (int16_t)SURFACE_BYTES_PER_ROW + byteX],
(size_t)byteW);
}
surfaceMarkDirtyRect(stage, x, y, w, h);
}
// Walk vm->objects[] and (re)draw every animated+drawn object. Per
// frame:
// pass 1: restore the prev-frame rect of every previously-drawn
// obj from the backdrop. Erases the prev draw without
// needing per-object save buffers.
// pass 2: for each visible obj, compute its cel's stage bbox,
// draw the cel, and record the bbox as its new prev rect.
//
// Two passes are required so an obj's restore can't accidentally
// erase another obj's draw -- all restores happen before any draws.
static void drawObjects(jlSurfaceT *stage) {
uint8_t i;
// Pass 1: restore prev-frame rect from backdrop for every obj
// that was drawn last frame.
for (i = 0u; i < (uint8_t)AGI_MAX_OBJECTS; i++) {
if (gObjPrev[i].valid) {
restoreRectFromBackdrop(stage, gObjPrev[i].x, gObjPrev[i].y,
gObjPrev[i].w, gObjPrev[i].h);
gObjPrev[i].valid = false;
}
}
// Pass 2: draw current frame's visible objects.
for (i = 0u; i < (uint8_t)AGI_MAX_OBJECTS; i++) {
const AgiObjectT *obj;
const AgiViewT *view;
uint8_t loop;
uint8_t cel;
uint8_t actorPri;
int16_t stageX;
int16_t stageY;
int16_t stageW;
int16_t stageH;
obj = &gVm.objects[i];
if ((obj->flags & AGI_OBJ_FLAG_ANIMATED) == 0u) {
continue;
}
if ((obj->flags & AGI_OBJ_FLAG_DRAWN) == 0u) {
continue;
}
view = fetchView(obj->viewId);
if (view == NULL || view->loopCount == 0u) {
continue;
}
loop = obj->loop;
// Auto-loop from direction only applies to MOVING objects.
// A stopped obj keeps whatever loop the script (e.g.
// set.loop) assigned -- otherwise KQ3's title-screen wizard
// eyes (obj 0, view=65 loop=1, direction=0) get clobbered
// to loop 0 (the body view) by the stopped-default branch.
if ((obj->flags & AGI_OBJ_FLAG_LOOP_FIXED) == 0u &&
obj->direction != 0u) {
uint8_t autoLoop = loopForDirection(obj->direction, view->loopCount);
if (autoLoop < view->loopCount) {
loop = autoLoop;
}
}
if (loop >= view->loopCount) {
loop = 0u;
}
cel = obj->cel;
if (cel >= view->loops[loop].celCount) {
cel = 0u;
}
if ((obj->flags & AGI_OBJ_FLAG_PRI_FIXED) != 0u && obj->priority >= AGI_PRIORITY_BG) {
actorPri = obj->priority;
} else {
actorPri = agiActorPriorityForY(obj->y);
}
agiViewDraw(view, loop, cel,
(int16_t)obj->x, (int16_t)obj->y,
actorPri, gPic.priority,
stage, PIC_DEST_Y);
// Record the cel's exact stage bbox so next frame's restore
// covers only that region (works for cels of any size, fixes
// the trailing-pixel artifact wider/taller cels suffered with
// the previous fixed-size save-under).
{
uint8_t cw = view->loops[loop].cels[cel].width;
uint8_t ch = view->loops[loop].cels[cel].height;
stageX = (int16_t)(obj->x * 2);
stageY = (int16_t)(PIC_DEST_Y + obj->y - (int16_t)ch + 1);
stageW = (int16_t)(cw * 2);
stageH = (int16_t)ch;
gObjPrev[i].x = stageX;
gObjPrev[i].y = stageY;
gObjPrev[i].w = stageW;
gObjPrev[i].h = stageH;
gObjPrev[i].valid = true;
}
}
}
// Invalidate every object's prev-rect entry. Called when the picture
// changes (show.pic / add.to.pic) so we don't restore stale pixels
// over the new backdrop on the next frame.
static void invalidateObjectSaves(void) {
uint8_t i;
for (i = 0u; i < (uint8_t)AGI_MAX_OBJECTS; i++) {
gObjPrev[i].valid = false;
}
}
static const AgiViewT *fetchView(uint8_t viewId) {
ViewCacheSlotT *slot;
uint8_t *bytes;
uint16_t length;
slot = &gViewCache[viewId];
if (slot->loaded) {
return &slot->view;
}
if (viewId >= gGame.resCount[AGI_RES_VIEW]) {
return NULL;
}
bytes = agiResLoad(&gGame, AGI_RES_VIEW, (uint16_t)viewId, &length);
if (bytes == NULL) {
return NULL;
}
if (!agiViewParse(&slot->view, bytes, length)) {
return NULL;
}
slot->loaded = true;
return &slot->view;
}
// AGI's canonical loop assignment from direction (idx 0 = right):
// right=0, left=1, down=2, up=3
// Diagonal directions reuse the dominant axis loop.
static uint8_t loopForDirection(uint8_t direction, uint8_t loopCount) {
uint8_t loop;
if (loopCount <= 1u) {
return 0u;
}
switch (direction) {
case 1u: loop = 3u; break; // N
case 2u: loop = 0u; break; // NE -> right
case 3u: loop = 0u; break; // E
case 4u: loop = 0u; break; // SE -> right
case 5u: loop = 2u; break; // S
case 6u: loop = 1u; break; // SW -> left
case 7u: loop = 1u; break; // W
case 8u: loop = 1u; break; // NW -> left
default: loop = 2u; break; // stopped -> down
}
if (loop >= loopCount) {
return 0u;
}
return loop;
}
static uint8_t parseArgU8(const char *s) {
uint16_t val;
uint8_t digit;
if (s == NULL) {
return 0u;
}
val = 0u;
while (*s != '\0') {
if (*s < '0' || *s > '9') {
return 0u;
}
digit = (uint8_t)(*s - '0');
if (val > 25u || (val == 25u && digit > 5u)) {
return 0u;
}
val = (uint16_t)(val * 10u + digit);
s++;
}
return (uint8_t)val;
}
static void releaseLogicCache(void) {
uint16_t i;
for (i = 0u; i < AGI_MAX_RESOURCES; i++) {
if (gLogicCache[i].raw != NULL) {
free(gLogicCache[i].raw);
gLogicCache[i].raw = NULL;
gLogicCache[i].bytecode = NULL;
}
}
}
static void releaseViewCache(void) {
uint16_t i;
for (i = 0u; i < AGI_MAX_RESOURCES; i++) {
if (gViewCache[i].loaded) {
agiViewFree(&gViewCache[i].view);
gViewCache[i].loaded = false;
}
}
}
static void renderFrame(jlSurfaceT *stage) {
uint8_t r;
if (!gPicReady) {
jlStagePresent();
return;
}
// Step 1: restore text rows that had content last frame so a VM
// that cleared them this frame (clear.lines / row reassign) sees
// them disappear from the stage.
for (r = 0u; r < (uint8_t)AGI_TEXT_ROWS; r++) {
if (gTextRowDirty[r]) {
restoreRectFromBackdrop(stage, 0, (int16_t)((int16_t)r * 8),
(int16_t)SURFACE_WIDTH, 8);
gTextRowDirty[r] = false;
}
}
// Step 2: restore + draw animated/drawn objects.
drawObjects(stage);
// Step 3: draw current text rows on top. Mark dirty as we go so
// step 1 of the next frame knows to wipe them.
{
bool statusOn = gVm.statusLineOn;
uint8_t statusRow = gVm.statusLineRow;
if (statusOn) {
gTextRowDirty[statusRow] = true;
}
for (r = 0u; r < (uint8_t)AGI_TEXT_ROWS; r++) {
if (gVm.textRows[r].text[0] != '\0') {
gTextRowDirty[r] = true;
}
}
}
agiTextRender(&gVm, stage);
jlStagePresent();
}
static const char *resolveGameDir(int argc, char **argv) {
if (argc > 1 && argv[1] != NULL && argv[1][0] != '\0') {
return argv[1];
}
return ".";
}
static uint8_t resolveStartingRoom(int argc, char **argv) {
if (argc <= 2 || argv[2] == NULL) {
return 0u;
}
return parseArgU8(argv[2]);
}
static void runVmCycle(void) {
AgiVmHaltE reason;
const uint8_t *logic0;
uint16_t length;
uint8_t prevRoom;
uint8_t i;
uint8_t guard;
// Block the cycle entirely while a print() modal is up; the
// host's input loop dismisses it on the next ack key.
if (gVm.printModal.active) {
return;
}
guard = CYCLE_HALT_GUARD;
for (;;) {
if (guard == 0u) {
return;
}
guard--;
reason = agiVmRun(&gVm);
if (reason == AGI_VM_HALT_PRINT_PENDING) {
return;
}
if (reason == AGI_VM_HALT_QUIT) {
return;
}
if (reason == AGI_VM_HALT_RETURN) {
agiVmTickAnimation(&gVm);
gVm.flags[5] = 0u;
length = 0u;
logic0 = cbFetchLogic(NULL, 0u, &length);
if (logic0 != NULL) {
agiVmResetToLogic(&gVm, logic0, length, 0u);
}
return;
}
if (reason == AGI_VM_HALT_NEW_ROOM) {
prevRoom = gVm.vars[0];
if (gOverrideRoomActive) {
gVm.vars[0] = gOverrideRoom;
gOverrideRoomActive = false;
} else {
gVm.vars[0] = gVm.newRoomId;
}
gVm.vars[1] = prevRoom;
for (i = 0u; i < 32u; i++) {
gVm.flags[i] = 0u;
}
gVm.flags[5] = 1u;
// Clear the object table on room change so leftover NPCs
// don't bleed into the new scene. Ego (object 0) stays
// animated/drawn; the new room's init re-runs animate.obj
// with the same setup if it wants ego on screen.
for (i = 1u; i < (uint8_t)AGI_MAX_OBJECTS; i++) {
agiObjReset(&gVm.objects[i]);
}
// Clear text rows. Sierra's new.room expects a fresh text
// band -- without this, v226=3 credits from LOGIC.45 stay
// visible after the room.46 transition.
for (i = 0u; i < (uint8_t)AGI_TEXT_ROWS; i++) {
gVm.textRows[i].text[0] = '\0';
}
length = 0u;
logic0 = cbFetchLogic(NULL, 0u, &length);
if (logic0 != NULL) {
agiVmResetToLogic(&gVm, logic0, length, 0u);
}
continue;
}
// Unknown opcode, truncated stream, missing logic, stack
// overflow: stop cycling this frame and let the host render
// what we have.
return;
}
}
// Player-control input mapping for ego (when programControl is false).
// Sets var 6 (ego direction) so the engine's motion tick moves the
// player normally. Diagonals not bound; AGI canonical games rarely
// use them via keyboard.
static void updateEgoFromInput(void) {
uint8_t newDir;
if (gVm.programControl) {
return;
}
newDir = 0u;
if (jlKeyDown(KEY_UP)) { newDir = 1u; }
if (jlKeyDown(KEY_RIGHT)) { newDir = 3u; }
if (jlKeyDown(KEY_DOWN)) { newDir = 5u; }
if (jlKeyDown(KEY_LEFT)) { newDir = 7u; }
if (newDir != 0u || jlKeyPressed(KEY_UP) || jlKeyPressed(KEY_DOWN) || jlKeyPressed(KEY_LEFT) || jlKeyPressed(KEY_RIGHT)) {
gVm.objects[0].direction = newDir;
gVm.vars[6] = newDir;
} else if (!jlKeyDown(KEY_UP) && !jlKeyDown(KEY_DOWN) && !jlKeyDown(KEY_LEFT) && !jlKeyDown(KEY_RIGHT)) {
gVm.objects[0].direction = 0u;
gVm.vars[6] = 0u;
}
}
#ifdef JOEYLIB_PLATFORM_IIGS
segment "";
#endif
// ----- Entry point -----
int main(int argc, char **argv) {
jlConfigT cfg;
AgiVmCallbacksT cb;
const char *gameDir;
const uint8_t *logic0;
uint16_t length;
uint8_t startingRoom;
bool picOk;
bool vmReady;
cfg.codegenBytes = 0u; // no sprites needed -- backdrop-restore path is direct memcpy
cfg.maxSurfaces = 4u; // stage + backdrop (2 used)
// AGI sound is software-synthesized PSG squarewaves pushed
// through the Sound Blaster auto-init DMA path (see agiSynthFill
// and src/port/dos/audio.c). The audio pool covers the 4 KB
// DMA buffer + libxmp-lite SMIX state.
cfg.audioBytes = 32u * 1024u;
if (!jlInit(&cfg)) {
return 1;
}
jlLogReset();
jlLogF("AGI TRACE build=%s %s", __DATE__, __TIME__);
jlLogFlush();
gStage = jlStageGet();
jlPaletteSet(gStage, 0u, kAgiPalette);
jlRandomSeed(0xA61DA61Du);
// Bring up the SB DMA + mixer. AGI music feeds the SFX stream
// path inside jlAudioFrameTick, which depends on a successful
// init here.
(void)jlAudioInit();
{
uint8_t i;
for (i = 0u; i < (uint8_t)AGI_MAX_OBJECTS; i++) {
gObjPrev[i].valid = false;
}
}
gameDir = resolveGameDir(argc, argv);
startingRoom = resolveStartingRoom(argc, argv);
gOverrideRoom = startingRoom;
gOverrideRoomActive = (startingRoom != 0u);
picOk = false;
vmReady = false;
gBackdrop = NULL;
gPicReady = false;
gAnyKeyHit = false;
if (agiResOpen(&gGame, gameDir)) {
picOk = agiPicAlloc(&gPic);
if (picOk) {
agiPicClear(&gPic);
gBackdrop = jlSurfaceCreate();
agiVmInit(&gVm);
cb.fetchLogic = cbFetchLogic;
cb.loadPic = cbLoadPic;
cb.drawPic = cbDrawPic;
cb.showPic = cbShowPic;
cb.discardPic = cbDiscardPic;
cb.overlayPic = cbOverlayPic;
cb.haveKey = cbHaveKey;
cb.fetchMessage = cbFetchMessage;
cb.addToPic = cbAddToPic;
cb.soundDuration = cbSoundDuration;
cb.playSound = cbPlaySound;
cb.stopSound = cbStopSound;
cb.isPlayingSound = cbIsPlayingSound;
cb.viewCelCount = cbViewCelCount;
cb.viewLoopCount = cbViewLoopCount;
cb.ctx = NULL;
agiVmSetCallbacks(&gVm, &cb);
length = 0u;
logic0 = cbFetchLogic(NULL, 0u, &length);
if (logic0 != NULL) {
agiVmResetToLogic(&gVm, logic0, length, 0u);
vmReady = true;
}
}
}
jlSurfaceClear(gStage, COLOR_MISSING);
if (gBackdrop != NULL) {
jlSurfaceClear(gBackdrop, COLOR_MISSING);
}
if (vmReady) {
uint8_t pump;
for (pump = 0u; pump < STARTUP_CYCLE_LIMIT; pump++) {
if (gPicReady) {
break;
}
runVmCycle();
}
}
{
uint32_t lastVmCycleMs = 0u;
uint32_t lastRenderMs = 0u;
for (;;) {
jlInputPoll();
if (jlKeyPressed(KEY_ESCAPE)) {
break;
}
gAnyKeyHit = false;
dispatchAllPressedKeys();
// Pump the AGI tick generator EVERY frame, even with a
// print modal up. This drives music event playback AND
// the v11 seconds counter -- both keyed to the same
// AGI tick rate so script timing stays consistent with
// music tempo. Pausing this would freeze the AGI clock.
agiTickAdvance();
// If a print() modal is up, any key dismisses it and resumes
// the VM next cycle. Don't run the VM or update inputs while
// the modal is alive.
if (gVm.printModal.active) {
if (gAnyKeyHit) {
agiVmAckPrint(&gVm);
}
} else {
uint32_t nowMs = jlMillisElapsed();
uint8_t delayJiffies;
uint32_t cyclePeriodMs;
updateEgoFromInput();
delayJiffies = gVm.vars[10];
if (delayJiffies == 0u) { delayJiffies = 1u; }
cyclePeriodMs = (uint32_t)delayJiffies * (uint32_t)AGI_JIFFY_MS;
if (vmReady && (nowMs - lastVmCycleMs) >= cyclePeriodMs) {
runVmCycle();
lastVmCycleMs = nowMs;
}
if (gVm.haltReason == AGI_VM_HALT_QUIT) {
break;
}
}
// Wall-clock-paced render. Audio refill still runs every
// loop iteration so DMA half-buffers get serviced even
// when we're skipping the visual update.
{
uint32_t nowMs2 = jlMillisElapsed();
if (nowMs2 - lastRenderMs >= AGI_RENDER_PERIOD_MS) {
renderFrame(gStage);
lastRenderMs = nowMs2;
} else {
jlStagePresent();
}
}
// Pump the SB DMA mixer. Refills any half-buffer the
// ISR flagged for refill since last call, which in turn
// pulls the next chunk from agiSynthFill (when music
// is armed) or libxmp + SFX overlay (when not).
jlAudioFrameTick();
jlWaitVBL();
}
}
agiSoundReset();
jlAudioShutdown();
releaseLogicCache();
releaseViewCache();
if (gBackdrop != NULL) {
jlSurfaceDestroy(gBackdrop);
}
if (picOk) {
agiPicFree(&gPic);
}
agiResClose(&gGame);
jlShutdown();
return 0;
}