Files
SplitBit-Emulator/Source/Emulator/voyager.c
T
AnachronautandClaude Opus 5 556a14b288 Teach the console the sequences the corpus already speaks, and let the status port see the window
Three things Snake found the moment somebody ran it in a window, and all three are the same
kind of mistake: the console grew a screen and kept asking the terminal.

IT COULD NOT CLEAR THE SCREEN. Every program here that moves a cursor does it with ANSI
escapes, because until there was a screen the thing on the other end was somebody's
terminal. The controller drew "[2J" as three letters and left the board underneath. It now
parses them, which is what a video terminal did - a VT100 is exactly this. The whole corpus
uses two, ESC[2J and ESC[H, and the general shape is recognised so anything else is
swallowed rather than drawn: a sequence nobody implemented should leave no marks. Cursor
positioning is in too, since it is the same parse and one line more.

IT DID NOT SEE KEYS FROM THE WINDOW, but did when the terminal behind it was focused, which
is the whole diagnosis in one sentence. Snake polls the READY bit and never blocks, and
consoleFetch - what the status port asks - was polling standard input regardless of whether
a front end had installed a hook. So a window's keys were invisible to every program that
looks before it reads, and a keystroke aimed at the terminal would be picked up instead.

The hook now takes a question. Zero is the status port looking, and must not present or
sleep: a program polling in a loop would otherwise be charged a frame for every glance. One
is the data port blocking, where presenting is exactly right, because a machine waiting for
a key is still a machine somebody is looking at. One value for both would have made either
polling ruinous or waiting dead.

AND IT RAN SLOWLY, which was the same bug wearing a hat: a game that never receives a
steering key is a game that only ever goes one way.

Six more checks in Tests/video.sh, to 32: that ESC[2J clears, that ESC[H goes to the corner
without disturbing what is drawn, that ESC[3;5H counts rows and columns from one, and that
an unknown sequence is swallowed and leaves nothing behind.

The hook itself is still the one thing here the suite cannot reach - it exists only when
there is a window, and this host has no display. It was found by a person playing Snake,
which is where the Test Manual says these go on being found.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-28 22:43:27 -04:00

225 lines
9.3 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;
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();
}
// 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;
}
if (mayWait) {
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.
presentFrame();
}
// Asked without waiting, this takes whatever the last frame's event poll left behind
// and returns at once. A program polling the status port sixty times between frames
// must not be charged a frame for each look.
int character = GetCharPressed();
if (character > 0 && character < 128) {
return character;
}
switch (GetKeyPressed()) {
// The keys a character queue does not carry, because they are not characters.
case KEY_ENTER: case KEY_KP_ENTER: return '\n';
case KEY_BACKSPACE: return 0x08;
case KEY_TAB: return '\t';
case KEY_ESCAPE: return 0x1B;
default: return CONSOLE_NOTHING_YET;
}
}
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.
SetConfigFlags(FLAG_WINDOW_RESIZABLE);
InitWindow(VIDEO_MAX_WIDTH * SCREEN_SCALE, VIDEO_MAX_HEIGHT * SCREEN_SCALE,
"Segan Voyager");
SetTargetFPS(60);
// 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);
}