Files
SplitBit-Emulator/Source/Emulator/voyager.c
T
AnachronautandClaude Opus 5 f7657081be Put a reset button on the case, and stop Escape closing the window
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
2026-08-29 11:05:16 -04:00

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);
}