407 lines
11 KiB
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;
|
|
}
|