ESCAPE WAS A BUG I LEFT. This machine sends Escape to the console like any other key, and Raylib closes a window on Escape unless it is told not to - so a program reading keys could be ended by one of them, taking whatever was in memory with it. SetExitKey(KEY_NULL), and it is a byte again. F12 is the reset button. A button on the case rather than a key the machine can see: nothing sends a function key to the console, so nothing can be surprised by one. It does what writing MACHINE_RESET does, which is that the machine starts the way it started - the boot chain runs again and finds whatever the disk now says to run. Which is what makes a bare metal program escapable. Once puts a demo in front of the next start and deletes the request before jumping, so a demo that has taken the whole machine is one keypress from the system coming back, instead of closing the window and opening it again. IT HAD TO REACH A MACHINE THAT IS WAITING, and that took two more things. A reset is acted on between instructions, and a machine blocked on a key is part way through one - so the button would have set a flag that nothing ever came along to notice, in exactly the situation a reset button is for. The wait ends now: the console is told its input is over, which it is for a machine about to stop existing. And the reset puts the console's input back - nothing pushed back, no line half gathered, and not at the end of input. That was already wrong before the button existed: a reset after the input ran out left a console that had run out afterwards, so a machine could be restarted once and then never typed at again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
335 lines
14 KiB
C
335 lines
14 KiB
C
// voyager.c
|
|
|
|
// The Segan Voyager
|
|
// A SplitBit with a screen and a speaker attached
|
|
// Written by Anachronaut
|
|
//
|
|
// ---- What this is ----
|
|
//
|
|
// The same machine SplitBit runs, presented through a window instead of a terminal. Every
|
|
// instruction, every device and every cycle is in machine.c and shared; this file opens a
|
|
// window, gives the machine a slice of time per frame, and shows what came out.
|
|
//
|
|
// THAT ORDER MATTERS AND IS THE WHOLE DESIGN. The devices belong to the machine and advance
|
|
// on emulated cycles, so the same program produces the same frames and the same samples
|
|
// whether or not anybody is looking. Raylib presents; it does not decide. Which is what
|
|
// lets a test suite with no display hold this binary to the same behaviour as the other
|
|
// one.
|
|
//
|
|
// The window shows what the video device produced and decides nothing about it. Render is a
|
|
// pure function of video memory, so the same program draws the same picture whether or not
|
|
// anybody is watching - which is what lets a suite with no display check a screen.
|
|
|
|
#include "machine.h"
|
|
#include "video.h"
|
|
#include "io.h"
|
|
#include "utility.h"
|
|
#include "raylib.h"
|
|
#include <stdio.h>
|
|
#include <string.h>
|
|
#include <getopt.h>
|
|
|
|
// The window opens at the largest screen the device can produce, doubled, because a 640 by
|
|
// 400 window is small on a modern display and a 320 by 200 one is a postage stamp.
|
|
#define SCREEN_SCALE 2
|
|
|
|
// ---- Running without a window ----
|
|
//
|
|
// Taken out of the arguments here rather than in the shared parser, because it is a fact
|
|
// about this front end and the shared parser should not learn about a window that only one
|
|
// binary has. Everything else on the command line means exactly what it means to SplitBit.
|
|
//
|
|
// It exists so the suite can run this binary at all: a test machine has no display, and a
|
|
// front end that could only be exercised by a person looking at it would be a front end
|
|
// nothing checks. Headless, Voyager must print byte for byte what SplitBit prints, and
|
|
// Tests/voyager.sh holds it to that.
|
|
static int takeHeadless(int *argc, char *argv[]) {
|
|
int headless = 0;
|
|
int out = 0;
|
|
for (int i = 0; i < *argc; i++) {
|
|
if (strcmp(argv[i], "--headless") == 0) {
|
|
headless = 1;
|
|
continue;
|
|
}
|
|
argv[out++] = argv[i];
|
|
}
|
|
argv[out] = NULL;
|
|
*argc = out;
|
|
return headless;
|
|
}
|
|
|
|
// ---- The window, kept in one place ----
|
|
//
|
|
// Both the frame loop and the input hook have to be able to present, because a machine
|
|
// waiting for a key is still a machine somebody is looking at. A window that froze while a
|
|
// program asked a question would look broken every time it asked one.
|
|
static Texture2D screenTexture;
|
|
static int windowOpen = 0;
|
|
|
|
// ---- Keys are kept until they are asked for ----
|
|
//
|
|
// RAYLIB CLEARS ITS CHARACTER QUEUE ON EVERY POLL, and a poll happens inside EndDrawing, so
|
|
// a key survives exactly one frame unless something takes it. That is fine for a game that
|
|
// reads input every frame and wrong for everything else: Snake looks about ten times a
|
|
// second, so five keys in six were being thrown away by the next present before it ever
|
|
// glanced at them. The shell worked the whole time, because a blocking read presents and
|
|
// then looks immediately.
|
|
//
|
|
// So the window keeps its own queue, drained from Raylib at every present and emptied only
|
|
// when the console actually takes a byte. That is what the machine already promises - Snake's
|
|
// own comment says "the console keeps the next key until it is asked for" - and it makes the
|
|
// console's timing nobody else's business.
|
|
#define KEY_QUEUE 64
|
|
static unsigned char keyQueue[KEY_QUEUE];
|
|
static int keyHead = 0;
|
|
static int keyTail = 0;
|
|
|
|
static void keyPush(unsigned char byte) {
|
|
const int next = (keyTail + 1) % KEY_QUEUE;
|
|
if (next == keyHead) {
|
|
// Full, so the oldest goes. Somebody leaning on the keyboard while a program ignores
|
|
// it should not be able to push out what they typed most recently.
|
|
keyHead = (keyHead + 1) % KEY_QUEUE;
|
|
}
|
|
keyQueue[keyTail] = byte;
|
|
keyTail = next;
|
|
}
|
|
|
|
static int keyTake(void) {
|
|
if (keyHead == keyTail) {
|
|
return CONSOLE_NOTHING_YET;
|
|
}
|
|
const int byte = keyQueue[keyHead];
|
|
keyHead = (keyHead + 1) % KEY_QUEUE;
|
|
return byte;
|
|
}
|
|
|
|
// Everything Raylib has, taken before it can throw any of it away.
|
|
static void drainKeyboard(void) {
|
|
int character;
|
|
while ((character = GetCharPressed()) > 0) {
|
|
if (character < 128) {
|
|
keyPush((unsigned char)character);
|
|
}
|
|
}
|
|
int key;
|
|
while ((key = GetKeyPressed()) > 0) {
|
|
// Only the keys a character queue does not carry, because they are not characters.
|
|
// Everything else has already arrived above, and taking it again would double it.
|
|
switch (key) {
|
|
case KEY_ENTER: case KEY_KP_ENTER: keyPush('\n'); break;
|
|
case KEY_BACKSPACE: keyPush(0x08); break;
|
|
case KEY_TAB: keyPush('\t'); break;
|
|
case KEY_ESCAPE: keyPush(0x1B); break;
|
|
default: break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- The reset button ----
|
|
//
|
|
// F12, because it is a button on the case rather than a key the machine can see: nothing
|
|
// sends a function key to the console, so nothing can be surprised by one. What it does is
|
|
// exactly what writing MACHINE_RESET does - the machine starts the way it started, which
|
|
// means the boot chain runs again and finds whatever the disk now says to run.
|
|
//
|
|
// Which is what makes a bare metal program escapable. Once puts a demo in front of the next
|
|
// start and deletes the request before jumping, so a demo that has taken the whole machine
|
|
// is one keypress away from the system coming back - and nobody has to close the window and
|
|
// open it again to get there.
|
|
static int resetPending = 0;
|
|
|
|
static void checkResetButton(void) {
|
|
if (IsKeyPressed(KEY_F12)) {
|
|
requestReset();
|
|
// Remembered as well as asked for, so that a machine waiting on a key can be woken
|
|
// to go and notice it. See voyagerKey.
|
|
resetPending = 1;
|
|
}
|
|
}
|
|
|
|
static void presentFrame(void) {
|
|
// The device turns video memory into pixels; this puts them on the glass. Everything
|
|
// that decides what the screen looks like is in the machine, where the suite can
|
|
// reach it.
|
|
videoRender();
|
|
int width, height;
|
|
const uint8_t *frame = videoPixels(&width, &height);
|
|
if (width > 0 && height > 0) {
|
|
UpdateTextureRec(screenTexture, (Rectangle){ 0, 0, (float)width, (float)height },
|
|
frame);
|
|
}
|
|
BeginDrawing();
|
|
// Clearly not the screen. What is left over when the window's shape does not match the
|
|
// picture's is a bezel, and it should look like one rather than like more screen.
|
|
ClearBackground((Color){ 40, 40, 40, 255 });
|
|
if (width > 0 && height > 0) {
|
|
// ---- Filling the window, in whole pixels ----
|
|
//
|
|
// The largest whole-number scale that still fits. Whole numbers because a 320 by 200
|
|
// picture stretched by 2.7 is a picture with some rows twice as tall as their
|
|
// neighbours, which on eight pixel glyphs is the difference between text and mush.
|
|
//
|
|
// The two modes are exactly a factor of two apart and the window opens at twice the
|
|
// larger, so both fill it exactly: 320 by 200 at four, and 640 by 400 at two.
|
|
// Changing mode therefore changes how sharp the screen is and not how big it is.
|
|
const int windowWidth = GetScreenWidth();
|
|
const int windowHeight = GetScreenHeight();
|
|
int scale = windowWidth / width;
|
|
const int fits = windowHeight / height;
|
|
if (fits < scale) scale = fits;
|
|
if (scale < 1) scale = 1;
|
|
const int drawnWidth = width * scale;
|
|
const int drawnHeight = height * scale;
|
|
Rectangle from = { 0, 0, (float)width, (float)height };
|
|
Rectangle to = {
|
|
(float)((windowWidth - drawnWidth) / 2),
|
|
(float)((windowHeight - drawnHeight) / 2),
|
|
(float)drawnWidth, (float)drawnHeight
|
|
};
|
|
DrawTexturePro(screenTexture, from, to, (Vector2){ 0, 0 }, 0.0f, WHITE);
|
|
}
|
|
EndDrawing();
|
|
// EndDrawing has just polled, which is the one moment Raylib's queues hold anything.
|
|
drainKeyboard();
|
|
checkResetButton();
|
|
}
|
|
|
|
// What the console asks while it is waiting. Presenting from in here is what keeps the
|
|
// window answering, and EndDrawing paces it, so waiting for a key costs a frame rather
|
|
// than a spin.
|
|
static int voyagerKey(int mayWait) {
|
|
if (!windowOpen) {
|
|
return CONSOLE_GONE;
|
|
}
|
|
// ---- The button has to reach a machine that is waiting ----
|
|
//
|
|
// A reset is acted on between instructions, and a machine blocked on a key is part way
|
|
// through one - so pressing the button while a program sits waiting would set the flag
|
|
// and nothing would ever come along to notice it. Which is precisely the moment a reset
|
|
// button earns its keep: a program that is stuck is the one you want to get out of.
|
|
//
|
|
// So the wait ends. The console treats that as the end of input, which it is for the
|
|
// machine that is about to stop existing, and the reset puts the console's input back.
|
|
if (resetPending) {
|
|
resetPending = 0;
|
|
return CONSOLE_GONE;
|
|
}
|
|
// Whatever is already waiting, however long ago it was typed. This is the answer to
|
|
// both questions, and asking it first is what makes a program that polls rarely see
|
|
// every key rather than one in six.
|
|
const int waiting = keyTake();
|
|
if (waiting != CONSOLE_NOTHING_YET) {
|
|
return waiting;
|
|
}
|
|
if (!mayWait) {
|
|
// A poll is a poll. Presenting here would charge a frame for every glance, and a
|
|
// program that looks in a loop would run at the frame rate.
|
|
return CONSOLE_NOTHING_YET;
|
|
}
|
|
if (WindowShouldClose()) {
|
|
windowOpen = 0;
|
|
return CONSOLE_GONE;
|
|
}
|
|
// Presenting is what keeps the window answering while the machine waits, and EndDrawing
|
|
// paces it, so waiting for a key costs a frame rather than a spin. It drains the
|
|
// keyboard on the way out, so anything just typed is here now.
|
|
presentFrame();
|
|
return keyTake();
|
|
}
|
|
|
|
|
|
|
|
int main(int argc, char *argv[]) {
|
|
int headless = takeHeadless(&argc, argv);
|
|
|
|
EmulatorOptions options;
|
|
uint8_t result = parseOptions(argc, argv, &options);
|
|
if (result == OPTIONS_HELP) {
|
|
printf(" --headless Run with no window, which is how the tests run it.\n");
|
|
return 0;
|
|
} else if (result == OPTIONS_ERROR) {
|
|
return 1;
|
|
}
|
|
char *programFile = NULL;
|
|
if (optind < argc) {
|
|
programFile = argv[optind];
|
|
optind++;
|
|
}
|
|
if (optind < argc) {
|
|
fprintf(stderr, "Error: Unexpected argument: %s\n", argv[optind]);
|
|
return 1;
|
|
}
|
|
|
|
Machine machine;
|
|
uint8_t started = machineStart(&machine, &options, programFile);
|
|
if (started == MACHINE_NOTHING_TO_RUN) {
|
|
fprintf(stderr, "Error: No boot image and no disk, so there is nothing to run.\n");
|
|
printHelp(argv[0]);
|
|
return 1;
|
|
} else if (started != MACHINE_OK) {
|
|
return 1;
|
|
}
|
|
|
|
if (headless) {
|
|
// The same three lines SplitBit runs, and deliberately so: a headless Voyager is
|
|
// not a reduced machine, it is the machine with nobody watching.
|
|
while (machineRunning(&machine)) {
|
|
machineRunSlice(&machine);
|
|
}
|
|
} else {
|
|
// Resizable, because how big somebody wants a screen is not the machine's business.
|
|
// The picture is rescaled to whatever the window becomes, in whole pixels.
|
|
//
|
|
// And presented in step with the display. Without the hint the frame limiter sleeps
|
|
// towards sixty a second on its own clock, which beats against a screen refreshing on
|
|
// its own - some frames shown twice, some skipped, and the machine handed an uneven
|
|
// number of cycles each time because it takes them from the wall clock. The target
|
|
// stays as well, for a driver that ignores the hint.
|
|
SetConfigFlags(FLAG_WINDOW_RESIZABLE | FLAG_VSYNC_HINT);
|
|
InitWindow(VIDEO_MAX_WIDTH * SCREEN_SCALE, VIDEO_MAX_HEIGHT * SCREEN_SCALE,
|
|
"Segan Voyager");
|
|
SetTargetFPS(60);
|
|
// ---- Escape is a byte, not a way out ----
|
|
//
|
|
// Raylib closes a window on Escape unless it is told not to, and this machine sends
|
|
// Escape to the console like any other key. So a program reading keys could be
|
|
// ended by one of them, taking whatever was in memory with it - which is a poor way
|
|
// to find out that a default was left as it was found.
|
|
SetExitKey(KEY_NULL);
|
|
// One texture, updated in place. Making a new one every frame would be a new
|
|
// allocation sixty times a second for a picture that is the same size every time.
|
|
Image blank = GenImageColor(VIDEO_MAX_WIDTH, VIDEO_MAX_HEIGHT, BLACK);
|
|
ImageFormat(&blank, PIXELFORMAT_UNCOMPRESSED_R8G8B8);
|
|
screenTexture = LoadTextureFromImage(blank);
|
|
UnloadImage(blank);
|
|
windowOpen = 1;
|
|
// The keyboard becomes the console's input, in place of a standard input the window
|
|
// does not have.
|
|
consoleSetInputHook(voyagerKey);
|
|
// ---- A slice a frame ----
|
|
//
|
|
// The machine gets its turn, then the window gets its turn. Closing the window
|
|
// stops the machine, and the machine halting leaves the window up so that whatever
|
|
// it drew is still there to look at - a program that ends should not take its
|
|
// output off the screen with it.
|
|
// A slice, then a frame. The machine halting leaves the window up so that whatever
|
|
// it drew is still there to look at - a program that ends should not take its output
|
|
// off the screen with it.
|
|
while (windowOpen && !WindowShouldClose()) {
|
|
if (machineRunning(&machine)) {
|
|
machineRunSlice(&machine);
|
|
}
|
|
presentFrame();
|
|
}
|
|
windowOpen = 0;
|
|
// Taken back before the machine stops, so nothing can ask a window that has gone.
|
|
consoleSetInputHook(NULL);
|
|
UnloadTexture(screenTexture);
|
|
CloseWindow();
|
|
}
|
|
|
|
machineStop(&machine);
|
|
return machineReport(&machine);
|
|
}
|