Replace the escape parser with cursor registers
The console had grown an ANSI parser, and that was the wrong shape. ANSI exists because a screen used to be on the other end of a serial line and a byte stream was the only channel there was. This screen is memory the program can already address, so reaching it by sending characters for a state machine to take apart is a middleman for something the machine does better - and it meant accepting an open protocol somebody else defines, in hardware, with no natural end to it. Everything else on this machine is registers. So the console gets three: cursor row at 0x03, cursor column at 0x04, and a command port at 0x05 where 1 clears the screen. Both cursor registers are READ as well as written, which is the thing an escape cannot do without sending a query and parsing a reply - a routine that wants to put the cursor back where it found it can now ask. Clearing is one command against a thousand cells walked one at a time. Snake and Life are smaller for it: 2,168 bytes to 2,163 and 1,410 to 1,396. A HOST TERMINAL STILL SPEAKS ANSI, and bridging to the host is the emulator's job, the same job it does reading standard input. So the escapes are now GENERATED, outbound, for the set this device chooses, rather than parsed inbound as though the machine were a terminal. The set cannot grow behind our backs because we are the ones saying it. The cursor is announced lazily, at the next character rather than at the register write, so setting a row and a column costs one sequence rather than two. The console's block widens from three ports to six, which registryTest noticed: it had been asking about port 0x05 precisely BECAUSE nothing was there, and the console had just moved in. Re-blessing it would have left it checking nothing, so it asks about 0x80 instead - clear of the console, the disk, the screen, the controller, and the sound device coming to 0x40. Six checks in Tests/video.sh swapped from the sequences to the registers, including that the cursor reads back and that one sent past the edge is clamped rather than refusing. Those checks also stopped counting bytes from the ends of a file, which had quietly started measuring an escape the moment the console began announcing the cursor. SplitLint caught the one thing worth catching in the port: the clear command leaves A at 1 and key mode is also 1, so the second load looks redundant. Acting on it would tie a console command to a console mode by coincidence, and break silently if either ever moved, so it is suppressed with that reason rather than removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
This commit is contained in:
co-authored by
Claude Opus 5
parent
bdb2d0d8e6
commit
43a05b3df1
+66
-78
@@ -232,23 +232,9 @@ static void consoleNewLine(void) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- The sequences this machine already speaks ----
|
||||
//
|
||||
// Every program in the corpus that moves a cursor does it with ANSI escapes, because until
|
||||
// now the thing on the other end was somebody's terminal. A display controller that did not
|
||||
// understand them would draw "[2J" on the screen and leave the board underneath it, which is
|
||||
// exactly what happened the first time Snake was run in a window.
|
||||
//
|
||||
// So the controller parses them, the way a video terminal did - that is what a VT100 was.
|
||||
// The whole corpus uses two, ESC[2J and ESC[H, and the general shape is recognised so that
|
||||
// anything else is SWALLOWED RATHER THAN DRAWN: a sequence nobody implemented should leave
|
||||
// no marks, which is what a real terminal does with one it does not know.
|
||||
#define SEQUENCE_PARAMS 2
|
||||
|
||||
static enum { DRAW_TEXT, DRAW_SAW_ESCAPE, DRAW_IN_SEQUENCE } drawState = DRAW_TEXT;
|
||||
static int sequenceParam[SEQUENCE_PARAMS];
|
||||
static int sequenceParams;
|
||||
|
||||
// Clears the screen, or from the cursor to the end of it. Reached through the console's
|
||||
// Command port rather than through an escape sequence: this machine talks to its devices in
|
||||
// registers, and a screen it can address directly needs no protocol to reach it.
|
||||
static void consoleClearScreen(int fromCursor) {
|
||||
const int rows = videoRows();
|
||||
const int columns = videoColumns();
|
||||
@@ -260,72 +246,45 @@ static void consoleClearScreen(int fromCursor) {
|
||||
}
|
||||
}
|
||||
|
||||
// Returns 1 if the byte was part of a sequence and so is not a character to draw.
|
||||
static int consoleSequence(uint8_t byte) {
|
||||
switch (drawState) {
|
||||
case DRAW_TEXT:
|
||||
if (byte != 0x1B) {
|
||||
return 0;
|
||||
}
|
||||
drawState = DRAW_SAW_ESCAPE;
|
||||
return 1;
|
||||
case DRAW_SAW_ESCAPE:
|
||||
// Only the bracket form. An escape followed by anything else is not a sequence
|
||||
// this machine has ever sent, and swallowing the escape alone is enough.
|
||||
drawState = DRAW_TEXT;
|
||||
if (byte == '[') {
|
||||
drawState = DRAW_IN_SEQUENCE;
|
||||
sequenceParams = 0;
|
||||
sequenceParam[0] = 0;
|
||||
sequenceParam[1] = 0;
|
||||
}
|
||||
return 1;
|
||||
case DRAW_IN_SEQUENCE:
|
||||
break;
|
||||
// ---- Driving a terminal on the other end of the serial line ----
|
||||
//
|
||||
// The machine speaks registers. A HOST TERMINAL SPEAKS ANSI, and bridging to the host is the
|
||||
// emulator's job - the same job it does reading standard input. So the escapes are GENERATED
|
||||
// here, outbound, for the set this device chooses, rather than parsed inbound as though the
|
||||
// machine were a terminal itself.
|
||||
//
|
||||
// That is the whole difference in shape. Parsing means accepting an open protocol somebody
|
||||
// else defines and putting a state machine in the hardware. Generating means one device
|
||||
// knowing how to talk to one kind of host, in one direction, for exactly the things it can
|
||||
// be asked to do - and the set cannot grow behind our backs, because we are the ones saying
|
||||
// it.
|
||||
static void consoleTellTerminal(const char *sequence) {
|
||||
for (const char *at = sequence; *at != '\0'; at++) {
|
||||
putchar(*at);
|
||||
}
|
||||
if (byte >= '0' && byte <= '9') {
|
||||
if (sequenceParams < SEQUENCE_PARAMS) {
|
||||
sequenceParam[sequenceParams] = sequenceParam[sequenceParams] * 10 + (byte - '0');
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
// ---- Told once, and only when it matters ----
|
||||
//
|
||||
// A terminal cares where the cursor is at the moment something is about to be drawn there,
|
||||
// not at the moment a register was written. Announcing on every register write sent two
|
||||
// sequences for one move, because setting a row and a column is two writes. So a write only
|
||||
// marks it, and the next character sends it.
|
||||
static int cursorTold = 1;
|
||||
|
||||
static void consoleSayCursor(void) {
|
||||
if (cursorTold) {
|
||||
return;
|
||||
}
|
||||
if (byte == ';') {
|
||||
if (sequenceParams < SEQUENCE_PARAMS - 1) {
|
||||
sequenceParams++;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
drawState = DRAW_TEXT;
|
||||
switch (byte) {
|
||||
case 'J':
|
||||
// 2 is the whole screen, which is the one the corpus uses. Without a number it
|
||||
// is from the cursor down, which is what the standard says and costs nothing.
|
||||
consoleClearScreen(sequenceParam[0] != 2);
|
||||
break;
|
||||
case 'H': case 'f': {
|
||||
// Row then column, one-based on the wire and zero-based here. Missing or zero
|
||||
// means one, which is what makes a bare ESC[H the corner.
|
||||
int row = sequenceParam[0];
|
||||
int column = (sequenceParams >= 1) ? sequenceParam[1] : 0;
|
||||
if (row < 1) row = 1;
|
||||
if (column < 1) column = 1;
|
||||
cursorRow = row - 1;
|
||||
cursorColumn = column - 1;
|
||||
if (cursorRow >= videoRows()) cursorRow = videoRows() - 1;
|
||||
if (cursorColumn >= videoColumns()) cursorColumn = videoColumns() - 1;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
// Recognised as a sequence and not implemented, so it leaves no marks.
|
||||
break;
|
||||
}
|
||||
return 1;
|
||||
cursorTold = 1;
|
||||
// Room for the widest this can be, and then some. The compiler cannot see that a cursor
|
||||
// is bounded by the screen, and a warning about a buffer is not worth being clever over.
|
||||
char sequence[32];
|
||||
snprintf(sequence, sizeof(sequence), "\033[%d;%dH", cursorRow + 1, cursorColumn + 1);
|
||||
consoleTellTerminal(sequence);
|
||||
}
|
||||
|
||||
static void consoleDraw(uint8_t byte) {
|
||||
if (consoleSequence(byte)) {
|
||||
return;
|
||||
}
|
||||
switch (byte) {
|
||||
case '\n':
|
||||
consoleNewLine();
|
||||
@@ -868,10 +827,33 @@ uint8_t OutputHandler(uint8_t DataByte, uint8_t Address) {
|
||||
case CONSOLE_DATA:
|
||||
// Both, always. The screen because this machine has one, and standard output
|
||||
// because the serial line is how everything that is not a person reads it.
|
||||
//
|
||||
// The terminal is told where the cursor went first, if it has not been told
|
||||
// since it was moved. Here rather than at the move, so setting a row and a
|
||||
// column costs one sequence rather than two.
|
||||
consoleSayCursor();
|
||||
consoleDraw(DataByte);
|
||||
putchar(DataByte);
|
||||
break;
|
||||
case CONSOLE_CONTROL: consoleSetControl(DataByte); break;
|
||||
case CONSOLE_CURSOR_ROW:
|
||||
// Clamped rather than refused. A cursor asked to go off the screen has an
|
||||
// obvious place to be, and stopping the machine over one is a poor trade.
|
||||
cursorRow = DataByte < videoRows() ? DataByte : videoRows() - 1;
|
||||
cursorTold = 0;
|
||||
break;
|
||||
case CONSOLE_CURSOR_COLUMN:
|
||||
cursorColumn = DataByte < videoColumns() ? DataByte : videoColumns() - 1;
|
||||
cursorTold = 0;
|
||||
break;
|
||||
case CONSOLE_COMMAND:
|
||||
if (DataByte == CONSOLE_COMMAND_CLEAR) {
|
||||
consoleClearScreen(0);
|
||||
consoleTellTerminal("\033[2J");
|
||||
}
|
||||
// Anything else does nothing. A command block reserved for later should be
|
||||
// quiet rather than fatal, the same as the screen's spare registers.
|
||||
break;
|
||||
case CONSOLE_STATUS:
|
||||
// Read only. A device saying how it is does not take instructions through the
|
||||
// same hole, so a write here is ignored rather than meaning something.
|
||||
@@ -937,6 +919,12 @@ uint8_t InputHandler(uint8_t Address) {
|
||||
return consoleReadByte();
|
||||
break;
|
||||
case CONSOLE_STATUS: return consoleStatus();
|
||||
case CONSOLE_CURSOR_ROW: return (uint8_t)cursorRow;
|
||||
case CONSOLE_CURSOR_COLUMN: return (uint8_t)cursorColumn;
|
||||
case CONSOLE_COMMAND:
|
||||
// Write only. What it did is visible in the cursor and on the screen.
|
||||
return 0;
|
||||
break;
|
||||
case CONSOLE_CONTROL:
|
||||
// Write only. Reading it gives zero rather than what was last written, because
|
||||
// everything it sets is reported by the status port and one fact wants one
|
||||
|
||||
Reference in New Issue
Block a user