diff --git a/Programs/CosmOS/Source/cosmos.asm b/Programs/CosmOS/Source/cosmos.asm index e3cc51f..7766708 100644 --- a/Programs/CosmOS/Source/cosmos.asm +++ b/Programs/CosmOS/Source/cosmos.asm @@ -51,6 +51,18 @@ #Program boot: + ; ---- The screen this system wants ---- + ; + ; Eighty columns, because that is what CosmOS was written for: its own help text is + ; seventy-four characters wide, and dir, the monitor and the assembler's messages all + ; assume room. A machine wakes up in the smaller mode, which is right for a machine - it + ; is the system that knows what shape of screen its own output needs. + ; + ; Harmless where there is no screen. Writing to a port nothing answers on does nothing, + ; so this is one instruction wasted on a machine with a terminal instead. + INIA 0x01 + OUTA 0x31 + SETD.0 Banner CALL printString CALL newLine diff --git a/Source/Emulator/io.c b/Source/Emulator/io.c index 675a40e..3520b12 100644 --- a/Source/Emulator/io.c +++ b/Source/Emulator/io.c @@ -320,6 +320,61 @@ static void consoleDraw(uint8_t byte) { // when one is typed, or -1 to say the window has gone. static int (*inputHook)(int mayWait) = NULL; +// ---- Line editing, which the terminal used to do ---- +// +// A TERMINAL IN LINE MODE DOES NOT HAND A PROGRAM EVERY KEYSTROKE. It collects a line, +// rubs out a backspace, and delivers the finished thing when Return is pressed. CosmOS has +// always relied on that, and behind a window there is no terminal to do it - so the raw +// backspace reached the shell, which put 0x08 in its command buffer and then could not find +// a command by that name. Correcting a typo made the line unrecognisable while looking +// perfectly right on screen. +// +// So the console does it, because behind a window the console IS the terminal. In key mode +// it does not: a program in key mode asked for every keystroke as it happens, which is the +// whole point of key mode. +#define CONSOLE_LINE_BYTES 256 +static unsigned char consoleLine[CONSOLE_LINE_BYTES]; +static int consoleLineLength = 0; +static int consoleLineAt = 0; + +// Collects until Return, echoing as it goes, and leaves the line to be handed out a byte at +// a time. Returns 0 if the window closed while it was waiting. +static int consoleGatherLine(void) { + consoleLineAt = 0; + consoleLineLength = 0; + for (;;) { + const int got = inputHook(1); + if (got == CONSOLE_GONE) { + return 0; + } + if (got < 0) { + continue; + } + if (got == 0x08) { + // Nothing to rub out at the start of a line, and rubbing out past it would eat + // the prompt, which belongs to whoever printed it. + if (consoleLineLength > 0) { + consoleLineLength--; + consoleDraw(0x08); + } + continue; + } + if (got == '\n' || got == '\r') { + consoleLine[consoleLineLength++] = '\n'; + consoleDraw('\n'); + return 1; + } + // No room, or nothing a line is made of. A control byte that means something to a + // terminal means nothing here yet, and putting it in the line would only hand a + // program something it cannot use. + if (got < 0x20 || consoleLineLength >= CONSOLE_LINE_BYTES - 1) { + continue; + } + consoleLine[consoleLineLength++] = (unsigned char)got; + consoleDraw((uint8_t)got); + } +} + void consoleSetInputHook(int (*hook)(int mayWait)) { inputHook = hook; } @@ -337,19 +392,21 @@ uint8_t consoleReadByte(void) { } consoleShowWhatIsWritten(); if (inputHook != NULL) { + if (!consoleKeyMode) { + // A line already gathered is handed out a byte at a time, which is what the + // program is asking for. Only when it runs out is another one collected. + if (consoleLineAt >= consoleLineLength && !consoleGatherLine()) { + consoleEnded = 1; + return 0xFF; + } + return consoleLine[consoleLineAt++]; + } for (;;) { - int got = inputHook(1); + const int got = inputHook(1); if (got >= 0) { - // ---- The screen is the terminal now ---- - // - // In line mode a terminal echoes what is typed and rubs out a backspace, - // and CosmOS has always relied on that. There is no terminal behind a - // window, so the display controller does it - which is exactly the job a - // video terminal's character generator had. In key mode nothing echoes, - // because a program in key mode is drawing its own screen. - if (!consoleKeyMode) { - consoleDraw((uint8_t)got); - } + // Nothing echoes in key mode: a program that asked for every keystroke as it + // happens is drawing its own screen, and marks it did not make would be in + // the way. return (uint8_t)got; } if (got == CONSOLE_GONE) { @@ -408,7 +465,17 @@ static void consoleFetch(void) { // the window happened to be focused. Asked without waiting, because a poll is a poll - // the front end presents a frame when the console genuinely blocks, not when it looks. if (inputHook != NULL) { - int got = inputHook(0); + if (!consoleKeyMode) { + // READY means there is a byte to be had, and in line mode there is one only + // while a gathered line is still being handed out. A poll must not take a key + // from under the gatherer, and half a line is not a line. + if (consoleLineAt < consoleLineLength) { + consolePushback = consoleLine[consoleLineAt++]; + consoleAnnounce(); + } + return; + } + const int got = inputHook(0); if (got >= 0) { consolePushback = got; consoleAnnounce(); diff --git a/Source/Emulator/machine.c b/Source/Emulator/machine.c index 846c890..67b04fa 100644 --- a/Source/Emulator/machine.c +++ b/Source/Emulator/machine.c @@ -72,6 +72,32 @@ static void reportCycles(const CPURegisters *cpu, unsigned long cycleCount) { } +// ---- A keyboard made of a file ---- +// +// THE CONSOLE BEHIND A WINDOW IS NOT THE CONSOLE BEHIND A TERMINAL, and until this existed +// the difference was untestable. A terminal does the line editing; a window has none, so the +// console does it itself - gathering a line, rubbing out a backspace, handing it over only +// when Return arrives. That is real logic, it broke twice in two days, and both times it was +// found by a person typing rather than by anything here. +// +// So a file can be a keyboard. It installs the same hook a window does, which means the same +// path runs, and the suite can check what happens when a backspace arrives with nobody to +// interpret it. It does not test the window - Voyager's own key queue is still beyond reach +// - but it tests the console, which is where the logic is. +static FILE *keyboardFile = NULL; + +static int keyboardHook(int mayWait) { + (void)mayWait; // There is no window to keep alive, so both questions are the same. + if (keyboardFile == NULL) { + return CONSOLE_GONE; + } + const int byte = fgetc(keyboardFile); + if (byte == EOF) { + return CONSOLE_GONE; + } + return byte & 0xFF; +} + uint8_t machineStart(Machine *m, const EmulatorOptions *options, const char *programFile) { m->options = *options; m->programFile = programFile; @@ -115,6 +141,14 @@ uint8_t machineStart(Machine *m, const EmulatorOptions *options, const char *pro if (m->options.debug) { printRegisters(&m->cpu, Program, Data); } + if (options->keyboard != NULL) { + keyboardFile = fopen(options->keyboard, "rb"); + if (keyboardFile == NULL) { + fprintf(stderr, "Error: Couldn't read the keyboard file: %s\n", options->keyboard); + return MACHINE_ERROR; + } + consoleSetInputHook(keyboardHook); + } setDiskLatency(m->options.diskCycles); cycle_timer_init(&m->timer, CYCLE_RATE); return MACHINE_OK; @@ -220,6 +254,11 @@ void machineStop(Machine *m) { if (m->options.screen != NULL) { videoWriteImage(m->options.screen); } + if (keyboardFile != NULL) { + consoleSetInputHook(NULL); + fclose(keyboardFile); + keyboardFile = NULL; + } detachDisk(); } diff --git a/Source/Emulator/utility.c b/Source/Emulator/utility.c index 8383c8a..d444796 100644 --- a/Source/Emulator/utility.c +++ b/Source/Emulator/utility.c @@ -30,6 +30,10 @@ void printHelp(const char *programName) { printf(" -S, --screen FILE Save a picture of the screen, as a PPM, when the machine\n"); printf(" stops. Works with or without a window, which is how the\n"); printf(" tests look at a screen on a host that has no display.\n"); + printf(" -K, --keyboard FILE Feed the console from this file as though it were a\n"); + printf(" keyboard rather than a terminal. Which means the console does\n"); + printf(" its own line editing, the way it must when a window is open\n"); + printf(" and there is no terminal behind it to do it.\n"); printf(" -h, --help Display this help message.\n"); } @@ -42,6 +46,7 @@ uint8_t parseOptions(int argc, char *argv[], EmulatorOptions *options) { {"write-protect", no_argument, 0, 'W'}, {"disk-cycles", required_argument, 0, 'L'}, {"screen", required_argument, 0, 'S'}, + {"keyboard", required_argument, 0, 'K'}, {"help", no_argument, 0, 'h'}, {0, 0, 0, 0 } }; @@ -55,9 +60,10 @@ uint8_t parseOptions(int argc, char *argv[], EmulatorOptions *options) { options->writeProtect = 0; options->diskCycles = 0; options->screen = NULL; + options->keyboard = NULL; // Parse options - while ((opt = getopt_long(argc, argv, "dc:fhD:WL:S:", long_options, &option_index)) != -1) { + while ((opt = getopt_long(argc, argv, "dc:fhD:WL:S:K:", long_options, &option_index)) != -1) { switch (opt) { case 'd': options->debug = 1; @@ -90,6 +96,9 @@ uint8_t parseOptions(int argc, char *argv[], EmulatorOptions *options) { case 'S': options->screen = optarg; break; + case 'K': + options->keyboard = optarg; + break; case 'h': printHelp(argv[0]); return OPTIONS_HELP; diff --git a/Source/Emulator/utility.h b/Source/Emulator/utility.h index 3efd4f1..a716e47 100644 --- a/Source/Emulator/utility.h +++ b/Source/Emulator/utility.h @@ -23,6 +23,7 @@ typedef struct { const char *disk; // Disk image to attach, or NULL for a machine with no disk. uint8_t writeProtect; // Attach the disk read only, the way a tab on a floppy would. const char *screen; // Where to save a picture of the screen when the machine stops. + const char *keyboard; // Feed the console from this file as a keyboard, not a terminal. } EmulatorOptions; uint8_t parseOptions(int argc, char *argv[], EmulatorOptions *options); diff --git a/SplitBit Test Manual.md b/SplitBit Test Manual.md index 9c48cf2..4db09c0 100644 --- a/SplitBit Test Manual.md +++ b/SplitBit Test Manual.md @@ -78,7 +78,7 @@ from `make`, not from here. ### 1. Recorded output `Tests/run.sh` assembles each program named in `Tests/manifest`, runs it, and compares -everything it printed against a file in `Tests/expected`. 165 tests, of which 103 run, 35 +everything it printed against a file in `Tests/expected`. 166 tests, of which 104 run, 35 only assemble, 16 are expected to fail to assemble, and 11 boot from ROM with no image given at all. @@ -328,6 +328,21 @@ The `@N` form deserves a note. Every other test runs with the disk's answer avai before the next instruction, which is the one condition under which failing to wait looks exactly like working. +**keys** names a file in `Tests/input` to be fed to the console as a *keyboard* rather than +as standard input, and the difference between those is the whole reason the field exists. + +Standard input reaches a console that believes a terminal is doing the line editing, which +is true when there is one: the terminal collects a line, rubs out a backspace, and hands +over the finished thing at Return. **Behind a window there is no terminal**, so the console +does that itself, and that is real logic which nothing could reach. It broke twice in two +days and a person typing found it both times - once as keys that never arrived, once as a +corrected line that reached the shell with the backspaces still in it, looking perfectly +right on screen and matching no command at all. + +A keyboard file installs the same hook a window does, so the same path runs. It does not +test the window: Voyager's own key queue is still out of reach, and so is anything about +presenting frames. It tests the console, which is where the logic is. + ## Fixture Disks: `Tests/makedisks.sh` builds 26 images with SplitDisk before anything runs, into diff --git a/Tests/expected/cosmosTyped.out b/Tests/expected/cosmosTyped.out new file mode 100644 index 0000000..ebfdda6 --- /dev/null +++ b/Tests/expected/cosmosTyped.out @@ -0,0 +1,16 @@ +CosmOS +> dir list what is on the disk +load read a program off the disk +run [words] start what was loaded, and tell it those words + [words] look where you are and then in /Apps, and start that +cd [path] go to a directory, or to the root with nothing after it +mkdir make a directory +rmdir remove an empty one +delete take it off the disk +rename call it something else +monitor look at memory, change it, and jump into it +help this +exit stop, or leave the monitor if you are in it +> halted +Execution halted. +[exit 0] diff --git a/Tests/input/cosmosTyped.keys b/Tests/input/cosmosTyped.keys new file mode 100644 index 0000000..b5dc556 --- /dev/null +++ b/Tests/input/cosmosTyped.keys @@ -0,0 +1,2 @@ +halpmelpelp +exit diff --git a/Tests/manifest b/Tests/manifest index e167c95..6367fe4 100644 --- a/Tests/manifest +++ b/Tests/manifest @@ -4,7 +4,7 @@ # One test per line, fields separated by '|'. Blank lines and lines starting # with '#' are ignored. # -# name | source | mode | stdin | limit | disk +# name | source | mode | stdin | limit | disk | keys # # source is relative to Programs/. Everything assembles from there with # Libraries/ on the include path, and the binary is written into Tests/build. @@ -25,6 +25,13 @@ # nothing a test writes can be seen by the next one. Leave it off for a machine with no # disk, which is most of them. A trailing :ro attaches it write protected. # +# keys names a file in Tests/input to be fed to the console as a KEYBOARD rather than as +# standard input, and the difference is the point. Standard input reaches a console that +# believes a terminal is handling the line editing, which is true when one is. A keyboard +# reaches a console that knows it has to do the editing itself - gathering a line, rubbing +# out a backspace, handing it over only at Return - which is what happens behind a window, +# where there is no terminal to do any of it. +# # A disk name with a directory in it, such as disks/sbfs.img, is one of the images that # Tests/makedisks.sh builds with SplitDisk before the run. Those are used as they stand, # so a test can read a filesystem written by the other implementation of the format. @@ -442,6 +449,14 @@ wedgedImage | Boot/wedged.asm | assemble | - # about a problem it will not let you fix. Settle is a program rather than a shell word: # one job, reached through SWI, replaceable, and callable by anything that comes to call # programs in sequence. +# ---- The console doing a terminal's job ---- +# +# Behind a window nothing is handling the line editing, so the console does it. Typing "halp", +# backing over it, and arriving at "help" has to reach the shell as "help" - it used to arrive +# with the backspaces still in it, which made a corrected line unrecognisable while looking +# perfectly right on the screen. +cosmosTyped | CosmOS/Source/cosmos.asm | run | - | - | disks/cosmos.img | cosmosTyped.keys + cosmosSettle | CosmOS/Source/cosmos.asm | run | settle.in | 90000000 | disks/settle.img # Saying so when there is nothing to settle is as much a part of it as doing it. cosmosSettled | CosmOS/Source/cosmos.asm | run | settle.in | 90000000 | disks/settled.img diff --git a/Tests/run.sh b/Tests/run.sh index 3ba37f1..663166a 100755 --- a/Tests/run.sh +++ b/Tests/run.sh @@ -171,14 +171,16 @@ uncolour() { sed -i -E 's/\x1b\[[0-9;]*m//g' "$1" } -while IFS='|' read -r name src mode stdin limit disk; do +while IFS='|' read -r name src mode stdin limit disk keys; do name="$(trim "$name")" [ -z "$name" ] && continue case "$name" in \#*) continue ;; esac src="$(trim "$src")" mode="$(trim "$mode")"; stdin="$(trim "$stdin")" limit="$(trim "$limit")"; disk="$(trim "$disk")" + keys="$(trim "${keys:-}")" [ -z "$disk" ] && disk="-" + [ -z "$keys" ] && keys="-" wanted "$name" || continue @@ -231,6 +233,20 @@ while IFS='|' read -r name src mode stdin limit disk; do # clock, and --cycles for programs that never halt on their own, which # bounds them by cycle count rather than by wall clock. EMUARGS=(--fast "${EMULATOR_EXTRA[@]}") + # ---- A file standing in for a keyboard ---- + # + # Not the same as standard input, and that is the whole point of it. Input from a + # file reaches a console that believes a terminal is doing the line editing; a + # keyboard reaches one that knows it has to do the editing itself, which is what + # happens behind a window. The second had no test at all until it broke twice. + if [ "$keys" != "-" ]; then + if [ ! -f "$INPUT/$keys" ]; then + FAIL=$((FAIL + 1)); FAILED_NAMES+=("$name") + report "FAIL" "$name" "missing keyboard fixture $keys" + continue + fi + EMUARGS+=(--keyboard "$INPUT/$keys") + fi [ "$limit" != "-" ] && EMUARGS+=(--cycles "$limit") # A disk starts fresh for every run, so a test cannot pass because of what a # previous one left lying on it. The emulator makes the image if it is diff --git a/makefile b/makefile index 87593e7..d63bbb9 100644 --- a/makefile +++ b/makefile @@ -167,7 +167,12 @@ $(OBJ_DIR)/%.o: $(SRC_DIR_ASM)/%.c $(CC) $(CFLAGS) $(POSIXFLAGS) $(DEPFLAGS) -c $< -o $@ # Pull in the header dependencies written out by the compiler above. --include $(EMU_OBJS:.o=.d) $(ASM_OBJS:.o=.d) $(DSK_OBJS:.o=.d) $(LINT_OBJS:.o=.d) +# VOY_OBJS IS IN THIS LIST FOR A REASON. It was not, and voyager.o therefore never rebuilt +# when a header changed - so when EmulatorOptions grew a field, Voyager kept an object that +# disagreed with everything else about how big the struct was, and smashed its stack on every +# run. A clean build hid it, which is why 'make sanitize' would never have found it either. +# Tests/voyager.sh did, by failing all 115 tests that start the machine. +-include $(EMU_OBJS:.o=.d) $(VOY_OBJS:.o=.d) $(ASM_OBJS:.o=.d) $(DSK_OBJS:.o=.d) $(LINT_OBJS:.o=.d) # ---- The strict build the README promises ---- #