joeylib2/examples/agi/agiRes.c

407 lines
11 KiB
C

// 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 <stddef.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#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.<n>". 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;
}