AnachronautandClaude Opus 5 09c72e4951 srcNext asks its own questions instead of calling for the answers
Two calls per character of every pass: numCompare to ask whether the
buffer is used up, and numStep to move one further into it. Inlined, the
compare settles almost every time on the high bytes and settles them
FIRST - a full block is 256, so SrcCount's high byte is one and SrcAt's
is nought until the last sixteenth of the block. Six instructions where
the call was twenty.

And the newline test uses A, which still holds the character, where it
used to fetch it back out of the SrcChar it had just been put in.

              before        after
  hello      586,184      574,737
  Say      4,506,702    4,333,689
  Files    7,188,351    6,863,744
  Keys    15,069,880   14,399,801
  cosmos 789,982,899  772,826,841

Two to four and a half per cent, which is worth having and is much less
than expected - AND THAT IS THE INTERESTING PART. It says the per
character cost is not in the reading. Now that comments no longer come
through here at all, what is left is the tokenizer above it, and the
remaining time is there rather than in getting the bytes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-06 21:43:29 -04:00
2024-10-18 18:16:30 -04:00

SplitBit

SplitBit is an 8 bit computer that does not exist: a CPU with its own instruction set, split Program and Data memories, an interrupt and vector system, a bus that programs can enumerate, a memory controller that can write code into memory, and a disk. This repository is a C implementation of the machine, an assembler for it, a tool for its disks, and the software that runs on it, which now includes an operating system and an assembler written in SplitBit's own assembly language.

CosmOS assembling a program, running it, and disassembling what it built

The whole loop in one screen. Asm.sbx assembles hello.asm into hello.sbx; the shell loads that and runs it; then the monitor disassembles what is at 0x2000, which is the program the machine wrote for itself at the top of the screen. No host is involved at any point.

SplitBit assembles SplitBit. Programs/CosmOS/Assembler/ runs on the machine, reads source off a SplitBit disk, and writes a boot image or a loadable program back to it with no host involved. It builds the operating system it is running under, and it builds itself, both byte for byte identical to what the C assembler produces from the same source. The test suite then boots the CosmOS that CosmOS built and has that one assemble CosmOS again, and the second generation is identical to the first, so the machinery has been through itself. After that the host is a convenience rather than a necessity.

> load Asm.sbx
> run cosmos.asm
wrote cosmos.bin: program 7036, data 2448, labels 475
> run Asm.asm
wrote Asm.sbx: program 7533, data 4099, labels 555

What Is In Here:

Directory What it holds
Source/Emulator The machine: CPU, memory controller, devices, console, disk - and the two front ends that present it
Source/Assembler The assembler that runs on a host
Source/DiskTool SplitDisk, which reads and writes SplitBit's filesystem
Source/Linter SplitLint, which points out needlessly long assembly forms
Source/Patch SoundPatch, which turns a soundThing patch into a table the sound device takes
Source/Tune TuneC, which turns a written tune into the bytes the music player reads
Programs/Examples Programs to read: hello, a calculator, Fibonacci, a prime sieve, Life, the colours
Programs/Libraries Code included by name rather than linked, since there is no linker
Programs/Sounds Patches as soundThing saved them, and the tables SoundPatch made from them, both checked in so the build never needs soundThing
Programs/Loader The standalone loader CosmOS grew out of
Programs/CosmOS The operating system, its applications, and the native assembler
Programs/testPrograms What the test suite drives
Tests The suite: the manifest, the recorded output, and the scripts that check it

Two Front Ends:

make builds SplitBit, which is the machine with a terminal attached, and where Raylib is installed it also builds Voyager, which is the same machine with a screen and a speaker. Everything that is actually the machine - every instruction, every device, every cycle - is shared between them, and each brings one file of its own: a terminal or a window.

Voyager is deliberately not required. The machine, the assembler, the disk tool, the linter and the whole test suite build and run on a host with no graphics library at all, because a project about a small understandable CPU should not need OpenGL to run its tests. Where Raylib is missing, make says so once and builds everything else.

The suite holds the two to being the same machine rather than taking it on trust: it runs the entire manifest through Voyager as well, with --headless, and requires it to satisfy every recorded result byte for byte.

Control, Shift and R is the reset button. On real hardware it 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. Three things follow from that. It must not be a key software might want, because a machine with a keyboard has function keys and something will eventually have a use for them. It must not be reachable by accident, because restarting throws away everything in memory and a single key that does that sits one mistake away from losing work. And it must be a gesture the host has no opinion about.

