Massive optimizations. New music player. Serial support.

This commit is contained in:
Scott Duensing 2026-07-24 22:36:21 -05:00
parent a22f35078a
commit 94cc14ff28
13 changed files with 705 additions and 225 deletions

357
agi.c
View file

@ -13,6 +13,8 @@
// screen the VM can't yet advance through. 0 means "use // screen the VM can't yet advance through. 0 means "use
// whatever logic.0 picks", same as omitting the argument.) // whatever logic.0 picks", same as omitting the argument.)
#include "agi.h" #include "agi.h"
#include "joey/audio.h" #include "joey/audio.h"
@ -21,6 +23,7 @@
#include "joey/draw.h" #include "joey/draw.h"
#include "joey/input.h" #include "joey/input.h"
#include "joey/palette.h" #include "joey/palette.h"
#include "joey/platform.h"
#include "joey/present.h" #include "joey/present.h"
#include "joey/sprite.h" #include "joey/sprite.h"
#include "joey/surface.h" #include "joey/surface.h"
@ -110,6 +113,7 @@ static void agiSoundReset(void);
static uint32_t agiSynthFill(void *ctx, int8_t *dst, uint32_t count); static uint32_t agiSynthFill(void *ctx, int8_t *dst, uint32_t count);
static void agiTickAdvance(void); static void agiTickAdvance(void);
static void agiVoiceTick(uint8_t voice); static void agiVoiceTick(uint8_t voice);
static void psgNoiseApply(void);
static void psgRecompute(uint8_t chipCh); static void psgRecompute(uint8_t chipCh);
static void psgWriteData(uint8_t val); static void psgWriteData(uint8_t val);
static void cbAddToPic(void *ctx, uint8_t viewId, uint8_t loop, uint8_t cel, static void cbAddToPic(void *ctx, uint8_t viewId, uint8_t loop, uint8_t cel,
@ -129,6 +133,8 @@ static void cbStopSound(void *ctx);
static uint8_t cbViewCelCount(void *ctx, uint8_t viewId, uint8_t loopId); static uint8_t cbViewCelCount(void *ctx, uint8_t viewId, uint8_t loopId);
static uint8_t cbViewLoopCount(void *ctx, uint8_t viewId); static uint8_t cbViewLoopCount(void *ctx, uint8_t viewId);
static bool decodePicInto(uint8_t picId, bool clearFirst); static bool decodePicInto(uint8_t picId, bool clearFirst);
static void agiInputCapture(void);
static void agiParseCommand(void);
static void dispatchAllPressedKeys(void); static void dispatchAllPressedKeys(void);
static void drawObjects(jlSurfaceT *stage); static void drawObjects(jlSurfaceT *stage);
static const AgiViewT *fetchView(uint8_t viewId); static const AgiViewT *fetchView(uint8_t viewId);
@ -138,7 +144,6 @@ static uint8_t parseArgU8(const char *s);
static void releaseLogicCache(void); static void releaseLogicCache(void);
static void releaseViewCache(void); static void releaseViewCache(void);
static void renderFrame(jlSurfaceT *stage); static void renderFrame(jlSurfaceT *stage);
static const char *resolveGameDir(int argc, char **argv);
static uint8_t resolveStartingRoom(int argc, char **argv); static uint8_t resolveStartingRoom(int argc, char **argv);
static void runVmCycle(void); static void runVmCycle(void);
static void updateEgoFromInput(void); static void updateEgoFromInput(void);
@ -332,6 +337,149 @@ static void cbAddToPic(void *ctx, uint8_t viewId, uint8_t loop, uint8_t cel,
} }
// Accumulate typed characters into gVm.inputLine while the command line is
// active (accept.input). Printable keys append, backspace deletes, and Enter
// hands the finished line to agiParseCommand. Called every frame from the
// main loop; the on-screen echo is drawn in renderFrame.
static void agiInputCapture(void) {
uint8_t k;
if (jlKeyPressed(KEY_BACKSPACE)) {
if (gVm.inputLen > 0u) {
gVm.inputLen--;
gVm.inputLine[gVm.inputLen] = '\0';
}
}
if (jlKeyPressed(KEY_SPACE) && gVm.inputLen < AGI_INPUT_MAX) {
gVm.inputLine[gVm.inputLen] = ' ';
gVm.inputLen++;
gVm.inputLine[gVm.inputLen] = '\0';
}
for (k = 0u; k < 26u; k++) {
if (jlKeyPressed((jlKeyE)((uint8_t)KEY_A + k)) && gVm.inputLen < AGI_INPUT_MAX) {
gVm.inputLine[gVm.inputLen] = (char)('a' + k);
gVm.inputLen++;
gVm.inputLine[gVm.inputLen] = '\0';
}
}
for (k = 0u; k < 10u; k++) {
if (jlKeyPressed((jlKeyE)((uint8_t)KEY_0 + k)) && gVm.inputLen < AGI_INPUT_MAX) {
gVm.inputLine[gVm.inputLen] = (char)('0' + k);
gVm.inputLen++;
gVm.inputLine[gVm.inputLen] = '\0';
}
}
if (jlKeyPressed(KEY_RETURN)) {
if (gVm.inputLen > 0u) {
agiParseCommand();
}
gVm.inputLine[0] = '\0';
gVm.inputLen = 0u;
}
}
// Tokenize gVm.inputLine into dictionary group IDs (gVm.parsedWords), using
// greedy longest-match so multi-word entries ("crystal ball", "get in") beat
// their prefixes. Ignore-words (group 0) drop out; a word absent from the
// dictionary records its 1-based position in var 9 and voids the command
// (no parsedWords, so said() won't match and the room can report the miss).
// Sets flag 2 so said() tests fire this cycle.
static void agiParseCommand(void) {
static char toks[AGI_MAX_PARSED][AGI_WORD_MAX_LEN + 1u];
static uint8_t tokLen[AGI_MAX_PARSED];
char phrase[AGI_WORD_MAX_LEN + 1u];
uint8_t tokCount;
uint8_t ti;
uint16_t i;
uint8_t pos;
bool unknown;
// Split into lowercase alphanumeric tokens (cap at AGI_MAX_PARSED).
tokCount = 0u;
ti = 0u;
for (i = 0u; i <= gVm.inputLen; i++) {
char c;
c = (i < gVm.inputLen) ? gVm.inputLine[i] : ' ';
if (c >= 'A' && c <= 'Z') {
c = (char)(c + ('a' - 'A'));
}
if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')) {
if (tokCount < AGI_MAX_PARSED && ti < AGI_WORD_MAX_LEN) {
toks[tokCount][ti] = c;
ti++;
}
continue;
}
if (ti > 0u && tokCount < AGI_MAX_PARSED) {
toks[tokCount][ti] = '\0';
tokLen[tokCount] = ti;
tokCount++;
}
ti = 0u;
}
gVm.parsedWordCount = 0u;
gVm.vars[AGI_VAR_UNKNOWN_WORD] = 0u;
unknown = false;
pos = 0u;
while (pos < tokCount) {
uint16_t bestId;
uint8_t best;
uint8_t span;
best = 0u;
bestId = AGI_WORD_UNKNOWN;
// Longest join first: try tokens[pos..end], then shorter, down to one.
for (span = (uint8_t)(tokCount - pos); span >= 1u; span--) {
uint8_t pl;
uint8_t s;
uint16_t id;
pl = 0u;
for (s = 0u; s < span; s++) {
uint8_t j;
if (s > 0u && pl < AGI_WORD_MAX_LEN) {
phrase[pl] = ' ';
pl++;
}
for (j = 0u; j < tokLen[pos + s] && pl < AGI_WORD_MAX_LEN; j++) {
phrase[pl] = toks[pos + s][j];
pl++;
}
}
phrase[pl] = '\0';
id = agiWordId(&gGame, phrase);
if (id != AGI_WORD_UNKNOWN) {
best = span;
bestId = id;
break;
}
}
if (best == 0u) {
if (!unknown) {
gVm.vars[AGI_VAR_UNKNOWN_WORD] = (uint8_t)(pos + 1u);
unknown = true;
}
pos++;
continue;
}
if (bestId != AGI_WORD_IGNORE && gVm.parsedWordCount < AGI_MAX_PARSED) {
gVm.parsedWords[gVm.parsedWordCount] = bestId;
gVm.parsedWordCount++;
}
pos = (uint8_t)(pos + best);
}
if (unknown) {
gVm.parsedWordCount = 0u;
}
gVm.flags[AGI_FLAG_INPUT] = 1u;
}
static void cbDiscardPic(void *ctx, uint8_t picId) { static void cbDiscardPic(void *ctx, uint8_t picId) {
(void)ctx; (void)ctx;
(void)picId; (void)picId;
@ -569,15 +717,40 @@ static void cbShowPic(void *ctx) {
} }
// Push the chip's noise-channel state (gPsg[3]) to the JoeyLib noise
// voice. The SN76489 freqCount maps onto the portable 0..31 pitch as
// freqCount >> 3 (rate codes 0/1/2 -> 4/8/16, preserving the chip's
// octave-per-step; mode 3 tracks tone channel 2's snapshot). The
// white/periodic bit has no portable representation and renders as
// plain hiss.
static void psgNoiseApply(void) {
uint16_t count;
uint8_t pitch;
if (gPsg[3].attenuation >= 15u || gPsg[3].freqCount == 0u) {
jlAudioNoise(0u, 15u);
return;
}
count = gPsg[3].freqCount;
pitch = (uint8_t)(count >> 3);
if (pitch > 31u) {
pitch = 31u;
}
jlAudioNoise(pitch, gPsg[3].attenuation);
}
// Recompute derived (phaseInc, amplitude) for chip channel // Recompute derived (phaseInc, amplitude) for chip channel
// `chipCh` from its current freqCount + attenuation + genType. // `chipCh` from its current freqCount + attenuation + genType.
// Called whenever the chip state for a tone channel changes; // Called whenever the chip state for a tone channel changes; the
// noise channels aren't synthesized and skip recompute. // noise channel isn't synthesized into the PCM stream -- it goes to
// the hardware noise voice instead.
static void psgRecompute(uint8_t chipCh) { static void psgRecompute(uint8_t chipCh) {
PsgChannelT *p; PsgChannelT *p;
uint32_t freqHz; uint32_t freqHz;
if (chipCh >= 3u) { if (chipCh >= 3u) {
psgNoiseApply();
return; return;
} }
p = &gPsg[chipCh]; p = &gPsg[chipCh];
@ -631,7 +804,9 @@ static void psgWriteData(uint8_t val) {
case 2u: gPsg[3].freqCount = 128u; break; case 2u: gPsg[3].freqCount = 128u; break;
default: gPsg[3].freqCount = (uint16_t)(gPsg[2].freqCount * 2u); break; default: gPsg[3].freqCount = (uint16_t)(gPsg[2].freqCount * 2u); break;
} }
// No _reg update; no synth recompute (noise not synthesized). // No _reg update -- the DATA byte that follows still targets
// the previously latched channel.
psgNoiseApply();
} else if (val & 0x80u) { } else if (val & 0x80u) {
reg = (uint8_t)((val >> 5) & 0x03u); reg = (uint8_t)((val >> 5) & 0x03u);
gPsgReg = reg; gPsgReg = reg;
@ -652,6 +827,9 @@ static void agiSoundReset(void) {
uint8_t v; uint8_t v;
jlAudioStopSfx(0u); jlAudioStopSfx(0u);
// The noise voice is live hardware, separate from the PCM stream
// slot -- without an explicit off it latches on forever.
jlAudioNoise(0u, 15u);
for (v = 0u; v < AGI_SOUND_CHANNELS; v++) { for (v = 0u; v < AGI_SOUND_CHANNELS; v++) {
gVoiceSched[v].chOffset = 0u; gVoiceSched[v].chOffset = 0u;
gVoiceSched[v].eventFramesRem = 0u; gVoiceSched[v].eventFramesRem = 0u;
@ -804,6 +982,7 @@ static void agiTickAdvance(void) {
} }
if (allDone) { if (allDone) {
gSoundActive = false; gSoundActive = false;
jlAudioNoise(0u, 15u); // percussion must not outlive the song
jlLogF("synth EOS wallMs=%u v0done=%u v1done=%u v2done=%u v3done=%u", jlLogF("synth EOS wallMs=%u v0done=%u v1done=%u v2done=%u v3done=%u",
(unsigned)nowMs, (unsigned)nowMs,
(unsigned)gVoiceSched[0].done, (unsigned)gVoiceSched[0].done,
@ -1236,35 +1415,44 @@ static void dispatchAllPressedKeys(void) {
}; };
uint8_t k; uint8_t k;
uint8_t i; uint8_t i;
bool captureText;
if (jlKeyPressed(KEY_RETURN)) { // While the command line is live (and no modal is up), printable keys,
agiVmDispatchKey(&gVm, 13u, 0u); // Enter, Space and Backspace belong to the input line (agiInputCapture),
gAnyKeyHit = true; // not to controller/key-binding dispatch. Tab, function keys and the
} // joystick stay as controllers so status/menu/action keys still work.
if (jlKeyPressed(KEY_SPACE)) { captureText = gVm.acceptInput && !gVm.printModal.active;
agiVmDispatchKey(&gVm, 32u, 0u);
gAnyKeyHit = true; if (!captureText) {
if (jlKeyPressed(KEY_RETURN)) {
agiVmDispatchKey(&gVm, 13u, 0u);
gAnyKeyHit = true;
}
if (jlKeyPressed(KEY_SPACE)) {
agiVmDispatchKey(&gVm, 32u, 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;
}
}
} }
if (jlKeyPressed(KEY_TAB)) { if (jlKeyPressed(KEY_TAB)) {
agiVmDispatchKey(&gVm, 9u, 0u); agiVmDispatchKey(&gVm, 9u, 0u);
gAnyKeyHit = true; 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++) { for (i = 0u; i < 10u; i++) {
if (jlKeyPressed(fnKeys[i])) { if (jlKeyPressed(fnKeys[i])) {
agiVmDispatchKey(&gVm, 0u, fnScancodes[i]); agiVmDispatchKey(&gVm, 0u, fnScancodes[i]);
@ -1282,44 +1470,21 @@ static void dispatchAllPressedKeys(void) {
// Restore the exact stage rect `(x, y, w, h)` from the gBackdrop // Restore the exact stage rect `(x, y, w, h)` from the gBackdrop
// surface. Chunky-fast: a single memcpy per row from backdrop pixels // surface. Chunky-fast: a single memcpy per row from backdrop pixels
// to stage pixels. Bypassing jlDrawPixel keeps per-frame restore // to stage pixels via the library's rect copy (hunt-3 rank 3). The
// cost ~1KB memcpy per object instead of per-pixel function calls. // old body hand-rolled a chunky memcpy loop and, on the planar 68k
// Planar (s->pixels NULL) is uncommon at this point; fall back to // ports (stage->pixels NULL post-Phase-9/10), fell back to a FULL
// jlSurfaceCopy of the whole surface (slower but correct). // 32KB jlSurfaceCopy per call -- 3-5 times per frame -- which also
// re-marked the whole stage AND the SCB/palette dirty, defeating the
// group-skip presents. jlSurfaceCopyRect does everything this needs:
// clips, widens x to the 16-px group grid (safe -- the erase-all-
// then-redraw-all object pass repaints anything the widening grazes),
// routes to each port's fast copy (ST move.l spans, Amiga blitter
// arm, IIgs inline word, DOS memcpy), and marks exactly the rect.
static void restoreRectFromBackdrop(jlSurfaceT *stage, int16_t x, int16_t y, int16_t w, int16_t h) { 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) { if (stage == NULL || gBackdrop == NULL || w <= 0 || h <= 0) {
return; return;
} }
if (x < 0) { w += x; x = 0; } jlSurfaceCopyRect(stage, gBackdrop, x, y, (uint16_t)w, (uint16_t)h);
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);
} }
@ -1549,6 +1714,31 @@ static void renderFrame(jlSurfaceT *stage) {
} }
// Step 2: restore + draw animated/drawn objects. // Step 2: restore + draw animated/drawn objects.
drawObjects(stage); drawObjects(stage);
// Command line: echo "> " + the typed text (with a cursor) on the input
// row while accept.input is live; blank our prompt when it turns off so
// the line disappears. This reuses the text-row plane so agiTextRender
// and the dirty-row bookkeeping below draw and wipe it like any message.
{
char *dst = gVm.textRows[AGI_INPUT_ROW].text;
if (gVm.acceptInput) {
uint8_t n;
uint8_t i;
n = 0u;
dst[n++] = '>';
dst[n++] = ' ';
for (i = 0u; i < gVm.inputLen && n < (uint8_t)AGI_TEXT_COLS; i++) {
dst[n++] = gVm.inputLine[i];
}
if (n < (uint8_t)AGI_TEXT_COLS) {
dst[n++] = '_';
}
dst[n] = '\0';
} else if (dst[0] == '>') {
dst[0] = '\0';
}
}
// Step 3: draw current text rows on top. Mark dirty as we go so // Step 3: draw current text rows on top. Mark dirty as we go so
// step 1 of the next frame knows to wipe them. // step 1 of the next frame knows to wipe them.
{ {
@ -1569,19 +1759,20 @@ static void renderFrame(jlSurfaceT *stage) {
} }
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) { static uint8_t resolveStartingRoom(int argc, char **argv) {
if (argc <= 2 || argv[2] == NULL) { #if defined(JOEYLIB_PLATFORM_IIGS)
// GS/OS launches an S16 app with no usable argv (the crt0 leaves
// argc/argv uninitialized -- garbage), so always start at the title
// room. Game data comes from DATA/ regardless of argv now.
(void)argc;
(void)argv;
return 0u;
#else
if (argc <= 1 || argv[1] == NULL) {
return 0u; return 0u;
} }
return parseArgU8(argv[2]); return parseArgU8(argv[1]);
#endif
} }
@ -1615,6 +1806,11 @@ static void runVmCycle(void) {
if (reason == AGI_VM_HALT_RETURN) { if (reason == AGI_VM_HALT_RETURN) {
agiVmTickAnimation(&gVm); agiVmTickAnimation(&gVm);
gVm.flags[5] = 0u; gVm.flags[5] = 0u;
// The player's command was offered to every logic this cycle;
// consume it so said() can't re-fire it on later cycles. (A
// matching said() already cleared it; this covers the no-match
// case.)
gVm.flags[AGI_FLAG_INPUT] = 0u;
length = 0u; length = 0u;
logic0 = cbFetchLogic(NULL, 0u, &length); logic0 = cbFetchLogic(NULL, 0u, &length);
if (logic0 != NULL) { if (logic0 != NULL) {
@ -1696,7 +1892,6 @@ static void updateEgoFromInput(void) {
int main(int argc, char **argv) { int main(int argc, char **argv) {
jlConfigT cfg; jlConfigT cfg;
AgiVmCallbacksT cb; AgiVmCallbacksT cb;
const char *gameDir;
const uint8_t *logic0; const uint8_t *logic0;
uint16_t length; uint16_t length;
uint8_t startingRoom; uint8_t startingRoom;
@ -1732,6 +1927,7 @@ int main(int argc, char **argv) {
} }
} }
// The view cache is too large for bank-0 BSS on the IIgs; pull it // The view cache is too large for bank-0 BSS on the IIgs; pull it
// from the platform's large-allocator. jlAlloc does not zero its // from the platform's large-allocator. jlAlloc does not zero its
// result, so clear it so every slot starts with loaded == false. // result, so clear it so every slot starts with loaded == false.
@ -1742,7 +1938,6 @@ int main(int argc, char **argv) {
} }
memset(gViewCache, 0, sizeof(ViewCacheSlotT) * AGI_MAX_RESOURCES); memset(gViewCache, 0, sizeof(ViewCacheSlotT) * AGI_MAX_RESOURCES);
gameDir = resolveGameDir(argc, argv);
startingRoom = resolveStartingRoom(argc, argv); startingRoom = resolveStartingRoom(argc, argv);
gOverrideRoom = startingRoom; gOverrideRoom = startingRoom;
gOverrideRoomActive = (startingRoom != 0u); gOverrideRoomActive = (startingRoom != 0u);
@ -1752,7 +1947,7 @@ int main(int argc, char **argv) {
gPicReady = false; gPicReady = false;
gAnyKeyHit = false; gAnyKeyHit = false;
if (agiResOpen(&gGame, gameDir)) { if (agiResOpen(&gGame)) {
picOk = agiPicAlloc(&gPic); picOk = agiPicAlloc(&gPic);
if (picOk) { if (picOk) {
agiPicClear(&gPic); agiPicClear(&gPic);
@ -1814,6 +2009,12 @@ int main(int argc, char **argv) {
gAnyKeyHit = false; gAnyKeyHit = false;
dispatchAllPressedKeys(); dispatchAllPressedKeys();
// When the command line is live (and no modal is up), typed
// characters build the input line instead of firing controllers.
if (gVm.acceptInput && !gVm.printModal.active) {
agiInputCapture();
}
// Pump the AGI tick generator EVERY frame, even with a // Pump the AGI tick generator EVERY frame, even with a
// print modal up. This drives music event playback AND // print modal up. This drives music event playback AND
// the v11 seconds counter -- both keyed to the same // the v11 seconds counter -- both keyed to the same
@ -1863,6 +2064,14 @@ int main(int argc, char **argv) {
// pulls the next chunk from agiSynthFill (when music // pulls the next chunk from agiSynthFill (when music
// is armed) or libxmp + SFX overlay (when not). // is armed) or libxmp + SFX overlay (when not).
jlAudioFrameTick(); jlAudioFrameTick();
#if defined(JOEYLIB_PLATFORM_IIGS)
// The IIgs noise voice is an ~80 ms one-shot; re-assert
// held noise once per frame so it sustains (same model as
// the music sequencer's per-pump re-assert).
if (gSoundActive && gPsg[3].attenuation < 15u) {
psgNoiseApply();
}
#endif
jlWaitVBL(); jlWaitVBL();
} }
} }

56
agi.h
View file

@ -71,8 +71,38 @@ typedef struct {
FILE *volFiles[AGI_MAX_VOLUMES]; FILE *volFiles[AGI_MAX_VOLUMES];
uint16_t resCount[AGI_RES_COUNT]; uint16_t resCount[AGI_RES_COUNT];
AgiResEntryT resDir[AGI_RES_COUNT][AGI_MAX_RESOURCES]; AgiResEntryT resDir[AGI_RES_COUNT][AGI_MAX_RESOURCES];
// WORDS.TOK dictionary, loaded whole. Prefix-compressed entries are
// walked on demand by agiWordId (parse is infrequent -- once per typed
// command -- so a flat scan beats building a hashed table).
uint8_t *words;
uint32_t wordsLen;
} AgiGameT; } AgiGameT;
// Reserved AGI word group IDs.
#define AGI_WORD_IGNORE 0u // "a", "the", ... -- dropped by parse
#define AGI_WORD_ANYWORD 1u // said() wildcard: matches any one word
#define AGI_WORD_REST 9999u // said() wildcard: matches rest of line
#define AGI_WORD_UNKNOWN 0xFFFFu // agiWordId: typed word not in dictionary
// WORDS.TOK layout + limits. The header is 26 big-endian 2-byte offsets
// (one per initial letter); word entries follow. AGI v2 dictionaries are a
// few KB (KQ3 is ~5.7 KB); 16 KB is ample headroom.
#define AGI_WORDS_HEADER_BYTES 52u
#define AGI_WORDS_MAX 16384u
#define AGI_WORD_MAX_LEN 40u
// Command-line parser limits + the reserved flag/var the interpreter uses
// to talk to logic scripts about input:
// flag 2 -- set when the player has entered a command this cycle; said()
// matches only while it is set and clears it on a match so only
// the first matching said() in the logic scan fires.
// var 9 -- 1-based position of a typed word that isn't in the dictionary.
#define AGI_INPUT_MAX 40u // chars in the on-screen command line
#define AGI_MAX_PARSED 12u // word IDs kept from one command
#define AGI_INPUT_ROW 24u // bottom text row shows the command line
#define AGI_FLAG_INPUT 2u
#define AGI_VAR_UNKNOWN_WORD 9u
// Decoded PIC working buffers. Both planes are 160x168 chunky 8bpp; // Decoded PIC working buffers. Both planes are 160x168 chunky 8bpp;
// only the low nibble (4 bits) of each byte carries actual color // only the low nibble (4 bits) of each byte carries actual color
@ -388,6 +418,15 @@ typedef struct {
// ----- strings ----- // ----- strings -----
char strings[AGI_MAX_STRINGS][AGI_STRING_LEN + 1u]; char strings[AGI_MAX_STRINGS][AGI_STRING_LEN + 1u];
// ----- parser / command input -----
// inputLine is the raw text the player is typing; on Enter agi.c
// tokenizes it against the dictionary into parsedWords[] (group IDs,
// ignore-words dropped) and sets flag 2 for said() to test.
char inputLine[AGI_INPUT_MAX + 1u];
uint8_t inputLen;
uint16_t parsedWords[AGI_MAX_PARSED];
uint8_t parsedWordCount;
// ----- sound playback state ----- // ----- sound playback state -----
// The VM polls callbacks.isPlayingSound() each tick; when it // The VM polls callbacks.isPlayingSound() each tick; when it
// transitions from true to false, the sound-done flags fire. // transitions from true to false, the sound-done flags fire.
@ -410,12 +449,17 @@ void agiResClose(AgiGameT *game);
// resource payload length in bytes. // resource payload length in bytes.
uint8_t *agiResLoad(const AgiGameT *game, AgiResTypeE type, uint16_t index, uint16_t *outLength); uint8_t *agiResLoad(const AgiGameT *game, AgiResTypeE type, uint16_t index, uint16_t *outLength);
// Open an AGI v2 game directory. gameDir is a relative or absolute // Open the AGI v2 game whose LOGDIR/PICDIR/VIEWDIR/SNDDIR and VOL.0..VOL.N
// path to a directory containing LOGDIR/PICDIR/VIEWDIR/SNDDIR plus // files are staged under DATA/ (opened by bare name via jlDataOpen).
// VOL.0..VOL.N. Returns true if every directory file and at least // Returns true if every directory file and at least one VOL.* file opened.
// one VOL.* file opened. Partial-success on missing optional // Partial-success on missing optional volumes; agiResLoad reports the
// volumes; agiResLoad reports the missing-volume case per resource. // missing-volume case per resource.
bool agiResOpen(AgiGameT *game, const char *gameDir); bool agiResOpen(AgiGameT *game);
// Look up a word in the loaded WORDS.TOK dictionary (case-insensitive) and
// return its AGI group ID, or AGI_WORD_UNKNOWN if the word is absent (or no
// dictionary loaded). Synonyms share a group ID; group 0 = ignore-words.
uint16_t agiWordId(const AgiGameT *game, const char *word);
// ----- Picture decoder (agiPic.c) ----- // ----- Picture decoder (agiPic.c) -----

255
agiRes.c
View file

@ -22,6 +22,8 @@
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include "joey/file.h"
// AGI directory files are flat arrays of 3-byte entries. The maximum // AGI directory files are flat arrays of 3-byte entries. The maximum
// directory length the loader will accept is bounded by AGI_MAX_- // directory length the loader will accept is bounded by AGI_MAX_-
@ -35,9 +37,9 @@
#define AGI_VOL_COMPRESSED 0x80 #define AGI_VOL_COMPRESSED 0x80
#define AGI_VOL_VOLUME_MASK 0x7F #define AGI_VOL_VOLUME_MASK 0x7F
// Max directory + path string size. AGI filenames are 7 chars max; // Resource file names are opened relative to DATA/ via jlDataOpen (see
// gameDir is application-controlled. 256 leaves comfortable margin. // joey/file.h); the longest name AGI forms is "VOL.<n>". 16 is ample.
#define AGI_PATH_MAX 256 #define AGI_VOL_NAME_MAX 16
// Empty-slot sentinel: must be >= AGI_MAX_VOLUMES so agiResLoad // Empty-slot sentinel: must be >= AGI_MAX_VOLUMES so agiResLoad
// rejects empty entries. The on-disk empty marker is 0xFFFFFF (high // rejects empty entries. The on-disk empty marker is 0xFFFFFF (high
@ -55,44 +57,24 @@
// ----- Prototypes ----- // ----- Prototypes -----
static bool buildPath(char *out, size_t outSize, const char *dir, const char *file); static bool buildVolName(char *out, size_t outSize, uint8_t volNum);
static bool buildVolPath(char *out, size_t outSize, const char *dir, uint8_t volNum); static bool loadDirectory(AgiGameT *game, const char *fileName, AgiResTypeE type);
static bool loadDirectory(AgiGameT *game, AgiResTypeE type, const char *gameDir, const char *fileName); static bool loadWords(AgiGameT *game);
static bool openVolumes(AgiGameT *game, const char *gameDir); static bool openVolumes(AgiGameT *game);
static const char *resTypeFileName(AgiResTypeE type); static const char *resTypeFileName(AgiResTypeE type);
static bool wordEqCI(const char *a, const char *b);
// ----- Internal helpers (alphabetical) ----- // ----- Internal helpers (alphabetical) -----
static bool buildPath(char *out, size_t outSize, const char *dir, const char *file) { static bool buildVolName(char *out, size_t outSize, uint8_t volNum) {
size_t dirLen; char *p;
size_t fileLen;
dirLen = strlen(dir); // "VOL." + up to 2 digits + NUL = 7 chars max.
fileLen = strlen(file); if (outSize < 8u) {
if (dirLen + 1 + fileLen + 1 > outSize) {
return false; return false;
} }
memcpy(out, dir, dirLen); p = out;
out[dirLen] = '/';
memcpy(out + dirLen + 1, file, fileLen);
out[dirLen + 1 + fileLen] = '\0';
return true;
}
static bool buildVolPath(char *out, size_t outSize, const char *dir, uint8_t volNum) {
size_t dirLen;
char *p;
dirLen = strlen(dir);
// "/VOL." + up to 2 digits + NUL = 8 chars max.
if (dirLen + 8 > outSize) {
return false;
}
memcpy(out, dir, dirLen);
p = out + dirLen;
*p++ = '/';
*p++ = 'V'; *p++ = 'V';
*p++ = 'O'; *p++ = 'O';
*p++ = 'L'; *p++ = 'L';
@ -108,56 +90,44 @@ static bool buildVolPath(char *out, size_t outSize, const char *dir, uint8_t vol
} }
static bool loadDirectory(AgiGameT *game, AgiResTypeE type, const char *gameDir, const char *fileName) { static bool loadDirectory(AgiGameT *game, const char *fileName, AgiResTypeE type) {
char path[AGI_PATH_MAX]; // One directory is resident at a time; a file-scope buffer keeps this
FILE *fp; // ~768-byte read off the shallow IIgs soft stack.
long fileSize; static uint8_t dirBytes[AGI_MAX_RESOURCES * AGI_DIR_ENTRY_BYTES];
uint16_t entryCount; FILE *fp;
uint16_t i; uint16_t entryCount;
uint8_t buf[AGI_DIR_ENTRY_BYTES]; uint16_t i;
uint8_t volNibble; uint8_t volNibble;
uint32_t offset; uint32_t offset;
size_t got;
if (!buildPath(path, AGI_PATH_MAX, gameDir, fileName)) { // fileName is a bare name relative to DATA/ (e.g. "LOGDIR").
return false; fp = jlDataOpen(fileName, "rb");
}
fp = fopen(path, "rb");
if (fp == NULL) { if (fp == NULL) {
return false; return false;
} }
if (fseek(fp, 0L, SEEK_END) != 0) { // Read the whole flat 3-byte-entry array in one call rather than one
fclose(fp); // read per entry: on the IIgs each fread is a GS/OS round trip through
return false; // SmartPort firmware, and a directory never exceeds AGI_MAX_RESOURCES
} // entries, so the fixed buffer holds any valid file. A non-multiple-of-3
fileSize = ftell(fp); // length means the file is malformed.
if (fileSize < 0 || fileSize > (long)(AGI_MAX_RESOURCES * AGI_DIR_ENTRY_BYTES)) { got = fread(dirBytes, 1, sizeof(dirBytes), fp);
fclose(fp); fclose(fp);
return false; if (got == 0u || (got % AGI_DIR_ENTRY_BYTES) != 0u) {
}
if ((fileSize % AGI_DIR_ENTRY_BYTES) != 0) {
fclose(fp);
return false;
}
if (fseek(fp, 0L, SEEK_SET) != 0) {
fclose(fp);
return false; return false;
} }
entryCount = (uint16_t)(fileSize / AGI_DIR_ENTRY_BYTES); entryCount = (uint16_t)(got / AGI_DIR_ENTRY_BYTES);
for (i = 0; i < entryCount; i++) { for (i = 0; i < entryCount; i++) {
if (fread(buf, 1, AGI_DIR_ENTRY_BYTES, fp) != AGI_DIR_ENTRY_BYTES) { const uint8_t *e = &dirBytes[(uint32_t)i * AGI_DIR_ENTRY_BYTES];
fclose(fp);
return false;
}
volNibble = (uint8_t)(buf[0] >> 4); volNibble = (uint8_t)(e[0] >> 4);
// 20-bit offset, cast to uint32_t before shift so a // 20-bit offset, cast to uint32_t before shift so a
// 16-bit int doesn't truncate the (& 0x0F) << 16 term. // 16-bit int doesn't truncate the (& 0x0F) << 16 term.
offset = ((uint32_t)(buf[0] & 0x0F) << 16) | ((uint32_t)buf[1] << 8) | (uint32_t)buf[2]; offset = ((uint32_t)(e[0] & 0x0F) << 16) | ((uint32_t)e[1] << 8) | (uint32_t)e[2];
if (buf[0] == 0xFF && buf[1] == 0xFF && buf[2] == 0xFF) { if (e[0] == 0xFF && e[1] == 0xFF && e[2] == 0xFF) {
game->resDir[type][i].volume = AGI_DIR_EMPTY_VOLUME; game->resDir[type][i].volume = AGI_DIR_EMPTY_VOLUME;
game->resDir[type][i].offset = 0u; game->resDir[type][i].offset = 0u;
} else { } else {
@ -165,24 +135,55 @@ static bool loadDirectory(AgiGameT *game, AgiResTypeE type, const char *gameDir,
game->resDir[type][i].offset = offset; game->resDir[type][i].offset = offset;
} }
} }
fclose(fp);
game->resCount[type] = entryCount; game->resCount[type] = entryCount;
return true; return true;
} }
static bool openVolumes(AgiGameT *game, const char *gameDir) { static bool loadWords(AgiGameT *game) {
char path[AGI_PATH_MAX]; FILE *fp;
uint8_t *buf;
size_t got;
game->words = NULL;
game->wordsLen = 0u;
// The dictionary is optional: a game with no WORDS.TOK simply can't
// match said()/parse (no crash), so a missing file is not an error.
fp = jlDataOpen("WORDS.TOK", "rb");
if (fp == NULL) {
return true;
}
buf = (uint8_t *)malloc(AGI_WORDS_MAX);
if (buf == NULL) {
fclose(fp);
return false;
}
got = fread(buf, 1, AGI_WORDS_MAX, fp);
fclose(fp);
// Anything shorter than the letter-index header can't hold a word.
if (got <= AGI_WORDS_HEADER_BYTES) {
free(buf);
return true;
}
game->words = buf;
game->wordsLen = (uint32_t)got;
return true;
}
static bool openVolumes(AgiGameT *game) {
char name[AGI_VOL_NAME_MAX];
uint8_t v; uint8_t v;
bool anyOpened; bool anyOpened;
anyOpened = false; anyOpened = false;
for (v = 0; v < AGI_MAX_VOLUMES; v++) { for (v = 0; v < AGI_MAX_VOLUMES; v++) {
if (!buildVolPath(path, AGI_PATH_MAX, gameDir, v)) { if (!buildVolName(name, sizeof(name), v)) {
return false; return false;
} }
game->volFiles[v] = fopen(path, "rb"); game->volFiles[v] = jlDataOpen(name, "rb");
if (game->volFiles[v] != NULL) { if (game->volFiles[v] != NULL) {
anyOpened = true; anyOpened = true;
} }
@ -202,6 +203,31 @@ static const char *resTypeFileName(AgiResTypeE type) {
} }
// Case-insensitive ASCII equality. Both dictionary words and (lowercased)
// typed words are ASCII; fold A-Z so a stray uppercase still matches.
static bool wordEqCI(const char *a, const char *b) {
while (*a != '\0' && *b != '\0') {
char ca;
char cb;
ca = *a;
cb = *b;
if (ca >= 'A' && ca <= 'Z') {
ca = (char)(ca + ('a' - 'A'));
}
if (cb >= 'A' && cb <= 'Z') {
cb = (char)(cb + ('a' - 'A'));
}
if (ca != cb) {
return false;
}
a++;
b++;
}
return *a == '\0' && *b == '\0';
}
// ----- Public API (alphabetical) ----- // ----- Public API (alphabetical) -----
void agiResClose(AgiGameT *game) { void agiResClose(AgiGameT *game) {
@ -217,6 +243,11 @@ void agiResClose(AgiGameT *game) {
for (t = 0; t < AGI_RES_COUNT; t++) { for (t = 0; t < AGI_RES_COUNT; t++) {
game->resCount[t] = 0u; game->resCount[t] = 0u;
} }
if (game->words != NULL) {
free(game->words);
game->words = NULL;
game->wordsLen = 0u;
}
} }
@ -287,22 +318,90 @@ uint8_t *agiResLoad(const AgiGameT *game, AgiResTypeE type, uint16_t index, uint
} }
bool agiResOpen(AgiGameT *game, const char *gameDir) { bool agiResOpen(AgiGameT *game) {
uint8_t t; uint8_t t;
memset(game, 0, sizeof(*game)); memset(game, 0, sizeof(*game));
// Resource files are opened by bare name relative to DATA/ (jlDataOpen);
// the game's data is whatever the packager staged there, so there is no
// runtime game-directory to thread through.
for (t = 0; t < AGI_RES_COUNT; t++) { for (t = 0; t < AGI_RES_COUNT; t++) {
if (!loadDirectory(game, (AgiResTypeE)t, gameDir, resTypeFileName((AgiResTypeE)t))) { if (!loadDirectory(game, resTypeFileName((AgiResTypeE)t), (AgiResTypeE)t)) {
agiResClose(game); agiResClose(game);
return false; return false;
} }
} }
if (!openVolumes(game, gameDir)) { if (!openVolumes(game)) {
agiResClose(game);
return false;
}
// The dictionary is optional (loadWords only fails on OOM); a game
// without WORDS.TOK still runs, just with no parser matches.
if (!loadWords(game)) {
agiResClose(game); agiResClose(game);
return false; return false;
} }
return true; return true;
} }
uint16_t agiWordId(const AgiGameT *game, const char *word) {
const uint8_t *p;
const uint8_t *end;
char cur[AGI_WORD_MAX_LEN + 1u];
uint8_t len;
if (game->words == NULL || word == NULL) {
return AGI_WORD_UNKNOWN;
}
// Word entries follow the fixed 52-byte letter-index header. Each entry:
// 1 byte prefixLen -- chars shared with the previous word
// n bytes suffix -- each (c & 0x7F) ^ 0x7F; the char with bit 7
// set is the last one of the word
// 2 bytes groupId -- big-endian
// `cur` persists across entries so prefixLen chars carry forward.
p = game->words + AGI_WORDS_HEADER_BYTES;
end = game->words + game->wordsLen;
len = 0u;
while (p < end) {
uint8_t prefixLen;
uint16_t id;
prefixLen = *p++;
if (prefixLen > AGI_WORD_MAX_LEN) {
break; // malformed dictionary
}
len = prefixLen;
for (;;) {
uint8_t c;
if (p >= end) {
return AGI_WORD_UNKNOWN;
}
c = *p++;
if (len < AGI_WORD_MAX_LEN) {
cur[len] = (char)((c & 0x7Fu) ^ 0x7Fu);
len++;
}
if ((c & 0x80u) != 0u) {
break;
}
}
cur[len] = '\0';
if (p + 1 >= end) {
return AGI_WORD_UNKNOWN;
}
id = (uint16_t)(((uint16_t)p[0] << 8) | (uint16_t)p[1]);
p += 2;
if (wordEqCI(cur, word)) {
return id;
}
}
return AGI_WORD_UNKNOWN;
}

81
agiVm.c
View file

@ -301,7 +301,7 @@ static bool popFrame(AgiVmT *vm);
static bool pushFrame(AgiVmT *vm); static bool pushFrame(AgiVmT *vm);
static bool readByte(AgiVmT *vm, uint8_t *out); static bool readByte(AgiVmT *vm, uint8_t *out);
static bool readU16(AgiVmT *vm, uint16_t *out); static bool readU16(AgiVmT *vm, uint16_t *out);
static bool skipTestArgs(AgiVmT *vm, uint8_t testOp); static bool saidMatch(const AgiVmT *vm, const uint16_t *pat, uint8_t patCount);
static VmDispatchE vmStepCore(AgiVmT *vm, uint8_t op); static VmDispatchE vmStepCore(AgiVmT *vm, uint8_t op);
static VmDispatchE vmStepMisc(AgiVmT *vm, uint8_t op); static VmDispatchE vmStepMisc(AgiVmT *vm, uint8_t op);
static VmDispatchE vmStepObject(AgiVmT *vm, uint8_t op); static VmDispatchE vmStepObject(AgiVmT *vm, uint8_t op);
@ -416,11 +416,37 @@ static bool evalOneTest(AgiVmT *vm, uint8_t testOp) {
} }
if (testOp == TEST_SAID) { if (testOp == TEST_SAID) {
// 1-byte word count, followed by 2 bytes per word. // Variable-length: 1-byte word count, then that many 16-bit
if (!skipTestArgs(vm, testOp)) { // little-endian word IDs. Read the pattern (always, to keep pc in
// sync) then match it against what the player typed this cycle.
uint16_t pat[AGI_MAX_PARSED];
uint8_t count;
uint8_t lo;
uint8_t hi;
uint8_t k;
bool matched;
if (!readByte(vm, &count)) {
return false; return false;
} }
return false; for (k = 0u; k < count; k++) {
if (!readByte(vm, &lo) || !readByte(vm, &hi)) {
return false;
}
if (k < AGI_MAX_PARSED) {
pat[k] = (uint16_t)((uint16_t)lo | ((uint16_t)hi << 8));
}
}
// said() fires only on the cycle input was entered; a match consumes
// that input (clears flag 2) so no later said() double-handles it.
if (vm->flags[AGI_FLAG_INPUT] == 0u || count > AGI_MAX_PARSED) {
return false;
}
matched = saidMatch(vm, pat, count);
if (matched) {
vm->flags[AGI_FLAG_INPUT] = 0u;
}
return matched;
} }
argBytes = kTestArgBytes[testOp]; argBytes = kTestArgBytes[testOp];
@ -877,37 +903,34 @@ static bool readU16(AgiVmT *vm, uint16_t *out) {
} }
// Walk past the argument bytes of a test command without evaluating. // Match the parsed player command against a said() word pattern.
// For the variable-length `said` test, the first byte is the word // AGI_WORD_REST (9999) matches the rest of the line (including nothing);
// count and the rest are 2 bytes per word. // AGI_WORD_ANYWORD (1) matches exactly one arbitrary word;
static bool skipTestArgs(AgiVmT *vm, uint8_t testOp) { // any other id matches that exact group id.
uint8_t scratch; // An exact match requires every typed word to be consumed by the pattern.
uint8_t wordCount; static bool saidMatch(const AgiVmT *vm, const uint16_t *pat, uint8_t patCount) {
uint16_t toSkip; uint8_t pi;
uint16_t i; uint8_t wi;
if (testOp == TEST_SAID) { pi = 0u;
if (!readByte(vm, &wordCount)) { wi = 0u;
while (pi < patCount) {
uint16_t pw;
pw = pat[pi];
if (pw == AGI_WORD_REST) {
return true;
}
if (wi >= vm->parsedWordCount) {
return false; return false;
} }
toSkip = (uint16_t)((uint16_t)wordCount * 2u); if (pw != AGI_WORD_ANYWORD && pw != vm->parsedWords[wi]) {
for (i = 0u; i < toSkip; i++) {
if (!readByte(vm, &scratch)) {
return false;
}
}
return true;
}
if (testOp == 0u || testOp >= TEST_MAX) {
return false;
}
toSkip = (uint16_t)kTestArgBytes[testOp];
for (i = 0u; i < toSkip; i++) {
if (!readByte(vm, &scratch)) {
return false; return false;
} }
wi++;
pi++;
} }
return true; return wi == vm->parsedWordCount;
} }

74
scripts/make-agi-iigs-disk.sh Executable file
View file

@ -0,0 +1,74 @@
#!/usr/bin/env bash
# make-agi-iigs-disk.sh - Build the 2MB ProDOS data volume that holds the
# IIgs AGI interpreter plus one AGI v2 game's resources. run-iigs.sh mounts
# it as the CFFA2 card's second partition (-hard2); GS/OS boots from the
# first partition and auto-mounts this one.
#
# A single 800KB 3.5" floppy can't hold the ~240KB AGI binary plus KQ3's
# ~644KB of required resources (878KB total), and MAME's IIgs 3.5 drive is
# 800K-GCR-only (it rejects a 1.44MB image). A CFFA2 hard-disk partition
# has no such cap, so the whole payload lands on one 2MB ProDOS volume.
#
# Usage: scripts/make-agi-iigs-disk.sh <output.2mg> [game-name]
# game-name : subdirectory under examples/agi/gamedata/ (default kq3)
# Requires: toolchains/env.sh sourced (LLVM816_ROOT), built build/iigs/bin/AGI.
set -euo pipefail
repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
: "${LLVM816_ROOT:?make-agi-iigs-disk.sh: source toolchains/env.sh first}"
CADIUS="${CADIUS:-$LLVM816_ROOT/tools/cadius/cadius}"
BINDIR="${BINDIR:-$repo/build/iigs/bin}"
out=${1:?usage: make-agi-iigs-disk.sh <output.2mg> [game]}
game=${2:-kq3}
gamedata=$repo/examples/agi/gamedata/$game
agiBin=$BINDIR/AGI
VOL=AGI
# The AGI volume name and the game files use uppercase ProDOS names. AGI
# fopen()s LOGDIR / VOL.x etc. from its launch directory; GS/OS sets the
# prefix to the volume it launched from, and ProDOS lookups are
# case-insensitive, so the uppercase names match the lowercase fopen paths.
[ -x "$CADIUS" ] || { echo "make-agi-iigs-disk.sh: cadius not found at $CADIUS" >&2; exit 2; }
[ -f "$agiBin" ] || { echo "make-agi-iigs-disk.sh: $agiBin not built (make -f make/iigs.mk $agiBin)" >&2; exit 1; }
[ -d "$gamedata" ] || { echo "make-agi-iigs-disk.sh: $gamedata is not an AGI v2 game directory" >&2; exit 1; }
[ -f "$gamedata/LOGDIR" ] || { echo "make-agi-iigs-disk.sh: $gamedata has no LOGDIR -- not AGI v2" >&2; exit 1; }
work=$(mktemp -d -t agi-iigs-disk.XXXXXX)
trap 'rm -rf "$work"' EXIT
rm -f "$out"
"$CADIUS" CREATEVOLUME "$out" "$VOL" 2048KB >/dev/null
# AGI interpreter as a ProDOS type $B3 (S16 / GS-OS application). It stays
# at the volume root so Finder lists and launches it.
cp "$agiBin" "$work/AGI#B30000"
"$CADIUS" ADDFILE "$out" "/$VOL" "$work/AGI#B30000" >/dev/null
# Game resources live under DATA/ -- JoeyLib forces every data-file open
# through that prefix (jlDataOpen), so they must sit in /VOL/DATA. GS/OS
# sets prefix 0 to the launch volume, so AGI's "DATA/LOGDIR" resolves here.
"$CADIUS" CREATEFOLDER "$out" "/$VOL/DATA" >/dev/null
# Game resources as plain ProDOS BIN ($06). Only the subset AGI v2 reads
# (the four DIR indexes, the VOL.x archives, OBJECT, WORDS.TOK) is copied;
# Sierra's original binary / OVL drivers are left behind.
added=0
for f in LOGDIR PICDIR VIEWDIR SNDDIR OBJECT WORDS.TOK; do
if [ -f "$gamedata/$f" ]; then
cp "$gamedata/$f" "$work/${f}#060000"
"$CADIUS" ADDFILE "$out" "/$VOL/DATA" "$work/${f}#060000" >/dev/null
added=$((added + 1))
fi
done
for f in "$gamedata"/VOL.*; do
[ -f "$f" ] || continue
b=$(basename "$f")
cp "$f" "$work/${b}#060000"
"$CADIUS" ADDFILE "$out" "/$VOL/DATA" "$work/${b}#060000" >/dev/null
added=$((added + 1))
done
free=$("$CADIUS" CATALOG "$out" 2>/dev/null | grep -oE 'Free : [0-9]+' | grep -oE '[0-9]+' | head -1)
echo "agi-iigs-disk: $out (volume /$VOL, game $game)"
echo " AGI + $added resource files, $free blocks free"

View file

@ -18,9 +18,9 @@
# Required files in the game directory (others are ignored): # Required files in the game directory (others are ignored):
# LOGDIR PICDIR VIEWDIR SNDDIR VOL.0 [VOL.1 ...] [OBJECT] [WORDS.TOK] # LOGDIR PICDIR VIEWDIR SNDDIR VOL.0 [VOL.1 ...] [OBJECT] [WORDS.TOK]
# #
# IIgs is not yet wired (needs the disk packager extended to a larger # IIgs delegates to run-iigs.sh's AGI mode, which packs AGI + the game
# 2mg volume plus case-folded ProDOS file names); the script reports # onto a 2MB CFFA2 hard-disk volume (an 800KB floppy can't hold both) and
# what's missing rather than launching. # boots GS/OS from a second CFFA2 partition. See make-agi-iigs-disk.sh.
set -euo pipefail set -euo pipefail
@ -64,15 +64,19 @@ fi
# filesystems (e.g. Amiga FFS: "AGI" vs our "Agi"). # filesystems (e.g. Amiga FFS: "AGI" vs our "Agi").
stageGameData() { stageGameData() {
local destDir=$1 local destDir=$1
local dataDir=$destDir/DATA
local f local f
# JoeyLib forces every data-file open under DATA/ (jlDataOpen), so the
# AGI resources are staged into a DATA subdirectory of the mount root.
mkdir -p "$dataDir"
for f in LOGDIR PICDIR VIEWDIR SNDDIR OBJECT WORDS.TOK; do for f in LOGDIR PICDIR VIEWDIR SNDDIR OBJECT WORDS.TOK; do
if [[ -f $gamedata/$f ]]; then if [[ -f $gamedata/$f ]]; then
cp "$gamedata/$f" "$destDir/$f" cp "$gamedata/$f" "$dataDir/$f"
fi fi
done done
for f in "$gamedata"/VOL.*; do for f in "$gamedata"/VOL.*; do
[[ -f $f ]] && cp "$f" "$destDir/$(basename "$f")" [[ -f $f ]] && cp "$f" "$dataDir/$(basename "$f")"
done done
} }
@ -83,7 +87,7 @@ runDos() {
local work local work
if [[ ! -f $bin ]]; then if [[ ! -f $bin ]]; then
echo "$bin not built. Run 'make -f make/dos.mk EXAMPLE=agi' first." >&2 echo "$bin not built. Run 'make -f make/dos.mk $bin' first." >&2
exit 1 exit 1
fi fi
@ -116,13 +120,13 @@ runDos() {
cp "$bin" "$work/AGI.EXE" cp "$bin" "$work/AGI.EXE"
stageGameData "$work" stageGameData "$work"
# AGI.EXE reads game data from its CWD (we mount $work as C: and # AGI.EXE reads game data from DATA/ under its CWD (we mount $work as C:
# cd there first), so argv[1] is "." -- the starting-room override # and cd there first), so no game-directory argument is needed; the
# lives in argv[2]. Skip the argv when room=0 so the binary # starting-room override is argv[1]. Skip it when room=0 so the binary
# exercises its default code path. # exercises its default code path.
local agi_cmd="AGI.EXE" local agi_cmd="AGI.EXE"
if (( room != 0 )); then if (( room != 0 )); then
agi_cmd="AGI.EXE . $room" agi_cmd="AGI.EXE $room"
fi fi
cat <<EOF cat <<EOF
@ -160,7 +164,7 @@ runAtariST() {
local work local work
if [[ ! -f $bin ]]; then if [[ ! -f $bin ]]; then
echo "$bin not built. Run 'make -f make/atarist.mk EXAMPLE=agi' first." >&2 echo "$bin not built. Run 'make -f make/atarist.mk $bin' first." >&2
exit 1 exit 1
fi fi
if [[ ! -f $tos ]]; then if [[ ! -f $tos ]]; then
@ -198,7 +202,7 @@ runAmiga() {
local work local work
if [[ ! -f $bin ]]; then if [[ ! -f $bin ]]; then
echo "$bin not built. Run 'make -f make/amiga.mk EXAMPLE=agi' first." >&2 echo "$bin not built. Run 'make -f make/amiga.mk $bin' first." >&2
exit 1 exit 1
fi fi
@ -243,25 +247,21 @@ EOF
runIigs() { runIigs() {
cat <<EOF >&2 # IIgs uses a CFFA2 hard disk (the 800KB floppy can't hold AGI + KQ3),
IIgs AGI run is not wired up yet. # driven entirely by run-iigs.sh's AGI mode. Delegate to it: it builds
# the two-partition CFFA image via make-agi-iigs-disk.sh, boots GS/OS,
The existing IIgs disk packager (toolchains/iigs/package-disk.sh) builds # and launches AGI. The starting-room override is DOS-only (the IIgs
joey.2mg from build/iigs/bin/* only; it doesn't accept arbitrary game # autostart path can't pass argv), so $room is not forwarded here.
data, the disk size is fixed at 800K (KQ3's ~1.5 MB of resources won't if (( room != 0 )); then
fit), and ProDOS case-folding for game-data file names has not been echo "note: starting-room override is DOS-only; ignored on IIgs" >&2
audited. fi
# The IIgs build target is the binary path, not EXAMPLE=agi.
To wire this up the package-disk.sh tool needs a larger volume option if [[ ! -f "$repo/build/iigs/bin/AGI" ]]; then
and an "extra data" path argument, and run-iigs.sh needs an AGI mode echo "$repo/build/iigs/bin/AGI not built." >&2
that points GSplus at the resulting 2mg. Tracked separately. echo "Build it with: make -f make/iigs.mk $repo/build/iigs/bin/AGI" >&2
exit 1
Workaround for now: build with 'make -f make/iigs.mk EXAMPLE=agi' to fi
prove the IIgs link still works, but launch via run-iigs.sh and pick JOEY_AGI_GAME="$game" exec "$repo/scripts/run-iigs.sh" agi
JOEYLIB:AGI in Finder -- it will fail to find LOGDIR (no game data on
disk), which is the expected current state.
EOF
exit 1
} }

View file

@ -45,14 +45,41 @@ stubSrc=$build/hostStubs.c
cat > "$stubSrc" <<'EOF' cat > "$stubSrc" <<'EOF'
#include "joey/draw.h" #include "joey/draw.h"
#include "joey/core.h" #include "joey/core.h"
#include "joey/file.h"
#include "surfaceInternal.h" #include "surfaceInternal.h"
#include <stdlib.h> #include <stdlib.h>
#include <stdio.h>
#include <string.h>
// Host jlDataOpen: the real one (assetLoad.c) forces a DATA/ prefix, but
// the test gamedata dirs hold their files flat. Open <AGI_DATA_DIR>/<name>
// so agiRes.c's bare-name jlDataOpen calls resolve against the game dir the
// harness points us at.
FILE *jlDataOpen(const char *name, const char *mode) {
const char *base = getenv("AGI_DATA_DIR");
char path[1024];
if (base == NULL) { base = "."; }
if ((size_t)snprintf(path, sizeof(path), "%s/%s", base, name) >= sizeof(path)) { return NULL; }
return fopen(path, mode);
}
void jlDrawPixel(jlSurfaceT *s, int16_t x, int16_t y, uint8_t c) { (void)s; (void)x; (void)y; (void)c; } void jlDrawPixel(jlSurfaceT *s, int16_t x, int16_t y, uint8_t c) { (void)s; (void)x; (void)y; (void)c; }
void jlFillRect(jlSurfaceT *s, int16_t x, int16_t y, uint16_t w, uint16_t h, uint8_t c) { (void)s; (void)x; (void)y; (void)w; (void)h; (void)c; } void jlFillRect(jlSurfaceT *s, int16_t x, int16_t y, uint16_t w, uint16_t h, uint8_t c) { (void)s; (void)x; (void)y; (void)w; (void)h; (void)c; }
uint32_t jlRandom(void) { return (uint32_t)rand(); } uint32_t jlRandom(void) { return (uint32_t)rand(); }
uint16_t jlRandomRange(uint16_t bound) { return (bound == 0u) ? 0u : (uint16_t)((unsigned)rand() % bound); } uint16_t jlRandomRange(uint16_t bound) { return (bound == 0u) ? 0u : (uint16_t)((unsigned)rand() % bound); }
void jlRandomSeed(uint32_t seed) { srand((unsigned)seed); } void jlRandomSeed(uint32_t seed) { srand((unsigned)seed); }
void surfaceMarkDirtyRect(const jlSurfaceT *s, int16_t x, int16_t y, int16_t w, int16_t h) { (void)s; (void)x; (void)y; (void)w; (void)h; } void *jlAlloc(uint32_t bytes) { return malloc((size_t)bytes); }
void jlFree(void *p) { free(p); }
void jlLogF(const char *fmt, ...) { (void)fmt; }
void jlLogFlush(void) { }
void jlLog(const char *msg) { (void)msg; }
/* surfaceMarkDirtyRect is a MACRO since the Phase-6 mark rework -- the
* host TUs (agiPic/agiView) expand it inline, so the stubs provide the
* band state it references instead of a function. gStage stays NULL,
* so every expansion takes the s != gStage no-op branch; the symbols
* only need to exist to link. */
jlSurfaceT *gStage = 0;
uint8_t gStageMinWord[SURFACE_HEIGHT];
uint8_t gStageMaxWord[SURFACE_HEIGHT];
void surfaceMarkDirtyRows(uint16_t y, uint16_t yEnd, uint8_t minWord, uint8_t maxWord) { (void)y; (void)yEnd; (void)minWord; (void)maxWord; }
void surfaceMarkDirtyAll(const jlSurfaceT *s) { (void)s; } void surfaceMarkDirtyAll(const jlSurfaceT *s) { (void)s; }
EOF EOF
@ -60,6 +87,7 @@ echo "Building host tests..."
gcc "${hostCflags[@]}" \ gcc "${hostCflags[@]}" \
"$repo/tests/agi/testAgiRes.c" \ "$repo/tests/agi/testAgiRes.c" \
"$repo/examples/agi/agiRes.c" \ "$repo/examples/agi/agiRes.c" \
"$stubSrc" \
-o "$testRes" -o "$testRes"
gcc "${hostCflags[@]}" \ gcc "${hostCflags[@]}" \
@ -131,6 +159,9 @@ for game in "${games[@]}"; do
continue continue
fi fi
# agiRes.c opens data by bare name via jlDataOpen; the host stub
# resolves those against AGI_DATA_DIR (see stubSrc above).
export AGI_DATA_DIR="$dir"
gameOk=1 gameOk=1
echo "--- resource loader ---" echo "--- resource loader ---"
if ! "$testRes" "$dir"; then if ! "$testRes" "$dir"; then

View file

@ -370,7 +370,7 @@ int main(int argc, char **argv) {
fprintf(stderr, "usage: %s <game-directory> <logic-id> [end-pc]\n", argv[0]); fprintf(stderr, "usage: %s <game-directory> <logic-id> [end-pc]\n", argv[0]);
return 2; return 2;
} }
if (!agiResOpen(&game, argv[1])) { if (!agiResOpen(&game)) {
fprintf(stderr, "FAIL: agiResOpen rejected '%s'\n", argv[1]); fprintf(stderr, "FAIL: agiResOpen rejected '%s'\n", argv[1]);
return 1; return 1;
} }

View file

@ -117,7 +117,7 @@ int main(int argc, char **argv) {
return 2; return 2;
} }
if (!agiResOpen(&game, argv[1])) { if (!agiResOpen(&game)) {
fprintf(stderr, "FAIL: agiResOpen rejected '%s'\n", argv[1]); fprintf(stderr, "FAIL: agiResOpen rejected '%s'\n", argv[1]);
return 1; return 1;
} }

View file

@ -179,7 +179,7 @@ int main(int argc, char **argv) {
maxCycles = (uint32_t)MAX_CYCLES_DEFAULT; maxCycles = (uint32_t)MAX_CYCLES_DEFAULT;
} }
if (!agiResOpen(&gGame, argv[1])) { if (!agiResOpen(&gGame)) {
fprintf(stderr, "FAIL: agiResOpen rejected '%s'\n", argv[1]); fprintf(stderr, "FAIL: agiResOpen rejected '%s'\n", argv[1]);
return 1; return 1;
} }

View file

@ -81,7 +81,7 @@ int main(int argc, char **argv) {
} }
printf("Opening AGI game directory: %s\n", argv[1]); printf("Opening AGI game directory: %s\n", argv[1]);
if (!agiResOpen(&game, argv[1])) { if (!agiResOpen(&game)) {
fprintf(stderr, "FAIL: agiResOpen rejected '%s' (missing files? wrong format?)\n", argv[1]); fprintf(stderr, "FAIL: agiResOpen rejected '%s' (missing files? wrong format?)\n", argv[1]);
return 1; return 1;
} }

View file

@ -145,7 +145,7 @@ int main(int argc, char **argv) {
return 2; return 2;
} }
if (!agiResOpen(&game, argv[1])) { if (!agiResOpen(&game)) {
fprintf(stderr, "FAIL: agiResOpen rejected '%s'\n", argv[1]); fprintf(stderr, "FAIL: agiResOpen rejected '%s'\n", argv[1]);
return 1; return 1;
} }

View file

@ -86,7 +86,7 @@ int main(int argc, char **argv) {
return 2; return 2;
} }
if (!agiResOpen(&game, argv[1])) { if (!agiResOpen(&game)) {
fprintf(stderr, "FAIL: agiResOpen rejected '%s'\n", argv[1]); fprintf(stderr, "FAIL: agiResOpen rejected '%s'\n", argv[1]);
return 1; return 1;
} }