462 lines
15 KiB
C
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;
|
|
}
|