That last one rules out the obvious answer. Control, Alt and Delete has meant this since 1981 and cannot be used: it is a secure attention key, reserved by every serious operating system so that it always reaches the system and never an application - precisely so a program cannot imitate a login screen. On Windows an application cannot see it without a kernel driver, and on Linux the desktop takes it. It is unavailable for the same reason it seemed right.

It does exactly what writing to the machine port does: the machine starts the way it started, so the boot chain runs again and finds whatever the disk now says to run. And it works on a machine that has stopped - one that halted, or faulted, or is a bare metal demo that ended. That is the whole point of a button: a machine which is not going anywhere is exactly the one worth restarting, and it is the one that cannot notice a request by itself, because a reset is otherwise seen between instructions and a halted machine runs none.

This part is 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 sets the same flag the machine port sets. When those are designed, a keyboard controller will have to see the gesture and pull reset regardless of what the CPU is doing - which is the property that matters, and the one a port write can never have, since a port write needs a program willing and able to make it.

That is what makes a bare metal program escapable. Once puts one in front of the next start and deletes the request before jumping, so a demo that has taken the whole machine is one gesture away from the system coming back, without closing the window and opening it again. It works on a machine that is stuck waiting for a key, too, which is when a reset button earns its keep.

Escape reaches the machine. 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.

The screen belongs to the machine, not to the window. The video device is a tile engine on ports 0x30 to 0x3F that brings its own bank of video memory, and it renders into a buffer that is a pure function of that memory - so the same program draws the same picture whether or not anybody is watching. The console draws on it: it is a display controller as well as a port, with a font, a cursor and scrollback, which is why CosmOS runs in a window without a line of it being changed. Voyager puts that buffer on the glass and decides nothing about it. Either binary will save a picture of the screen with --screen, which is how a test suite on a host with no display checks what was drawn. See The Screen in the Programming Manual.

The Machine:

  • Harvard architecture. Two 64K memories, one for instructions and one for data. An instruction can only read the second, which is why strings live there and why the memory controller exists.
  • Its own instruction set, 72 instructions, four Data Pointers, and a Q register that holds what the ALU last worked out. Small enough that the table describing it fits in the machine's own memory, which is what lets it disassemble and assemble for itself.
  • Interrupts. Software traps, hardware lines from devices, and faults, all arriving through one vector table with a full context save.
  • A bus programs can enumerate, so a program can ask what a machine is made of rather than being told.
  • A memory controller that reads and writes Program Memory, moves blocks between banks, reaches memory that devices bring with them, and guards a range against being written by accident. It is how a SplitBit machine loads a program.
  • Storage: a block device with 256 byte blocks and up to 16 megabytes of them, backed by an image file. It knows blocks and not files, because a filesystem is meant to be software SplitBit runs.

The Software:

  • SBFS, a filesystem read and written by SplitBit itself and by a host tool that speaks the same format, so an image can be moved either way and each implementation checks the other.
  • CosmOS, an operating system: it boots the machine, mounts a disk, lists what is on it, loads a program and runs it, and takes the machine back when the program finishes. It comes with a library of programs including a game, a line editor, and a monitor that examines memory, disassembles, assembles a line at a time, and sets breakpoints.
  • Loadable programs. A program that was not booted from carries a header saying where it belongs, and may bring interrupt handlers of its own for the loader to install and take back again.
  • System services. A loaded program reaches the console and the disk through numbered software interrupts rather than carrying a copy of the code that drives them. The numbers are written down in one file both sides include, so neither ever types one. It took the editor from 4941 bytes to 1983 without changing a line of what it does.
  • Streaming reads. A file bigger than the machine's memory is read a block at a time, through services that keep nothing open between calls. CosmOS's own source is far larger than its 64K of Data Memory, and this is what the native assembler stands on.

Getting Started:

Clone it and build the six tools. You need gcc and make, or similar:

git clone https://github.com/RealBusinessAccount/SplitBit-Emulator.git
cd SplitBit-Emulator
make

The sources are ISO C and build clean under -std=c11 -pedantic with -Wall -Wextra. Beyond ISO C they need POSIX.1-2008, which the makefile asks for by name, and getopt_long for the long form of the command line options.

Building needs nothing else. Running the tests needs bash and Python 3, because two of the checking scripts are Python and one of them opens a pseudo-terminal to ask questions a recorded file cannot answer. They also use stty, timeout, and the usual text utilities: sed, awk, grep, cmp, diff, tr, sort, wc.

