// AGI v2 resource loader. // // Parses LOGDIR/PICDIR/VIEWDIR/SNDDIR into in-RAM index tables, keeps // every VOL.x file open for the session, and exposes a single // resource-by-id load primitive that hands back a freshly-allocated // payload buffer. // // AGI v2 directory entry format (3 bytes, big-endian within the byte // stream): byte0 high nibble is the volume number, the 20-bit value // formed from (byte0 & 0x0F):byte1:byte2 is the file offset. All-FF // means the slot is empty (no resource with that ID). // // VOL.x record header (5 bytes at the directory's offset): 0x12 0x34 // signature, then the volume number, then a little-endian 16-bit // payload length. v2 payloads are stored raw; v3 sets bit 7 of the // volume byte to flag LZW compression (not handled here). #include "agi.h" #include #include #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_- // RESOURCES; larger files are reported as bad rather than truncated. #define AGI_DIR_ENTRY_BYTES 3 // VOL.x resource records start with a 5-byte header. #define AGI_VOL_HEADER_BYTES 5 #define AGI_VOL_SIG_0 0x12 #define AGI_VOL_SIG_1 0x34 #define AGI_VOL_COMPRESSED 0x80 #define AGI_VOL_VOLUME_MASK 0x7F // 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 // nibble 0xF = volume 15); 15 is a legitimate volume number, so we // don't reuse it as the sentinel. #define AGI_DIR_EMPTY_VOLUME 0xFF // IIgs 64 KB code-bank rule: park this TU in its own load segment // so it doesn't pile onto _ROOT (which still holds main() and the // runtime startup). Cross-platform builds ignore this pragma. #ifdef JOEYLIB_PLATFORM_IIGS #endif // ----- Prototypes ----- 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 buildVolName(char *out, size_t outSize, uint8_t volNum) { char *p; // "VOL." + up to 2 digits + NUL = 7 chars max. if (outSize < 8u) { return false; } p = out; *p++ = 'V'; *p++ = 'O'; *p++ = 'L'; *p++ = '.'; if (volNum >= 10u) { *p++ = (char)('0' + (volNum / 10u)); *p++ = (char)('0' + (volNum % 10u)); } else { *p++ = (char)('0' + volNum); } *p = '\0'; return true; } 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; // fileName is a bare name relative to DATA/ (e.g. "LOGDIR"). fp = jlDataOpen(fileName, "rb"); if (fp == NULL) { return false; } // 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)(got / AGI_DIR_ENTRY_BYTES); for (i = 0; i < entryCount; i++) { const uint8_t *e = &dirBytes[(uint32_t)i * AGI_DIR_ENTRY_BYTES]; 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)(e[0] & 0x0F) << 16) | ((uint32_t)e[1] << 8) | (uint32_t)e[2]; 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 { game->resDir[type][i].volume = volNibble; game->resDir[type][i].offset = offset; } } game->resCount[type] = entryCount; return true; } 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 (!buildVolName(name, sizeof(name), v)) { return false; } game->volFiles[v] = jlDataOpen(name, "rb"); if (game->volFiles[v] != NULL) { anyOpened = true; } } return anyOpened; } static const char *resTypeFileName(AgiResTypeE type) { switch (type) { case AGI_RES_LOGIC: return "LOGDIR"; case AGI_RES_PIC: return "PICDIR"; case AGI_RES_VIEW: return "VIEWDIR"; case AGI_RES_SOUND: return "SNDDIR"; default: return NULL; } } // 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) { uint8_t v; uint8_t t; for (v = 0; v < AGI_MAX_VOLUMES; v++) { if (game->volFiles[v] != NULL) { fclose(game->volFiles[v]); game->volFiles[v] = NULL; } } 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; } } uint8_t *agiResLoad(const AgiGameT *game, AgiResTypeE type, uint16_t index, uint16_t *outLength) { const AgiResEntryT *entry; FILE *fp; uint8_t header[AGI_VOL_HEADER_BYTES]; uint8_t *payload; uint16_t length; uint8_t flagsAndVolume; if (outLength != NULL) { *outLength = 0u; } if (type >= AGI_RES_COUNT) { return NULL; } if (index >= game->resCount[type]) { return NULL; } entry = &game->resDir[type][index]; if (entry->volume >= AGI_MAX_VOLUMES) { return NULL; } fp = game->volFiles[entry->volume]; if (fp == NULL) { return NULL; } if (fseek(fp, (long)entry->offset, SEEK_SET) != 0) { return NULL; } if (fread(header, 1, AGI_VOL_HEADER_BYTES, fp) != AGI_VOL_HEADER_BYTES) { return NULL; } if (header[0] != AGI_VOL_SIG_0 || header[1] != AGI_VOL_SIG_1) { return NULL; } flagsAndVolume = header[2]; if (flagsAndVolume & AGI_VOL_COMPRESSED) { // v3 LZW-compressed resource. Phase 2 work; not handled yet. return NULL; } if ((flagsAndVolume & AGI_VOL_VOLUME_MASK) != entry->volume) { return NULL; } length = (uint16_t)(((uint16_t)header[4] << 8) | (uint16_t)header[3]); if (length == 0u) { return NULL; } payload = (uint8_t *)malloc(length); if (payload == NULL) { return NULL; } if (fread(payload, 1, length, fp) != length) { free(payload); return NULL; } if (outLength != NULL) { *outLength = length; } return payload; } 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, resTypeFileName((AgiResTypeE)t), (AgiResTypeE)t)) { agiResClose(game); return false; } } 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; }