1873 lines
66 KiB
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;
|
|
}
|