Assemble something and run it:

./Assembler Programs/Examples/hello.asm
./SplitBit hello.bin

Or boot the operating system, with a disk of programs and all of its own source on it:

make run-voyager

make builds a disk as well as the tools, so there is one to boot. make run-cosmos is the same system in the terminal, for a machine with no graphics library.

Both put a second disk in drive 1, at Disks/personal.img. It is made once and then never rebuilt, cleaned or committed: everything else here can be thrown away and made again from source, and that one is where anything made ON the machine lives.

Starting the machine copies it first, three starts back, as personal.img.1 and so on. Not one copy: the way a disk is lost is that something goes wrong and the very next thing anybody does is start the machine again to see how bad it is, which is exactly when a single backup would be overwritten by the wreckage.

Then dir to see what is there, load Snake.sbx and run to play something, or load Asm.sbx and run cosmos.asm to watch the machine build itself.

Every source in Programs/ is on that disk, under /Source, so anything not shipped as a binary can still be assembled on the machine: cd /Source/Examples and Asm colours.asm.

make run-voyager boots the same disk on the machine with a screen instead of a terminal. Both targets depend on the disk, so a disk built before a change to the machine is rebuilt rather than booted as it stands: what is on a disk is whatever was built when the disk was made, and a system whose console has changed will happily start an image full of programs written for the old one.

Running Programs: SplitBit

./SplitBit [options] [boot image]
Option What it does
-d, --debug Single step through cycles. Each key press advances one instruction.
-c, --cycles N Stop after N cycles rather than running until the program halts. Useful for programs that never halt, and for getting the same output from a run every time.
-f, --fast Run as fast as the host allows, ignoring the emulated cycle rate.
-D, --disk <file> Attach a disk image, creating a 128K one if the file is not there.
-L, --disk-cycles N How many cycles a block read or write takes. Zero, the default, finishes before the next instruction starts.
-W, --write-protect Attach the disk read only. A disk whose image the host will not let you write is read only whether you ask for this or not.
-h, --help Show help and usage information.

The boot image is optional now. Named one, the emulator places it into memory and starts it, which is what a debugger does and how every test here runs - a real thing real machines allow, not a shortcut to apologise for. Given only a disk, the machine starts the way hardware would:

./SplitBit --disk system.img

The emulator carries Programs/Boot/stage1.asm as a shadowed ROM: at reset its bytes are copied into Program Memory, boot vector included, and the CPU then does exactly what it has always done - reads the boot vector and starts where it points. Nothing about the CPU changed to make a machine that starts itself.

Because it is a copy rather than a mapping, those bytes are ordinary Program Memory once stage one has jumped away. The system may write over them, and a reset puts them back, which is why rebooting has to be a reset rather than a jump - and SWI SoftReset is the instruction for it.

The ROM is generated from the assembly by the makefile rather than kept beside it, because a copy of a program stored next to the program is a copy that goes stale.

A cycle is one access to memory, not one instruction. Fetching an opcode is a cycle, fetching each byte after it is another, reading or writing Data Memory is one, every byte a CALL pushes or a RET pops is one, and reaching a device port is one. Nothing overlaps - there is no fetching the next instruction while this one finishes - so the count is simply how many times the machine used the bus.

That makes the numbers describe something buildable. RSTA costs 1 and SETD costs 4, because one is a byte and the other is four. CALL and RET together cost 24 and RCAL and RRET cost 8, because the first pair moves twenty bytes of Stack and the second moves four. Counting instructions said those were the same, which is not true of any machine anybody could build - and it is the emulator's job to be the thing the hardware is designed against.

The average SplitBit instruction costs 3.72 cycles, measured over the native assembler assembling a program. Which is what a nominal 1 MHz means here: about 270,000 instructions a second, not a million. The same program takes 3.72 times the wall clock it did when a cycle was an instruction - nothing got slower, the number got honest, and the number it replaced described no machine anybody could build.

Two measurements that put that in proportion, taken on the machine assembling its own operating system:

The emulator, in --fast 195 million cycles a second
CosmOS assembling CosmOS 654 million cycles: 11 minutes at 1 MHz, 3.3 seconds at --fast

At a hypothetical 100 MHz that build is six and a half seconds, and that is a pessimistic figure rather than a hopeful one: it assumes hardware overlaps nothing, which is the same conservative reading the memory controller's cost model takes and the same one that wants measuring before it is designed.

