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

462 lines
15 KiB
C

// Headless host port of JoeyLib for running the whole interpreter on the
// build machine: JoeyLib's BLANK platform with these services filled in.
//
// Time is virtual. Every jlWaitVBL is one 1/60 s frame, so a run is exactly
// reproducible: the same script gives the same frames, independent of how
// fast the host is. The display is the stage itself, resolved through each
// row's palette at every present.
//
// The input script is the one scripts/dosDrive.py feeds DOSBox, so the same
// file drives Sierra's interpreter, JoeyAGI in DOSBox and this harness:
// wait SECONDS let SECONDS of virtual time pass
// key NAME [HOLD] press key NAME (xdotool names) for HOLD seconds
// down NAME / up NAME hold / release a key
// type TEXT press each character's key in turn, typing it
// shot NAME write the displayed frame to OUTDIR/NAME.ppm
// clear press Return until no message window is open
// record NAME note every frame from here ...
// stop ... to here: OUTDIR/NAME.frames lists each
// frame's hash and OUTDIR/NAME-HASH.ppm holds each
// different frame once
// '#' starts a comment. When the script ends the run stops.
//
// Every shot prints its frame's FNV-1a hash (the gate compares those) and,
// unless the output folder is "-", writes a PPM; a recording prints one
// hash for its whole sequence of frames, as a shot named after it.
#include "hostPort.h"
#include "port.h"
#include "surfaceInternal.h"
#include "inputInternal.h"
#include "agi.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define HOST_FRAME_HZ 60u
#define HOST_LINE_MAX 512
#define HOST_PATH_MAX 1024
// A file path: the output folder, a name from the script and a suffix.
#define HOST_FILE_MAX (HOST_PATH_MAX + HOST_LINE_MAX + 64)
#define HOST_KEY_HOLD_FRAMES 7u // 0.12 s, dosDrive.py's default press
#define HOST_KEY_GAP_FRAMES 5u // 0.08 s, dosDrive.py's gap after a press
#define HOST_CHANNEL_MAX 255u
#define HOST_NIBBLE_SCALE 17u // $0RGB nibble -> 8-bit channel
#define HOST_PIXELS_PER_BYTE 2u
#define HOST_CLEAR_GAP_FRAMES 36u // 0.6 s, dosDrive.py's pause between Returns
#define HOST_CLEAR_TRIES 8u // dosDrive.py's limit on Returns per clear
#define HOST_NO_FILES "-"
#define FNV_OFFSET_BASIS 2166136261u
#define FNV_PRIME 16777619u
#define HASH_BYTES 4u
#define BYTE_BITS 8u
#define BYTE_MASK 0xFFu
#define RECORD_DISTINCT_MAX 64u
typedef struct {
const char *name;
jlKeyE key;
uint8_t ch;
} HostKeyNameT;
// ----- Prototypes -----
static void hostDump(const char *name);
static uint32_t hostFrameHash(void);
static bool hostKeyLookup(const char *name, jlKeyE *outKey, uint8_t *outCh);
static void hostPress(jlKeyE key, uint8_t ch, uint32_t holdFrames);
static void hostRecordFrame(void);
static void hostStep(void);
static void hostWritePpm(const char *path);
// ----- Module state -----
static const HostKeyNameT kKeyNames[] = {
{ "Return", KEY_RETURN, JL_CHAR_RETURN },
{ "Escape", KEY_ESCAPE, JL_CHAR_ESCAPE },
{ "Tab", KEY_TAB, JL_CHAR_TAB },
{ "BackSpace", KEY_BACKSPACE, JL_CHAR_BACKSPACE },
{ "space", KEY_SPACE, ' ' },
{ "Up", KEY_UP, 0u },
{ "Down", KEY_DOWN, 0u },
{ "Left", KEY_LEFT, 0u },
{ "Right", KEY_RIGHT, 0u },
{ "F1", KEY_F1, 0u },
{ "F2", KEY_F2, 0u },
{ "F3", KEY_F3, 0u },
{ "F4", KEY_F4, 0u },
{ "F5", KEY_F5, 0u },
{ "F6", KEY_F6, 0u },
{ "F7", KEY_F7, 0u },
{ "F8", KEY_F8, 0u },
{ "F9", KEY_F9, 0u },
{ "F10", KEY_F10, 0u },
{ "ctrl", KEY_LCTRL, 0u },
{ "alt", KEY_LALT, 0u },
{ "shift", KEY_LSHIFT, 0u },
{ "period", KEY_NONE, '.' },
{ "comma", KEY_NONE, ',' },
{ "apostrophe", KEY_NONE, '\'' },
{ "minus", KEY_NONE, '-' },
{ "question", KEY_NONE, '?' },
{ "exclam", KEY_NONE, '!' }
};
static FILE *gScript;
static char gOutDir[HOST_PATH_MAX];
static uint32_t gFrame;
static uint32_t gResumeFrame;
static uint32_t gHoldUntil[KEY_COUNT];
static bool gHeld[KEY_COUNT];
static uint8_t gDisplay[SURFACE_HEIGHT][SURFACE_WIDTH][3];
// Characters still to type for the current 'type' line, one per press.
static char gTypeBuf[HOST_LINE_MAX];
static long gLinePos;
static uint16_t gTypePos;
static uint8_t gClearTries;
// A recording: its name, its frame list, the hash of its frame sequence
// and the frames written so far.
static char gRecordName[HOST_LINE_MAX];
static FILE *gRecordLog;
static bool gRecording;
static uint32_t gRecordHash;
static uint32_t gRecordFrames;
static uint32_t gRecordSeen[RECORD_DISTINCT_MAX];
static uint8_t gRecordSeenCount;
// ----- Internal helpers (alphabetical) -----
static void hostDump(const char *name) {
char path[HOST_FILE_MAX];
printf("[%6.1f] shot %s %08x\n", (double)gFrame / (double)HOST_FRAME_HZ, name, (unsigned)hostFrameHash());
if (strcmp(gOutDir, HOST_NO_FILES) == 0) {
return;
}
snprintf(path, sizeof(path), "%s/%s.ppm", gOutDir, name);
hostWritePpm(path);
}
static uint32_t hostFrameHash(void) {
const uint8_t *p;
uint32_t hash;
size_t i;
hash = FNV_OFFSET_BASIS;
p = &gDisplay[0][0][0];
for (i = 0; i < sizeof(gDisplay); i++) {
hash = (hash ^ p[i]) * FNV_PRIME;
}
return hash;
}
// Map an xdotool key name (or a single character) to a JoeyLib key and
// the character it types.
static bool hostKeyLookup(const char *name, jlKeyE *outKey, uint8_t *outCh) {
size_t i;
char c;
for (i = 0; i < sizeof(kKeyNames) / sizeof(kKeyNames[0]); i++) {
if (strcmp(kKeyNames[i].name, name) == 0) {
*outKey = kKeyNames[i].key;
*outCh = kKeyNames[i].ch;
return true;
}
}
if (name[0] == '\0' || name[1] != '\0') {
return false;
}
c = name[0];
if (c >= 'a' && c <= 'z') {
*outKey = (jlKeyE)(KEY_A + (c - 'a'));
*outCh = (uint8_t)c;
return true;
}
if (c >= 'A' && c <= 'Z') {
*outKey = (jlKeyE)(KEY_A + (c - 'A'));
*outCh = (uint8_t)c;
return true;
}
if (c >= '0' && c <= '9') {
*outKey = (jlKeyE)(KEY_0 + (c - '0'));
*outCh = (uint8_t)c;
return true;
}
*outKey = KEY_NONE;
*outCh = (uint8_t)c;
return true;
}
static void hostPress(jlKeyE key, uint8_t ch, uint32_t holdFrames) {
if (key != KEY_NONE) {
gHoldUntil[key] = gFrame + holdFrames;
}
// Like the real keyboards, a Ctrl or Alt chord types no character.
if (ch != 0u && !gHeld[KEY_LALT] && !gHeld[KEY_LCTRL]) {
jlInputCharPush(ch);
}
gResumeFrame = gFrame + holdFrames + HOST_KEY_GAP_FRAMES;
}
// One frame of a recording: into the sequence hash and the frame list,
// and written out the first time it is seen.
static void hostRecordFrame(void) {
char path[HOST_FILE_MAX];
uint32_t hash;
uint8_t i;
hash = hostFrameHash();
for (i = 0u; i < HASH_BYTES; i++) {
gRecordHash = (gRecordHash ^ ((hash >> (i * BYTE_BITS)) & BYTE_MASK)) * FNV_PRIME;
}
gRecordFrames++;
if (gRecordLog == NULL) {
return;
}
fprintf(gRecordLog, "%u %08x\n", (unsigned)gRecordFrames, (unsigned)hash);
for (i = 0u; i < gRecordSeenCount; i++) {
if (gRecordSeen[i] == hash) {
return;
}
}
if (gRecordSeenCount >= RECORD_DISTINCT_MAX) {
return;
}
gRecordSeen[gRecordSeenCount++] = hash;
snprintf(path, sizeof(path), "%s/%s-%08x.ppm", gOutDir, gRecordName, (unsigned)hash);
hostWritePpm(path);
}
// Run script lines until one asks for time to pass.
static void hostStep(void) {
char line[HOST_LINE_MAX];
char *op;
char *arg;
jlKeyE key;
uint8_t ch;
double secs;
while (gScript != NULL && gFrame >= gResumeFrame) {
if (gTypeBuf[gTypePos] != '\0') {
char one[2];
one[0] = gTypeBuf[gTypePos];
one[1] = '\0';
gTypePos++;
if (one[0] == ' ') {
hostPress(KEY_SPACE, ' ', HOST_KEY_HOLD_FRAMES);
} else if (hostKeyLookup(one, &key, &ch)) {
hostPress(key, ch, HOST_KEY_HOLD_FRAMES);
}
continue;
}
gLinePos = ftell(gScript);
if (fgets(line, sizeof(line), gScript) == NULL) {
printf("[%6.1f] script done\n", (double)gFrame / (double)HOST_FRAME_HZ);
exit(0);
}
line[strcspn(line, "\r\n")] = '\0';
op = line;
while (*op == ' ' || *op == '\t') {
op++;
}
if (strncmp(op, "type ", 5) == 0) {
strncpy(gTypeBuf, op + 5, sizeof(gTypeBuf) - 1u);
gTypeBuf[sizeof(gTypeBuf) - 1u] = '\0';
gTypePos = 0u;
continue;
}
op[strcspn(op, "#")] = '\0';
arg = op + strcspn(op, " \t");
if (*arg != '\0') {
*arg = '\0';
arg++;
while (*arg == ' ' || *arg == '\t') {
arg++;
}
}
if (*op == '\0') {
continue;
}
if (strcmp(op, "wait") == 0) {
secs = atof(arg);
gResumeFrame = gFrame + (uint32_t)(secs * HOST_FRAME_HZ + 0.5);
} else if (strcmp(op, "key") == 0) {
char *holdArg;
double hold;
holdArg = arg + strcspn(arg, " \t");
hold = 0.0;
if (*holdArg != '\0') {
*holdArg = '\0';
hold = atof(holdArg + 1);
}
if (!hostKeyLookup(arg, &key, &ch)) {
fprintf(stderr, "agiHost: unknown key '%s'\n", arg);
exit(2);
}
hostPress(key, ch, (hold > 0.0) ? (uint32_t)(hold * HOST_FRAME_HZ + 0.5) : HOST_KEY_HOLD_FRAMES);
} else if (strcmp(op, "down") == 0 || strcmp(op, "up") == 0) {
if (!hostKeyLookup(arg, &key, &ch) || key == KEY_NONE) {
fprintf(stderr, "agiHost: unknown key '%s'\n", arg);
exit(2);
}
gHeld[key] = (op[0] == 'd');
if (gHeld[key] && ch != 0u) {
jlInputCharPush(ch);
}
} else if (strcmp(op, "shot") == 0) {
hostDump(arg);
} else if (strcmp(op, "record") == 0) {
char path[HOST_FILE_MAX];
strncpy(gRecordName, arg, sizeof(gRecordName) - 1u);
gRecording = true;
gRecordHash = FNV_OFFSET_BASIS;
gRecordFrames = 0u;
gRecordSeenCount = 0u;
if (strcmp(gOutDir, HOST_NO_FILES) != 0) {
snprintf(path, sizeof(path), "%s/%s.frames", gOutDir, gRecordName);
gRecordLog = fopen(path, "w");
}
} else if (strcmp(op, "stop") == 0) {
gRecording = false;
if (gRecordLog != NULL) {
fclose(gRecordLog);
gRecordLog = NULL;
}
printf("[%6.1f] shot %s %08x\n", (double)gFrame / (double)HOST_FRAME_HZ, gRecordName, (unsigned)gRecordHash);
} else if (strcmp(op, "clear") == 0) {
// Dismiss message windows: Return while one is open, a few
// times at most (a window that wants another key stays).
if (gAgi != NULL && gAgi->windowOpen && gClearTries < HOST_CLEAR_TRIES) {
gClearTries++;
hostPress(KEY_RETURN, JL_CHAR_RETURN, HOST_KEY_HOLD_FRAMES);
gResumeFrame = gFrame + HOST_CLEAR_GAP_FRAMES;
fseek(gScript, gLinePos, SEEK_SET);
} else {
gClearTries = 0u;
}
} else {
fprintf(stderr, "agiHost: bad script op '%s'\n", op);
exit(2);
}
}
}
static void hostWritePpm(const char *path) {
FILE *fp;
fp = fopen(path, "wb");
if (fp == NULL) {
fprintf(stderr, "agiHost: cannot write %s\n", path);
return;
}
fprintf(fp, "P6\n%d %d\n%u\n", SURFACE_WIDTH, SURFACE_HEIGHT, HOST_CHANNEL_MAX);
fwrite(gDisplay, 1, sizeof(gDisplay), fp);
fclose(fp);
}
// ----- Port services -----
uint16_t jlpFrameCount(void) {
return (uint16_t)gFrame;
}
uint16_t jlpFrameHz(void) {
return HOST_FRAME_HZ;
}
bool jlpInit(const jlConfigT *config) {
(void)config;
return true;
}
void jlpInputInit(void) {
}
void jlpInputPoll(void) {
uint16_t k;
for (k = 0u; k < (uint16_t)KEY_COUNT; k++) {
gKeyState[k] = (uint8_t)((gHeld[k] || gFrame < gHoldUntil[k]) ? 1u : 0u);
}
}
void jlpInputShutdown(void) {
}
void jlpJoystickReset(jlJoystickE js) {
(void)js;
}
void jlpPresent(const jlSurfaceT *src) {
uint16_t x;
uint16_t y;
const uint16_t *pal;
uint8_t byte;
uint8_t nib;
uint16_t rgb;
for (y = 0u; y < SURFACE_HEIGHT; y++) {
pal = src->palette[src->scb[y]];
for (x = 0u; x < SURFACE_WIDTH; x++) {
byte = src->pixels[(uint32_t)y * SURFACE_BYTES_PER_ROW + x / HOST_PIXELS_PER_BYTE];
nib = (uint8_t)((x & 1u) ? (byte & 0x0Fu) : (byte >> 4));
rgb = pal[nib];
gDisplay[y][x][0] = (uint8_t)(((rgb >> 8) & 0x0Fu) * HOST_NIBBLE_SCALE);
gDisplay[y][x][1] = (uint8_t)(((rgb >> 4) & 0x0Fu) * HOST_NIBBLE_SCALE);
gDisplay[y][x][2] = (uint8_t)((rgb & 0x0Fu) * HOST_NIBBLE_SCALE);
}
}
}
void jlpShutdown(void) {
}
void jlpWaitVBL(void) {
gFrame++;
if (gRecording) {
hostRecordFrame();
}
hostStep();
}
// ----- Harness -----
// Read the input script from scriptPath; shots go to outDir ("-" for none).
bool hostScriptOpen(const char *scriptPath, const char *outDir) {
gScript = fopen(scriptPath, "r");
if (gScript == NULL) {
fprintf(stderr, "agiHost: cannot read %s\n", scriptPath);
return false;
}
strncpy(gOutDir, outDir, sizeof(gOutDir) - 1u);
// Line by line, so a run killed by a timeout still shows how far it got.
setvbuf(stdout, NULL, _IOLBF, 0);
return true;
}