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