The memory controller is charged for what it moves, on the same terms. Banks are separate memories, and that is what sets the rate: a move between two of them can overlap its read and its write, while a move within one bank cannot and costs twice as much. A fill has nothing to read and goes at the between-banks rate. Against the ten cycles a transfer used to cost - the five port writes that set it up, and nothing at all for the quarter of a kilobyte that moved.

And the controller's path to memory is sixteen bits wide. A transfer whose source, destination and length are all even moves two bytes a cycle between banks and one within a bank; anything odd falls back to a byte a cycle, because lining bytes up across word boundaries is a second design and this is not it. So a 256 byte block is 129 cycles between banks and 257 within one when it is aligned, and 257 and 513 when it is not. The CPU still sees eight bits and no instruction means anything different: this is a peripheral being faster, not a new machine.

The transfer stalls the program that asked for it. Whether hardware would let the two run at once is left open, the same way pipelining is: the memories are separate, so it plausibly could, and the measurements say it would buy less than it sounds like.

The disk can be given a latency with --disk-cycles, and then it really does take that long: it says busy, finishes when the machine has run that far, and a program that does not wait reads the block before the one it asked for. That is not an error anywhere - just quietly the wrong bytes - which is why the filesystem now watches the busy bit rather than trusting the answer to be there. Zero is the default and is how the machine has always run.

The waiting is one small routine, and it is reached with RCAL rather than CALL because what it hands back is the settled status in A, and an ordinary call would put A back the way it found it.

And waiting is not the same kind of cycle as working. A machine stopped in a WAIT is clocked but is not using the bus, so those cycles are counted apart from the rest and the halt line says so when there are any:

Execution halted after 1042474 cycles, 119772 of them waiting.

Added together they are elapsed time, which is what --cycles measures. Told apart they say whether a program was working or waiting - and that distinction is the only thing that separates a machine which slept through a slow disk from one which spun on it. The two take the same wall clock time and print the same characters. When the filesystem's wait was first written, taking the line-clearing out of WAIT moved the total by a single cycle, 20,100 against 20,099, while the idle half halved.

Whether real hardware would overlap a fetch with the end of the previous instruction is left open, and deliberately: this is the conservative model, and pipelining is a decision to make while drawing the hardware rather than one to inherit from an emulator.

If the CPU reads a byte that is not an instruction, it goes to the fault handler the program installed. If it installed none, it raises the Fault Flag and halts, and the emulator reports the byte and the address it was found at and exits with a non zero status. The same happens if a program or a device asks for a handler that was never installed.

Assembling: Assembler

./Assembler [options] [assembly file]
Option What it does
-o <file> Write the output to this path.
-I <dir> Look in this directory for included files. May be given more than once.
-M <file> Write out which source files the output depends on, as a make rule.
-S <file> Write every label and the address it was given, in address order.
-h, --help Show help and usage information.

-S is the only thing that knows what a program's addresses are called. A program on the disk is bytes; the machine's own monitor can disassemble it but has no idea what any of it is named. So a count of which addresses get called says a great deal and names nothing, and this is what turns such a count into a list of routine names.

Without -o the output takes the source file's name, in the directory you called the assembler from, with the extension the format asks for: .bin for a boot image and .sbx for a loadable program. Included files are looked for beside the file that includes them, and then along the directories given with -I.

Writing A Tune: TuneC

./TuneC [-I <dir>] <tune.score> <output.tune>

Four voices on one clock. A tune names its instruments, writes sequences of notes and durations, and gives each voice an order list of sequence names - which is where repetition comes from, since a phrase played four times is written once and named four times.

; Four bars, and the bass is the same one under two of them.
#Tick 0d125000              ; cycles a tick: a sixteenth note at 120 beats a minute
#Patch Oboe    oboe.patch
#Patch Strings strings.patch
#Voice 0d0 Oboe             ; which instrument each voice starts on

#Sequence Verse
  0d64 0d4  0d67 0d4  0d72 0d8

#Sequence Ending
  #Use Strings              ; a command, which takes no time at all
  0d72 0d16

#Order 0d0
  Verse Verse Ending

# is a directive and ; is a comment, exactly as in SplitBit assembly and in the shell's scripts, and numbers are written the way the assembler writes them - 0d and 0x, and no bare numbers. One rule across the machine rather than a third dialect.

