joeyagi/agiRes.c
2026-10-06 16:59:41 -05:00

455 lines
14 KiB
C

// AGI v2 resources: the four directories, the VOL files, WORDS.TOK and
// OBJECT (AGI specification chapters 5 and 10).
//
// A directory is a flat array of 3-byte entries: the high nibble of the
// first byte is the VOL file, the remaining 20 bits the offset into it;
// FF FF FF marks an unused number. A resource in a VOL file starts with a
// 5-byte header: 12 34, the volume, then the little-endian length.
#include "agi.h"
#include <stddef.h>
#include <string.h>
#include "joey/core.h"
#include "joey/file.h"
#define DIR_ENTRY_BYTES 3u
#define DIR_EMPTY_BYTE 0xFFu
#define DIR_EMPTY_VOLUME 0xFFu
#define DIR_VOLUME_SHIFT 4u
#define DIR_OFFSET_HIGH_MASK 0x0Fu
#define DIR_OFFSET_HIGH_SHIFT 16u
#define VOL_HEADER_BYTES 5u
#define VOL_SIG_0 0x12u
#define VOL_SIG_1 0x34u
#define VOL_COMPRESSED 0x80u
#define VOL_VOLUME_MASK 0x7Fu
#define VOL_NAME_MAX 8u
#define DECIMAL_BASE 10u
// OBJECT: a 3-byte header (offset of the names, the number of animated
// objects) then a 3-byte entry per item (name offset, starting room).
// Offsets count from the first entry. The file is XORed with the same
// key as LOGIC messages.
#define OBJECT_HEADER_BYTES 3u
#define OBJECT_ENTRY_BYTES 3u
#define OBJECT_ROOM_BYTE 2u
#define OBJECT_MAX_BYTES 8192u
#define OBJECT_MAX_OBJS_BYTE 2u
// WORDS.TOK: 26 big-endian letter offsets, then entries of a prefix
// count, characters XORed with 0x7F (bit 7 marks the last) and a
// big-endian group number.
#define WORD_CHAR_XOR 0x7Fu
#define WORD_LAST_CHAR 0x80u
#define WORD_INDEX_BYTES 2u
#define CRYPT_KEY "Avis Durgan"
#define CRYPT_KEY_LEN 11u
// The interpreter's version string, "Version 2.435", in AGIDATA.OVL (or
// in the AGI executable of interpreters without one).
#define VERSION_TAG "Version "
#define VERSION_FILE_MAX 65535u
#define VERSION_MINOR_DIGITS 3u
#define VERSION_MAJOR_SCALE 1000u
// ----- Prototypes -----
static void buildVolName(char *out, uint8_t volNum);
static uint16_t detectVersion(void);
static void loadDirectory(AgiGameT *game, const char *fileName, AgiResTypeE type);
static uint8_t *loadFile(const char *name, uint16_t maxBytes, uint16_t *outLength);
static bool loadObjects(AgiGameT *game);
// ----- Tables -----
// The directory files, by AgiResTypeE.
static const char *const kDirNames[AGI_RES_COUNT] = { "LOGDIR", "PICDIR", "VIEWDIR", "SNDDIR" };
// Where the version string may be, in the order to look.
static const char *const kVersionFiles[] = { "AGIDATA.OVL", "AGI" };
// The interpreter versions each quirk is known in. Measured on 2.435
// (King's Quest III 2.00) only, the one Sierra interpreter at hand; they
// come from the PC interpreter's window, input-line and picture code, so
// they are assumed for every 2.x interpreter until one is measured
// otherwise.
typedef struct {
uint16_t first;
uint16_t last;
} QuirkRangeT;
static const QuirkRangeT kQuirkVersions[AGI_QUIRK_COUNT] = {
{ 2000u, 2999u }, // AGI_QUIRK_WINDOW_BYTE_X
{ 2000u, 2999u }, // AGI_QUIRK_ECHO_STEPS_BACK
{ 2000u, 2999u } // AGI_QUIRK_LINE_BYTE_ERROR
};
// ----- Internal helpers (alphabetical) -----
static void buildVolName(char *out, uint8_t volNum) {
uint8_t n;
n = 0u;
out[n++] = 'V';
out[n++] = 'O';
out[n++] = 'L';
out[n++] = '.';
if (volNum >= DECIMAL_BASE) {
out[n++] = (char)('0' + volNum / DECIMAL_BASE);
}
out[n++] = (char)('0' + volNum % DECIMAL_BASE);
out[n] = '\0';
}
// The version Sierra's interpreter files in the game folder name, or
// AGI_VERSION_MEASURED when there are none.
static uint16_t detectVersion(void) {
uint8_t *data;
uint16_t length;
uint16_t i;
uint16_t j;
uint16_t major;
uint16_t minor;
uint8_t f;
for (f = 0u; f < sizeof(kVersionFiles) / sizeof(kVersionFiles[0]); f++) {
data = loadFile(kVersionFiles[f], VERSION_FILE_MAX, &length);
if (data == NULL) {
continue;
}
for (i = 0u; (uint32_t)i + sizeof(VERSION_TAG) + VERSION_MINOR_DIGITS + 1u < length; i++) {
if (memcmp(&data[i], VERSION_TAG, sizeof(VERSION_TAG) - 1u) != 0) {
continue;
}
j = (uint16_t)(i + sizeof(VERSION_TAG) - 1u);
if (data[j] < '0' || data[j] > '9' || data[j + 1u] != '.') {
continue;
}
major = (uint16_t)(data[j] - '0');
minor = 0u;
for (j = (uint16_t)(j + 2u); j < length && data[j] >= '0' && data[j] <= '9'; j++) {
minor = (uint16_t)(minor * DECIMAL_BASE + (uint16_t)(data[j] - '0'));
}
jlFree(data);
return (uint16_t)(major * VERSION_MAJOR_SCALE + minor);
}
jlFree(data);
}
return AGI_VERSION_MEASURED;
}
// A missing or empty directory just means none of that type (a game
// without sounds); the game needs logic 0 (agiResOpen).
static void loadDirectory(AgiGameT *game, const char *fileName, AgiResTypeE type) {
uint8_t *bytes;
uint16_t length;
uint16_t count;
uint16_t i;
bytes = loadFile(fileName, (uint16_t)(AGI_MAX_RESOURCES * DIR_ENTRY_BYTES), &length);
if (bytes == NULL) {
return;
}
count = (uint16_t)(length / DIR_ENTRY_BYTES);
for (i = 0u; i < count; i++) {
const uint8_t *e = &bytes[i * DIR_ENTRY_BYTES];
AgiResEntryT *d = &game->resDir[type][i];
if (e[0] == DIR_EMPTY_BYTE && e[1] == DIR_EMPTY_BYTE && e[2] == DIR_EMPTY_BYTE) {
d->volume = DIR_EMPTY_VOLUME;
} else {
d->volume = (uint8_t)(e[0] >> DIR_VOLUME_SHIFT);
d->offsetHigh = (uint8_t)(e[0] & DIR_OFFSET_HIGH_MASK);
d->offsetLow = (uint16_t)(((uint16_t)e[1] << 8) | e[2]);
}
}
game->resCount[type] = count;
jlFree(bytes);
}
// Read a whole data file, at most maxBytes of it, into a buffer of its
// own size (one fread: on the IIgs each call is a GS/OS round trip).
// Returns NULL when it is missing or empty.
static uint8_t *loadFile(const char *name, uint16_t maxBytes, uint16_t *outLength) {
FILE *fp;
uint8_t *buf;
long size;
fp = jlDataOpen(name, "rb");
if (fp == NULL) {
return NULL;
}
buf = NULL;
if (fseek(fp, 0L, SEEK_END) == 0) {
size = ftell(fp);
if (size > (long)maxBytes) {
size = (long)maxBytes;
}
if (size > 0L && fseek(fp, 0L, SEEK_SET) == 0) {
buf = (uint8_t *)jlAlloc((uint32_t)size);
}
if (buf != NULL && fread(buf, 1, (size_t)size, fp) != (size_t)size) {
jlFree(buf);
buf = NULL;
}
*outLength = (uint16_t)size;
}
fclose(fp);
return buf;
}
static bool loadObjects(AgiGameT *game) {
uint8_t *data;
uint16_t length;
uint16_t namesOffset;
data = loadFile("OBJECT", OBJECT_MAX_BYTES, &length);
if (data == NULL) {
return false;
}
if (length < OBJECT_HEADER_BYTES) {
jlFree(data);
return false;
}
agiDecrypt(data, length);
namesOffset = (uint16_t)(data[0] | ((uint16_t)data[1] << 8));
game->objectData = data;
game->itemCount = (uint16_t)(namesOffset / OBJECT_ENTRY_BYTES);
if (game->itemCount > AGI_MAX_ITEMS) {
game->itemCount = AGI_MAX_ITEMS;
}
game->maxObjects = data[OBJECT_MAX_OBJS_BYTE];
if (game->maxObjects == 0u || game->maxObjects > AGI_MAX_OBJECTS) {
game->maxObjects = AGI_MAX_OBJECTS;
}
return true;
}
// ----- Public API (alphabetical) -----
// Undo Sierra's XOR with "Avis Durgan" (OBJECT, and LOGIC messages from
// the start of their text).
void agiDecrypt(uint8_t *data, uint16_t length) {
uint16_t i;
for (i = 0u; i < length; i++) {
data[i] = (uint8_t)(data[i] ^ (uint8_t)CRYPT_KEY[i % CRYPT_KEY_LEN]);
}
}
const char *agiItemName(const AgiGameT *game, uint8_t item) {
const uint8_t *e;
uint16_t off;
if (game->objectData == NULL || item >= game->itemCount) {
return "";
}
e = &game->objectData[OBJECT_HEADER_BYTES + (uint16_t)item * OBJECT_ENTRY_BYTES];
off = (uint16_t)(e[0] | ((uint16_t)e[1] << 8));
return (const char *)&game->objectData[OBJECT_HEADER_BYTES + off];
}
uint8_t agiItemStartRoom(const AgiGameT *game, uint8_t item) {
if (game->objectData == NULL || item >= game->itemCount) {
return 0u;
}
return game->objectData[OBJECT_HEADER_BYTES + (uint16_t)item * OBJECT_ENTRY_BYTES + OBJECT_ROOM_BYTE];
}
// Whether the game's interpreter version has a quirk.
bool agiQuirk(AgiQuirkE quirk) {
uint16_t v;
v = gAgi->game.interpreterVersion;
return quirk < AGI_QUIRK_COUNT && v >= kQuirkVersions[quirk].first && v <= kQuirkVersions[quirk].last;
}
void agiResClose(AgiGameT *game) {
uint8_t v;
for (v = 0u; v < AGI_MAX_VOLUMES; v++) {
if (game->volFiles[v] != NULL) {
fclose(game->volFiles[v]);
game->volFiles[v] = NULL;
}
}
jlFree(game->words);
game->words = NULL;
jlFree(game->objectData);
game->objectData = NULL;
}
uint8_t *agiResLoad(const AgiGameT *game, AgiResTypeE type, uint16_t index, uint16_t *outLength) {
const AgiResEntryT *entry;
FILE *fp;
uint8_t header[VOL_HEADER_BYTES];
uint8_t *payload;
uint16_t length;
long offset;
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;
}
offset = ((long)entry->offsetHigh << DIR_OFFSET_HIGH_SHIFT) | (long)entry->offsetLow;
if (fseek(fp, offset, SEEK_SET) != 0) {
return NULL;
}
if (fread(header, 1, VOL_HEADER_BYTES, fp) != VOL_HEADER_BYTES) {
return NULL;
}
if (header[0] != VOL_SIG_0 || header[1] != VOL_SIG_1) {
return NULL;
}
// v3 resources are LZW-compressed and flag it in the volume byte.
if ((header[2] & VOL_COMPRESSED) != 0u || (header[2] & VOL_VOLUME_MASK) != entry->volume) {
return NULL;
}
length = (uint16_t)(header[3] | ((uint16_t)header[4] << 8));
if (length == 0u) {
return NULL;
}
payload = (uint8_t *)jlAlloc(length);
if (payload == NULL) {
return NULL;
}
if (fread(payload, 1, length, fp) != length) {
jlFree(payload);
return NULL;
}
if (outLength != NULL) {
*outLength = length;
}
return payload;
}
bool agiResOpen(AgiGameT *game) {
char name[VOL_NAME_MAX];
uint8_t t;
uint8_t v;
bool anyVolume;
memset(game, 0, sizeof(*game));
for (t = 0u; t < AGI_RES_COUNT; t++) {
loadDirectory(game, kDirNames[t], (AgiResTypeE)t);
}
if (game->resCount[AGI_RES_LOGIC] == 0u) {
agiResClose(game);
return false;
}
anyVolume = false;
for (v = 0u; v < AGI_MAX_VOLUMES; v++) {
buildVolName(name, v);
game->volFiles[v] = jlDataOpen(name, "rb");
if (game->volFiles[v] != NULL) {
anyVolume = true;
}
}
if (!anyVolume) {
agiResClose(game);
return false;
}
// A game without WORDS.TOK just never matches said(); without OBJECT
// it has no inventory.
game->words = loadFile("WORDS.TOK", AGI_WORDS_MAX, &game->wordsLen);
if (game->words != NULL && game->wordsLen <= AGI_WORDS_HEADER_BYTES) {
jlFree(game->words);
game->words = NULL;
}
if (!loadObjects(game)) {
game->maxObjects = AGI_MAX_OBJECTS;
}
game->interpreterVersion = detectVersion();
return true;
}
// Find the longest dictionary entry that starts `text` and ends at a word
// boundary (the end of text or a space). Entries can hold several words
// ("pick up"). text is lower case with single spaces. Returns the word's
// group, or AGI_WORD_NONE, and its length in *outLen.
uint16_t agiWordMatch(const AgiGameT *game, const char *text, uint8_t *outLen) {
char cur[AGI_WORD_MAX_LEN + 1u];
const uint8_t *p;
const uint8_t *end;
uint16_t best;
uint8_t bestLen;
uint8_t len;
uint8_t letter;
uint16_t start;
*outLen = 0u;
if (game->words == NULL || text[0] < 'a' || text[0] > 'z') {
return AGI_WORD_NONE;
}
letter = (uint8_t)(text[0] - 'a');
start = (uint16_t)(((uint16_t)game->words[letter * WORD_INDEX_BYTES] << 8) | game->words[letter * WORD_INDEX_BYTES + 1u]);
if (start == 0u || start >= game->wordsLen) {
return AGI_WORD_NONE;
}
best = AGI_WORD_NONE;
bestLen = 0u;
p = game->words + start;
end = game->words + game->wordsLen;
while (p < end) {
uint8_t c;
uint16_t id;
len = *p++;
if (len > AGI_WORD_MAX_LEN) {
break;
}
do {
if (p >= end) {
*outLen = bestLen;
return best;
}
c = *p++;
if (len < AGI_WORD_MAX_LEN) {
cur[len] = (char)((c & (uint8_t)~WORD_LAST_CHAR) ^ WORD_CHAR_XOR);
len++;
}
} while ((c & WORD_LAST_CHAR) == 0u);
if (p + 1 >= end) {
break;
}
id = (uint16_t)(((uint16_t)p[0] << 8) | p[1]);
p += 2;
// Entries are sorted, so once the first letter changes nothing
// further can match.
if (cur[0] != text[0]) {
break;
}
if (len > bestLen && strncmp(cur, text, len) == 0 && (text[len] == '\0' || text[len] == ' ')) {
best = id;
bestLen = len;
}
}
*outLen = bestLen;
return best;
}