Files
SplitBit-Emulator/Source/Emulator/voyager.c
T
AnachronautandClaude Opus 5 773b0f8add Put CosmOS on the screen without changing a line of it
The console is now a display controller as well as a port: it owns a font, keeps a cursor,
handles newline, carriage return, backspace and wrapping, and scrolls. That is an ordinary
kind of chip - it is what a video terminal's character generator did - and it is the reason
this rung needed no changes to CosmOS at all. CosmOS already writes bytes to port 0x00.

It writes to BOTH the screen and standard output, which is deliberate. A machine with a
screen and a serial line is an ordinary machine, the emulator's standard output is that
serial line, and one console drives both. It is also what keeps all 165 recorded results
passing under Voyager, and what makes --screen work on the plain SplitBit: there is one
console and it drives everything it has.

Scrolling moves the video device's origin and no memory. The row arriving at the bottom is
cleared because the map is a ring and it holds what was there 128 rows ago; the rows going
off the top are not, and that is a hundred rows of scrollback nothing had to keep. The test
reads the register back rather than looking at the screen, because a console blitting rows
instead would look identical and cost twelve percent of a frame for every line printed.

The font is vendored from Hatchet-GPU with a note saying where it came from, since that
repository is not part of this one. 135 glyphs in ASCII order, which is the thing that makes
it worth keeping - PETSCII's whole inconvenience was that its order was not ASCII's, so a
machine using it needed a translation table in front of every string. Here the machine
subtracts 32. It is stored one bit a pixel and expanded into tile memory at reset: 1,088
bytes against 16 kilobytes.

Voyager gets a keyboard. A window has no standard input, and a machine blocking on it inside
a frame would stop drawing and stop answering, so a front end with a window installs a hook
that the console calls while it has nothing: it keeps the window alive and hands back a key.
The hook has to tell "nobody has typed yet", which happens sixty times a second, apart from
"the window has gone", which is the end of input - one value for both would have made the
first keystroke look like a closed machine. In line mode the console echoes what it is
given, because there is no terminal behind a window to do it and that was always the
terminal's job.

Tests/video.sh grew from 14 checks to 26, half of them about the console rather than the
device: those programs ask the video device for nothing and write bytes to port 0x00 like
every SplitBit program always has. Verified by breaking two things - removing the scroll
failed exactly the two checks about scrolling, and removing the cursor advance failed
exactly the three that depend on it.

Two video checks had quietly depended on palette entry 0 being black, which stopped being
true the moment a machine woke up able to show text. They now set what they are about to
look at, and a new check pins the waking state itself.

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

197 lines
7.8 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 is the largest screen the video device can produce, scaled up because a 320 by
// 200 window is a postage stamp on a modern display. A smaller mode is drawn into the middle
// of it rather than resizing the window out from under whoever is looking at it.
#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();
// Not black. The border around a smaller mode should look like a machine with a screen
// on, rather than like a window that failed to open.
ClearBackground((Color){ 18, 22, 20, 255 });
if (width > 0 && height > 0) {
// Centred, so changing mode moves the picture rather than the window.
Rectangle from = { 0, 0, (float)width, (float)height };
Rectangle to = {
(float)((VIDEO_MAX_WIDTH * SCREEN_SCALE - width * SCREEN_SCALE) / 2),
(float)((VIDEO_MAX_HEIGHT * SCREEN_SCALE - height * SCREEN_SCALE) / 2),
(float)(width * SCREEN_SCALE), (float)(height * SCREEN_SCALE)
};
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(void) {
if (!windowOpen || WindowShouldClose()) {
windowOpen = 0;
return CONSOLE_GONE;
}
presentFrame();
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 {
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);
}