A .score is written and a .tune is what the machine reads, the same way a .asm is written and a .sbx is loaded. Patches come from SoundPatch --blob, which stays the only thing that understands what a soundThing patch means, and are looked for beside the tune and then on the -I path the way an include is.

What it refuses is everything the player cannot notice for itself. The machine has no names, so it cannot say a sequence does not exist; it has no lengths, so it cannot say the voices will come apart four bars after the mistake; and a duration of nought is counted down to 255 and held, which sounds like a hang rather than an error. A voice with a part and no instrument is refused too, because by the time a tune is loaded, "never said" and "instrument nought" are the same byte.

Making A Patch: SoundPatch

./SoundPatch <patch.json> <label> [output.asm]
./SoundPatch --blob <patch.json> <name> <output.patch>

The sound device is soundThing's voice engine with the editor taken off, so a patch designed in soundThing makes the same sound here. What differs is how it arrives: soundThing writes seconds and hertz, and the device takes bytes through a selector. SoundPatch converts one into the other and writes a table a program can hand to the device.

A byte is not a number of seconds, and that is the whole reason this exists. Times are squared into four seconds, because the difference between five and fifty milliseconds is the character of a percussive sound and the difference between three and four seconds is nothing anybody can hear. Cutoff and LFO rate are exponential, because hearing is logarithmic. Depths and detune are centred on 128, so half of nothing is no change. The first sound written for a game here was guessed at directly in bytes: a cutoff of 40 looks small and is 57 Hz, and the bang came out as a low gurgle.

A field the tool does not recognise stops it rather than being skipped, because a patch format that has moved on would otherwise produce a table that quietly means something else.

The build does not depend on it. soundThing lives in its own repository and is not required to build anything here. Programs/Sounds holds both halves - the patch as soundThing saved it and the table made from it - so a sound can be regenerated, or read, without going and finding the editor first. The tool is for when a sound is being changed.

Checking Assembly: SplitLint

./SplitLint [--fatal-warnings] [--machine] <sourcefile> [sourcefile ...]

Every warning names the rule that produced it, in brackets the way a compiler names a flag, and --machine prints one tab-separated line per warning and nothing else - file, line, rule, message, help - so that nothing downstream has to read prose. A run that finds nothing says so rather than exiting silently, because a tool that says nothing has not told you it found nothing; it has told you nothing at all, and from outside those look the same:

No style warnings: 121 files checked against 12 rules.

SplitLint reports valid assembly that has a shorter direct expression, beginning with zero loads that can use RSTA or RSTB, Q-to-register transfers that need not pass through the stack, self-push/pop pairs, register assignments overwritten by the next instruction, instructions with no ordinary fallthrough path, and one-byte Data Pointer changes that can use INCD or DECD, including direct branches whose target is already the next labeled address. It also tracks symbolic Data Pointer bases and known offsets to find a SETD that reloads an address the pointer already holds. Device input and stack pops are excluded from dead-assignment checks because consuming their input is itself an effect.

Nothing it knows survives a call. CALL really does restore A, B and Data Pointers 0 to 2, so a pointer set before one is genuinely still set after it - and saying so produced advice that was correct today and unsafe to take. 122 of the 178 redundant SETDs it first found were redundant only because of that restore, and removing them would have become a wrong-pointer bug the moment the callee was reached with RCAL instead, which is what RCAL was added to this machine for. Worse, the linter would have gone quiet rather than complained, because it forgets everything across an RCAL. Fifty four recommendations that stay true are worth more than a hundred and seventy eight that are conditional on a change the project intends to make.

SplitLint's control-flow knowledge is otherwise deliberately local: labels begin reachable regions, while an unparsed directive or data item ends the current claim. It does not yet expand includes or build a complete control-flow graph. Warnings normally leave a successful exit status, while --fatal-warnings makes any warning fail the command for use in automated checks.

A line whose comment says splitlint: <reason> is not reported on, and the reason is required. A suppression with no explanation is a way to make a tool quiet rather than a way to say something, and a bare marker is refused rather than honoured. The corpus has three of them, all in test programs: branchTest.asm exists to check that a branch whose carry is known behaves correctly, so a diagnostic saying the outcome is known is exactly right and exactly unwanted. splitlint[rule]: <reason> silences one rule and leaves the line honest about the others.

Suppressions are counted and reported at the end of a run, and a marker that no longer silences anything is itself reported - an exception that outlived its reason is the thing the required reason was meant to prevent.

