Files
SplitBit-Emulator/Source/Emulator/voyager.c
T
AnachronautandClaude Opus 5 eb695a3f3b --keyboard was a flag that did nothing behind a window
Voyager installed its own input hook after machineStart, which had already
installed the keyboard file's - so naming a keyboard file and running the
window silently got the window, and the flag said nothing about being
ignored.

Both together is the combination a demo wants. Recording a flight needs
the typing that STARTS it to be the same every time, because a human
reaching the shell a moment later shifts every frame of the recording
after it - while the flying itself has to come from whatever is actually
in somebody's hands.

So the window only takes the keyboard when no file was named.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-03 12:52:17 -04:00

538 lines
26 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 "pad.h"
#include "sound.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.
// ---- The pads, read as levels ----
//
// Once a frame, from whatever is actually there. THIS IS THE ONE THING THE CONSOLE CANNOT DO:
// a window knows which keys are down, a terminal only ever learns which one was pressed, and
// asking the console to report a key coming up would have been a promise it could keep behind
// a window and nowhere else.
//
// The keyboard drives pad nought as well as any real controller, so a game written for a pad
// is playable on a machine with none - and so is a game written for four, badly. What a game
// reads is the pad; it never learns which one of them somebody used.
static void readPads(void) {
for (int n = 0; n < PAD_COUNT; n++) {
uint8_t held = 0;
// ---- Pad nought is always there, because the keyboard is behind it ----
//
// Which is the useful answer rather than the literal one: a game that asks for a
// controller and finds none falls back to whatever the console can tell it, and
// behind a window the console is the worse of the two ways to read the same keys.
int there = (n == 0);
if (IsGamepadAvailable(n)) {
there = 1;
if (IsGamepadButtonDown(n, GAMEPAD_BUTTON_LEFT_FACE_RIGHT)) { held |= PAD_RIGHT; }
if (IsGamepadButtonDown(n, GAMEPAD_BUTTON_LEFT_FACE_LEFT)) { held |= PAD_LEFT; }
if (IsGamepadButtonDown(n, GAMEPAD_BUTTON_LEFT_FACE_DOWN)) { held |= PAD_DOWN; }
if (IsGamepadButtonDown(n, GAMEPAD_BUTTON_LEFT_FACE_UP)) { held |= PAD_UP; }
if (IsGamepadButtonDown(n, GAMEPAD_BUTTON_RIGHT_FACE_DOWN)) { held |= PAD_A; }
if (IsGamepadButtonDown(n, GAMEPAD_BUTTON_RIGHT_FACE_RIGHT)){ held |= PAD_B; }
if (IsGamepadButtonDown(n, GAMEPAD_BUTTON_MIDDLE_RIGHT)) { held |= PAD_START; }
if (IsGamepadButtonDown(n, GAMEPAD_BUTTON_MIDDLE_LEFT)) { held |= PAD_SELECT; }
// ---- And the stick, which is what most people actually push ----
//
// The four buttons above are the D-PAD, and a lot of controllers made this
// century have one that nobody uses: the thumb goes on the stick, which reports
// as an axis and not as a button, so a pad that was plugged in and working did
// nothing at all.
//
// Past halfway counts as held. That is a blunt line and the right kind of blunt
// for a device that reports what is DOWN - a machine with eight bits a pad has
// nothing to say about three-fifths of a push, and picking the threshold here
// rather than in every program is the point of the pad being a device.
const float across = GetGamepadAxisMovement(n, GAMEPAD_AXIS_LEFT_X);
const float down = GetGamepadAxisMovement(n, GAMEPAD_AXIS_LEFT_Y);
if (across > 0.5f) { held |= PAD_RIGHT; }
if (across < -0.5f) { held |= PAD_LEFT; }
if (down > 0.5f) { held |= PAD_DOWN; }
if (down < -0.5f) { held |= PAD_UP; }
}
if (n == 0) {
// ---- And the keyboard, on top of it ----
//
// Arrows or WASD for the direction, Z and X for the buttons. OR-ed with whatever
// a real pad is doing rather than chosen between, so unplugging one mid-game does
// not leave somebody holding nothing.
if (IsKeyDown(KEY_RIGHT) || IsKeyDown(KEY_D)) { held |= PAD_RIGHT; }
if (IsKeyDown(KEY_LEFT) || IsKeyDown(KEY_A)) { held |= PAD_LEFT; }
if (IsKeyDown(KEY_DOWN) || IsKeyDown(KEY_S)) { held |= PAD_DOWN; }
if (IsKeyDown(KEY_UP) || IsKeyDown(KEY_W)) { held |= PAD_UP; }
if (IsKeyDown(KEY_Z)) { held |= PAD_A; }
if (IsKeyDown(KEY_X)) { held |= PAD_B; }
if (IsKeyDown(KEY_ENTER)) { held |= PAD_START; }
if (IsKeyDown(KEY_TAB)) { held |= PAD_SELECT; }
}
padSet(n, there, held);
}
}
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;
// ---- And the keys that are not characters at all ----
//
// These used to fall through the default below and vanish, because there was no
// byte to turn them into. There is now, and it is the console's rather than
// this window's - a terminal reaches the same values by a different road, and a
// program is entitled not to know which of the two it is talking to.
case KEY_UP: keyPush(CONSOLE_KEY_UP); break;
case KEY_DOWN: keyPush(CONSOLE_KEY_DOWN); break;
case KEY_LEFT: keyPush(CONSOLE_KEY_LEFT); break;
case KEY_RIGHT: keyPush(CONSOLE_KEY_RIGHT); break;
case KEY_HOME: keyPush(CONSOLE_KEY_HOME); break;
case KEY_END: keyPush(CONSOLE_KEY_END); break;
case KEY_DELETE: keyPush(CONSOLE_KEY_DELETE); break;
default: break;
}
}
}
// ---- The reset button ----
//
// EMULATOR MAGIC, AND KNOWN TO BE. There is no reset line on this machine yet and no keyboard
// controller to assert one: the window reaches in and pokes the same flag a program pokes
// through the machine port. When those are designed, a keyboard controller will have to see
// this gesture and pull reset REGARDLESS OF WHAT THE CPU IS DOING - which is the property
// that matters and the one a port write cannot have, since a port write needs a program
// willing and able to make it.
//
// The shape of that is already visible here. A reset is normally noticed between
// instructions, and a halted machine runs none - so the window asks every frame rather than
// leaving it to the machine to notice, which is what real hardware would do with a line.
//
// ON REAL HARDWARE THIS IS NOT A KEY AT ALL. A Voyager has a button on the case, and what a
// window has instead of a case is a gesture. So the gesture wants two properties a single
// key does not have.
//
// It must not be a key SOFTWARE MIGHT WANT. A machine with a keyboard has function keys on
// it, and something will eventually have a use for F12 - which is where this was, and which
// would have meant taking it away again later.
//
// And it must not be reachable BY ACCIDENT. Restarting the machine throws away everything in
// memory, and a single key that does that sits one mistake away from losing work. Three keys
// together are not pressed by mistake.
//
// Control, Shift and R. It was Control, Alt and Delete, which has meant this since 1981 and
// is the one gesture nobody has to be told the meaning of - AND WHICH CANNOT BE USED.
//
// It is a secure attention key. Every serious operating system reserves it so that it always
// reaches the system and never an application, precisely so that a program cannot imitate a
// login screen; on Windows an application cannot see it at all without a kernel driver, and
// on Linux the desktop takes it. That is not an oversight to work around - it is the same
// guarantee a reset button wants, being enforced one layer further down, and there is no
// call this program can make that would win the argument.
//
// So the gesture has to be one the host has no opinion about. Control and Shift with a
// letter is about as free as a combination gets: it is not window management, not a virtual
// terminal switch, and not a shortcut any desktop claims by default.
//
// If a platform does send a character for it, nothing comes of that either - whatever
// arrives is in memory that is about to be thrown away.
//
// What it does is what writing MACHINE_RESET does: the machine starts the way it started, so
// 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 gesture from
// the system coming back, rather than closing the window and opening it again.
static void checkResetButton(void) {
const int control = IsKeyDown(KEY_LEFT_CONTROL) || IsKeyDown(KEY_RIGHT_CONTROL);
const int shift = IsKeyDown(KEY_LEFT_SHIFT) || IsKeyDown(KEY_RIGHT_SHIFT);
if (control && shift && IsKeyPressed(KEY_R)) {
requestReset();
}
}
// ---- The speaker ----
//
// The device makes its samples on emulated cycles and puts them in a ring; this takes them
// out and hands them to Raylib. Nothing here decides what a sound is, the same as nothing in
// presentFrame decides what the screen looks like - which is why the headless binary and this
// one make the same sound, and why Tests/sound.sh can check a device with no speaker in it.
//
// A sub-buffer at a time, because that is the unit Raylib hands back when it has finished
// playing one. At 48,000 a second and sixty frames, a frame of machine time is 800 samples,
// so 1,024 is a little over one and there are two of them.
#define SOUND_BUFFER 1024
static AudioStream soundStream;
static int speakerOn;
// ---- When the machine cannot keep up, and when it runs away ----
//
// Both directions happen and neither should be a crash. The machine runs a slice per frame
// against the wall clock, so a host that stalls leaves the ring short and a host running
// --fast fills it faster than anything can play it. The device drops when full, which is the
// runaway case. This is the other one: what is missing is filled by HOLDING THE LAST SAMPLE
// rather than by zeroes, because a jump to silence and back is a click and a held level is
// not. It is still a glitch; it is the quieter kind.
static void feedSpeaker(void) {
static int16_t buffer[SOUND_BUFFER];
static int16_t lastSample;
while (IsAudioStreamProcessed(soundStream)) {
const int taken = soundTake(buffer, SOUND_BUFFER);
if (taken > 0) {
lastSample = buffer[taken - 1];
}
for (int i = taken; i < SOUND_BUFFER; i++) {
buffer[i] = lastSample;
}
UpdateAudioStream(soundStream, buffer, SOUND_BUFFER);
}
}
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();
readPads();
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.
//
// ASKED OF THE REQUEST ITSELF rather than remembered here. A flag of its own outlived
// the reset it belonged to: a program that never read the console - picture.bin, say,
// which draws and halts - left it set, and the NEXT machine's first read came back as
// the end of input. CosmOS booted and stopped immediately, having been told there was
// nobody there. There is one fact and it lives in one place.
if (resetIsPending()) {
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);
// ---- What controllers the host can see, said out loud ----
//
// A pad that is plugged in and does nothing is indistinguishable from a pad the front
// end never noticed, and the difference is the whole of what to do about it: one is a
// mapping to fix and the other is a driver. Saying which at startup costs a line and
// answers it without anybody having to guess.
for (int n = 0; n < PAD_COUNT; n++) {
if (IsGamepadAvailable(n)) {
printf("Controller %d: %s\n", n, GetGamepadName(n));
}
}
// ---- And a speaker, if the host has one ----
//
// Asked for rather than assumed: a machine with no audio device is a perfectly good
// machine to look at, and a front end that refused to start without one would make
// the window depend on something the picture does not need.
InitAudioDevice();
if (IsAudioDeviceReady()) {
SetAudioStreamBufferSizeDefault(SOUND_BUFFER);
soundStream = LoadAudioStream(SOUND_SAMPLE_RATE, 16, 1);
PlayAudioStream(soundStream);
speakerOn = 1;
}
// 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. UNLESS A KEYBOARD FILE WAS
// NAMED, which used to be overridden here without a word: machineStart installs the
// file's hook and this replaced it, so --keyboard was a flag that did nothing behind
// a window and said nothing about it.
//
// Both together is the combination a demo wants. Recording a flight needs the typing
// that STARTS it to be the same every time - a human reaching the shell at a slightly
// different moment shifts every frame of the recording after it - while the flying
// itself comes from whatever is actually in somebody's hands.
if (options.keyboard == NULL) {
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.
while (windowOpen && !WindowShouldClose()) {
// Before the running check, not after it: a machine that has stopped is the one
// worth restarting, and it is the one that cannot notice a reset by itself.
machineTakeReset(&machine);
if (machineRunning(&machine)) {
machineRunSlice(&machine);
}
presentFrame();
// After the slice, so what the machine just made is what gets played.
if (speakerOn) {
feedSpeaker();
}
}
windowOpen = 0;
// Taken back before the machine stops, so nothing can ask a window that has gone.
consoleSetInputHook(NULL);
if (speakerOn) {
UnloadAudioStream(soundStream);
speakerOn = 0;
}
CloseAudioDevice();
UnloadTexture(screenTexture);
CloseWindow();
}
machineStop(&machine);
return machineReport(&machine);
}