From 94cc14ff280008c6fb3068a872404a92ef38de16 Mon Sep 17 00:00:00 2001 From: Scott Duensing Date: Fri, 24 Jul 2026 22:36:21 -0500 Subject: [PATCH] Massive optimizations. New music player. Serial support. --- agi.c | 357 +++++++++++++++++++++++++++------- agi.h | 56 +++++- agiRes.c | 255 ++++++++++++++++-------- agiVm.c | 81 +++++--- scripts/make-agi-iigs-disk.sh | 74 +++++++ scripts/run-agi.sh | 62 +++--- scripts/test-agi.sh | 33 +++- tests/disasmAgi.c | 2 +- tests/testAgiPic.c | 2 +- tests/testAgiPipeline.c | 2 +- tests/testAgiRes.c | 2 +- tests/testAgiView.c | 2 +- tests/testAgiVm.c | 2 +- 13 files changed, 705 insertions(+), 225 deletions(-) create mode 100755 scripts/make-agi-iigs-disk.sh diff --git a/agi.c b/agi.c index 53bc38d..16cde9e 100644 --- a/agi.c +++ b/agi.c @@ -13,6 +13,8 @@ // 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" @@ -21,6 +23,7 @@ #include "joey/draw.h" #include "joey/input.h" #include "joey/palette.h" +#include "joey/platform.h" #include "joey/present.h" #include "joey/sprite.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 void agiTickAdvance(void); static void agiVoiceTick(uint8_t voice); +static void psgNoiseApply(void); 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, @@ -129,6 +133,8 @@ 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 agiInputCapture(void); +static void agiParseCommand(void); static void dispatchAllPressedKeys(void); static void drawObjects(jlSurfaceT *stage); static const AgiViewT *fetchView(uint8_t viewId); @@ -138,7 +144,6 @@ 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); @@ -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) { (void)ctx; (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 // `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. +// Called whenever the chip state for a tone channel changes; the +// noise channel isn't synthesized into the PCM stream -- it goes to +// the hardware noise voice instead. static void psgRecompute(uint8_t chipCh) { PsgChannelT *p; uint32_t freqHz; if (chipCh >= 3u) { + psgNoiseApply(); return; } p = &gPsg[chipCh]; @@ -631,7 +804,9 @@ static void psgWriteData(uint8_t val) { 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). + // No _reg update -- the DATA byte that follows still targets + // the previously latched channel. + psgNoiseApply(); } else if (val & 0x80u) { reg = (uint8_t)((val >> 5) & 0x03u); gPsgReg = reg; @@ -652,6 +827,9 @@ static void agiSoundReset(void) { uint8_t v; 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++) { gVoiceSched[v].chOffset = 0u; gVoiceSched[v].eventFramesRem = 0u; @@ -804,6 +982,7 @@ static void agiTickAdvance(void) { } if (allDone) { gSoundActive = false; + jlAudioNoise(0u, 15u); // percussion must not outlive the song jlLogF("synth EOS wallMs=%u v0done=%u v1done=%u v2done=%u v3done=%u", (unsigned)nowMs, (unsigned)gVoiceSched[0].done, @@ -1236,35 +1415,44 @@ static void dispatchAllPressedKeys(void) { }; uint8_t k; uint8_t i; + bool captureText; - if (jlKeyPressed(KEY_RETURN)) { - agiVmDispatchKey(&gVm, 13u, 0u); - gAnyKeyHit = true; - } - if (jlKeyPressed(KEY_SPACE)) { - agiVmDispatchKey(&gVm, 32u, 0u); - gAnyKeyHit = true; + // While the command line is live (and no modal is up), printable keys, + // Enter, Space and Backspace belong to the input line (agiInputCapture), + // not to controller/key-binding dispatch. Tab, function keys and the + // joystick stay as controllers so status/menu/action keys still work. + captureText = gVm.acceptInput && !gVm.printModal.active; + + 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)) { 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]); @@ -1282,44 +1470,21 @@ static void dispatchAllPressedKeys(void) { // 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). +// to stage pixels via the library's rect copy (hunt-3 rank 3). The +// old body hand-rolled a chunky memcpy loop and, on the planar 68k +// ports (stage->pixels NULL post-Phase-9/10), fell back to a FULL +// 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) { - 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); + jlSurfaceCopyRect(stage, gBackdrop, x, y, (uint16_t)w, (uint16_t)h); } @@ -1549,6 +1714,31 @@ static void renderFrame(jlSurfaceT *stage) { } // Step 2: restore + draw animated/drawn objects. 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 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) { - 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 parseArgU8(argv[2]); + return parseArgU8(argv[1]); +#endif } @@ -1615,6 +1806,11 @@ static void runVmCycle(void) { if (reason == AGI_VM_HALT_RETURN) { agiVmTickAnimation(&gVm); 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; logic0 = cbFetchLogic(NULL, 0u, &length); if (logic0 != NULL) { @@ -1696,7 +1892,6 @@ static void updateEgoFromInput(void) { int main(int argc, char **argv) { jlConfigT cfg; AgiVmCallbacksT cb; - const char *gameDir; const uint8_t *logic0; uint16_t length; 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 // from the platform's large-allocator. jlAlloc does not zero its // 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); - gameDir = resolveGameDir(argc, argv); startingRoom = resolveStartingRoom(argc, argv); gOverrideRoom = startingRoom; gOverrideRoomActive = (startingRoom != 0u); @@ -1752,7 +1947,7 @@ int main(int argc, char **argv) { gPicReady = false; gAnyKeyHit = false; - if (agiResOpen(&gGame, gameDir)) { + if (agiResOpen(&gGame)) { picOk = agiPicAlloc(&gPic); if (picOk) { agiPicClear(&gPic); @@ -1814,6 +2009,12 @@ int main(int argc, char **argv) { gAnyKeyHit = false; 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 // print modal up. This drives music event playback AND // 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 // is armed) or libxmp + SFX overlay (when not). 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(); } } diff --git a/agi.h b/agi.h index c0aa459..0b5c99e 100644 --- a/agi.h +++ b/agi.h @@ -71,8 +71,38 @@ typedef struct { FILE *volFiles[AGI_MAX_VOLUMES]; uint16_t resCount[AGI_RES_COUNT]; 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; +// 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; // only the low nibble (4 bits) of each byte carries actual color @@ -388,6 +418,15 @@ typedef struct { // ----- strings ----- 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 ----- // The VM polls callbacks.isPlayingSound() each tick; when it // transitions from true to false, the sound-done flags fire. @@ -410,12 +449,17 @@ void agiResClose(AgiGameT *game); // resource payload length in bytes. uint8_t *agiResLoad(const AgiGameT *game, AgiResTypeE type, uint16_t index, uint16_t *outLength); -// Open an AGI v2 game directory. gameDir is a relative or absolute -// path to a directory containing LOGDIR/PICDIR/VIEWDIR/SNDDIR plus -// VOL.0..VOL.N. Returns true if every directory file and at least -// one VOL.* file opened. Partial-success on missing optional -// volumes; agiResLoad reports the missing-volume case per resource. -bool agiResOpen(AgiGameT *game, const char *gameDir); +// Open the AGI v2 game whose LOGDIR/PICDIR/VIEWDIR/SNDDIR and VOL.0..VOL.N +// files are staged under DATA/ (opened by bare name via jlDataOpen). +// Returns true if every directory file and at least one VOL.* file opened. +// Partial-success on missing optional volumes; agiResLoad reports the +// missing-volume case per resource. +bool agiResOpen(AgiGameT *game); + +// 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) ----- diff --git a/agiRes.c b/agiRes.c index ab9fe32..6627a59 100644 --- a/agiRes.c +++ b/agiRes.c @@ -22,6 +22,8 @@ #include #include +#include "joey/file.h" + // AGI directory files are flat arrays of 3-byte entries. The maximum // directory length the loader will accept is bounded by AGI_MAX_- @@ -35,9 +37,9 @@ #define AGI_VOL_COMPRESSED 0x80 #define AGI_VOL_VOLUME_MASK 0x7F -// Max directory + path string size. AGI filenames are 7 chars max; -// gameDir is application-controlled. 256 leaves comfortable margin. -#define AGI_PATH_MAX 256 +// Resource file names are opened relative to DATA/ via jlDataOpen (see +// joey/file.h); the longest name AGI forms is "VOL.". 16 is ample. +#define AGI_VOL_NAME_MAX 16 // Empty-slot sentinel: must be >= AGI_MAX_VOLUMES so agiResLoad // rejects empty entries. The on-disk empty marker is 0xFFFFFF (high @@ -55,44 +57,24 @@ // ----- Prototypes ----- -static bool buildPath(char *out, size_t outSize, const char *dir, const char *file); -static bool buildVolPath(char *out, size_t outSize, const char *dir, uint8_t volNum); -static bool loadDirectory(AgiGameT *game, AgiResTypeE type, const char *gameDir, const char *fileName); -static bool openVolumes(AgiGameT *game, const char *gameDir); +static bool buildVolName(char *out, size_t outSize, uint8_t volNum); +static bool loadDirectory(AgiGameT *game, const char *fileName, AgiResTypeE type); +static bool loadWords(AgiGameT *game); +static bool openVolumes(AgiGameT *game); static const char *resTypeFileName(AgiResTypeE type); +static bool wordEqCI(const char *a, const char *b); // ----- Internal helpers (alphabetical) ----- -static bool buildPath(char *out, size_t outSize, const char *dir, const char *file) { - size_t dirLen; - size_t fileLen; +static bool buildVolName(char *out, size_t outSize, uint8_t volNum) { + char *p; - dirLen = strlen(dir); - fileLen = strlen(file); - if (dirLen + 1 + fileLen + 1 > outSize) { + // "VOL." + up to 2 digits + NUL = 7 chars max. + if (outSize < 8u) { return false; } - memcpy(out, dir, dirLen); - out[dirLen] = '/'; - memcpy(out + dirLen + 1, file, fileLen); - out[dirLen + 1 + fileLen] = '\0'; - return true; -} - - -static bool buildVolPath(char *out, size_t outSize, const char *dir, uint8_t volNum) { - size_t dirLen; - char *p; - - dirLen = strlen(dir); - // "/VOL." + up to 2 digits + NUL = 8 chars max. - if (dirLen + 8 > outSize) { - return false; - } - memcpy(out, dir, dirLen); - p = out + dirLen; - *p++ = '/'; + p = out; *p++ = 'V'; *p++ = 'O'; *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) { - char path[AGI_PATH_MAX]; - FILE *fp; - long fileSize; - uint16_t entryCount; - uint16_t i; - uint8_t buf[AGI_DIR_ENTRY_BYTES]; - uint8_t volNibble; - uint32_t offset; +static bool loadDirectory(AgiGameT *game, const char *fileName, AgiResTypeE type) { + // One directory is resident at a time; a file-scope buffer keeps this + // ~768-byte read off the shallow IIgs soft stack. + static uint8_t dirBytes[AGI_MAX_RESOURCES * AGI_DIR_ENTRY_BYTES]; + FILE *fp; + uint16_t entryCount; + uint16_t i; + uint8_t volNibble; + uint32_t offset; + size_t got; - if (!buildPath(path, AGI_PATH_MAX, gameDir, fileName)) { - return false; - } - - fp = fopen(path, "rb"); + // fileName is a bare name relative to DATA/ (e.g. "LOGDIR"). + fp = jlDataOpen(fileName, "rb"); if (fp == NULL) { return false; } - if (fseek(fp, 0L, SEEK_END) != 0) { - fclose(fp); - return false; - } - fileSize = ftell(fp); - if (fileSize < 0 || fileSize > (long)(AGI_MAX_RESOURCES * AGI_DIR_ENTRY_BYTES)) { - fclose(fp); - return false; - } - if ((fileSize % AGI_DIR_ENTRY_BYTES) != 0) { - fclose(fp); - return false; - } - if (fseek(fp, 0L, SEEK_SET) != 0) { - fclose(fp); + // Read the whole flat 3-byte-entry array in one call rather than one + // read per entry: on the IIgs each fread is a GS/OS round trip through + // SmartPort firmware, and a directory never exceeds AGI_MAX_RESOURCES + // entries, so the fixed buffer holds any valid file. A non-multiple-of-3 + // length means the file is malformed. + got = fread(dirBytes, 1, sizeof(dirBytes), fp); + fclose(fp); + if (got == 0u || (got % AGI_DIR_ENTRY_BYTES) != 0u) { return false; } - entryCount = (uint16_t)(fileSize / AGI_DIR_ENTRY_BYTES); + entryCount = (uint16_t)(got / AGI_DIR_ENTRY_BYTES); for (i = 0; i < entryCount; i++) { - if (fread(buf, 1, AGI_DIR_ENTRY_BYTES, fp) != AGI_DIR_ENTRY_BYTES) { - fclose(fp); - return false; - } + const uint8_t *e = &dirBytes[(uint32_t)i * AGI_DIR_ENTRY_BYTES]; - volNibble = (uint8_t)(buf[0] >> 4); + volNibble = (uint8_t)(e[0] >> 4); // 20-bit offset, cast to uint32_t before shift so a // 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].offset = 0u; } else { @@ -165,24 +135,55 @@ static bool loadDirectory(AgiGameT *game, AgiResTypeE type, const char *gameDir, game->resDir[type][i].offset = offset; } } - fclose(fp); game->resCount[type] = entryCount; return true; } -static bool openVolumes(AgiGameT *game, const char *gameDir) { - char path[AGI_PATH_MAX]; +static bool loadWords(AgiGameT *game) { + 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; bool anyOpened; anyOpened = false; for (v = 0; v < AGI_MAX_VOLUMES; v++) { - if (!buildVolPath(path, AGI_PATH_MAX, gameDir, v)) { + if (!buildVolName(name, sizeof(name), v)) { return false; } - game->volFiles[v] = fopen(path, "rb"); + game->volFiles[v] = jlDataOpen(name, "rb"); if (game->volFiles[v] != NULL) { 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) ----- void agiResClose(AgiGameT *game) { @@ -217,6 +243,11 @@ void agiResClose(AgiGameT *game) { for (t = 0; t < AGI_RES_COUNT; t++) { 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; 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++) { - if (!loadDirectory(game, (AgiResTypeE)t, gameDir, resTypeFileName((AgiResTypeE)t))) { + if (!loadDirectory(game, resTypeFileName((AgiResTypeE)t), (AgiResTypeE)t)) { agiResClose(game); 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); return false; } 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; +} diff --git a/agiVm.c b/agiVm.c index c23e476..35515ee 100644 --- a/agiVm.c +++ b/agiVm.c @@ -301,7 +301,7 @@ static bool popFrame(AgiVmT *vm); static bool pushFrame(AgiVmT *vm); static bool readByte(AgiVmT *vm, uint8_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 vmStepMisc(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) { - // 1-byte word count, followed by 2 bytes per word. - if (!skipTestArgs(vm, testOp)) { + // Variable-length: 1-byte word count, then that many 16-bit + // 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; + 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]; @@ -877,37 +903,34 @@ static bool readU16(AgiVmT *vm, uint16_t *out) { } -// Walk past the argument bytes of a test command without evaluating. -// For the variable-length `said` test, the first byte is the word -// count and the rest are 2 bytes per word. -static bool skipTestArgs(AgiVmT *vm, uint8_t testOp) { - uint8_t scratch; - uint8_t wordCount; - uint16_t toSkip; - uint16_t i; +// Match the parsed player command against a said() word pattern. +// AGI_WORD_REST (9999) matches the rest of the line (including nothing); +// AGI_WORD_ANYWORD (1) matches exactly one arbitrary word; +// any other id matches that exact group id. +// An exact match requires every typed word to be consumed by the pattern. +static bool saidMatch(const AgiVmT *vm, const uint16_t *pat, uint8_t patCount) { + uint8_t pi; + uint8_t wi; - if (testOp == TEST_SAID) { - if (!readByte(vm, &wordCount)) { + pi = 0u; + wi = 0u; + while (pi < patCount) { + uint16_t pw; + + pw = pat[pi]; + if (pw == AGI_WORD_REST) { + return true; + } + if (wi >= vm->parsedWordCount) { return false; } - toSkip = (uint16_t)((uint16_t)wordCount * 2u); - 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)) { + if (pw != AGI_WORD_ANYWORD && pw != vm->parsedWords[wi]) { return false; } + wi++; + pi++; } - return true; + return wi == vm->parsedWordCount; } diff --git a/scripts/make-agi-iigs-disk.sh b/scripts/make-agi-iigs-disk.sh new file mode 100755 index 0000000..85eafed --- /dev/null +++ b/scripts/make-agi-iigs-disk.sh @@ -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 [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 [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" diff --git a/scripts/run-agi.sh b/scripts/run-agi.sh index 442a390..6ac3b06 100755 --- a/scripts/run-agi.sh +++ b/scripts/run-agi.sh @@ -18,9 +18,9 @@ # Required files in the game directory (others are ignored): # LOGDIR PICDIR VIEWDIR SNDDIR VOL.0 [VOL.1 ...] [OBJECT] [WORDS.TOK] # -# IIgs is not yet wired (needs the disk packager extended to a larger -# 2mg volume plus case-folded ProDOS file names); the script reports -# what's missing rather than launching. +# IIgs delegates to run-iigs.sh's AGI mode, which packs AGI + the game +# onto a 2MB CFFA2 hard-disk volume (an 800KB floppy can't hold both) and +# boots GS/OS from a second CFFA2 partition. See make-agi-iigs-disk.sh. set -euo pipefail @@ -64,15 +64,19 @@ fi # filesystems (e.g. Amiga FFS: "AGI" vs our "Agi"). stageGameData() { local destDir=$1 + local dataDir=$destDir/DATA 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 if [[ -f $gamedata/$f ]]; then - cp "$gamedata/$f" "$destDir/$f" + cp "$gamedata/$f" "$dataDir/$f" fi done for f in "$gamedata"/VOL.*; do - [[ -f $f ]] && cp "$f" "$destDir/$(basename "$f")" + [[ -f $f ]] && cp "$f" "$dataDir/$(basename "$f")" done } @@ -83,7 +87,7 @@ runDos() { local work 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 fi @@ -116,13 +120,13 @@ runDos() { cp "$bin" "$work/AGI.EXE" stageGameData "$work" - # AGI.EXE reads game data from its CWD (we mount $work as C: and - # cd there first), so argv[1] is "." -- the starting-room override - # lives in argv[2]. Skip the argv when room=0 so the binary + # AGI.EXE reads game data from DATA/ under its CWD (we mount $work as C: + # and cd there first), so no game-directory argument is needed; the + # starting-room override is argv[1]. Skip it when room=0 so the binary # exercises its default code path. local agi_cmd="AGI.EXE" if (( room != 0 )); then - agi_cmd="AGI.EXE . $room" + agi_cmd="AGI.EXE $room" fi cat <&2 + echo "$bin not built. Run 'make -f make/atarist.mk $bin' first." >&2 exit 1 fi if [[ ! -f $tos ]]; then @@ -198,7 +202,7 @@ runAmiga() { local work 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 fi @@ -243,25 +247,21 @@ EOF runIigs() { - cat <&2 -IIgs AGI run is not wired up yet. - - The existing IIgs disk packager (toolchains/iigs/package-disk.sh) builds - joey.2mg from build/iigs/bin/* only; it doesn't accept arbitrary game - data, the disk size is fixed at 800K (KQ3's ~1.5 MB of resources won't - fit), and ProDOS case-folding for game-data file names has not been - audited. - - To wire this up the package-disk.sh tool needs a larger volume option - and an "extra data" path argument, and run-iigs.sh needs an AGI mode - that points GSplus at the resulting 2mg. Tracked separately. - -Workaround for now: build with 'make -f make/iigs.mk EXAMPLE=agi' to -prove the IIgs link still works, but launch via run-iigs.sh and pick -JOEYLIB:AGI in Finder -- it will fail to find LOGDIR (no game data on -disk), which is the expected current state. -EOF - exit 1 + # IIgs uses a CFFA2 hard disk (the 800KB floppy can't hold AGI + KQ3), + # 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, + # and launches AGI. The starting-room override is DOS-only (the IIgs + # autostart path can't pass argv), so $room is not forwarded here. + if (( room != 0 )); then + echo "note: starting-room override is DOS-only; ignored on IIgs" >&2 + fi + # The IIgs build target is the binary path, not EXAMPLE=agi. + if [[ ! -f "$repo/build/iigs/bin/AGI" ]]; then + echo "$repo/build/iigs/bin/AGI not built." >&2 + echo "Build it with: make -f make/iigs.mk $repo/build/iigs/bin/AGI" >&2 + exit 1 + fi + JOEY_AGI_GAME="$game" exec "$repo/scripts/run-iigs.sh" agi } diff --git a/scripts/test-agi.sh b/scripts/test-agi.sh index 9551c9c..b92fc04 100755 --- a/scripts/test-agi.sh +++ b/scripts/test-agi.sh @@ -45,14 +45,41 @@ stubSrc=$build/hostStubs.c cat > "$stubSrc" <<'EOF' #include "joey/draw.h" #include "joey/core.h" +#include "joey/file.h" #include "surfaceInternal.h" #include +#include +#include +// Host jlDataOpen: the real one (assetLoad.c) forces a DATA/ prefix, but +// the test gamedata dirs hold their files flat. Open / +// 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 jlFillRect(jlSurfaceT *s, int16_t x, int16_t y, uint16_t w, uint16_t h, uint8_t c) { (void)s; (void)x; (void)y; (void)w; (void)h; (void)c; } uint32_t jlRandom(void) { return (uint32_t)rand(); } uint16_t jlRandomRange(uint16_t bound) { return (bound == 0u) ? 0u : (uint16_t)((unsigned)rand() % bound); } void jlRandomSeed(uint32_t seed) { srand((unsigned)seed); } -void surfaceMarkDirtyRect(const jlSurfaceT *s, int16_t x, int16_t y, int16_t w, int16_t h) { (void)s; (void)x; (void)y; (void)w; (void)h; } +void *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; } EOF @@ -60,6 +87,7 @@ echo "Building host tests..." gcc "${hostCflags[@]}" \ "$repo/tests/agi/testAgiRes.c" \ "$repo/examples/agi/agiRes.c" \ + "$stubSrc" \ -o "$testRes" gcc "${hostCflags[@]}" \ @@ -131,6 +159,9 @@ for game in "${games[@]}"; do continue 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 echo "--- resource loader ---" if ! "$testRes" "$dir"; then diff --git a/tests/disasmAgi.c b/tests/disasmAgi.c index 32fdfe2..2963877 100644 --- a/tests/disasmAgi.c +++ b/tests/disasmAgi.c @@ -370,7 +370,7 @@ int main(int argc, char **argv) { fprintf(stderr, "usage: %s [end-pc]\n", argv[0]); return 2; } - if (!agiResOpen(&game, argv[1])) { + if (!agiResOpen(&game)) { fprintf(stderr, "FAIL: agiResOpen rejected '%s'\n", argv[1]); return 1; } diff --git a/tests/testAgiPic.c b/tests/testAgiPic.c index 433a5d2..c18eb52 100644 --- a/tests/testAgiPic.c +++ b/tests/testAgiPic.c @@ -117,7 +117,7 @@ int main(int argc, char **argv) { return 2; } - if (!agiResOpen(&game, argv[1])) { + if (!agiResOpen(&game)) { fprintf(stderr, "FAIL: agiResOpen rejected '%s'\n", argv[1]); return 1; } diff --git a/tests/testAgiPipeline.c b/tests/testAgiPipeline.c index 9645614..b329dda 100644 --- a/tests/testAgiPipeline.c +++ b/tests/testAgiPipeline.c @@ -179,7 +179,7 @@ int main(int argc, char **argv) { maxCycles = (uint32_t)MAX_CYCLES_DEFAULT; } - if (!agiResOpen(&gGame, argv[1])) { + if (!agiResOpen(&gGame)) { fprintf(stderr, "FAIL: agiResOpen rejected '%s'\n", argv[1]); return 1; } diff --git a/tests/testAgiRes.c b/tests/testAgiRes.c index e04b118..b56cc58 100644 --- a/tests/testAgiRes.c +++ b/tests/testAgiRes.c @@ -81,7 +81,7 @@ int main(int argc, char **argv) { } 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]); return 1; } diff --git a/tests/testAgiView.c b/tests/testAgiView.c index 6971fa1..9c332e0 100644 --- a/tests/testAgiView.c +++ b/tests/testAgiView.c @@ -145,7 +145,7 @@ int main(int argc, char **argv) { return 2; } - if (!agiResOpen(&game, argv[1])) { + if (!agiResOpen(&game)) { fprintf(stderr, "FAIL: agiResOpen rejected '%s'\n", argv[1]); return 1; } diff --git a/tests/testAgiVm.c b/tests/testAgiVm.c index 3507cd0..b0948f2 100644 --- a/tests/testAgiVm.c +++ b/tests/testAgiVm.c @@ -86,7 +86,7 @@ int main(int argc, char **argv) { return 2; } - if (!agiResOpen(&game, argv[1])) { + if (!agiResOpen(&game)) { fprintf(stderr, "FAIL: agiResOpen rejected '%s'\n", argv[1]); return 1; }