The corpus is held to a baseline. Sixty one warnings are left in it on purpose, and Tests/lint-baseline.txt records how many of each rule each file is expected to produce, so a sixty second fails make test while the sixty one stay quiet. It counts rather than recording line numbers, because recording lines would churn the whole file whenever anything was inserted above a warning. ./Tests/lint.sh --bless records it again once warnings have been deliberately fixed or deliberately accepted.

The same local model tracks whether carry is known set or clear. It reports a redundant CCF, a BRC or BNC whose outcome is already determined, and computes carry through increment, decrement, addition, and subtraction when their inputs are known.

Managing Disks: SplitDisk

./SplitDisk <command> <image> [arguments]
Command What it does
boot <image> <file> [slot] Write a file into a boot slot, padding the rest of it with zeroes. Slot 0 unless told otherwise.
bootstate <image> [0|1|2] Show how the last start went, or set it. Setting it to 0 is how a disk that fell back is told to try again.
bootslot <image> <slot> Choose which slot the machine starts from. One byte, on its own, so writing a slot and committing to it stay separate decisions.
format <image> [blocks] [dirblocks] [bootblocks] Lay down a fresh filesystem. 512 blocks and 8 of directory by default, which is 128K and room for 64 entries. A fourth number reserves a boot area of two slots that size.
list <image> [path] Show the whole disk, or one directory of it.
put <image> <file> [path] Put a host file onto it. Without a path it uses the file's own name, which is often longer than the 22 characters a name may be.
get <image> <path> [file] Take one off it.
delete <image> <path> Remove a file.
mkdir <image> <path> Make a directory.
rmdir <image> <path> Remove an empty one.

The Boot Area:

Blocks between the superblock and the directory, which the filesystem never allocates and never sees. Nothing was added to reserve them. Both implementations work out the first usable block as directoryStart + directoryBlocks, and directoryStart has always been a field in the superblock rather than a constant, so moving the directory up reserves everything below it by arithmetic that was already there. Neither allocator changed.

A disk made before any of this has directoryStart of 1 and a boot area of zero, which reads as not bootable - true, and the same shape as the version two parent field, where the value an older disk already held turned out to be the right answer.

There are always two slots, because a boot slot is raw blocks. A file being rewritten is protected by writing a temporary and renaming it, and there is no name here to rename - so a machine interrupted while updating its only boot slot would not boot at all, which is the one failure on this disk with no way back. Writing the slot that is not live and then moving one byte in the superblock turns that into a machine that boots what it had before.

Starting Again:

Writing 1 to port 0x13 asks the machine to start over, and Reboot is the program that does it - forty five bytes, most of them the word it prints.

A port rather than a service, because a reset has to work when the system does not. Something that could only be asked for through SWI would be unavailable in exactly the case that wants it most, and a program that owns the whole machine has no system to ask.

What a reset repeats is how the machine started. Named an image, the emulator places it again; named none, the ROM is shadowed again and reads the disk for the rest. Anything else would mean a reset changed what the machine is, which is the one thing a reset must not do. It is taken between instructions, because a device cannot restart the machine from inside the instruction that asked.

The disk is not unplugged and keeps everything written to it. That is what warm means: the machine starts again, the world it starts into does not. The vector table is cleared, which is the one deliberate departure from leaving memory alone - a vector points into whatever installed it, and after a reset that program is not running, so a handler left behind would aim an interrupt at an address belonging to something gone. It is the argument CosmOS already makes when it takes a program's vectors back at exit.

Starting Something Else Just This Once:

A program that owns the whole machine has nowhere to run. It cannot be started from the shell, because starting it means there is no shell; and pointing boot.cfg at it means a machine that keeps starting it, which is a poor place to find a mistake.

> Once /System/Boot/mine.bin
next start: /System/Boot/mine.bin, once

That writes /System/Boot/once.cfg, in the same format as boot.cfg and read with the same routines, because a second format for one setting would be a second format. The loader reads it before boot.cfg and deletes it before it jumps - the only moment there is, since after the jump the loader does not exist.

Consumed by being read, not by working. A one shot that hangs cannot hang twice: the request is gone before the image ran, so the next start reads boot.cfg like any other.

And the boot state is not touched by a one shot, which the first version got wrong. A program with the whole machine has no filesystem to clear a mark with, and is doing nothing wrong by not having one - so marking it reported every successful bare metal boot as a start that never arrived.

