// 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 #include #include #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; }