Which closes the loop on the machine itself: write a bare metal program in Edit, assemble it with Asm, ask for it with Once, restart, watch it run, and the system comes back without being asked.

Knowing Whether The Last Start Arrived:

The loader marks the disk before it hands over, and the system clears the mark when it reaches its prompt. A system that crashes on the way there leaves the mark, and the loader finding it still set next time is how a machine that will not start says so to the only thing in a position to do anything about it.

State Means
0 Settled. The last start arrived, so start what the configuration says.
1 Trying. The loader handed over and nothing came back to say it got there.
2 Fell back. A try failed and the fallback was used, and will be until this is settled.

Three starts of a machine whose configuration names something broken:

stage two
a system that never reaches a prompt        <- marks the disk, dies

stage two
the last start did not arrive               <- finds the mark, uses the fallback
CosmOS
this is the fallback: what boot.cfg asks for did not start

stage two
still on the fallback: settle it to try again   <- does NOT retry

That third one is the part worth having. A system known not to start is not tried every other boot for ever; it waits to be told the situation has changed.

Reaching the prompt is a deliberate choice of threshold. It is not a claim that the system works - a shell can be reached by something broken in every other way. It is the point where somebody can type, which is exactly what the fallback exists to give back: anything wrong past there can be fixed from the prompt, and nothing wrong before it can be fixed at all.

bootBlocks and directoryStart describe the same fact from two sides, so a disk where they disagree is refused rather than guessed at.

Programs/Boot/stage1.asm is what starts a machine from one, and is written to end up in ROM: 330 bytes, and everything it knows is a thing that will be true forever. Which port the disk is on, that a SplitBit disk begins with its own name, and where two numbers sit in that first block. It does not know what a file is, what a directory is, or that SBFS has versions - all of that lives in the boot area, on the disk, where it can be replaced. It reads the live slot into Program Memory, jumps to the first byte, and prints one character and stops if there is nothing to start.

Programs/Boot/stage2.asm is what sits in the slot. It mounts the filesystem, reads /System/Boot/boot.cfg to find out what to start, and takes the image apart: code into Program Memory, data into Data Memory, and each vector written through the controller, which is the only thing that can write Program Memory at all. The entry point is noticed on the boot vector's way past, because Program Memory cannot be read back to look it up afterwards.

What it loads is an ordinary boot image - the same SPBT file the emulator has always been handed directly. That is the whole trick: a second stage that loads the machine's normal image format is not a boot-specific mechanism, so bare metal SplitBit stops being a special case. A program that wants no operating system under it is just an image, written under CosmOS like any other, and startable because it is a file.

Configuration:

One setting to a line: a key, a space, and the rest of the line is the value. A semicolon starts a comment and a blank line is nothing.

; /System/Boot/boot.cfg
system    /System/Boot/cosmos.bin
fallback  /System/Boot/cosmos-previous.bin

That format was noticed rather than designed. textSplit already cuts the first word off a line and leaves the rest; textSame already compares two strings and insists they end together, so system and systemd are different words. Reading a setting is those two routines and a loop - and it is the shape the shell already reads, so a configuration line is a command line the machine reads instead of a person typing one.

Configuration is advice. A missing file, a missing key, a value that makes no sense, a line too long to read: all of them mean use the default, and none of them is a failure. A program that cannot run without its configuration has turned its configuration into a single point of failure, and for the thing that starts the machine that would mean a mistyped file is a machine that will not start.

But quiet is not silent. A setting somebody meant, which did not take effect, says so:

a setting nothing asked for: fallbcak
a setting with no value: system
a line too long to read: ...

So config.asm has two routines rather than one. cfgGet reads and never says anything - reading three settings should not report the same bad line three times. cfgCheck reads the file once and reports what it did not understand, and is given the caller's list of keys, because whether a key means anything is the only part of this the reader cannot judge for itself.

Keys are matched exactly and case sensitively, the first line that matches wins, and unknown keys are ignored rather than refused - so a file written by a newer system loses the settings an older one never had and keeps the ones it did. A line is at most 128 bytes and a key at most 22, which is what a name is everywhere else on this machine.

Deliberately absent: sections, nesting, types, includes, substitution, conditions. Each is a step from reading a file toward running one, and the boot loader is the worst place on this machine to put an interpreter. The test to apply when the pressure arrives is: if this file cannot be read, can the machine still start?

Stage two arranges its own Data Segment. Stage one places Program Memory and nothing else, since knowing where a payload's data ended would mean knowing a format, so the image is written into the slot as code followed by data and stage two's first act is to blit its data down from just past its own code.

SplitDisk speaks the same on disk format SplitBit does, so an image it makes is one the machine can read, and one the machine writes is one it can read back. It is a convenience rather than a necessity: SplitBit writes its own filesystem, and now assembles its own programs, so a disk can be filled without leaving the machine.

Files are laid down contiguously, so a disk can have free blocks without having them in one piece. When that happens put says so rather than putting part of a file on.

A path is names with / between them, always from the root, since a command line tool has nowhere to keep a working directory between one run and the next. . and .. mean what they usually do, and .. from the root is the root.

A disk has two ceilings and it is usually the less obvious one that bites: blocks, and entries. Every file and every directory costs one entry, and list says how many of them are gone as well as how many blocks are. On a disk of small files the entries run out long before the space does, which is a matter of how the disk was formatted rather than a limit of the format - dirblocks is carried per disk, and each one is 256 bytes and holds eight entries.

Two Versions:

Version What it means
1 Flat. Every file is in the root, because there is nowhere else.
2 Directories. Each entry says which directory it is in.

A version one disk is already a valid version two disk. The parent is stored as an entry index plus one, so the zeroes a version one disk has in those bytes read as "in the root" - which is exactly where all of its files are. There is nothing to convert.

A disk is at the lowest version that describes what is on it, so format makes a version one disk and mkdir is what raises it. That is deliberate: a disk stays readable by anything that has never heard of a directory right up until it actually has one. Compatibility runs one way, which is the ordinary shape of it - version one code reading a version two disk would list directories as strange empty files.

Building Programs With Make:

The assembler is built to work with make. -o puts the output where the build system wants it, and -M writes out which libraries went into it, so that editing a library reassembles everything that includes it.

The makefile does this for the programs in this repository:

make programs

The rule it uses is small enough to copy into your own projects:

$(BUILD)/%.bin: %.asm
	@mkdir -p $(@D)
	$(ASM) $(INCLUDES) -M $(@:.bin=.d) -o $@ $<

-include $(BINARIES:.bin=.d)

Tests:

make test

Builds the six tools, checks they build clean under strict ISO C, and runs nine scripts. Tests/run.sh assembles and runs every program in Programs/ and compares the results against recorded output; six more ask the questions a recorded file cannot answer. Between them they check the two assemblers against each other byte for byte, the two SBFS implementations against each other on the same disk, the disk tool against the format, the linter against a fixture written to trip it, the terminal behaviour that a pipe makes invisible, and the manuals against the code.

make bless

Records the current output as the expected result, after you have checked that it is correct.

make sanitize

Rebuilds all six tools with the address and undefined behaviour sanitizers and runs the whole suite under them. It catches reads and writes past the end of an array, use after free, leaks, and undefined arithmetic, takes about twice as long, and puts the ordinary binaries back when it finishes.

Everything is built inside Tests/build, so running the suite never overwrites anything in Programs/. To run only some of the tests, call the runner directly with their names:

./Tests/run.sh hello 8bitFibonacci

What each script can and cannot answer, how to add a test, and where the suite is blind are in the SplitBit Test Manual.

Documentation:

Five documents, divided by what they are about rather than by who reads them.

SplitBit Programming Manual describes the machine: the instruction set, the registers, the vector table, interrupts, devices, the memory controller, the console, storage, and faults. Everything here is true of any SplitBit, whatever is running on it.

SplitBit Assembler Manual describes the language and the file formats: literal values, labels, segments, the directives, the loadable program header, and the assembler that runs on SplitBit itself.

Programs/CosmOS/README.md describes the operating system: its shell, its applications, what a program may ask it for, and the libraries it owns. A different system on the same machine would answer all of that differently, which is why it is documented with the system rather than with the CPU.

SplitBit Tune Manual describes music: what a tune is, how to write one, what TuneC refuses and why, and the bytes a tune file holds for anything that would rather write one itself.

SplitBit Test Manual describes the test suite: what each of its scripts claims, which of those claims are worth the most, how to add to it, and what it is blind to. It is about this repository rather than about the machine, which is why it comes last.

License:

Apache License, Version 2.0. You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0.

S
Description
The modern version of the SplitBit emulator and assembler, along with the system's OS, CosmOS.
Readme Apache-2.0
5.3 MiB
Languages
Assembly 49.4%
C 28.5%
Shell 20%
Makefile 1.7%
Python 0.4%