Compare commits

...
62 Commits
Author SHA1 Message Date
AnachronautandClaude Opus 5 736037462e The shell edits the line it is given
Three different things used to do this job, and which one you got depended on
where the machine was running. On a terminal the host held the line and did the
echoing and the backspacing; behind a window the console's own gatherer did it;
from a file nothing did it at all. One job, three implementations, none of them
in the system - which is why there was no way to move about in a line and
nowhere for a history to live.

So editLine does it. Key mode while a line is being read and line mode straight
after, so nothing else in the system and no program calling osReadLine notices
anything changed. Left and Right, Home and End, Backspace for the character
before the cursor and Delete for the one under it, and anything typed goes in
where the cursor is with the rest of the line moving along.

Ctrl-D means the end of input again, on an empty line, because that was a thing
the terminal did while it was holding the line and it is not holding it now.
Same trade as the echoing.

MOST KEYSTROKES DRAW NOTHING BUT THEMSELVES. A character typed at the end of a
line needs no cursor moved: printing it is the whole change, and a backspace
there is three ordinary bytes. That matters beyond speed - moving the cursor by
hand is what a terminal is TOLD about, in an escape sequence, so redrawing on
every keypress would fill every recorded transcript in this suite with them.
The line is only reprinted when something happened in the middle of it.

Where the line STARTS is worked out backwards from where printing ended, rather
than trusted from what was remembered. That is what makes it survive the screen
scrolling: a line printed on the bottom row moves everything up by one, and a
remembered row would be one too low from then on.

The command line holds 127 characters, up from 63. The limit started to be felt
the moment a line could be moved about in.

58 recordings changed, and every one of them by the echo. THE PROOF IS NOT A
HEURISTIC: a CosmOS built with the echo silenced reproduces 187 of the 188
recordings byte for byte. The one exception is cosmosTyped, the backspace test,
where the rub-out marks now come from the shell instead of from the console's
gatherer - same marks, different author.

cosmosEditKeys is the new test, and every line in it is typed wrong and then
corrected with a different key. Its last line is eighty six characters at a
prompt in column two on an eighty column screen, so the line runs onto the row
below and the shell has to find the start of something it can no longer see;
breaking either half of that arithmetic fails it.

Also: agree.sh looked for "> the same", anchored to a prompt that no longer
precedes what a command prints.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-31 23:01:55 -04:00
AnachronautandClaude Opus 5 b3726c950a Deliver the keys that are not characters
An arrow key has never reached this machine. Voyager threw it away for want of
anywhere to put it, and a terminal sent ESC [ A, which arrived in the middle of
whatever was being read and made it unrecognisable - typing Up at the CosmOS
prompt put three bytes in the command line and got "I do not know".

So the console names them: one byte each, 0x80 upward, above ASCII so nothing
written before them can collide. Up, Down, Left, Right, Home, End and forward
Delete, with room above for the paging and function keys.

The console normalises, which is what it already does. Behind a window it turns
the key somebody pressed into a byte; on a terminal it turns the sequence into
the same byte. That is the act it has always performed on Return and Backspace,
one layer further along, and it is why a program need not know which of the two
it is talking to. What a key MEANS is not the console's business - that belongs
to whoever is reading, the same way what is on a disk belongs to the system and
what a drive is belongs to the machine.

Translated only when standard input really is a terminal. Nothing else sends
these sequences, a pipe holds exactly the bytes somebody put in it, and it keeps
the Escape-or-Up timing problem out of every test here: a test writes the key
values themselves. Line mode drops them, in both front ends, because line mode
delivers characters and a line somebody else has finished editing cannot be
moved about in.

Press.sbx says what it was handed, in hexadecimal and by name, and reads a line
before it reads keys so both halves of that rule are checked. Two recordings,
one fed as standard input and one as a keyboard, agreeing byte for byte; each
break fails exactly one of them. Three checks in terminal.sh type real escape
sequences at a pseudo-terminal, which is the only place they are ever read as
sequences: that they arrive as keys, that Escape alone is still Escape, and that
a character typed straight after an escape is held rather than swallowed.

Five recordings re-blessed for Press.sbx appearing on the shared disk, and the
whole of that diff is the file's own line and the counts above it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-31 22:24:11 -04:00
AnachronautandClaude Opus 5 2a29cebc6b Make the screen come back on the machine people actually run
Found by running it: Grid exits and the shell prints its prompt into the
grid, with the view up to seven pixels out of alignment. Three faults, and
the first is the one that made the other two visible.

MAKE RUN-VOYAGER HAD NO SCRATCH DRIVE. It gives drive 1 to Disks/personal.img,
which is a file and not volatile, so there was nowhere to keep a screen -
osTakeScreen answered no and the whole feature silently did not happen. It
was tested with --ram-disk and shipped without one, which is as good a
description of testing the wrong machine as I can write. There is now a RAM
disk in drive 2, after the personal disk so that drive 1 stays the one that
is yours.

A PROGRAM TOLD NO MUST COPE. A refusal is not a fault, it means doing what
the program did before there was anywhere to save a screen. Grid deleted
its own tidying up when it started asking, so being refused left the grid
on screen with a prompt written into it. It clears up again when refused,
and only then.

AND THE SYSTEM ALWAYS LEAVES THE SCREEN USABLE. The fine scroll registers
go back to zero at every program exit, whether or not the picture could be
saved: the console draws in whole cells, so a view three pixels into one
puts every character three pixels out for ever. That is not part of saving
a screen and should never have depended on it.

Both paths are checked now. With a scratch drive the screen comes back cell
for cell; without one, no grid is left behind. Breaking either fails one of
them and not the other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-31 20:31:26 -04:00
AnachronautandClaude Opus 5 ab72443b99 Give the screen back: osTakeScreen, and the RAM disk earns its keep
A program that takes the whole screen leaves the shell a blank one, and
whatever was on it is gone. There was nowhere to put 48K of video memory on
a machine with 64K of Data Memory that CosmOS already lives in.

A DRIVE MADE OF MEMORY IS SOMEWHERE. The screen goes to a file on the
scratch drive - the first volatile drive found at boot - like any other
file, and comes back from handleExit alongside the vectors and console mode
already put back there. The filesystem does the allocating, so this
invented nothing: it is 196 pages of tiles, map and palette, with a block
on the front holding the cursor, the four scroll registers and the mode.

NOT AUTOMATIC, and that is the whole design. Saving on every program start
would be cheap enough; restoring on every exit would be wrong, because dir
and Files and Say print and stop and their output is the reason you ran
them. A program says it took the screen, and one that says nothing behaves
exactly as every program did before this existed.

It deleted thirty lines of Grid, and they were all wrong anyway: four
scroll registers put back by hand, the map filled with spaces, the cursor
sent home, palette bank 0 written out - and the other fifteen banks kept
Grid's colours, because there was nowhere to have kept the real ones. Grid
is 64 bytes smaller and gives back what was actually there.

The check compares the screen before against the screen after, CELL BY
CELL, and allows only the rows around the cursor to differ - found from
where the text ends rather than guessed at, because the first version
assumed the cursor was near the bottom of the screen and let three real
differences through.

Two things cost time and neither was the feature:

  - An edit adding "SWI osTakeScreen" to Grid was in the same script as a
    failing s.index, so the file was never written - and the COMMENT
    describing the call did land, from a later edit. Grid documented a call
    it did not make, and read as though it should have worked.
  - docs.sh caught osTakeScreen having no row in the services table, which
    is the check the service layer added for exactly this and the second
    time it has earned itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-31 18:47:44 -04:00
AnachronautandClaude Opus 5 04f1ffabd4 A disk made of memory, brought up by whoever owns it
THE MACHINE SUPPLIES BLOCKS AND SAYS WHAT A DRIVE IS. It says nothing about
filesystems, which is what leaves room for a system that would rather have
its own - and is why the volatile bit is a fact about the hardware rather
than a promise about SBFS.

  0x26        what the selected drive is: bit 0, contents do not survive
  0x27, 0x28  how many blocks it has
  --ram-disk N   a drive of N blocks with memory behind it

A drive of memory selects, reads, writes and has a size like any other, and
a program cannot tell the difference except by how fast it was. The one
thing it cannot work out for itself is that the contents are volatile,
because an empty disk and a volatile disk look identical from outside.

THAT BIT IS THE DIFFERENCE BETWEEN A DRIVE A SYSTEM MAY FORMAT ON SIGHT AND
ONE IT MUST NOT. CosmOS formats a volatile drive it cannot read, because
there was never anything on it to lose, and leaves every other unreadable
drive alone - an unformatted floppy is not an invitation, it is a blank
floppy. Removing that check formats somebody's blank disk, which is checked
rather than asserted: cosmosBlankDisk boots with one and requires it to be
refused.

So CosmOS grew a format. The size comes from the drive rather than from a
superblock, since a superblock states a size too and that is no use on a
disk which has not got one yet. Sixteen directory blocks, 128 names, chosen
rather than worked out: a scratch disk runs out of names long before room,
and this machine cannot divide.

The RAM disk is no faster on this emulator by default, and that is honest
rather than disappointing: the emulated disk has no seek time unless asked
for one. With --disk-cycles 10000 the same copy is 7.94M cycles against
8.70M, the difference being every write.

run.sh takes "ram:2048" where an image name goes, which needs no removing
between runs because there is nothing to remove.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-31 17:56:41 -04:00
AnachronautandClaude Opus 5 6b51d6391f A beat a program sets for itself
The only regular thing on this machine was the screen finishing a frame,
sixty times a second and not negotiable - a clock a program BORROWS rather
than one it sets. Every duration became a multiple of 16,667 cycles, so a
sixteenth note at 120 beats a minute, which is 125,000, is seven and a half
frames and cannot be asked for at all. The way round it was to choose a
tempo whose subdivisions happen to land on whole frames, which is making
the music fit the machine. Examples/tune.asm says so in its own header.

  0x50  Status: a period went by, it is running, it will interrupt
  0x51  Control: run, repeat, interrupt
  0x52-0x54  The period, in cycles, most significant first

THE PERIOD IS IN CYCLES because that is what everything else here is
counted in - the cost model counts them and a frame is measured in them -
so a timer counting anything else would be a second unit to remember.
Twenty four bits reaches from one cycle to sixteen and a half seconds, with
120 beats a minute at 500,000 in the middle, and there is no range left for
a prescaler to buy.

Starting loads the period; asking it to run while it already is does not,
so turning interrupts on half way through a period does not silently move
the beat being kept. What is left over carries into the next period, so a
period of 1,000 ticks every 1,000 and not every 1,000 plus however late
anybody looked. Reading the status takes the tick down and the line with
it, which is the rule this machine settled two days ago about every status
port.

The timing check is in terminal.sh and not the manifest, and the reason is
worth keeping: settle() strips cycle counts from recordings, which is right
for every other program and useless for a clock. "It printed eight dots"
would pass on a timer that fired them all at once. terminal.sh measures
that eight periods of 125,000 come to a million within a couple of hundred
cycles, and that 99.97% of them were spent asleep.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-31 17:06:28 -04:00
AnachronautandClaude Opus 5 3e48e9690d Put a disk of your own in drive 1
make run-voyager and make run-cosmos now attach Disks/personal.img as the
second drive. It is made the first time it is wanted and then left alone:
a rule with no prerequisites, so make never looks at it again.

NOT UNDER build/, and that is the whole point. Everything else in this
repository is made from source and can be thrown away without losing
anything, so 'clean' empties build/ without a thought - and a disk of your
own that a clean deletes is not a disk of your own. It is the one place
here where something MADE ON THE MACHINE can live, which starts to matter
the moment there are tools on it that make things.

Disks/ is in .gitignore for the same reason: what you make on the machine
is yours and not the repository's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-31 16:45:17 -04:00
AnachronautandClaude Opus 5 e4f4bae762 Work across two disks: copy between them, and run a program from one on
files from the other

Two things anybody expects of a second disk, and each needed something
different.

COPYING NEEDED TWO THINGS TO REMEMBER A DRIVE.

The write stream is the only thing here that lives across service calls, so
it is the only thing whose drive can change underneath it: every
osFileBlock names its source path again and goes back to the source drive,
and then osFileWrite has to come home. It records the drive it was opened
on and returns there.

And the file lookup CACHE. It keeps the last path resolved so a reader
walking a file does not re-walk the directory for every block - and
skipping the walk skipped the drive the path named, so block one of a
cross-drive copy read the source's block numbers off the DESTINATION disk.
It only showed on files of more than one block, because a file of one is
never looked up twice. One block worked and two did not, which is a
suspicious enough shape to have suspected sooner.

RUNNING A PROGRAM FROM ELSEWHERE NEEDED A THIRD PLACE TO LOOK, and two
restorations.

The shell tried where you are and /Apps on the disk you are on. It now
tries /Apps on drive 0 as well, which is what makes the system's programs
work from a disk of your own - one with your files on it and no system,
which is most of the point of having a second disk.

The drive goes back after the load, because by then the program is in
memory and the blocks it came from mean nothing; and again when it exits,
because a program that copies between disks moves the drive as its own
paths need to and being left wherever it finished is not what was asked
for. Copy 1:/a 0:/b now leaves you exactly where you were.

The fixture disk grew an /Apps, because it kept its programs at the root
and so could not exercise the third place at all.

Two hours of the debugging above were spent on a stale disk image. The
machine boots the system that is ON the image, so a rebuilt cosmos.bin
means nothing until the image is rebuilt too - and the trace said my new
code never ran, which was true. Third time this project has been misled by
one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-31 16:14:53 -04:00
AnachronautandClaude Opus 5 4cc6393f5b Name a drive in a path
"1:/notes", or "1:" on its own for wherever that drive already was. Done in
sbfsWalk, which is where every path in the system arrives - eight callers
between the shell, the config reader and the filesystem - so it works for
anything that takes a path rather than for whichever commands somebody
remembered to change.

NAMING A DRIVE GOES THERE AND STAYS THERE. Switching for the length of one
command and switching back reads better and cannot work: a path resolves to
a start block and a length, and those mean nothing without the drive they
were read from. A load that resolved on drive 1 and then read its blocks on
drive 0 would read the right blocks of the wrong disk.

A name beginning with a digit is still a name. The colon is the whole of
what tells them apart, and /2things is on the fixture disk to keep it that
way.

Two bugs, and the second is the interesting one.

SUB sets carry on a BORROW, so a character below '0' leaves it set - and
the test for "not a digit" branched on clear. Every prefix was ignored.

Then the leading-separator test reads the first character through DP0,
which sbfsPathDrive could not move because RET puts DP0 back the way it
found it. It advanced SbfsPathAt and DP0 still pointed at the digit, so
every prefixed path was judged relative and walked from the named drive's
working directory. IT ONLY SHOWED WHEN THAT DRIVE WAS STANDING SOMEWHERE
OTHER THAN ITS ROOT, because a relative walk from the root is an absolute
one - so "cd 1:/2things" worked from a fresh boot and failed after "cd
1:/notes". The test does it in that order for that reason.

Copying between two disks is still not one command: each path resolves on
its own drive and the drive stays where the last path left it. That wants
Copy to change drives between blocks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-31 15:35:11 -04:00
AnachronautandClaude Opus 5 5644c24113 CosmOS knows about all four drives
A mounted disk is EIGHT BYTES - where its directory starts, how many
blocks it is, how big the disk is, and where you are on it. They now sit
together in the data segment, and changing drives is one copy out and one
copy in. The other three thousand lines of filesystem go on reading the
same four names they always have and never learn there is more than one
disk, which is the whole reason this was affordable.

The version is not in the record. It is checked at mount and thrown away,
because a version one disk's zero parent already reads as "in the root".

Every drive is mounted at boot: the controller says how many are plugged
in and each is tried in turn. One with nothing in it, or a disk this
cannot read, is left unmounted rather than stopping the others, so a
machine with a good disk in drive 0 and a blank in drive 1 starts.

'drive' says which one, 'drive 1' goes to another, and the working
directory goes with it - where you are on a disk is part of which disk you
are on. A drive the machine has not got is refused, and refused
differently from one that is there with nothing readable in it.

Three things the assembly caught me on, all the same misunderstanding of
what survives a call:

  - OR reads A and B, and the bit came back from sbfsDriveBit in Q, which
    RET does not disturb - but RET does put A back. The mounted mask never
    got set and drive 0 was reported unmountable.
  - MVQA then RSTA throws away the copy it just made, so doubling a bit
    doubled nothing. SHL does it in one instruction, because A and B are
    one register to it.
  - There is no move from A to B. INB reads a port straight into B, which
    is what the drive count comparison wanted.

run.sh takes more than one image now, separated by a plus, since the
machine has four drives and a test that could only name one could not
check any of this.

The buffer note is forgotten on a drive change and that is DELIBERATELY
kept although nothing can currently reach it: only the file read-ahead
consults it, a directory scan does not, and finding a file requires a
scan which overwrites the note on the way past. Two disks were built with
the same file at the same block to try to catch it and the answer was
right either way. Three instructions to hold an invariant rather than a
story about a bug - and the comment says so instead of claiming a fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-31 12:30:09 -04:00
AnachronautandClaude Opus 5 b1538e0618 Give the disk four drives, behind one controller
SEVERAL DISKS ARE ONE CONTROLLER AND NOT SEVERAL DEVICES, and the
instruction set decided that rather than taste. A port is an immediate byte
inside the OUT that names it - portOut takes it from Program Memory - so a
program cannot compute one. "The disk on port 0x20 plus drive times four"
is not something this machine can say, and two disks as two devices would
mean a branch on the drive number in all eleven places sbfs.asm names a
disk port. A drive register is what a floppy controller has always been.

  0x24  Drive, which the block, command and status registers refer to
  0x25  Drives, read only: how many are plugged in

--disk given more than once fills them in order. What is per drive is the
image, its size and its write protection; the block register, the status
and the one buffer belong to the controller, which is the same division
real hardware makes.

A drive that is not there is refused rather than wrapped, because wrapping
means a program asking for a drive this machine has not got quietly reading
the one it has - the same shape of fault as taking a bank number somebody
else was using. An EMPTY drive is a different thing and is selectable: a
controller has its drives whether or not there are disks in them, and
reading one fails with the error bit the way an empty drive should.

Changing drives finishes whatever the one being left was in the middle of.
A transfer waits for the clock, so one may be owed at any moment, and
running it against the disk that is arriving would be a fault with no
owner.

Also stops parseOptions setting its defaults field by field. It was nine
assignments beside a struct, and a list beside a thing drifts from the
thing: adding two fields left them holding whatever was on the stack, so a
machine given one disk was told it already had four drives. It is one
zeroing now, and a default that is not nought can be written under it where
it reads as the exception. That struct growing a field once before left
Voyager linked against an object that disagreed about its size.

Nothing in CosmOS uses any of this yet. The mount record is next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-31 10:10:38 -04:00
AnachronautandClaude Opus 5 3b650cabcd Grid took the disk's bank number, and gave the screen back untidy
Found by playing with it: after running Grid, the shell could not start
anything by name and dir said the disk was empty. Several commands after
the program that did it had exited, and nothing had said a word.

BANK NUMBERS ARE ONE NAMESPACE FOR THE WHOLE MACHINE. Grid registered video
memory as bank 3, which is the number CosmOS gives the disk's buffer when
it mounts - and that does not fail, it succeeds. Every read the filesystem
made afterwards came out of video memory. Grid uses 4 now, and the CosmOS
README has a table of who owns what, because the one place this was written
down was a line in a service description about sbfsMount.

Nothing hands bank numbers out and nothing refuses one that is taken. If
programs start wanting banks routinely, a service that allocates them is
what should exist rather than a longer table - noted there rather than
built, since one program wanting one bank is not yet a system.

Also puts the cursor home on the way out. The map was emptied and the
console was not told, so the shell carried on writing from wherever the
cursor had been standing when Grid started - twelve rows down a screen with
nothing on it. Clearing is what homes a cursor and it costs one write.

The regression test runs a program by name, then Grid, then the same
program again; the second one is the check. Putting Grid back on bank 3
fails it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-30 21:14:07 -04:00
AnachronautandClaude Opus 5 1aa45fcfc4 Grid: fill the map, not the window
Scrolling sideways walked off the end of what the program had filled, so
the grid went blank for six seconds and then came round again. A map row
holds 128 cells - 256 bytes at two a cell, whatever mode the screen is in -
and an eighty column screen shows eighty of them, so 48 were empty.

This is the third thing this loop has counted and the first right one. It
said forty, which filled half the screen. Then it asked the screen how wide
it was, which fixed what could be seen and was still wrong. ASKING THE
SCREEN IS RIGHT FOR FILLING A SCREEN AND WRONG FOR FILLING A MAP: a program
writing one screenful wants the window, and a program that scrolls wants
everything the window can be moved over. There is no register for that
because it is a property of video memory rather than of the display.

The check that should have caught it did not, and that is the more useful
half. periodic.py looked at 32 pixels - four cells at the left edge - so it
could not see a gap that was on the right, and at the cycle count it samples
the origin had moved to column 22 and the gap was off in the middle
distance. It reads the whole scanline now and says which column the picture
stops repeating at, which is how the two failures above were told apart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-30 19:00:28 -04:00
AnachronautandClaude Opus 5 bcd42e75ca Scroll the screen sideways, and by less than a cell
The screen could move one way, a cell at a time. Three registers were
missing and this adds them: a column origin so the map can be wider than
the screen as well as taller, and a pixel remainder for each axis so the
step can be one pixel rather than eight.

  0x36  Scroll column, in cells, wrapping at 128
  0x37  Fine X, 0 to 7 pixels
  0x38  Fine Y, 0 to 7 pixels

FINE DOES NOT CARRY INTO COARSE. Writing 8 to a fine register writes 0,
because only its low three bits mean anything. The alternative was for a
write of 8 to step the coarse register, and it was rejected for one reason:
a program that scrolls has to know where it has got to, and if the hardware
carries then the only way to find out is to read the register back. Keeping
them apart means the program already knows, because it did the arithmetic
itself. It is also what the machines this one is pretending to be did.

The renderer now draws one more row and one more column than fit and clips
them, because with a fine offset the screen no longer begins on a cell
boundary and the cells at two edges are partly off it.

videoPutCell follows the column origin as it has always followed the row -
a caller means a cell of the SCREEN, and the screen is a window onto the
map. The fine offsets are deliberately not applied there: they move the
finished picture by less than a cell, and there is no such thing as less
than a cell to write into. So a program may scroll to any pixel without the
console's idea of where row three, column five is moving underneath it.

Grid now scrolls diagonally, a pixel a frame, in four port writes and two
carries. It moved eight pixels every fourth frame before, which reads as
the picture jumping rather than travelling.

Seven checks, each one the same program with one register changed, so what
is compared is where the picture stopped. Breaking fine X, fine Y, the
column origin, the three-bit mask, or the console's use of the origin each
fails exactly one of them.

Grid's own two checks had to be rewritten, and the reason is worth keeping:
they asked whether pixel 4 was a grid line, which was really a check that
the scroll happened to be at a cell boundary. A picture that moves a pixel
a frame can only be asked things that are true at every offset - that it
repeats every eight pixels, and that one band of eight rows holds different
colours from the next.

Also repairs docs.sh, which found the minimal CosmOS application by taking
the first asm block in the README. Documenting a program with an example
above it made that a different block, and the check complained that the
minimal application had no #Base about something that never claimed to be
one. It looks under System Services now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-30 18:52:15 -04:00
AnachronautandClaude Opus 5 848103f5e4 Grid: ask the screen how wide it is
It said forty and filled exactly half of an eighty column screen.

CosmOS asks for the wide mode when it starts, because its own help text is
seventy-four characters across. So a program that assumes the shape the
MACHINE wakes up in is wrong about the shape the SYSTEM is running in - and
the Programming Manual says as much where it describes the columns and rows
registers: how big the screen is, is asked for rather than assumed.

Port 0x32 says. One instruction, and the program now fills whatever it is
given.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-30 17:46:49 -04:00
AnachronautandClaude Opus 5 1a8a5efe03 Grid: the first program to use the screen as a screen
Everything drawn on this machine so far has been text or a bitmap. The tile
engine has been there since the screen was built and only the console had
touched it, and only ever to put a letter in a cell - the one thing it can
do that a plain character display could do too.

Grid redefines a tile, fills all 128 map rows with it, and scrolls by
writing ONE BYTE A FRAME. Nothing moves. The rows above and below the
screen are already drawn, so a screenful of movement costs one OUTA and the
rows that leave the top are still there.

Its tile goes at 200 because the machine wakes with the font in tile memory
- glyph n at tile n, for 135 of the 256 - so a program starting at zero
paints over the alphabet and the shell it is about to hand the machine back
to. Its sixteen colour bands are one tile and not sixteen: the attribute
nibble is added to every index in a cell, so the same 64 bytes come out in
sixteen colourings.

Three things it cost, all of them the same lesson about this machine:

  - "SETD.0 X" then "STD.0.1" stores through DP1, which had not been set
    yet. It assembles, and the blit then reads its 64 bytes from wherever
    DP1 was last left, so the tile came out as noise.
  - The palette entry for scheme n is at 0xFC00 + 64n, which reaches
    0xFFC0 - four pages, not one. And doubling A by adding B needs B to
    hold A, which RSTB is the opposite of. Both went away by writing all
    256 entries in order and letting the controller step the address, so
    nothing computes an address at all.
  - The screen it hands back had the right cells and the wrong colours,
    because restoring the map is not restoring the palette.

That last one is a gap in the machine rather than in this program, and is
written up in the CosmOS README. The console's colours live at exactly the
entries the attribute nibble lands on, so any program using the nibble
overwrites them and has nowhere else to write. Grid puts bank 0 back - grey
on black - and leaves the other fifteen. The real answer is a command to
the screen meaning "give me back what you woke up with", the way the
console has one for clearing. There is not one, and this is the first
program that ever wanted it.

Two checks in video.sh, which boots the whole system and reads the pixels
the renderer produced rather than trusting what the program believed.
Breaking the tile fails one and breaking the attribute fails the other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-30 17:38:30 -04:00
AnachronautandClaude Opus 5 0e0731e2b1 Put the cursor home when the screen is cleared
A screen with nothing on it and a cursor half way down it is not a cleared
screen: the next thing written lands where the last thing happened to leave
off, at a position whose meaning was just erased. The shell's clear did
exactly that, and left the next line one row below wherever it had been.

Both halves were missing. consoleClearScreen blanks the cells and does not
touch cursorRow or cursorColumn, and 2J on a terminal empties the screen
without moving anything - H is what puts the cursor at the top.

Life and Snake never showed this because they follow their clear with an
explicit 1;1H of their own. They were working around it, which is why the
bug survived until a command cleared the screen and then let somebody type.

The attribute is deliberately not reset. Clearing is about what is on the
screen rather than how the next thing will be drawn, and a program that
chose a colour and then cleared still wants that colour - which is what a
terminal does too.

Two checks in video.sh, and they took two goes to make independent. The
first pointed at the row where the letter lands when the cursor is NOT
homed, so removing the clear and removing the homing failed the same pair
and neither said which. The second now looks at a row nothing writes to
either way - and at a letter whose ink actually reaches the pixel it reads,
which "two" did not, so it passed on a screen that had never been cleared.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-30 17:08:27 -04:00
AnachronautandClaude Opus 5 553882d28d Start CosmOS with a script, and let a script hold its tongue
Three things scripts wanted, and they are one thing: a machine that can
have a face.

/System/Boot/startup.sh runs before anybody can type. Every way of reaching
the prompt for the first time goes through it, including the one where
there is no disk - in which case there is nothing to find and nothing is
said. A MISSING one is ordinary and silent, because a clean install has
none and a machine that complained every boot about a file nobody wrote
would be teaching its owner to ignore it. One that is THERE and does not
begin with #! is the other case entirely: somebody meant that to run.

#quiet stops each line being echoed, #loud puts it back. The prompt and the
echo go together, because together they are what makes a script look like
typing, so a quiet script gets neither and what it prints is all that
appears. A nested script inherits quiet - a build that asked for it meant
its helpers too - and gets its own setting back when the helper returns.
Anything else beginning with # is handed to the shell, which does not know
it and stops the script, because a script that asked for something this
shell cannot do should not carry on as though it had been given it.

clear empties the screen, which the console has been able to do since
before there was a screen to do it on.

THE PROMPT IS NOW SAID BY WHOEVER SUPPLIES THE LINE. It used to be said at
the top of the loop, which is a decision made before the line is read and
an answer not known until after - and it was wrong at both ends. #quiet is
itself a line, so its prompt went out before anything knew to stay silent;
and the line after a quiet script's last one comes from the console, having
already been denied one. Off by exactly one line in opposite directions. A
first attempt at this remembered whether the prompt had been skipped, which
worked and was a flag standing in for a structure. The monitor's assembler
prints a prompt of its own, so it reads through shellReadRaw, which is the
same source without one.

One admission. Handing the console its prompt back when a quiet script
ended was a real fix when I wrote it and stopped being one an hour later,
because the restructure above means the console's own path prompts whatever
the flag holds. The comment claimed it fixed something. Breaking it on
purpose changed nothing, which is how that was found, and it is now a
comment saying so instead of a line pretending to work.

The startup fixture ends QUIET on purpose: nothing puts the flag back when
the outermost script finishes, so a script ending #loud would have tested
the easy half.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-30 16:59:23 -04:00
AnachronautandClaude Opus 5 c28826df77 Let a script run a script, four deep
A build script calling a setup script is the first thing anybody tries.

What is saved when one script starts another is A POSITION AND NOT A
BUFFER: the name, which block comes next, how many are left, and where in
the block it had got to. Seventy bytes, and they sit next to each other in
the data segment on purpose so that saving them is one copy. The block
itself is read again on the way back, which costs one disk read per return
and saves 257 bytes a level - the inner script reads its own block into the
single buffer there is, so coming back means fetching the outer one's block
again and landing on the byte it left.

The slot is reached by stepping rather than by multiplying, because this
machine has no multiply and the depth is never more than three steps.

Four levels. Deep enough for a script calling a script that calls a helper,
shallow enough that a script running itself says so rather than filling
memory. A line that fails now stops every level and not just the innermost,
because a build whose helper failed should not carry on in its caller.

The caller's place is saved BEFORE the new file is looked at, and put back
on every way out that is not success. Opening writes the name into the live
state in order to ask the disk about it, so by the time "there is no such
file" is known, the caller's place has already been overwritten - a failed
'do' inside a script would otherwise leave the script that ran it reading
from a name it never chose.

The test resumes in the outer script's SECOND block, which is the case the
whole design turns on and the one an ordinary nesting test would miss.
Breaking the re-read, the save, or the limit each fails it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-30 16:22:09 -04:00
AnachronautandClaude Opus 5 a12d61fb80 Give the shell an echo, so a script can say what it is doing
Say.sbx has printed words since long before there were scripts, and is the
wrong shape for one. It is a program: it has to be found on the disk,
loaded and started, it prefixes what it was told with "it says:", and the
system prints "finished" after it. Three lines of noise around one line of
narration, and a load off the disk to produce them.

echo is a command, so it costs a comparison. With nothing after it, a blank
line - which is what anybody expects and is worth having to space a long
script out.

Not called "say", although that is the better word. Built-in commands are
tried before the disk and always win, so a built-in say would shadow Say.sbx
and quietly change what every existing script and test meant by it.

Also puts "do" in the help, which the commit that added it forgot, and
splits the help text: the new lines pushed it over the assembler's 255
character limit for one string. That failure was hidden for a few minutes
by a 'make' whose errors were going to /dev/null - the build kept the disk
it already had, and the machine cheerfully reported "I do not know: echo"
from a system assembled before echo existed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-30 16:07:44 -04:00
AnachronautandClaude Opus 5 2466d79d9c Let the shell run a file of commands
'do <file>' runs the lines in a file as though they had been typed. The
only thing a script changes is where the next line comes from: everything
below shellReadLine - splitting the line, matching it, loading a program -
cannot tell the difference and does not have to.

What makes a file a script is '#!' on the front of it, not its name and not
a flag in its entry. The rule this filesystem keeps is that an entry holds
only what the content cannot say about itself, and a script can say what it
is; the loader already refuses anything that is not SBEX, so the two kinds
of runnable file turn each other away without either knowing about the
other. It is also the deferred half of the file-typing design, which said
to wait for a second kind of runnable thing before building any of it. This
is that second kind.

'#' is a directive and ';' is a comment, as in SplitBit assembly - one rule
across the machine rather than two dialects. Not Unix's convention: there
'#!' really is a comment that only the kernel reads, while here the shell
requires it and refuses the file without it, so calling it a comment would
be a lie about what it does.

A script stops at the first line that does not work, which is what the
LineFailed groundwork was for. Comments and blank lines are dropped by the
reader rather than by the dispatch, so they are not echoed either. A script
running out hands back to the console rather than ending the shell, because
running out of file and running out of typing are not the same thing. The
interactive assembler reads through the same path, so a script can contain
a block of assembly.

Three things this cost that were not obvious:

  - RET puts A and B back, so a routine cannot answer in them. scriptByte
    returning the character in A assembled, ran, and handed the caller its
    own A back every time. It answers in memory now.
  - A last line with no newline is still a line. Text files do not reliably
    end with one and an editor eating it is a bad way to find out a command
    did not run.
  - Not LastStatus. See the commit before this one.

Six checks in three tests, two of which are about byte positions rather
than behaviour - a command lying across the boundary between two blocks,
and that missing newline - so their fixtures are generated rather than
committed, where an editor cannot helpfully repair them.

Nesting is not in yet: a script cannot run a script. That wants a stack of
positions rather than the one the reader keeps.

Also derives native.sh's self-hosting source list from cosmos.asm's own
#Include lines. It was a hand written list and went stale the moment
script.asm existed - the fourth time a list beside a thing has drifted from
the thing - so it now asks the thing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-30 15:50:26 -04:00
AnachronautandClaude Opus 5 fc56e815fc Have the shell remember whether a line worked
Groundwork for scripts, and invisible until there is something to read it:
the suite passes unchanged, which is the point of doing it on its own.

A script has to decide whether to run the next line, and nothing in the
shell knew whether the last one worked. LineFailed is cleared as each line
is read and set by the fourteen paths that fail.

Cleared at the start rather than set at the end, because there are thirty
seven ways back to the prompt and only fourteen are failures - and the
twenty three successes would have to be found again every time a command
grew a new way to finish. A command that says nothing worked. Twelve of the
fourteen already funnelled through fileComplain, so this is fourteen lines
rather than the refactor it looked like.

It is deliberately NOT LastStatus, which was the obvious place and is
wrong. That one is a program's own answer, reported by the status command
and recorded by two tests; clearing it as each line began wiped the answer
before the command that reports it could read its own line. The tests said
so immediately. Two questions, two bytes - and a program exiting non-zero
now sets both, because a program answering "no" is one of the ways a line
can fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-30 15:38:02 -04:00
AnachronautandClaude Opus 5 e52d8d84f8 Collapse the two makefiles into one
Programs/makefile is gone and everything it did is here. The split was not
paying for itself: nothing at this level ever ran that file, so it rotted,
and all three bugs found in it this week share that one cause. It named two
source files that had been renamed months earlier and failed outright. The
disk did not depend on the tree it mirrors, so a new file silently was not
on it. And the disk was in no default target, so 'make clean' threw it away
and 'make' did not bring it back - which is what prompted this.

The platform and the system stay separate, as TARGETS rather than as files:
'make SplitBit Assembler' builds the machine and its tools, 'make cosmos'
and 'make disk' build the system, and somebody who wants to write their own
system can ignore the second group entirely. That boundary is now one make
enforces, which the directory boundary never did - Tests/makedisks.sh has
always reached across it to build its own fixtures.

'make' now builds a bootable disk as well as the tools, because a machine
with nothing in the drive does not do anything and the first thing anybody
wants after building this is to watch CosmOS come up. It costs half a
second: the whole system, twenty six apps and the native assembler assemble
in less time than the emulator links.

Two things the merge needed that the split did not. Assembling now takes an
order-only dependency on the assembler, which came free when you had already
built the tools before changing directory. And the mirror is told which
directory to walk: it was ".", meaning Programs/, and left alone it would
have meant the whole repository - the C sources, the tests and the manuals
mirrored onto a disk for an 8-bit machine.

The disk comes out at 188 files where it was 189. The one that went is
Programs/makefile, which was itself being mirrored onto it as
/Source/makefile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-30 09:47:06 -04:00
AnachronautandClaude Opus 5 2f22807458 Give the demo a real tune: the Anachronaut Labs theme
The composition is the user's - a leitmotif they already have variants of,
written into the note table by hand. It replaces the eight note scale that
was there to prove a note could be played at all, and it is a better demo
for the obvious reason and one less obvious one: it is long enough to hear
whether the machine keeps time, which a run of eight notes is not.

Forty five notes, 567 frames, 9.45 seconds, peaking at 19,461 of 32,767.
The test budget goes to twelve million cycles, which is a duration rather
than a guess: at 16,667 cycles a frame the music is 9.45 million, so there
is room to add bars before anybody has to come back here.

Also records what borrowing the screen's frame actually costs, which came
out of writing music rather than out of theory. The frame is not too slow,
it is FIXED: every duration is a whole number of 16.67 ms, so a note worth
a third of a beat cannot be written, and the way round it is choosing a
tempo whose subdivisions land on whole frames - making the tune fit the
machine. That is the argument for the timer peripheral wanting an arbitrary
tick rather than a faster fixed one, and for the screen not being the clock:
a display refresh and a music routine have no reason to share a rate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-29 22:33:33 -04:00
AnachronautandClaude Opus 5 85329f13c3 Take a device's line down when its status port is read
A device raises a line and something has to take it down. Two things did:
being interrupted, and being woken from WAIT with the Interrupt Flag down -
the second because a masked program has nowhere to dispatch to, so nobody
else would.

There was a third way to learn a device had finished and nothing answered
it. The documented idiom reads the status, branches out if the device is
already done, and only WAITs otherwise; on a disk quick enough to finish
before the first look, which is every disk here, the WAIT is unreachable.
The line then stood for the rest of the machine's life.

The program that leaves it standing never pays for it - it was masked
throughout. The bill arrives at whoever next sets the Interrupt Flag. The
boot chain reads the disk to load a program, leaves the line up, and hands
over; the loaded program is then interrupted on behalf of a read that
finished before it existed, through a vector table with no entry for a
device it never touched, and faults on the instruction after its SIF.

Found by running Examples/tune.asm through Once. It set up its whole sound
and died four bytes before its first note, which is why it was silent
rather than wrong - and why it looked like a sound bug for a while.

So reading the port that answers a device takes its line down, the same way
taking the byte already took the console's down. Disk and screen do it on
their status port. And a reset now clears every line, which is the sentence
the manual already makes about the vector table: a handler left behind aims
an interrupt into a program that is no longer running, and so does a line.

testPrograms/diskLineTest.asm pins it - the racy idiom, then SIF with no
handler installed anywhere. It faults without the fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-29 22:15:02 -04:00
AnachronautandClaude Opus 5 62a657f1a7 Give Voyager a speaker
The device already made the samples; this takes them out of its ring and
hands them to Raylib, a sub-buffer at a time. Nothing here decides what a
sound is, the same as nothing in presentFrame decides what the screen
looks like - which is why the headless binary and this one make the same
sound, and why the suite can check a device with no speaker in it.

Asked for rather than assumed: a host with no audio device still gets a
window, because a machine worth looking at should not need one to start.

When the ring runs short the missing samples are filled by holding the
last one rather than by zeroes. It is still a glitch; a jump to silence
and back is a click, and a held level is not.

Also removes a comment that had been left in twice above the frame loop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-29 21:05:38 -04:00
AnachronautandClaude Opus 5 8366bf7721 Fix the Programs build, and make the disk depend on what it mirrors
Two faults, both introduced by 0852666 and both invisible because nothing
in the test suite runs this makefile - Tests/makedisks.sh builds its own
images.

That commit renamed 16bitSegmentedSieve.asm to 16bitSieve.asm and left
PROGRAMS naming the old file, so 'make' in Programs/ has failed outright
ever since. Which means the disk under Programs/build has been whatever it
was on that day, and 'make run-voyager' has been booting it.

The second is the same disease one layer up. The mirror exists so that
adding a file is the whole of putting it on the disk, and that only works
if adding a file also rebuilds the disk - but the prerequisites were as
hand-maintained as the list the mirror replaced. tune.asm went into
Examples, the image was not remade, and it simply was not there to
assemble on the machine. Nothing said so, which is exactly the failure a
mirror is for.

Found by looking for tune.asm on the disk and not finding it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-29 21:05:38 -04:00
AnachronautandClaude Opus 5 d388cd3122 Give the machine a sound device
Four channels on ports 0x40 to 0x4F, each one a whole soundThing voice:
two oscillators, two envelopes, a filter and the routing between them. A
channel keeps its patch between notes, so a program sets an instrument up
once and then plays it.

Six ports rather than forty, because a voice has around forty settings and
four of them would spend more than half the port space on one device.
There is a selector and a value instead: say which channel, say which
setting, write it. That is three writes to change a setting and two to
play a note, which is the right way round - patches are loaded, notes are
played in an inner loop.

Samples come from the machine's clock and not the host's: 48,000 a second
of emulated time, worked out in whole numbers so it never drifts. A
million cycles is exactly 48,000 samples on any host at any speed, which
is what makes a sound something a test can compare. --sound writes them
out, the way --screen writes a picture, for the same reason: the suite has
no speaker.

Tests/sound.sh is 22 checks and found three real defects the first time it
ran, all the same shape - a synthesizer written for a patch editor, wired
up as hardware and inheriting the editor's assumptions:

  - Only one voice had an oscillator switched on, so three of the four
    channels could not make a sound whatever was written to them.
  - That voice's oscillator arrived at full gain and every other one
    arrived at nothing, an asymmetry with no reason behind it.
  - A note with no sustain is silent but not over, so the obvious way to
    wait for a sound to finish waits for ever.

The first two are fixed by the device defining its own power-on state
rather than inheriting synthInit's: every channel arrives able to make a
sound, so writing a note number is the whole of playing a note. The third
was already written into the manual as advice, an hour before the check
existed. The check disagreed with the documentation and the check was
right; the manual now says the one rule, which is that a note sounds until
the gate is dropped.

Programs/Examples/tune.asm plays eight notes, taking its tempo from the
screen's frame interrupt because that is the only regular beat this
machine has. It spends 99.8% of its cycles asleep in WAIT.

Voyager has no speaker yet - this is the device and its tests. Playing the
samples out of the window is the next commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-29 20:59:17 -04:00
AnachronautandClaude Opus 5 b0d06aa6e5 Make the vendored synth plain ASCII
soundThing's comments use em dashes and an arrow, and this repository is
plain ASCII throughout because the tooling around it does not do Unicode.
Tests/docs.sh caught it the first time it ran against the new file, which
is what that check is for.

Comments only; nothing the compiler sees has changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-29 20:59:01 -04:00
AnachronautandClaude Opus 5 f58b0f93af Vendor the voice engine, with the level it always had and could not say
soundThing's synth.c and synth.h, and nothing else of it: the voice engine pulls in maths,
stdlib, stdint and stdio and knows nothing about Raylib, MIDI, patches or the interface, which
is what made it liftable. Copied rather than submoduled - two files against tying this build
to another repository's history is not a close call - so the header carries the whole of the
difference and is meant to stay current.

A VOICE'S LEVEL IS A ROUTING NOW. Envelope 0 was multiplied into the output unconditionally,
so routing it to a filter or an oscillator meant it shaped the volume as well whether that was
wanted or not, which is most of the trouble with making percussion. There was no flag to turn
that off and there never had been: Envelope is the only one of the four modulating things
without an active flag, where LFO, Filter and Oscillator all have one.

It could not take the filter's shape either. filterTick returning its input unchanged is the
right nothing-happens answer, but an envelope returning a constant would be useless as a mod
source - and being a mod source while NOT being the volume is exactly the thing wanted. So the
voice names the source that shapes its level, the way every other destination names its
source, and MOD_SOURCE_NONE means nothing shapes it. Two things fall out that were not
possible before: envelope 1 shaping the volume, and an LFO doing it, which is tremolo.

Envelope 0 still decides how long a voice lasts, because it still ticks. A voice with its
level unrouted will therefore cut rather than fade when the envelope reaches idle - fine or
wanted for percussion, a click if the oscillators are loud at that moment, and a one line
change to end on note-off instead if it turns out to matter.

NOISE COMES FROM A SEEDED GENERATOR, one to each thing that makes it. rand() is global state
shared with the whole process and varies between C libraries, so the same program sounded
different on different machines and no recorded result could have meant anything. One
generator each rather than one shared, because two noise sources drawing the same stream are
not two noise sources. Checked rather than assumed: two synths from the same start produce
identical samples, and 4,617 of 4,800 of them are non-zero, so it is noise and not silence.

AND A CHANNEL IS THE CHANNEL YOU ASKED FOR. synthNoteOn hunts for a free voice and steals
round-robin, which is what a keyboard wants and what the standalone synthesizer still does.
Channel two is channel two. Both old calls are untouched.

Nothing links it yet. It compiles clean and standalone under -std=c11 -pedantic, which is
what make strict already checks, and the 169 tests are unmoved because nothing calls it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-29 20:26:35 -04:00
AnachronautandClaude Opus 5 33afc20abc Give the memory controller to a bus rather than to the machine
The third and largest piece of the peripheral core, and like the two before it nothing
changes: 169 tests, and the two that would notice a misroute pass loudest of all.

A CONTROLLER IS THE MOST STATEFUL THING ON THIS MACHINE - a source bank and address, a
destination, a length, five guard registers and a table describing every bank it can reach.
Two processors sharing one would interleave into nonsense: one sets a source, the other sets
a destination, the first issues a blit and moves the wrong bytes somewhere else again. No
arbitration fixes that, because there is nothing to arbitrate - both writes were legal and
the result belongs to neither of them.

So it is a struct threaded through all seventeen functions that touch it, rather than a
pointer to a current one set on the way in. The smaller change was tempting and is the shape
that produced the stale reset flag and the editor's IsNew surviving a second run, both this
week: state that has to be set on the right path before anything reads it. A controller is
where that goes wrong quietly rather than loudly.

THE STATICS WERE DELETED RATHER THAN LEFT, which is what made this safe to do mechanically.
A missed reference is a compile error rather than a variable that still exists and serves the
wrong core, so "did I catch all 165?" stopped being a judgement and became a question the
compiler answered.

Two things the transformation nearly got wrong, both caught by reading rather than by
building. guardStart and guardEnd are fields of Bank as well as registers of the controller,
so banks[n].guardStart had to keep its name while a bare guardStart changed - the difference
between a fence and the register about to be written into one. And a definition and a call
look alike enough that the first attempt turned publishBank(number) into
publishBank(Controller *c, number); definitions start at column zero here and calls never do.

Tests/agree.sh is what says this is right, more than the count does. It builds the same disk
with SplitDisk and with CosmOS and compares byte for byte, and every filesystem operation on
the machine goes through the controller - so a blit that went to the wrong place would corrupt
a disk that is checked against one built by different code entirely. Tests/cycles.sh covers
the other half, since what a transfer costs depends on pendingCycles landing in the right one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-29 17:32:10 -04:00
AnachronautandClaude Opus 5 5be5bea994 Make the interrupt lines belong to a bus rather than to the machine
The second piece of the peripheral core, and like the first it changes nothing: one array of
bits became a struct, and the machine's own devices reach it through wrappers because every
one of them really is on this bus.

IT WAS ONE ARRAY INDEXED BY PORT FOR THE WHOLE MACHINE. With two processors that is not a
tidiness problem, it is each of them seeing the other's lines: a disk finishing would
interrupt a sound core, a sound core's tick would interrupt the shell, and both would arrive
at a handler installed for something else entirely. Hardware vectors are per port, so the
numbers would even look plausible on the way in.

Unlike the shift register, which was harmless until there was a second core to share it with,
this one was always going to be wrong the moment there was one.

Still to come on this rung: a controller each, since it holds the source, the destination, the
length and five guard registers, and two cores setting those between each other's instructions
would interleave into nonsense. Then the interleaving rule, which belongs in the manual as
machine behaviour rather than as something the emulator happens to do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-29 15:43:54 -04:00
AnachronautandClaude Opus 5 4e3258e3f7 Put the libraries on the disk, and say which include is missing
Sieve-16.asm, Life.asm and Fib-16.asm could be read on the machine and not assembled on it.
All three #Include print.asm, and print.asm was not on the disk.

/LIB WAS STILL A HAND-WRITTEN LIST. Programs/Libraries is what an #Include means when it is
not a CosmOS source - print, the integer helpers, the maths - and nobody had thought to name
them, which is the exact failure mirroring the sources was meant to end. It is mirrored now,
so the next one nobody thinks of is there anyway.

AND THE ASSEMBLER SAID NOTHING USEFUL. A missing include returned a failure and printed no
message, so the catch-all at the top said "nothing was written", which is true and useless -
finding out why meant reading the assembler rather than the message. It names the file now,
and where it looked:

  cannot find print.asm, not beside the file that asked and not as /Lib/print.asm

Both halves are worth having, because a name that is in neither place is usually one or the
other spelt wrong. The same shape as More's "error 2": a program that knows exactly what
happened, reporting a number or nothing at all.

The disk also depends on the recipe that lays it out now. It did not, so adding the libraries
left an image built without them and the next run reported the same thing missing - which
sends you looking at the change you just made rather than at the stale thing in front of you.
It cost twenty minutes to notice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-29 15:27:24 -04:00
AnachronautandClaude Opus 5 dae3455da0 Give the CPU a bus, so that there can be more than one
The first piece of the peripheral core, and it changes no behaviour: the machine still has
exactly one processor, and every one of the 169 tests still passes. What changes is that the
code has stopped assuming so.

FIVE THINGS A CPU ASKS OF THE WORLD OUTSIDE ITSELF, and every one of them was a call to a
function there was exactly one of: the port handlers, what the controller spent moving memory,
what was spent stopped waiting on a device, and the two that work the interrupt lines. Fine
for a machine with one processor and wrong for a machine with two, because A PERIPHERAL CORE'S
BUS IS ITS OWN - it sees the devices its own device gives it, raises its own lines, and stalls
on its own controller, none of which are the host's.

They are gathered into a Bus that the CPU holds a pointer to, rather than threaded through as
a bus number, because a bus is something a device PROVIDES. A device that provides one should
hand over the answers rather than be looked up by an index somebody else has to keep right.
initializeCPU puts the machine's own there, which is what every processor was on when there
could only be one, so nothing at any call site moved.

And shiftRegister is a local now. It always was one in effect - written and read inside a
single instruction and never carried to the next - but it sat at file scope, where a second
processor would have shared it. Two cores taking each other's shift halfway through an SHL is
a poor thing to discover later, and it cost two braces to make impossible.

Still to come on this rung: the interrupt bitmap, which is one file-scope array indexed by
port for the whole machine; a controller each; and the interleaving rule, which has to be
written into the manual as machine behaviour rather than left as something the emulator does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-29 15:11:54 -04:00
AnachronautandClaude Opus 5 f2e26c1852 Stop a pending reset outliving the reset it belonged to
Reset out of picture.bin and CosmOS booted and then halted at once, having been told there
was nobody at the keyboard.

The button set two things: the machine's reset request, and a flag of the window's own that
said "end the next console read, so a machine blocked on a key can get to the point where it
notices". The second was only cleared when the console actually asked - and picture.bin never
asks. It draws and halts. So the flag survived the restart and answered the NEXT machine's
first read with the end of input, which for CosmOS means stop.

There is one fact and it now lives in one place. The window asks whether a reset is still
waiting rather than remembering that it asked for one, so the read ends only while a restart
is genuinely on its way and goes back to normal the moment it has happened. The local flag is
gone.

Two pieces of state meaning one thing, one of them cleared on a path the other did not need -
which is the same shape as the console's line editing flag surviving a second run, a fortnight
ago. Worth noticing twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-29 14:59:15 -04:00
AnachronautandClaude Opus 5 adefce975b Let the reset button reach a machine that has stopped
The gesture rebooted CosmOS and could not reboot picture.bin, which is the case it was added
for. picture.asm ends in HALT, and a halted machine runs no instructions - so nothing ever
reached the code that notices a reset, because a reset is noticed BETWEEN INSTRUCTIONS and
there are none. It only ever worked because CosmOS was still going.

Which is backwards: a machine that is not going anywhere is exactly the one worth restarting,
and it is the one that cannot hear a request by itself. The restart is lifted out of the run
loop into machineTakeReset, and the window asks every frame whether the machine is running or
not.

NAMED AS EMULATOR MAGIC, because it is. There is no reset line on this machine 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. The
shape of that is already visible here: asking every frame rather than leaving it to the
machine to notice is what a line does.

A restart now clears the cycle limit as well, since a machine stopped for reaching one is
another thing somebody would press the button over.

The three existing reset tests still pass, and they are the ones that matter: they exercise
the same restart through the machine port. What no test reaches is the gesture itself, which
exists only when there is a window.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-29 14:48:31 -04:00
AnachronautandClaude Opus 5 8c6ed62044 Reset on a gesture the host has no opinion about
Control, Shift and R. It was Control, Alt and Delete, which brings up a shutdown prompt
rather than reaching the machine - and no amount of asking will change that.

IT IS A SECURE ATTENTION KEY. Every serious operating system reserves it 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; on Linux the desktop
takes it. That is not an oversight to work around, it is the same guarantee a reset button
wants being enforced one layer further down, and there is no call this program could make
that would win the argument. It is unavailable for exactly the reason it seemed right.

So the requirement list grew a third item that was not obvious until it bit: the gesture has
to be one the host has no opinion about. Control and Shift with a letter is about as free as
a combination gets - not window management, not a virtual terminal switch, and not a shortcut
any desktop claims by default. It keeps the other two properties: plain F12 and plain Delete
stay free for software, and three keys are not pressed by accident.

If some platform does send a character for it, nothing comes of that either. Whatever arrives
is in memory that is about to be thrown away.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-29 14:41:05 -04:00
AnachronautandClaude Opus 5 0afead00de Make the reset a gesture rather than a key
Control, Alt and Delete, which was F12. Two reasons, and the first one is about hardware
rather than about the emulator.

ON REAL HARDWARE THIS 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 standing in for one. So it must not be a key
software might want - a machine with a keyboard has function keys on it and something will
eventually have a use for F12, which would have meant taking it away again later, from
programs already written to expect it.

And it must not be reachable by accident. Restarting throws away everything in memory, which
is the same data loss Escape was one keypress from causing, arriving by a different door.
Three keys together are not pressed by mistake.

It has meant this since 1981, so it is also the one gesture nobody has to be told the meaning
of - and Delete stays free for software, since the gesture is all three or nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-29 11:09:41 -04:00
AnachronautandClaude Opus 5 f7657081be Put a reset button on the case, and stop Escape closing the window
ESCAPE WAS A BUG I LEFT. This machine sends Escape to the console like any other key, and
Raylib closes a window on Escape unless it is told not to - so a program reading keys could
be ended by one of them, taking whatever was in memory with it. SetExitKey(KEY_NULL), and it
is a byte again.

F12 is the reset button. A button on the case rather than a key the machine can see: nothing
sends a function key to the console, so nothing can be surprised by one. It does what writing
MACHINE_RESET does, which is that the machine starts the way it started - the boot chain runs
again and finds whatever the disk now says to run.

Which is what makes a bare metal program escapable. Once puts a demo in front of the next
start and deletes the request before jumping, so a demo that has taken the whole machine is
one keypress from the system coming back, instead of closing the window and opening it again.

IT HAD TO REACH A MACHINE THAT IS WAITING, and that took two more things. A reset is acted on
between instructions, and a machine blocked on a key is part way through one - so the button
would have set a flag that nothing ever came along to notice, in exactly the situation a
reset button is for. The wait ends now: the console is told its input is over, which it is
for a machine about to stop existing.

And the reset puts the console's input back - nothing pushed back, no line half gathered, and
not at the end of input. That was already wrong before the button existed: a reset after the
input ran out left a console that had run out afterwards, so a machine could be restarted
once and then never typed at again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-29 11:05:16 -04:00
AnachronautandClaude Opus 5 13b20c8834 Give the screen a bitmap mode
V4. Mode 2 is 320 by 200 with a byte a pixel: no tile to look up and no attribute to add,
the byte IS the palette index. Programs/Examples/picture.asm fills a whole one in 127 bytes
of program and 47,498 cycles.

IT IS THE SAME MEMORY AS THE TILES AND THE MAP, which is what shared video memory has always
been, and there is nowhere else it could be - 64,000 bytes of picture in a 65,536 byte bank
leaves room for nothing beside it. Going to bitmap mode does not clear the text screen, it
stops calling it one, and coming back finds the tiles holding whatever the picture put there.
Taking the screen means taking it.

The palette moves to 0xFC00, the top of video memory, because it is the one thing that has to
mean the same in every mode and 64,000 bytes of picture leaves nowhere in the middle for it
to hide. That is a documented address, so the example, the tests and the manual move with it.

A BITMAP HAS NO COLUMNS AND NO ROWS, and both registers read zero rather than a leftover from
the last mode. The console asks: told there is no character screen, it has nowhere to put a
glyph and draws nothing, while still saying everything down the serial line. The honest
alternative is what a machine with shared video memory really does, which is scribble marks
nobody can read across somebody's picture - honest and useless, since a program that has
taken the screen has not stopped wanting to print.

Six checks in Tests/video.sh, to 55: that the mode is 320 by 200, that a byte is one pixel's
colour and only that pixel, that printing leaves a picture alone while the letter still goes
out, and that the columns register says nought and then forty again.

The example is worth reading for one thing beyond the mode: Fill leaves its destination past
what it touched, so two hundred rows are drawn from one address set once. Working out where
row n begins would be n times 320, and this machine has no multiply.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-29 10:44:15 -04:00
AnachronautandClaude Opus 5 1174bd9af5 Give the machine a frame to wait for
V3. The screen interrupts at each frame on hardware vector 0x30, and WAIT finally has
something worth sleeping on.

THERE WAS NO CLOCK. Every program that wanted to happen at a certain speed counted
instructions and hoped, which is why Snake's pause silently halved the day a cycle stopped
being an instruction and became a memory access - the program was right and the thing it was
counting changed underneath it. A screen finishing sixty times a second is a real beat, and
it is counted in the MACHINE'S cycles rather than the host's, so the same program sees the
same number of frames in the same number of cycles however fast anything really ran. That is
what makes a frame something a test can count and a recorded result can hold.

Status bit 0 goes up when a frame has gone by and reading the status port puts it down, so a
program with no handler can watch for it instead. Control bit 0 asks to be interrupted, and
is OFF when the machine starts: an interrupt with nothing installed to catch it is a fault,
so a screen that began interrupting the moment it was switched on would take down every
program written before frames existed.

More than one frame can pass between two looks, and the flag and the line are each one
thing, so several still mean one of each. A missed frame is missed.

Programs/Examples/frames.asm prints a dot a frame for a second: 1,000,324 cycles, and 996,460
of them spent asleep. That split is the thing worth seeing - a program that polled instead
would print the same sixty dots, take the same second, and spend every cycle of it on the
bus. Its header explains why waiting is not spinning and why a machine with a beat can stop
guessing at one.

Six checks in Tests/video.sh, and two of them are about the clock rather than the output,
because the output cannot tell the difference. That the machine slept through nearly all of
ten frames, and that polling three frames actually took three frames - a status flag that
stayed up once set would print exactly the same character and look perfectly correct.

Breaking the frame interrupt on purpose left a machine asleep for ever and hung the whole
suite, which is a worse way to be told than a failing check. Tests/video.sh bounds its runs
at ten seconds now, the way Tests/run.sh always has.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-29 10:24:09 -04:00
AnachronautandClaude Opus 5 3da020898c Write down what the machine costs, before it has to be worked out again
Three measurements that would otherwise be re-derived from scratch in three months, put
where a reader will meet them rather than where somebody has to know to look.

The README's cost model section says what a nominal 1 MHz actually means: about 270,000
instructions a second, since the average instruction costs 3.72 cycles. The same program
therefore 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. Beside it, the two figures that put that in proportion: the emulator runs 195
million cycles a second in --fast, and CosmOS assembling CosmOS is 654 million of them,
which is eleven minutes at a megahertz, 3.3 seconds at --fast, and six and a half seconds at
a hypothetical 100 MHz. That last is pessimistic rather than hopeful: it assumes hardware
overlaps nothing, which is the same conservative reading the controller's cost model takes.

The CosmOS README gets what that means for its assembler, which is superlinear in what it
reads: 1,383 cycles a byte for a four kilobyte source, 6,290 for a hundred kilobyte one.
IT IS NOT THE DISK, and the number that says so is worth keeping - 654 million on a disk
carrying the whole source tree against 653 million on a flat one with a sixth as many files.
The suspected cause is walking a table of some nine hundred labels once per reference,
suspected rather than measured.

Nothing is being done about it, and the reason is written down too: development happens with
the host assembler. But faster hardware buys a constant factor and does not change the shape
of the curve, so the program that forces this is not CosmOS - it is the first one twice its
size.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-29 10:10:12 -04:00
AnachronautandClaude Opus 5 5732a31b2e Stop the allocator starting the directory again for every file in its way
Placing one file on a disk with the source tree on it cost 9.4 million cycles. It costs 1.4
million now, and assembling colours.asm went from 13.9 to 6.0 seconds.

sbfsAllocate gave up the moment it found anything in the candidate's way: it moved the
candidate past that one entry and STARTED THE DIRECTORY AGAIN FROM THE FIRST BLOCK. With
files laid down one after another that is a restart per file, and every restart reads
directory blocks off the disk until it reaches the next thing in the way - which is further
in each time. Placing one file among 183 of them cost thousands of block reads.

The candidate moves along DURING the pass now, and the pass carries on from where it is, so
entries later in the directory are tested against where the candidate has got to. On a disk
that has been appended to - which is what a disk mostly is - one pass walks it past
everything and a second confirms nothing is left. Two passes rather than one per file.

IT IS STILL FIRST FIT, and Tests/agree.sh is what says so: the machine and SplitDisk build
the same tree and the images still match byte for byte, which they could not if allocation
had started choosing differently. The argument is that the candidate only ever moves past
something that genuinely overlaps it, and when it does there is nowhere below to go - the
entry in the way covers everything up to its end and begins before the candidate ends.

The first attempt at this was slower than what it replaced, by three times. It finished the
pass and jumped to the FURTHEST overlap, which sounds better and is worse: with files laid
contiguously only one entry ever overlaps, so the old early exit was the fast path and
reading the whole directory to find the one thing was pure loss. The number of passes was
never the thing to fix - restarting them was.

The boot slot in the test fixtures goes from 32 blocks to 40, which is what a shipped disk
has. Stage two is 8,231 bytes and 32 blocks is 8,192: a fixture tighter than the thing it
stands in for fails on a change the real disk would have taken, and says "the boot slot is
too small" rather than what actually grew.

WHAT THIS DOES NOT FIX is assembling CosmOS, and that is worth saying plainly. It takes 654
million cycles on the mirrored disk and 653 million on a flat test disk with a sixth as many
files, so it is not a filesystem problem at all. Cycles per byte of source climb with the
size of it - 1,383 for colours.asm, about 3,000 for Edit.asm, 6,290 for cosmos.asm - which
says the native assembler is superlinear in what it reads. That is a separate thing to go
and look at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-29 09:54:29 -04:00
AnachronautandClaude Opus 5 7073b972e6 Say what went wrong, and give the file tools room for a path
The makefile on the disk was fine. "Makefile" is not "makefile", and SBFS names are case
sensitive - but neither tool said so, and both failed in ways that pointed somewhere else.

MORE PRINTED A NUMBER THAT MEANT NOTHING. "cannot find the file, error 2" invents a
vocabulary the system does not have: the filesystem library documents its answer as zero or
not zero, never as a code, so 2 could not be looked up anywhere. It says "there is no file by
that name" now, which is the only way opening fails that a person can do anything about, and
is nearly always a name typed slightly wrong.

EDIT SAID "0 LINES", which is also what an empty file that IS on the disk says. A name typed
slightly wrong therefore looked exactly like the document you meant to open, right up until
you saved it somewhere new. It says "new file" instead.

Two bugs came out of writing that, and both are worth more than the feature.

The first is mine and the label lied to me: loadNothing is not where a load FAILS, it is
where every load FINISHES, reached at the end of splitLast on files that opened perfectly
well. A flag set there on the strength of the name was set on everything. It is called
loadDone now, and the failure has its own name.

The second is older and general: a program is loaded once and may be run many times, so
"load Edit.sbx" then "run" twice is two sessions over one copy of the Data Segment. Anything
a session changes has to be put back by the session. A zero written in the Data Segment is
the state a program starts in the first time and never again - and cosmosEdit runs Edit twice
from one load, which is why it caught it immediately.

AND THE FILE TOOLS COULD NOT ADDRESS THE TREE THEY NOW HAVE. Edit took 23 characters of name
and More and Type took 29, which were right when everything lived in the root. With the
sources mirrored onto the disk, "/Source/CosmOS/Assembler/classify.asm" is an ordinary thing
to type - thirty-seven characters, cut down to a name meaning something else, or nothing. All
of them take sixty-three now, which is what the shell reads of a command line, so nothing
longer can arrive. Wander with it, since a directory is a path too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-29 09:24:10 -04:00
AnachronautandClaude Opus 5 0852666e73 Mirror the source tree onto the system disk
A list of files in a makefile goes stale the moment somebody adds a program and forgets to
name it, and what they forgot is invisible until they go looking for it on the machine. So
SplitDisk gained a mirror command and the disk rule is one line: putting a file where the
others live is now the whole of putting it on the disk.

EVERY FILE GOES THROUGH put AND EVERY DIRECTORY THROUGH mkdir. That is the point of it -
mirror adds a walk and no filesystem code at all, so anything the format refuses here it
refuses everywhere, in the same words. What is new is the walk, and the walk is what the
six checks in Tests/disk.sh are about: that it goes all the way down, that it leaves dotfiles
and named directories behind, and that a name too long stops it.

REFUSED RATHER THAN SKIPPED, because a disk quietly missing a file is the exact failure a
mirror exists to prevent. Which meant four sources had to be renamed - a directory entry
holds 22 characters and they were 23, 23, 24 and 29:

  16bitSegmentedSieve.asm        -> 16bitSieve.asm
  16bitSegmentedSieveModern.asm  -> 16bitSieveModern.asm
  consoleInterruptTest.asm       -> consoleInterrupt.asm
  controllerWriteTest.asm        -> controllerWrite.asm

The test names in the manifest are unchanged, since those are identifiers and every recorded
result is filed under them. Only where the source lives has moved.

The entries are sorted before anything is written. readdir hands them back in whatever order
the host filesystem feels like, and a disk image that comes out different from one run to the
next is an image no test could compare against another.

The disk grew from one megabyte to four and from 192 directory entries to 1,024. The sources
are 2,850 blocks and the mirror filled the old directory on its first run, which is a thing
that should not need thinking about again.

The Tests fixture disk is deliberately NOT mirrored. It is a controlled fixture with known
contents, and the shipped disk is the one meant to be useful; they want different things.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-29 08:45:46 -04:00
AnachronautandClaude Opus 5 ff4b025058 Make the cursor blink while the machine is waiting, and show how the palette works
THE CURSOR DID NOT BLINK, and the reason is worth stating: it blinks on the machine's clock,
and the machine's clock had stopped. A console waiting on a key stops the CPU, so no cycles
passed, so the phase never moved - and the one moment somebody is looking at a cursor is the
moment they are being asked to type.

Waiting is now charged as IDLE CYCLES, which is what they were built for: a machine stopped
on a device is not using memory, the same distinction WAIT makes, arrived at from the other
direction. And the devices are told as it happens rather than when the instruction finally
finishes, because a display controller does not stop blinking because the processor is
waiting on a keyboard, any more than a disk stops turning.

A keyboard file can now say NOTHING happened. A zero is a byte no keyboard sends, so it is
free to mean "a moment went by with nobody typing" - which is the commonest thing behind a
window and the only thing a file otherwise could not express. That unlocked the whole waiting
path: three checks that the cursor is lit, then dark half a second later, then lit again,
which is what blinking is.

And Programs/Examples/colours.asm, because the palette had nowhere a newcomer could read it.
It prints the sixteen pairs, prints each one again turned inside out, and then CHANGES ONE by
writing three bytes into the palette - so the difference between using the colours a machine
wakes up with and choosing your own is visible in one program. Its header explains what a
cell is, what the attribute nibble does, why palette entries are four bytes rather than
three, and why video memory has to be reached through the controller.

The manual now says where the palette lives and points at it.

SplitLint found a redundant RSTA in the example, which was worth acting on rather than
suppressing: the zero was already in A from the mode write two lines up, and saying so in a
comment teaches that SETD does not touch A, which is a thing worth knowing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-29 08:26:15 -04:00
AnachronautandClaude Opus 5 d6feddd1b6 Give the console colour and a cursor
COLOUR COSTS A NIBBLE AND NO HARDWARE. A glyph is drawn in palette indices 0 and 1, paper
and ink, and a cell's attribute nibble adds sixteen to both - so sixteen banks is already
sixteen ink and paper pairs, and all that was missing was a register saying which one the
console draws in. That is port 0x06, read as well as written like the rest.

The palette a machine wakes up with is arranged so that HIGHLIGHTING IS ONE BIT: banks 0 to
7 are colours on black, banks 8 to 15 are the same colours as paper with black ink. So
attribute XOR 8 turns any pair inside out. That is a convention rather than a rule of the
machine - the device only ever adds the nibble and looks the answer up - but it is the
convention that makes a highlighted line and a cursor free.

Bank 0 is still grey on black, so nothing that was written before this has changed colour.

THE CURSOR IS THE SAME BIT AGAIN. It is drawn by turning its cell inside out rather than by
putting a block over it, so the character underneath stays readable, which matters to
somebody editing a line. The device draws it rather than the window, because on a machine
with a screen a cursor is a hardware feature - one drawn by the presenter would not be in a
picture the machine saved.

It blinks on the machine's own clock, half a second each way, so the phase is a pure
function of the cycle count and a screen saved at a given cycle is the same screen every
time. A blink on the host's clock would have made every saved picture a matter of luck.

Off unless asked for, with bit 2 of the control port. That is right for a machine - a
program painting its own screen does not want something blinking in the middle of it - and
CosmOS asks for one at boot. It also asks again when it takes the console back from a
program that has stopped, because a program handing key mode back the way it was told to
writes zero, which turns the cursor off. The shell owns the prompt, so the shell is what
makes sure there is something blinking at it.

Nine more checks in Tests/video.sh, to 41: that the attribute colours the ink and not the
paper, that XOR 8 turns both, that it reads back, that a cursor appears where the registers
put it and only when asked for, and that it goes dark again half a million cycles later.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-29 08:13:28 -04:00
AnachronautandClaude Opus 5 978aec4809 Let the console edit a line, and let a file be a keyboard
BACKSPACE REACHED THE SHELL. 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 at
Return. CosmOS has always relied on that, and behind a window there is no terminal to do it,
so the raw 0x08 went into the command buffer. Correcting a typo produced a line that looked
perfectly right on the screen and matched no command at all - "I do not know: help".

So the console does it, because behind a window the console IS the terminal. In key mode it
does not, and must not: a program in key mode asked for every keystroke as it happens.

CosmOS now asks for eighty columns at boot. Its own help text is seventy-four characters
wide, and dir, the monitor and the assembler's messages all assume room. The machine still
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, and a game that wants forty columns says so.

AND A FILE CAN BE A KEYBOARD, which is the part that matters beyond today. The console
behind a window is not the console behind a terminal, and until now the difference was
unreachable: it broke twice in two days and a person typing found it both times. --keyboard
installs the same hook a window does, so the same path runs, and the manifest has a column
for it. cosmosTyped types "halp", backs over it, arrives at "help", and requires the help to
come out. Verified by removing the rub-out, which loses the whole help text.

It does not test the window. Voyager's key queue and everything about presenting frames are
still out of reach. It tests the console, which is where the logic is.

Along the way: VOY_OBJS was missing from the dependency include, so voyager.o never rebuilt
when a header changed. EmulatorOptions grew a field, Voyager kept an object that disagreed
about the size of the struct, and smashed its stack on every run. A clean build hides it and
'make sanitize' cleans first, so that would never have found it either. Tests/voyager.sh did,
by failing all 115 tests that start the machine - which is the differential test earning its
keep on a bug that has nothing to do with what it was built to check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-28 23:39:35 -04:00
AnachronautandClaude Opus 5 761c11a66b Add run-voyager, and make running depend on the disk
The jitter was a stale disk. An image built before the escape sequences came out still had
the old Snake on it, which sends ESC [ H every frame: the console swallows the escape,
because it is below the font's first character, and then draws "[H" as two ordinary letters.
So every frame began two characters to the right and one line further down than the last,
and the board walked down the screen. Not timing at all.

WHAT IS ON A DISK IS WHATEVER WAS BUILT WHEN THE DISK WAS MADE, and a machine whose console
has changed will start that image quite happily. That is the compatibility break we chose
when the parser came out, and it is fine - but it should not be a puzzle, so both run
targets depend on the disk rather than merely using it, and both READMEs say why.

run-voyager boots the same disk on the machine with a screen. It existed only as
EMU=../Voyager in front of run-cosmos, which is not a thing anybody should have to know.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-28 23:25:18 -04:00
AnachronautandClaude Opus 5 310804e267 Give Snake back the speed its comment promised, and present in step with the display
Two things, one certain and one likely.

THE CERTAIN ONE IS NOT THE WINDOW'S FAULT. Snake's pause loop said "at the emulated rate
this is about an eighth of a second", and it was, when a cycle was one instruction. A cycle
became one memory access, every loop in the machine got dearer, and this one silently
doubled: the game has been running at half the speed it documents ever since, in a terminal
as much as in a window. Measured rather than guessed - the inner loop is a DECA and a BNA,
one byte and three, so four cycles a turn, and a whole run went from 3,848,610 cycles to
1,920,504 when the outer count came down from 256 to 122. Almost exactly half, which is what
the arithmetic said it would be.

That is the cost model change reaching a program nobody thought to re-measure. Worth looking
for others: any loop tuned by eye before that change is running at half its intended speed.

THE LIKELY ONE is the frame limiter. Without the vsync hint, Raylib sleeps towards sixty
frames a second on its own clock, which beats against a display refreshing on its own -
frames shown twice or skipped, and the machine handed an uneven number of cycles each time,
since it takes its budget from the wall clock. The hint puts presentation in step with the
screen. SetTargetFPS stays for a driver that ignores it.

Snake is one byte bigger, because RSTB became INIB.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-28 23:19:53 -04:00
AnachronautandClaude Opus 5 43a05b3df1 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
2026-08-28 23:08:52 -04:00
AnachronautandClaude Opus 5 bdb2d0d8e6 Keep a key until it is asked for
Input worked at the shell and not in Snake, and that split is the whole diagnosis: the shell
blocks on a read, Snake polls the READY bit. Only the polling path was broken.

RAYLIB CLEARS ITS CHARACTER QUEUE ON EVERY POLL, and a poll happens inside EndDrawing, so a
key survives exactly one frame unless something takes it in that frame. The window presents
sixty times a second and Snake looks about ten, so five keys in six were thrown away by the
next present before the game ever glanced at them. A blocking read presented and then looked
immediately, which is why the shell never noticed.

The window now keeps its own queue, drained from Raylib at every present and emptied only
when the console actually takes a byte. That is what this machine already promises about its
console, and Snake's own comment is the specification: "the console keeps the next key until
it is asked for, so a key pressed while the snake was moving is still there next frame". The
hook was not honouring it.

Asking the queue first also makes the two questions the same question. A poll takes whatever
is waiting and returns at once, and a blocking read takes whatever is waiting, then presents
a frame and looks again - so neither path can see a key the other would have missed.

The queue drops its oldest when it fills, so somebody leaning on the keyboard while a program
ignores it cannot push out what they typed most recently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-28 22:50:20 -04:00
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
AnachronautandClaude Opus 5 6f8ad42277 Fill the window, let it be resized, and make black black
Three things a person looking at a real screen found in five minutes, none of which the
headless tests could have seen.

THE PICTURE FILLED A QUARTER OF THE WINDOW. The window opened at the largest screen the
device can make, doubled, and then drew a 320 by 200 mode at that same doubling - so three
quarters of it was bezel. It now takes the largest whole-number scale that fits: the two
modes are exactly a factor of two apart and the window opens at twice the larger, so both
fill it exactly, at four and at two. Changing mode changes how sharp the screen is rather
than how big it is.

Whole numbers on purpose. A 320 by 200 picture stretched by 2.7 has some rows twice as tall
as their neighbours, which on eight pixel glyphs is the difference between text and mush.

THE WINDOW WOULD NOT RESIZE. It does now, and the picture rescales to whatever it becomes,
still in whole pixels and still centred. How big somebody wants a screen is not the
machine's business.

AND BLACK WAS NOT BLACK. Both the paper and the bezel were tinted towards green, on the
theory that a phosphor never was neutral. On a real screen that reads as a fault rather
than as character: a background that is nearly black looks like a background that failed to
be black. Paper is black now and ink is a neutral grey, because a default should be the
unsurprising thing - anything with a point of view about colour is 254 palette entries away
and belongs to a program. The bezel is a clearly lighter grey, so what is left over when
the window's shape does not match the picture's looks like a bezel rather than like more
screen.

The two checks that name the waking colours name the new ones. That they had to change is
the check working: they say what they depend on rather than assuming it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-28 22:32:27 -04:00
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
AnachronautandClaude Opus 5 83623a3df3 Give the Voyager a screen
A tile engine on ports 0x30 to 0x3F, bringing one bank of video memory registered the way
the disk's buffer is. The CPU writes cell indices and the device turns them into pixels,
which is the whole reason a screen is affordable at a megahertz: a frame is 16,667 cycles,
a full 320 by 200 picture is 64,000 bytes, and a 40 by 25 map is 2,000. A program that
changes two cells writes four bytes. The cost of a screen becomes the number of cells that
changed rather than the number of pixels on it.

Which makes colour depth free, so the tiles are eight bits: an 8 by 8 cell is 64 pixels and
each picks independently out of 256 colours, with no per-cell limit of the kind that made a
Spectrum two and C64 multicolour four. The low nibble of a cell's attribute is ADDED to
every index in its tile, sixteen at a time, so a tile drawn in 0 to 15 appears in any of
sixteen schemes without a second copy in tile memory - and a tile wanting all 256 leaves the
nibble at zero and gets them. Neither use costs the other anything.

Two decisions are arithmetic rather than taste, and both come from the machine having no
multiply. A map row is a page whether the mode fills it or not, so a cell address is the row
number as the high byte and the doubled column as the low byte with no arithmetic at all;
otherwise every cursor move on a 40 column screen would cost a row-times-40 in software. And
a palette entry is four bytes rather than three, so entry n is at n times four, a shift.

THE MAP IS A RING and the Scroll register says which of its 128 rows is on top. Scrolling
moves a register and no memory: blitting a 40 by 25 screen up one line is 1,920 bytes inside
one bank, which is twelve percent of a frame even with the controller widened, and a program
printing one page would spend six frames shuffling memory. It is now one port write - and
the rows that scrolled off are still there, which is where a terminal gets scrollback it
never had.

The device is part of the machine rather than part of the window. It renders into a buffer
that is a pure function of video memory, so the same program draws the same picture with
nobody watching; Voyager puts that buffer on the glass and decides nothing. Both binaries
take --screen, which saves a PPM when the machine stops, and that is what makes a screen
checkable on a host with no display at all.

Tests/video.sh checks fourteen named behaviours rather than comparing a recorded image,
because a recorded image would say "something changed" and leave which of the palette, the
tile, the attribute, the map or the scroll register broke to be found by hand. Verified by
breaking three things in turn: the additive nibble failed exactly one check, the scroll
origin exactly two, and moving every cell one pixel sideways exactly the four about
placement.

Tests/docs.sh could not count past nine, which is how a suite of ten scripts reported
itself as wrong for the wrong reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-28 22:11:13 -04:00
AnachronautandClaude Opus 5 e3ef25e3b3 Split the machine from its front end, and add Voyager
The Segan Voyager is the same SplitBit with a screen and a speaker instead of a terminal,
and this is the rung that makes there be two of them at all. Everything that is actually
the machine - the CPU, the controller, the devices, the run loop, the reporting - moves to
machine.c, and each front end brings one file of its own. emulator.c is now sixty lines of
argument handling and a three line loop.

The machine runs in SLICES rather than to completion, because that is the cut a window
needs: run a slice, present a frame, run another. A terminal runs slices until the machine
stops. Both loops are three lines, which is why the cut is there rather than anywhere else.

At this stage Voyager's window is empty. There is no video device yet and inventing a
temporary way to draw would mean building something to throw away.

PLAIN MAKE STILL WORKS WITH NO GRAPHICS LIBRARY. Raylib is probed by compiling and linking
against it rather than by looking for a file, because a header with no library behind it
passes a file check and then fails at link time. Where it is missing, make says so once and
builds everything else - the machine, the assembler, the disk tool, the linter and the whole
suite. A project about a small understandable CPU should not need OpenGL to run its tests.
That nearly broke here: make strict globs Source/Emulator/*.c, so it would have tried to
compile voyager.c and failed on precisely the machines the split exists to support, and this
machine has Raylib so nothing would have caught it.

Tests/voyager.sh runs the WHOLE MANIFEST through Voyager and holds it to the recorded
results SplitBit is held to. Not that the two look alike: that one satisfies every recording
the other does, byte for byte, exit status included. It reuses run.sh, which now takes the
machine from SPLITBIT_EMULATOR, rather than keeping a second copy of the runner that would
drift. Voyager not being built is not a failure - it says so and passes.

Verified both ways. Made Voyager print one extra line, and 114 of 165 failed: exactly the
tests that run the emulator, with the 51 assemble-only and xfail cases correctly untouched.
Removed the binary, and the script skipped. Built with HAVE_RAYLIB=no, and everything else
still built and checked clean.

--headless is taken out of the arguments in voyager.c rather than in the shared parser,
which should not learn about a window only one binary has. It exists so the suite can run
this binary at all: a front end that could only be exercised by a person looking at it would
be a front end nothing checks.

loadFile takes a const char * now, which it always should have.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-28 21:57:09 -04:00
AnachronautandClaude Opus 5 4c3eac8d9c Widen the memory controller's path to sixteen bits
The controller now reaches bank memory two bytes at a time, so a transfer whose source,
destination and length are all even moves two bytes a cycle between banks and one within
a bank - twice what each was. A 256 byte block between banks falls from 257 cycles to 129.

Alignment is required all three ways because a word is read at an even address and written
at an even address; an odd anything would mean shifting bytes across word boundaries to
line them up, which is a different design. A misaligned transfer falls back to the byte a
cycle it cost before, so nothing already written got slower.

THE CPU DOES NOT CHANGE. It still sees eight bits, a Data Pointer still addresses a byte,
and no instruction means anything different. This is a peripheral getting faster, which is
why it is worth doing now rather than after more is built on top of it.

The rule is deliberately visible rather than smoothed over: aligning a buffer costs nothing
and halves what moving it costs, and a cost a program cannot see is a cost it cannot avoid.

Tests/cycles.sh is new, and is the test the Test Manual has always said this kind of change
would need - run.sh strips the cycle count from every recorded result, so nothing else in
the suite can see any of this. It pins the RATE rather than a total: each case runs twice
from programs whose instructions are identical but for the byte written to the Command
port, once asking for the transfer and once for GuardOff, which costs nothing beyond the
port write. The difference is the transfer and nothing else. Verified by disabling the
widening, which failed exactly the three aligned cases and left the five misaligned ones
passing.

The Programming Manual gains a section saying what a transfer costs, which it never said at
all - it only promised a transfer does not wait, which is a different claim and could be
read as promising it is free.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-28 21:30:12 -04:00
AnachronautandClaude Opus 5 c3c2451afe Say what the strict build and the sanitizers actually establish
Three claims in the Test Manual were true enough to pass and loose enough to mislead.

The headline said the two assemblers produce identical bytes and the two filesystems
produce identical disks, with no qualifier. That reads as universal and is not: it is
evidence about the corpus assembled and the operations performed. The detailed sections
already said so; the headline now says so too, because a document arguing about what
evidence is worth cannot overstate its own.

"Compiles under strict ISO C" omitted -D_XOPEN_SOURCE=700. The check is strict C11 with
the POSIX interfaces the code uses explicitly selected - realpath, strdup, dirname and
getopt - not freestanding ISO C, and the flag is part of the check rather than a hole
in it.

And the sanitizer section leaned on AddressSanitizer's junk fill, which is a toolchain
default this build does not configure and which buys almost nothing here anyway: there
are six heap allocations in the whole repository, all in the assembler, the largest a
deliberate calloc, and the machine's own memories are static arrays the sanitizers
neither fill nor bound-check. That last part is the overrun blind spot seen from the
other side, so it now points at it. The same claim is corrected in the makefile, where
it originated.

Found by ChatGPT reviewing the manual.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-28 18:07:47 -04:00
AnachronautandClaude Opus 5 8fbbeb6ec9 Make xfail compare the diagnostic, not just the failure
The Test Manual said an xfail test records the assembler's refusal message and so
catches both an error that stops being detected and a message that changes without
anybody meaning it to. It did not. run.sh checked only that the assembler exited
non zero, printed the first line for a person to read, and compared nothing; --bless
recorded nothing for these sixteen tests at all.

So an xfail passed four different ways that look identical from outside: the intended
error fired, an unrelated error fired, the message changed, or the assembler fell over
on its way to the point. That is the documentation describing behaviour the code does
not have, which is the exact failure Tests/docs.sh exists to prevent, in the manual
that argues for knowing what your evidence is worth.

The diagnostic is now stripped of colour, given the same [exit N] line every other
recorded result carries, and compared through check() like anything else. Sixteen
results recorded; every existing one is byte for byte unchanged. Verified the way the
manual asks: one diagnostic was broken on purpose, its test failed with the changed
line in the diff, and its neighbour passed.

Found by ChatGPT reviewing the manual.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-28 18:07:35 -04:00
AnachronautandClaude Opus 5 999451e935 Ignore External, which is other people's repositories
soundThing and Hatchet-GPU are kept in the working tree to be read, not built: one is
where the synthesizer comes from, the other is where the 8x8 font and the scroll register
idea come from. Neither belongs to this repository. They carry their own .git directories,
their own build outputs and three compiled binaries between them, and a plain
'git add -A' would have taken all of it.

What this repository takes from them will be vendored deliberately, with a note in the
file saying where it came from and what changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-28 17:49:38 -04:00
AnachronautandClaude Opus 5 d6bc416698 Write the Test Manual, and make the suite check it
The test system had grown to seven scripts making five genuinely different
kinds of claim, and nothing said which was which. A recorded transcript and a
byte-for-byte comparison against a second implementation both print [ok  ] and
are worth wildly different amounts, so the fourth manual exists to say so: what
each script can and cannot answer, why every determinism rule is there, how to
add a test, and - the part written nowhere else - where the suite is blind.

That last section is the reason for the document. Three buffer overruns into
adjacent variables were all found by a person using the machine and none by the
suite, the sanitizers cannot see them because emulated Data Memory is one
legitimate host array, and there is no second opinion about the CPU at all. A
document listing only strengths teaches the wrong lesson.

The bullets describing each script move out of the README, so docs.sh now reads
the manual for them, and five more numbers in it are settled from the source
rather than trusted: the shape of the manifest, the xfail count, how many
fixture disks makedisks.sh builds, how large the lint baseline is, and the tool
count in either document. Each of the new checks was broken on purpose and
watched to report before being kept, which is the discipline the manual itself
argues for.

Also drops the stale "70 instructions" from instructiontable.py's docstring.
There are 72, and a number that carries no meaning is better removed than
corrected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-27 23:52:12 -04:00
174 changed files with 14607 additions and 1347 deletions
+12
View File
@@ -8,6 +8,7 @@
/SplitBit /SplitBit
/SplitDisk /SplitDisk
/SplitLint /SplitLint
/Voyager
/CLAUDE.md /CLAUDE.md
/claudeResume.sh /claudeResume.sh
/codexResume.sh /codexResume.sh
@@ -28,3 +29,14 @@ __pycache__/
# Kate leaves these beside a file it has open. # Kate leaves these beside a file it has open.
.*.kate-swp .*.kate-swp
Source/Emulator/rom.c Source/Emulator/rom.c
# Other people's repositories, kept here to be read rather than to be built. soundThing is
# where the synthesizer comes from and Hatchet-GPU is where the font and the scroll register
# idea come from, and neither belongs to this one: they carry their own .git directories,
# their own build outputs and their own compiled binaries. What this repository takes from
# them gets vendored deliberately, with a note saying where it came from.
/External/
# The personal disk 'make run-voyager' puts in drive 1. Made on demand, never rebuilt, and
# not cleaned - it is the one place in here where something made ON the machine can live.
/Disks/
+54 -5
View File
@@ -54,8 +54,15 @@
#Base 0x4000 #Base 0x4000
start: start:
; ---- Room for a PATH, not a name ----
;
; Sixty-three, which was twenty-three. That was the right size when everything lived in
; the root and a name was a name. The disk now carries the whole source tree, so
; "/Source/CosmOS/Assembler/classify.asm" is an ordinary thing to type - thirty-seven
; characters, and it was being cut down to a name that meant something else, or nothing.
; The shell reads sixty-three characters of command line, so nothing longer can arrive.
SETD.0 FileName SETD.0 FileName
INIB 0d23 INIB 0d63
SWI osArgument SWI osArgument
SETD.0 FileName SETD.0 FileName
LDA.0 LDA.0
@@ -78,6 +85,14 @@ start:
RSTA RSTA
SETD.0 TooLong SETD.0 TooLong
STA.0 STA.0
; ---- Cleared here rather than in the Data Segment ----
;
; A program is loaded once and may be run many times: "load Edit.sbx" then "run" twice is
; two sessions over one copy of the data, so anything a session changes has to be put back
; by the session and not by the loader. A zero written in the Data Segment is the state
; this program starts in the FIRST time and never again.
SETD.0 IsNew
STA.0
CALL loadFile CALL loadFile
SETD.0 TooLong SETD.0 TooLong
@@ -88,6 +103,17 @@ start:
SWI osPrintString SWI osPrintString
SETD.0 CommaText SETD.0 CommaText
SWI osPrintString SWI osPrintString
; A file that was not there is a new document, which this is the way to start - but say so,
; rather than saying nothing and letting it look like an empty one that already existed.
SETD.0 IsNew
LDA.0
BRA sayCount
SETD.0 NewText
SWI osPrintString
CALL newLine
BRI announced
sayCount:
CALL countLines CALL countLines
MVQA MVQA
CALL printByte CALL printByte
@@ -102,6 +128,7 @@ oneLine:
sayLines: sayLines:
SWI osPrintString SWI osPrintString
CALL newLine CALL newLine
announced:
commandLoop: commandLoop:
SETD.0 PromptText SETD.0 PromptText
@@ -578,7 +605,7 @@ loadFile:
SETD.0 FileName SETD.0 FileName
SETD.1 0x40 0x00 SETD.1 0x40 0x00
SWI osFileRead SWI osFileRead
BNQ loadNothing BNQ loadNew
; How many bytes came back. The service says so in DP3, which is one of the two things a ; How many bytes came back. The service says so in DP3, which is one of the two things a
; service is allowed to answer in, and a file that fits in memory has a length that fits ; service is allowed to answer in, and a file that fits in memory has a length that fits
@@ -677,13 +704,29 @@ splitLast:
; A file that does not end in a newline still has a last line in it. ; A file that does not end in a newline still has a last line in it.
SETD.2 EntryLength SETD.2 EntryLength
LDA.2 LDA.2
BRA loadNothing BRA loadDone
RSTA RSTA
STA.1 STA.1
SETD.0 Entry SETD.0 Entry
CALL makeNode CALL makeNode
CALL appendNode CALL appendNode
loadNothing: ; ---- Where a load finishes ----
;
; NOT WHERE ONE FAILS, which the old name for this said and which is a different thing: every
; successful load arrives here too, once its last line has been dealt with. That is worth
; being plain about, because a flag set here on the strength of the name was set on every
; file that opened perfectly well.
loadDone:
RET
; A name that is not on the disk is a new document rather than a mistake, which is what makes
; this the way to start one. Remembered, because "0 lines" is what an empty file that IS on
; the disk also says, and a name typed slightly wrong looks exactly like the document you
; meant to open - right up until you save it somewhere new.
loadNew:
SETD.0 IsNew
INIA 0x01
STA.0
RET RET
; DP3 is a node. Puts it on the end of the list. ; DP3 is a node. Puts it on the end of the list.
@@ -838,6 +881,8 @@ ColonText:
": " ": "
CommaText: CommaText:
", " ", "
NewText:
"new file"
LinesText: LinesText:
" lines" " lines"
LineText: LineText:
@@ -861,8 +906,12 @@ NoWriteText:
TooLongText: TooLongText:
"a line in it is longer than this can edit, so it has not been opened" "a line in it is longer than this can edit, so it has not been opened"
; Whether the name was not on the disk, which makes this a new document rather than one
; being opened. Zero until loadFile finds out otherwise.
IsNew:
0x00
FileName: FileName:
#Reserve 0d24 #Reserve 0d64
Command: Command:
#Reserve 0d41 #Reserve 0d41
; A hundred and twenty eight and the zero that ends it, which is what a line is everywhere ; A hundred and twenty eight and the zero that ends it, which is what a line is everywhere
+455
View File
@@ -0,0 +1,455 @@
; Grid, the first program to use the screen as a screen.
;
; Everything drawn on this machine so far has been text or a bitmap. The tile engine has
; been there since the screen was built and nothing had touched it: the console uses it,
; but only ever to put a letter in a cell, which is the one thing it can do that a plain
; character display could do too.
;
; This redefines a tile, fills a map bigger than the screen with it, and then scrolls that
; map by writing ONE BYTE A FRAME. No memory moves. The rows above and below the screen are
; already there, so what a scroll costs is not the 2,000 bytes of a screenful but the one
; byte that says which row is on top.
;
; ---- Where its tiles live ----
;
; At 200, and the font is why. The machine wakes with the font in tile memory - glyph n at
; tile n, for 135 of the 256 - so a program that starts writing tiles at zero paints over
; the alphabet and the shell it is going to hand the machine back to. Above 135 is empty and
; nobody else's, so nothing here has to be put back afterwards except the map.
;
; Written by Anachronaut
#Include services.asm
#Program
#Base 0x4000
start:
; ---- Reaching video memory ----
;
; The CPU cannot touch it. It belongs to the device, and the only way in is to give it a
; bank number and go through the memory controller - the same as the disk's buffer.
;
; FOUR, BECAUSE THREE IS THE DISK'S. Bank numbers are one namespace for the whole machine
; and nothing hands them out: 0 is Program Memory, 1 is Data, 2 is the bank table, and
; CosmOS gives 3 to the disk's buffer when it mounts. This asked for 3, which does not
; fail - it succeeds, and the disk's buffer quietly becomes the screen. Every read the
; filesystem made after that came out of video memory, so the shell found an empty disk
; and could not start anything by name. Nothing said a word.
INIA 0d4
OUTA 0xE3 ; DestBank: the number it will answer to.
INIA 0x30
OUTA 0xE2 ; SourceLow: the port of the device that owns it.
INIA 0x03
OUTA 0xE8 ; RegisterBank.
; ---- Asking for the screen back afterwards ----
;
; Everything below overwrites a tile, all sixteen colour schemes and every cell of the map,
; and none of that is this program's to keep. The system puts it somewhere and gives it back
; at exit - and if it says it cannot, this carries on anyway, because it did before there
; was anywhere to put it.
SWI osTakeScreen
MVQA
SETD.0 ScreenKept
STA.0 ; Nought means the system will put it back.
CALL putTile
CALL putPalette
CALL putMap
; Key mode, so that a key arrives when it is pressed rather than when Return is. Put back
; before this returns, and CosmOS puts it back too if a program forgets.
INIA 0x01
OUTA 0x02
; ---- The loop ----
;
; A frame, then one pixel down and one across. This used to move a whole cell every fourth
; frame, because a cell was as fine as the screen could be moved - eight pixels at a time,
; which reads as the picture jumping rather than travelling.
everyFrame:
CALL waitFrame
; Anything typed ends it. Asked for, never waited for: the console holds the key until
; somebody wants it, so nothing pressed between frames is lost.
INA 0x01
INIB 0x01 ; READY
AND
BNQ finished
; ---- A pixel down, and the cell it belongs to ----
;
; Fine is the low three bits of the register and does not carry, so this does: eight steps
; inside the cell and then one step of the origin. The AND is both the wrap and the test -
; Q coming out as nought is exactly the moment the cell boundary was crossed.
SETD.0 FineDown
LDA.0
INCA
INIB 0x07
AND
STQ.0
OUTQ 0x38
BNQ stepAcross
; The map is 128 rows against a screen of 25 or 50, so the origin walks a ring: what leaves
; the top has not gone anywhere and comes back round.
SETD.0 OriginDown
LDA.0
INCA
INIB 0x7F
AND
STQ.0
OUTQ 0x34
stepAcross:
; And the same sideways, which is the axis that did not exist at all until now. There are
; 128 columns against the 80 shown, so this ring is shallower but it is the same ring.
SETD.0 FineAcross
LDA.0
INCA
INIB 0x07
AND
STQ.0
OUTQ 0x37
BNQ everyFrame
SETD.0 OriginAcross
LDA.0
INCA
INIB 0x7F
AND
STQ.0
OUTQ 0x36
BRI everyFrame
finished:
; The key that stopped it, taken so the shell does not find it waiting.
INA 0x00
; ---- Putting the screen back ----
;
; ---- What used to be here ----
;
; Four scroll registers put back, the whole map filled with spaces, the cursor sent home,
; and palette bank 0 written out by hand - and it was STILL wrong, because the other fifteen
; banks kept this program's colours and there was nowhere to have put the real ones.
;
; All of it is osTakeScreen's now, and it gives back what was actually there rather than
; what a clean machine looks like.
;
; ---- Unless it said no ----
;
; A machine with no volatile drive has nowhere to keep a screen, and answers so. Being told
; no is not a fault and not a reason to stop: it means doing what this program did before
; there was anywhere to put one, which is leaving a blank screen rather than a grid with
; somebody's prompt printed into it.
SETD.0 ScreenKept
LDA.0
BRA gridScreenKept
RSTA
OUTA 0x34
OUTA 0x36 ; The origins, or the shell looks at a corner of the map.
INIA 0d4
OUTA 0xE3
INIA 0x40
OUTA 0xE4
RSTA
OUTA 0xE5
OUTA 0xE2 ; Tile nought is the space and attribute nought is plain.
INIA 0x80
OUTA 0xE6
RSTA
OUTA 0xE7
INIA 0x02
OUTA 0xE8 ; Fill the whole map with it.
; And the colours the machine wakes up in, which is as near as this can get to the ones it
; took: grey on black is what plain text has always been here.
INIA 0d4
OUTA 0xE3
INIA 0xFC
OUTA 0xE4
RSTA
OUTA 0xE5
OUTA 0xE9
OUTA 0xE9
OUTA 0xE9
OUTA 0xE9
INIA 0xD8
OUTA 0xE9
OUTA 0xE9
OUTA 0xE9
RSTA
OUTA 0xE9
INIA 0x01
OUTA 0x05 ; Cleared, which also puts the cursor home.
gridScreenKept:
RSTA
OUTA 0x02 ; Line mode, the way it was found.
RSTA ; splitlint[redundant-assignment]: an exit status, not a mode
SWI osExit
; ---- A frame ----
;
; Asked for rather than waited on with an interrupt. Polling costs a program nothing it
; needs here and saves installing a vector, and the status bit is honest: reading it is what
; answers it, so this cannot see the same frame twice.
waitFrame:
INA 0x30
INIB 0x01
AND
BRQ waitFrame
RET
; ---- The tile ----
;
; Blitted rather than written a byte at a time, because it is already sixty four bytes of
; Data Segment and the controller will move it in one command. Tile n starts at n times 64,
; so tile 200 starts at 12,800, which is 0x3200.
putTile:
INIA 0x01
OUTA 0xE0 ; SourceBank: Data Memory.
; The pointer BEFORE storing through it. Written the other way round the first time, which
; assembles perfectly and stores the address into wherever DP1 was last left - so the blit
; read its sixty four bytes from nowhere in particular and tile 200 came out as noise.
SETD.1 TileArtAt
SETD.0 TileArt
STD.0.1
LDA.1
OUTA 0xE1
INCD.1
LDA.1
OUTA 0xE2
INIA 0d4
OUTA 0xE3
INIA 0x32
OUTA 0xE4
RSTA
OUTA 0xE5
OUTA 0xE6
INIA 0d64
OUTA 0xE7
INIA 0x01
OUTA 0xE8 ; Blit.
RET
; ---- The colours ----
;
; The tile is drawn in indices 0 and 1, and the low nibble of a cell's attribute is ADDED to
; every index in it, sixteen at a time. So the same sixty four bytes appear in sixteen colour
; schemes, and what this writes is those schemes: entry 16n is the ground and 16n+1 the line.
; Nothing is duplicated to get them.
;
; ---- Nothing here works out an address ----
;
; The first try computed where entry 16n lives - 0xFC00 plus 64n - and was wrong twice for
; the same reason, which is that this machine cannot multiply and pretending otherwise is
; where the bugs go. 64n reaches 960, so the address spans four pages and the high byte moves
; too; and doubling A by adding B to it needs B to hold A first, which RSTB is the opposite
; of.
;
; So it writes all 256 entries in order and never computes anything. The controller's Data
; port steps the address on after every byte, so the whole palette is one sweep of 1,024
; writes with no arithmetic in it at all. The colours that change do so by adding sixteen to
; a running value, which is the same reason.
putPalette:
INIA 0d4
OUTA 0xE3
INIA 0xFC
OUTA 0xE4
RSTA
OUTA 0xE5
SETD.0 Scheme
STA.0
SETD.0 LineRed
STA.0
INIA 0xFF
SETD.0 LineGreen
STA.0
everyScheme:
; Entry 16n, the ground: the same dark under every scheme.
INIA 0d16
OUTA 0xE9
OUTA 0xE9
INIA 0d24
OUTA 0xE9
RSTA
OUTA 0xE9
; Entry 16n+1, the line: more red and less green the further down the map it is, so that
; scrolling is visibly going somewhere rather than showing the same row again.
SETD.0 LineRed
LDA.0
OUTA 0xE9
SETD.0 LineGreen
LDA.0
OUTA 0xE9
INIA 0d96
OUTA 0xE9
RSTA
OUTA 0xE9
; The fourteen this tile never asks for. Written anyway, because the sweep is what keeps
; the address right and skipping them would mean working one out.
INIA 0d14
SETD.0 Spare
STA.0
everySpare:
RSTA
OUTA 0xE9
OUTA 0xE9
OUTA 0xE9
OUTA 0xE9
SETD.0 Spare
LDA.0
DECA
STA.0
BNA everySpare
SETD.0 LineRed
LDA.0
INIB 0d16
CCF
ADD
STQ.0
SETD.0 LineGreen
LDA.0
CCF
SUB ; B is still sixteen, from the red just above.
STQ.0
SETD.0 Scheme
LDA.0
INCA
STA.0
CCF
SUB ; And still sixteen here, which is also how many schemes there are.
BNQ everyScheme
RET
; ---- The map ----
;
; All 128 rows, not the 25 the screen shows. That is the whole point of a map bigger than
; the screen: the rows above and below are already drawn, so scrolling is a change of origin
; rather than a change of anything.
;
; A ROW IS A PAGE, which is why the row number is written straight into DestHigh and the
; column arithmetic disappears. The controller's Data port steps the address on after every
; byte, so a row is a loop over two writes with no address handling in it at all.
putMap:
RSTA
SETD.0 MapRow
STA.0
everyRow:
INIA 0d4
OUTA 0xE3
SETD.0 MapRow
LDA.0
INIB 0x40
CCF
ADD
OUTQ 0xE4 ; The map starts at 0x4000 and a row is a page.
RSTA
OUTA 0xE5
; The attribute is the row number's low nibble, so the schemes band down the map and
; repeat every sixteen rows. DP0 is still MapRow, from the row's address above.
LDA.0
INIB 0x0F
AND
SETD.0 RowAttribute
STQ.0
; ---- The map's width, which is not the screen's ----
;
; A hundred and twenty-eight, because that is how many cells a map row holds: 256 bytes at
; two bytes a cell, whatever mode the screen is in. It is a property of video memory rather
; than of the display, so there is no register to ask and nothing to ask it of.
;
; This said forty first, and filled half of an eighty column screen. Then it asked the
; screen how wide it was, which fixed what could be seen and was still wrong: scrolling
; sideways walked off the 80 filled columns into the 48 that were not, and the grid went
; blank for six seconds before coming round again.
;
; ASKING THE SCREEN IS RIGHT FOR FILLING A SCREEN AND WRONG FOR FILLING A MAP. A program
; that writes one screenful wants the window; a program that scrolls wants everything the
; window can be moved over.
INIA 0d128
SETD.0 RowCells
STA.0
everyCell:
INIA 0d200
OUTA 0xE9
SETD.0 RowAttribute
LDA.0
OUTA 0xE9
SETD.0 RowCells
LDA.0
DECA
STA.0
BNA everyCell
SETD.0 MapRow
LDA.0
INCA
STA.0
INIB 0x80
CCF
SUB
BNQ everyRow
RET
#Data
#Base 0x2000
ScreenKept:
0x00
FineDown:
0x00
FineAcross:
0x00
OriginDown:
0x00
OriginAcross:
0x00
Scheme:
0x00
Spare:
0x00
LineRed:
0x00
LineGreen:
0x00
MapRow:
0x00
RowAttribute:
0x00
RowCells:
0x00
TileArtAt:
0x00 0x00
; ---- Eight by eight, a byte a pixel ----
;
; A line along the top and one down the left. Tiled edge to edge they meet, so a screenful
; of this one tile is a continuous grid rather than 1,000 separate boxes.
TileArt:
0x01 0x01 0x01 0x01 0x01 0x01 0x01 0x01
0x01 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x01 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x01 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x01 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x01 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x01 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x01 0x00 0x00 0x00 0x00 0x00 0x00 0x00
+5 -10
View File
@@ -36,8 +36,8 @@
start: start:
CALL seedGlider CALL seedGlider
SETD.0 ClearScreen INIA 0x01
CALL printString OUTA 0x05 ; Console command: clear the screen
SETD.3 GenerationsLeft SETD.3 GenerationsLeft
INIA 0xFF INIA 0xFF
STA.3 STA.3
@@ -126,8 +126,9 @@ seedGlider:
RET RET
renderBoard: renderBoard:
SETD.0 CursorHome RSTA
CALL printString OUTA 0x03
OUTA 0x04 ; Cursor to row 0, column 0
SETD.1 RowCount SETD.1 RowCount
SETD.2 ColCount SETD.2 ColCount
INIA 0d16 INIA 0d16
@@ -371,12 +372,6 @@ RanOutText:
StoppedText: StoppedText:
"stopped" "stopped"
ClearScreen:
0x1B
"[2J"
CursorHome:
0x1B
"[H"
; 18 by 18 cells with the current and next states interleaved, so 648 bytes. The ; 18 by 18 cells with the current and next states interleaved, so 648 bytes. The
; original leaves this implicit and leans on Data Memory being zero, which works but ; original leaves this implicit and leans on Data Memory being zero, which works but
+17 -11
View File
@@ -12,7 +12,7 @@
start: start:
SETD.0 Name SETD.0 Name
INIB 0d29 INIB 0d63
SWI osArgument SWI osArgument
SETD.0 Name SETD.0 Name
LDA.0 LDA.0
@@ -111,19 +111,23 @@ noName:
SWI osPrintString SWI osPrintString
INIA 0d2 INIA 0d2
SWI osExit SWI osExit
; ---- What went wrong, in words ----
;
; It used to print the number the filesystem answered with, as "error 2". THERE IS NO SUCH
; VOCABULARY: the library documents its answer as zero or not zero and never as a code, so
; the number named nothing and could not be looked up - it just looked like it could.
;
; A name that is not on the disk is the only way opening fails that a person can do anything
; about, and it is nearly always a name typed slightly wrong. Saying so is more use than any
; number would have been.
openFailed: openFailed:
SETD.0 OpenError SETD.0 OpenError
SWI osPrintString SWI osPrintString
BRI printError BRI failed
readFailed: readFailed:
SETD.0 ReadError SETD.0 ReadError
SWI osPrintString SWI osPrintString
printError: failed:
RSTA
MVQB
SWI osPrintNumber
SETD.0 NewLine
SWI osPrintString
; ITS OWN EXIT, and it did not have one. This fell through into finished and reported ; ITS OWN EXIT, and it did not have one. This fell through into finished and reported
; that everything was fine, having just printed the reason it was not - which nobody ; that everything was fine, having just printed the reason it was not - which nobody
; noticed while the only reader was a person, who could see both. ; noticed while the only reader was a person, who could see both.
@@ -140,9 +144,11 @@ Usage:
"more: give me a file name "more: give me a file name
" "
OpenError: OpenError:
"more: cannot find the file, error " "more: there is no file by that name
"
ReadError: ReadError:
"more: cannot read the file, error " "more: the disk would not give me that file
"
NewLine: NewLine:
0x0A 0x00 0x0A 0x00
MorePrompt: MorePrompt:
@@ -150,7 +156,7 @@ MorePrompt:
ClearPrompt: ClearPrompt:
0x0A 0x00 0x0A 0x00
Name: Name:
#Reserve 0d29 #Reserve 0d64
Remaining: Remaining:
0x00 0x00 0x00 0x00
LinesLeft: LinesLeft:
+178
View File
@@ -0,0 +1,178 @@
; What the console just handed over, in hexadecimal and by name.
;
; The keys that are not characters - the arrows, Home, End and forward Delete - arrive as
; the console's own values above ASCII rather than as the escape sequences a terminal sends
; or as nothing at all, which is what a window used to make of them. This is what shows
; that, and it shows both halves of the rule in one run:
;
; A LINE FIRST, read the way everything reads one. Line mode delivers characters, so the
; keys are dropped before they reach the buffer and what comes back is what a person could
; have typed. Pressing Up while something else is collecting a line does nothing, which is
; an improvement on putting an escape and a bracket in the middle of it.
;
; THEN THE KEYS, in key mode, where a program has asked for every keystroke as it happens
; and these are keystrokes like any other.
;
; Written by Anachronaut
#Include services.asm
#Program
#Base 0x4000
start:
SETD.0 LineText
CALL printString
CALL newLine
SETD.0 Buffer
INIB 0d63
CALL readLine
SETD.0 Buffer
CALL showBytes
SETD.0 KeyText
CALL printString
CALL newLine
INIA 0x01
OUTA 0x02 ; Key mode. Nothing echoes, so everything below says what it saw.
keyLoop:
INA 0x00
INIB 0xFF
XOR
BRQ keyDone ; Nothing more is coming.
INIB 0x71 ; q, which is how this is stopped.
XOR
BRQ keyDone
CALL showKey
BRI keyLoop
keyDone:
RSTA
OUTA 0x02 ; Line mode, the way it was found.
SETD.0 DoneText
CALL printString
CALL newLine
RSTA
SWI osExit
; DP0 names a string of bytes ending in a zero. Prints each as two hexadecimal digits, so
; that what is in the buffer can be read rather than guessed at.
showBytes:
LDA.0
BRA showBytesDone
CALL printByteHex
INIA 0x20
OUTA 0x00
INCD.0
BRI showBytes
showBytesDone:
CALL newLine
RET
; A holds a key. Prints its value and then what it is.
;
; A survives a CALL, so the byte is still here after printing it - but only until something
; else is put in A, which the space below does. So it is kept where the naming can find it.
showKey:
SETD.1 KeyByte
STA.1
CALL printByteHex
INIA 0x20
OUTA 0x00
SETD.1 KeyByte
LDA.1
; XOR leaves the answer in Q and A alone, so one load stands for the whole ladder.
INIB 0x80
XOR
BRQ keyUp
INIB 0x81
XOR
BRQ keyDown
INIB 0x82
XOR
BRQ keyLeft
INIB 0x83
XOR
BRQ keyRight
INIB 0x84
XOR
BRQ keyHome
INIB 0x85
XOR
BRQ keyEnd
INIB 0x86
XOR
BRQ keyDelete
; An ordinary character, which is its own best name.
OUTA 0x00
CALL newLine
RET
keyUp:
SETD.0 UpText
BRI keySay
keyDown:
SETD.0 DownText
BRI keySay
keyLeft:
SETD.0 LeftText
BRI keySay
keyRight:
SETD.0 RightText
BRI keySay
keyHome:
SETD.0 HomeText
BRI keySay
keyEnd:
SETD.0 EndText
BRI keySay
keyDelete:
SETD.0 DeleteText
keySay:
CALL printString
CALL newLine
RET
#Data
#Base 0x2000
LineText:
"a line, then keys. q stops."
KeyText:
"keys:"
DoneText:
"done"
UpText:
"up"
DownText:
"down"
LeftText:
"left"
RightText:
"right"
HomeText:
"home"
EndText:
"end"
DeleteText:
"delete"
KeyByte:
0x00
Buffer:
#Reserve 0d64
#Vectors
Boot start
#Include console.asm
+1 -1
View File
@@ -1,7 +1,7 @@
; The 16-bit segmented sieve rewritten for SplitBit's four-Data-Pointer ISA. ; The 16-bit segmented sieve rewritten for SplitBit's four-Data-Pointer ISA.
; ;
; This deliberately implements the same algorithm and emits the same text as ; This deliberately implements the same algorithm and emits the same text as
; 16bitSegmentedSieve.asm, making the two versions useful as a direct comparison. ; 16bitSieve.asm, making the two versions useful as a direct comparison.
; DP0 walks PrimeStates, DP1 holds Page, DP2 walks Segment, and volatile DP3 ; DP0 walks PrimeStates, DP1 holds Page, DP2 walks Segment, and volatile DP3
; marks multiples. CALL preserves the first three pointers automatically. ; marks multiples. CALL preserves the first three pointers automatically.
+22 -16
View File
@@ -60,13 +60,17 @@ start:
CALL placeSnake CALL placeSnake
CALL placeFood CALL placeFood
SETD.0 ClearScreen INIA 0x01
CALL printString OUTA 0x05 ; Console command: clear the screen
; Key mode, so that one key is one byte and arrives when it is pressed. It is put back ; Key mode, so that one key is one byte and arrives when it is pressed. It is put back
; before this returns, and CosmOS puts it back too in case a program stops without ; before this returns, and CosmOS puts it back too in case a program stops without
; doing so. ; doing so.
INIA 0x01 ;
; A is already 1 from the clear above, and leaving this out would save a byte by tying a
; console COMMAND to a console MODE that happens to share a number. That is a coincidence
; rather than a saving, and it would break silently if either ever moved.
INIA 0x01 ; splitlint[redundant-assignment]: see above
OUTA 0x02 OUTA 0x02
gameLoop: gameLoop:
@@ -529,8 +533,9 @@ removeTail:
; The whole board, every frame, from the top left corner. Sixteen by sixteen is small ; The whole board, every frame, from the top left corner. Sixteen by sixteen is small
; enough that working out what changed would cost more than sending it all again. ; enough that working out what changed would cost more than sending it all again.
draw: draw:
SETD.0 CursorHome RSTA
CALL printString OUTA 0x03
OUTA 0x04 ; Cursor to row 0, column 0
SETD.0 BorderText SETD.0 BorderText
CALL printString CALL printString
CALL newLine CALL newLine
@@ -586,12 +591,19 @@ drawPut:
; ---- Waiting ---- ; ---- Waiting ----
; ;
; There is no clock on this machine, so time is counted in instructions. At the emulated ; There is no clock on this machine, so time is counted in cycles. At the emulated rate this
; rate this is about an eighth of a second, which is a speed a person can play at. Running ; is about an eighth of a second, which is a speed a person can play at. Running the emulator
; the emulator faster or slower moves it, and that is the honest answer: the machine has ; faster or slower moves it, and that is the honest answer: the machine has no way to know
; no way to know how long a second is and this program is not going to pretend it does. ; how long a second is and this program is not going to pretend it does.
;
; THE COUNT USED TO BE 256 AND THE COMMENT USED TO SAY INSTRUCTIONS. When a cycle stopped
; being an instruction and became a memory access, every loop in the machine got dearer and
; this one silently doubled - the game has been running at half the speed it says ever since,
; in a terminal as much as in a window. The inner loop is a DECA and a BNA, one byte and
; three, so four cycles a turn: 122 times 256 times 4 is about 125,000, which is an eighth of
; a second at a megahertz.
pause: pause:
RSTB INIB 0d122
pauseOuter: pauseOuter:
RSTA RSTA
pauseInner: pauseInner:
@@ -642,12 +654,6 @@ RandomSeed:
RandomState: RandomState:
0x00 0x00 0x00 0x00
ClearScreen:
0x1B
"[2J"
CursorHome:
0x1B
"[H"
BorderText: BorderText:
"+----------------+" "+----------------+"
+2 -2
View File
@@ -10,7 +10,7 @@
start: start:
SETD.0 Name SETD.0 Name
INIB 0d29 INIB 0d63
SWI osArgument SWI osArgument
SETD.0 Name SETD.0 Name
LDA.0 LDA.0
@@ -81,7 +81,7 @@ ReadError:
NewLine: NewLine:
0x0A 0x00 0x0A 0x00
Name: Name:
#Reserve 0d29 #Reserve 0d64
Remaining: Remaining:
0x00 0x00 0x00 0x00
+2 -2
View File
@@ -16,7 +16,7 @@ start:
; Where to go is the argument. Nothing else about this program says a directory name, so ; Where to go is the argument. Nothing else about this program says a directory name, so
; running it anywhere else moves it anywhere else. ; running it anywhere else moves it anywhere else.
SETD.0 Where SETD.0 Where
INIB 0d40 INIB 0d63
SWI osArgument SWI osArgument
SETD.0 Where SETD.0 Where
LDA.0 LDA.0
@@ -88,7 +88,7 @@ noFile:
#Base 0x2000 #Base 0x2000
Where: Where:
#Reserve 0d40 #Reserve 0d64
Left: Left:
0x00 0x00 0x00 0x00
Went: Went:
+27
View File
@@ -244,6 +244,26 @@ srcIncludeSkip:
RET RET
srcIncludeGone: srcIncludeGone:
; ---- Say which one ----
;
; It said nothing at all, and the assembler's catch-all then said "nothing was written" -
; which is true and useless. Three programs on the disk could be read and not assembled,
; and finding out why meant reading the assembler rather than the message.
;
; SrcName holds the library path by now, because that was the second place tried, so this
; says where it looked as well as what for. Both are worth having: a name that is not in
; /Lib and not beside you is usually one or the other spelt wrong.
SETD.0 GoneText
SWI osPrintString
SETD.0 IncWanted
SWI osPrintString
SETD.0 GoneWhereText
SWI osPrintString
SETD.0 SrcName
SWI osPrintString
SETD.0 GoneEndText
SWI osPrintString
; The file is not there. The stack is left as it is: the caller stops the assembly, and ; The file is not there. The stack is left as it is: the caller stops the assembly, and
; unwinding for the sake of tidiness would only hide where it happened. ; unwinding for the sake of tidiness would only hide where it happened.
RSTA RSTA
@@ -596,6 +616,13 @@ IncSlot:
IncSlotLeft: IncSlotLeft:
0x00 0x00
GoneText:
"cannot find "
GoneWhereText:
", not beside the file that asked and not as "
GoneEndText:
"
"
TooDeepText: TooDeepText:
"included files are nested deeper than this assembler will follow "included files are nested deeper than this assembler will follow
" "
+416 -11
View File
@@ -56,17 +56,12 @@ emulator, and disk-image tool. From the repository root, build those tools first
make make
``` ```
Then build CosmOS and all of its applications: That builds CosmOS, all of its applications, and a disk to boot them from - one makefile
covers the machine and the system. To rebuild only part of it:
```sh ```sh
cd Programs
make cosmos make cosmos
``` make disk
Build a fresh SBFS application disk as well:
```sh
make cosmos-disk
``` ```
To boot CosmOS with that disk attached: To boot CosmOS with that disk attached:
@@ -75,15 +70,158 @@ To boot CosmOS with that disk attached:
make run-cosmos make run-cosmos
``` ```
Or on the Voyager, which is the same machine with a screen and a speaker instead of a
terminal:
```sh
make run-voyager
```
Both depend on the disk rather than merely using it, which is worth knowing: **what is on a
disk is whatever was built when the disk was made.** A machine whose console has changed
will start an old image quite happily and its programs will draw whatever the old way now
means, which is a confusing thing to debug and an easy thing to avoid.
The generated files are kept under `Programs/build/`: The generated files are kept under `Programs/build/`:
- `CosmOS/Source/cosmos.bin` is the bootable CosmOS image. - `CosmOS/Source/cosmos.bin` is the bootable CosmOS image.
- `CosmOS/Apps/*.sbx` are loadable application images. - `CosmOS/Apps/*.sbx` are loadable application images.
- `cosmos.img` is the SBFS disk containing those applications. - `cosmos.img` is the SBFS disk containing those applications.
**The disk carries every source in `Programs/`, mirrored.** Not a list kept in the makefile -
a list goes stale the moment somebody adds a program and forgets to name it, and what they
forgot is invisible until they go looking for it on the machine. Putting a file where the
others live is the whole of putting it on the disk.
That matters most for the things nobody thought worth shipping a binary of. A demo that is
not interesting enough to build by default is still worth having the source of, because the
machine can build it:
```
> cd /Source/Examples
/Source/Examples> Asm colours.asm
wrote colours.bin: program 114, data 86, labels 8
```
Two things are left behind. `build`, because what a project builds is not what it wrote. And
anything whose name is longer than a directory entry holds, which is **refused rather than
skipped**: a disk quietly missing a file is exactly the failure a mirror exists to prevent,
so the build stops and says which name to shorten.
`/Lib` still holds the library sources separately, because that is where an `#Include` looks
after looking beside the file that asked. The same files therefore appear twice - once as
what a program includes, once as part of the source tree - and that is the difference between
an installed library and a copy of the source.
The disk is rebuilt from scratch when its applications change, so its contents describe The disk is rebuilt from scratch when its applications change, so its contents describe
the current source tree rather than accumulating files left by older builds. the current source tree rather than accumulating files left by older builds.
## Scripts:
`do <file>` runs the lines in a file as though somebody had typed them. Every command works
the same way it does at the prompt, because the only thing a script changes is where the next
line comes from - the shell splits it, matches it and runs it without knowing the difference.
```
#! script
; Build the system and put it where the machine will find it.
echo building CosmOS
cd /Source/CosmOS
Asm cosmos.asm
echo done
```
`echo` is a command rather than a program on purpose. `Say.sbx` has printed words since before
there were scripts and is the wrong shape for one: being a program, it has to be found on the
disk and loaded and started, it prefixes what it was told with `it says:`, and the system
prints `finished` after it - three lines of noise around one line of narration.
**The first two bytes must be `#!`**, or the shell refuses the file and says so. That is what
tells a script from anything else, and it is deliberately not the name and not a flag in the
directory entry: the rule this filesystem keeps is that an entry holds only what the content
cannot say about itself, and a script can say what it is. The loader already refuses anything
that does not begin `SBEX`, so the two kinds of runnable file turn each other away without
either of them having been told about the other.
What follows the `#!` is ignored. It is where the name of an interpreter goes if there is ever
a second one; today there is one and it is this shell.
**`#` is a directive and `;` is a comment, exactly as in SplitBit assembly.** One rule across
the machine rather than two dialects: `#` means this line is about the file, `;` means ignore
this line. Comments and blank lines never reach the shell at all - they are dropped by the
reader, so they are not echoed and the dispatch never sees a line it would have to know to
ignore. This is not Unix's convention and is not trying to be; there `#!` genuinely is a
comment that only the kernel looks at, while here the shell requires it.
**Each line is echoed as it runs**, after the prompt, so that a script reads exactly like
somebody typing it and a script that stops says where.
**`#quiet` turns that off and `#loud` turns it back on.** The prompt and the echo go together,
because together they are what makes a script look like typing - so a quiet script gets
neither, and what it prints is all that appears. That is for the scripts whose own output is
the point, where prompts interleaved with the message are just in the way:
```
#! script
#quiet
clear
echo Segan Voyager
echo CosmOS ready.
```
A nested script inherits quiet from the one that started it, on the grounds that a build which
asked for quiet meant its helpers too, and gets its own setting back when the helper returns.
A script started from the prompt always begins loud.
**Anything else beginning with `#` is handed to the shell**, which does not know it, says so,
and stops the script. A script that asked for something this shell cannot do should not carry
on as though it had been given it.
## Starting Itself:
If `/System/Boot/startup.sh` is there, it runs before anybody can type - every way of reaching
the prompt for the first time goes through it, including the one where there is no disk, in
which case there is simply nothing to find.
**A missing one says nothing**, because a clean install has none and a machine that complained
every boot about a file nobody wrote would be teaching its owner to ignore it. A file that *is*
there and does not begin with `#!` is the other case entirely - somebody meant that to run - so
it says so and carries on to the prompt.
Between them, `startup.sh` and `#quiet` are how a machine gets a face:
```
#! script
#quiet
clear
echo Segan Voyager
echo CosmOS ready.
```
**A script stops at the first line that does not work.** A build whose first step failed and
whose second step ran anyway produces something wrong and reports success, which is the whole
reason the shell now remembers whether a line worked. What counts as not working is a command
that failed, a name the shell does not know, or a program that exited with a status. Nothing
is printed but `stopped: that line did not work` - whatever failed has already said what was
wrong in words.
**A script running out is not the same as typing running out.** The console ending means
there is nobody there and the shell stops; a script ending means go back to whoever asked for
it, so the next line comes from the console again.
The interactive assembler reads its lines the same way, so a script can contain a block of
assembly and end it with a `.` just as you would by hand.
**A script can run another script, four deep.** What is remembered when one script starts
another is a position and not a buffer - the name, which block comes next, how many are left,
and where in the block it had got to. The block itself is read again on the way back, which
costs one disk read per return and saves a 257-byte buffer per level. Four is deep enough for
a script calling a script that calls a helper, and shallow enough that a script which runs
itself says `do: scripts are only four deep` rather than filling memory.
**A line that fails stops every level**, not just the innermost. A build whose helper script
failed should not carry on in the script that called the helper.
## Shell Commands: ## Shell Commands:
CosmOS currently provides these built-in commands: CosmOS currently provides these built-in commands:
@@ -93,6 +231,10 @@ CosmOS currently provides these built-in commands:
| `dir` | List the files on the mounted disk and their sizes. | | `dir` | List the files on the mounted disk and their sizes. |
| `load <path>` | Read and validate an SBEX application, then place its code and data where its header requests. | | `load <path>` | Read and validate an SBEX application, then place its code and data where its header requests. |
| `run [words]` | Start the loaded application and make the rest of the line available to it as an argument. | | `run [words]` | Start the loaded application and make the rest of the line available to it as an argument. |
| `do <script>` | Run the lines in a file as though they had been typed. See Scripts. |
| `echo [words]` | Say the rest of the line, or a blank line with nothing after it. |
| `clear` | Empty the screen. |
| `drive [n]` | Say which disk the shell is on, or go to another. See Several Disks. |
| `cd [path]` | Go to a directory, or to the root with nothing after it. | | `cd [path]` | Go to a directory, or to the root with nothing after it. |
| `mkdir <path>` | Make a directory. | | `mkdir <path>` | Make a directory. |
| `rmdir <path>` | Remove one, if it is empty. | | `rmdir <path>` | Remove one, if it is empty. |
@@ -141,6 +283,37 @@ facility and as a test that CosmOS correctly restores its Stack and vector table
every run; and `load` is how the monitor puts an arbitrary file in front of itself, which every run; and `load` is how the monitor puts an arbitrary file in front of itself, which
is a thing typing a name deliberately cannot do. is a thing typing a name deliberately cannot do.
### Typing A Line:
The shell reads what you type a key at a time and edits the line itself, which is why the
line can be moved about in at all.
| Key | What it does |
| -- | -- |
| Left, Right | Move a character. |
| Home, End | Go to the start of the line or the end of it. |
| Backspace | Take out the character before the cursor. |
| Delete | Take out the one under it. |
| Return | Finish the line, wherever the cursor happens to be sitting. |
Anything typed goes in where the cursor is, so a word left out of the middle of a line is
put back by moving there and typing it, and the rest of the line moves along.
**This used to be three different things depending on where the machine was running.** On a
terminal the host held the line and did the echoing and the backspacing; behind a window the
console's own gatherer did it; from a file nothing did it at all. One job, three
implementations, and none of them here - which is why there was no way to move about in a
line, and nowhere for a history to live. Now the console delivers keys and says nothing
about what they mean, the same way it reports what a drive is and says nothing about what
should be on it, and the shell decides.
The line holds 127 characters. It held 63 until the shell could edit one, which is when the
limit started to be felt: a copy between two disks with a directory on each is most of the
way there before anything has been said.
Everything else that reads a line - an application calling `osReadLine`, the editor - is
unchanged and still gets a plain line with no editing in it.
### Paths: ### Paths:
Everywhere CosmOS takes a filename it will take a path: names with `/` between them, Everywhere CosmOS takes a filename it will take a path: names with `/` between them,
@@ -163,6 +336,29 @@ Each *name* along a path is still the 22 characters a directory entry holds, and
one is refused rather than cut short, because a name cut to 22 characters is a different one is refused rather than cut short, because a name cut to 22 characters is a different
name that might well be some other file's. name that might well be some other file's.
### What It Costs:
**The assembler is superlinear in what it reads**, and that is worth knowing before reaching
for it on something large. Cycles per byte of source climb with the size of the source:
| Source | Bytes | Cycles a byte |
| --- | --- | --- |
| `colours.asm` | 4,299 | 1,383 |
| `Edit.asm` | 16,778 | about 3,000 |
| `cosmos.asm` | about 104,000 | 6,290 |
So assembling the operating system is 654 million cycles, which is eleven minutes at a
megahertz. **It is not the disk**: the same build costs 654 million on a disk carrying the
whole source tree and 653 million on a flat one with a sixth as many files. The suspected
cause is looking a label up by walking the whole table, of which there are about nine
hundred, once for every reference - suspected rather than measured.
Nothing is being done about it, deliberately. Development happens with the host assembler,
which is where the tooling is, and the machine assembling itself is a demonstration that it
can rather than the way anybody works. But faster hardware buys a constant factor and does
not change the shape of the curve, so the program that eventually forces this is not CosmOS
- it is the first one twice its size.
### The Working Directory: ### The Working Directory:
`cd` moves the machine. A path beginning with `/` is measured from the root and anything `cd` moves the machine. A path beginning with `/` is measured from the root and anything
@@ -323,7 +519,7 @@ without stopping the machine.
## What Is On The Disk: ## What Is On The Disk:
`make -C Programs cosmos-disk` builds the disk this system is meant to be met on, and it is `make disk` builds the disk this system is meant to be met on, and it is
laid out in three directories: laid out in three directories:
| Where | What | | Where | What |
@@ -367,6 +563,7 @@ from every assembly file in it. Several are old programs written for the bare ma
| Settle | Says how the last start went and tells the machine to stop falling back, in 353 bytes. A program rather than a shell word, because the shell is for what cannot be done without it. | | Settle | Says how the last start went and tells the machine to stop falling back, in 353 bytes. A program rather than a shell word, because the shell is for what cannot be done without it. |
| Files | Writes a file, reads it back, renames it and deletes it, in 675 bytes, including nothing but the service names. It is what says a program does not need a filesystem inside it. | | Files | Writes a file, reads it back, renames it and deletes it, in 675 bytes, including nothing but the service names. It is what says a program does not need a filesystem inside it. |
| Break | Stops itself twice with SWI osBreak, so that the registers can be seen changing between one stop and the next. | | Break | Stops itself twice with SWI osBreak, so that the registers can be seen changing between one stop and the next. |
| Grid | The first program to use the screen as a screen. Redefines a tile above the font, fills all 128 map rows, and scrolls it diagonally a pixel at a time. |
| Edit | A line editor. | | Edit | A line editor. |
| Stream | Reads an 84,000 byte file through a buffer of 256, which is what says a file bigger than Data Memory can be read at all. | | Stream | Reads an 84,000 byte file through a buffer of 256, which is what says a file bigger than Data Memory can be read at all. |
| Type | Prints a named text file a block at a time, including one too large to fit in Data Memory. | | Type | Prints a named text file a block at a time, including one too large to fit in Data Memory. |
@@ -466,7 +663,7 @@ Typed in as bytes, checked by disassembling it back, and run. It ends with `SWI
`Edit` is the first program on this machine that makes a file a person typed - every byte on every disk before it was put there by the host tool. It is line oriented in the manner of `ed`: `l` lists, `a` adds at the end, `i` and `c` and `d` take a line number, `w` writes and `q` stops. `Edit` is the first program on this machine that makes a file a person typed - every byte on every disk before it was put there by the host tool. It is line oriented in the manner of `ed`: `l` lists, `a` adds at the end, `i` and `c` and `d` take a line number, `w` writes and `q` stops.
It includes nothing but `services.asm` and `text.asm`: the filesystem and the console are the system's, asked for rather than carried. That is what brought `Edit` down from 4,941 bytes to 2,157 bytes without a line of its own logic changing - and the way that was checked is worth knowing, because the recorded output of the `cosmosEdit` test did not move by a single byte across the rewrite. It includes nothing but `services.asm` and `text.asm`: the filesystem and the console are the system's, asked for rather than carried. That is what brought `Edit` down from 4,941 bytes to 2,243 bytes without a line of its own logic changing - and the way that was checked is worth knowing, because the recorded output of the `cosmosEdit` test did not move by a single byte across the rewrite.
**A line it reads in is at most 128 characters**, the same length a line is everywhere else **A line it reads in is at most 128 characters**, the same length a line is everywhere else
on this machine, and a file with a longer one is refused rather than opened. Refused rather on this machine, and a file with a longer one is refused rather than opened. Refused rather
@@ -491,7 +688,7 @@ which assembles its own now.
### A Clean Install: ### A Clean Install:
`make cosmos-disk` in `Programs/` lays down a disk the machine can start itself from, and `make disk` lays down a disk the machine can start itself from, and
`make run-cosmos` starts it - with no boot image named, so the emulator shadows its ROM and `make run-cosmos` starts it - with no boot image named, so the emulator shadows its ROM and
reads the disk for everything else. reads the disk for everything else.
@@ -515,6 +712,56 @@ none, and a clean install having nothing to configure is the right default.
outside with nothing on the disk consulted. That is what a debugger does, and it is what to outside with nothing on the disk consulted. That is what a debugger does, and it is what to
use when the thing being debugged is the boot chain, since it skips the boot chain. use when the thing being debugged is the boot chain, since it skips the boot chain.
### Grid, and what a tile engine costs:
Everything else drawn on this machine has been text or a bitmap. `Grid` is the first program
to use the tile engine as an engine, and it is worth reading for the size of the numbers.
It scrolls **a pixel a frame, diagonally**, and the whole of that is four port writes and two
carries. The map is 128 rows and 128 columns against a screen of 50 and 80, so the cells
around the edge are already drawn - scrolling moves the origin rather than 2,000 bytes of
screen, and what leaves the top has not gone anywhere.
It fills **all 128 columns of every map row**, not the eighty the screen shows. That is the
distinction a scrolling program has to make: asking the screen how wide it is - which there is
a register for - gives you the window, and a program that scrolls wants everything the window
can be moved over. Filling only the window leaves 48 empty columns, and scrolling sideways
walks into them.
The coarse registers move a whole cell and the fine ones move the remainder, and **they do not
carry into each other**, so the program does:
```asm
SETD.0 FineDown
LDA.0
INCA
INIB 0x07
AND
STQ.0
OUTQ 0x38
BNQ stepAcross ; Still inside the cell.
; ... and here, one step of the row origin.
```
The `AND` is both the wrap and the test: `Q` coming out as nought is exactly the moment the
cell boundary was crossed. It moved eight pixels every fourth frame before the fine registers
existed, which reads as the picture jumping rather than travelling.
It puts its tile at **200**, because the machine wakes with the font in tile memory - glyph n
at tile n, for 135 of the 256 - so a program that starts at zero paints over the alphabet and
the shell it is about to hand the machine back to. Above 135 is empty and nobody else's.
Its sixteen colour schemes are **one tile**, not sixteen. A cell's attribute nibble is added
to every palette index in it, sixteen at a time, so the same 64 bytes come out in sixteen
colourings and the map bands down the screen as it scrolls.
**What it cannot give back is the palette.** The console's colours are sixteen banks at
exactly the entries the attribute nibble lands on, so any program using the nibble overwrites
them and there is nowhere else for it to write. `Grid` restores bank 0 - grey on black, what
plain text has always been - and leaves the other fifteen as it made them. The proper answer
is a command to the screen meaning "give me back what you woke up with", the way the console
has one for clearing. There is not one yet, and this is the first program that ever wanted it.
## The Application Model: ## The Application Model:
CosmOS divides the two SplitBit address spaces by convention: CosmOS divides the two SplitBit address spaces by convention:
@@ -582,6 +829,163 @@ An application may also include its own libraries or access hardware ports direc
The services are an interface offered by the system, not the only way software is allowed The services are an interface offered by the system, not the only way software is allowed
to use the computer. to use the computer.
### Several Disks:
The machine has four drives behind one controller, and `drive` says which one the shell is
standing on. `drive 1` goes to another, and **the working directory goes with it** - where you
are on a disk is part of which disk you are on, not something the shell keeps on the side. Go
back and you are where you were.
Every drive is mounted at boot: the controller says how many are plugged in and each is tried
in turn. A drive with nothing in it, or a disk this cannot read, is left unmounted rather than
stopping the others, so a machine with a good disk in drive 0 and a blank in drive 1 starts
normally. `drive 1` then says there is nothing readable there, which is a different answer
from there being no such drive.
**What a mounted disk is, is eight bytes**: where its directory starts, how many blocks it is,
how big the disk is, and where you are on it. They sit together in the data segment on purpose,
because changing drives is one copy out and one copy in - and the other three thousand lines
of filesystem go on reading the same four names they always have and never learn that more
than one disk exists. That is the whole reason this was affordable.
The version is not among them. It is checked at mount and thrown away, because a version one
disk's zero parent already reads as "in the root", which is where all of its files are.
**A path may name a drive**, as a digit and a colon on the front: `1:/notes`, or `1:` on its
own for wherever that drive already was. It is handled where every path in the system arrives,
so it works for anything that takes one rather than for whichever commands somebody remembered.
**Naming a drive goes there and stays there.** Switching for the length of one command and
switching back reads better in a listing and cannot work: what a path resolves to is a start
block and a length, and those mean nothing without the drive they were read from. A `load`
that resolved on drive 1 and then read its blocks on drive 0 would read the right blocks of
the wrong disk.
**A name that begins with a digit is still a name**, because the colon is the whole of what
tells the two apart. `2things` is a directory; `2:` is a drive.
A drive the machine cannot read makes the whole path unfindable, and says so as `no such
file` - which it is, since there is nowhere for the rest of it to be.
**Copying between two disks is one command**: `Copy 1:/notes.txt 0:/keep.txt`. Every
`osFileBlock` names its path again and so goes back to the source drive; the write stream
remembers the drive it was opened on and returns there for each block. Between them the copy
walks back and forth without `Copy` itself knowing there is more than one disk.
### A Disk Of Your Own:
`make run-voyager` puts a second disk in **drive 1**, at `Disks/personal.img`, and a scratch
drive made of memory in **drive 2**. The scratch drive is what `osTakeScreen` writes to; drive
1 is yours and comes after nothing, so adding the scratch drive later did not renumber it. It is made the
first time it is needed and then left alone: never rebuilt, never cleaned, never committed.
That last part is the point. Everything else in this repository is made from source and can be
thrown away without losing anything - but a disk is where something *made on the machine*
lives, and a disk that `make clean` deletes is not a disk of your own. It sits outside
`build/` for exactly that reason, and `Disks/` is in `.gitignore`.
Delete it by hand if you ever want a fresh one.
### Disks Made Of Memory:
A drive the machine calls **volatile** loses everything when the machine stops. CosmOS formats
one it cannot read, because a drive whose contents do not survive never had anything to lose,
and mounts it like any other - so `--ram-disk 2048` gives you a working disk with nothing on
it, brought up before you reach a prompt.
**It leaves every other unreadable drive alone.** An unformatted floppy is not an invitation.
That distinction is the machine's to state and the system's to act on: the hardware says what
a drive *is*, and says nothing about filesystems, which is what leaves room for a system that
would rather have its own.
The size to format comes from the drive, not from a superblock - a superblock states a size
too, and that is no use on a disk which has not got one. The directory is sixteen blocks, 128
names, chosen rather than worked out: a scratch disk runs out of names long before it runs out
of room, and this machine cannot divide.
### Giving The Screen Back:
A program that takes the whole screen leaves the shell a blank one, and whatever was on it -
the listing you were reading, the error you were about to act on - is gone. There is nowhere
to put 48K of video memory on a machine with 64K of Data Memory that CosmOS is already living
in.
**A drive made of memory is somewhere.** `SWI osTakeScreen` says *"I am about to use the whole
screen, and would like what is on it now put back when I exit."* The system writes video
memory to a file on the scratch drive and restores it from `handleExit`, alongside the vectors
and console mode it already puts back. Q is zero if that was arranged; a machine with no
volatile drive says no, and **a program told no should carry on regardless**, because it was
going to before this existed.
**A program told no must cope.** There is no volatile drive on every machine, and a refusal is
not a fault - it means doing what the program would have done before there was anywhere to
save a screen. `Grid` clears up after itself when refused, which is the difference between a
clean prompt and a prompt printed into somebody's grid.
**The system always leaves the screen usable, refusal or not.** The fine scroll registers go
back to zero at every program exit, because the console draws in whole cells and a view three
pixels into one puts every character three pixels out for ever. That is true whether or not
the picture could be saved, so it is not part of the saving.
**It is not automatic, and that is the point.** Saving on every program start would be cheap
enough, but restoring on every exit would be wrong: `dir`, `Files` and `Say` print and stop,
and their output is the reason you ran them. A program that says nothing behaves exactly as
every program did before this existed.
**Tiles, map and palette all go** - 196 pages, and a block on the front holding the cursor,
the four scroll registers and the mode. The map's off-screen rows are the console's
scrollback; the tiles are the font, which a program that redefines one has overwritten; and
the palette is where the console's own colours live. `Grid` used to give back the map and not
the colours, and handed the shell green text on a blue ground.
It also replaced thirty lines of `Grid`: four scroll registers put back by hand, the map
filled with spaces, the cursor sent home, and palette bank 0 written out - all of which was
*still* wrong, because the other fifteen banks kept `Grid`'s colours and there was nowhere to
have kept the real ones.
### Where A Program Is Looked For:
Three places, tried in order:
1. Where you are.
2. `/Apps` on the disk you are on.
3. `/Apps` on drive 0.
The first makes a program you are working on the one that runs. The second makes `Snake` work
from any directory. **The third makes the system's programs work from a disk of your own** -
one with your files on it and no system - which is most of the point of having a second disk.
**Fetching a program does not move you, and neither does running one.** The drive is put back
after the load, because by then the program is in memory and the block numbers it came from
mean nothing; and put back again when the program exits, because a program that copies between
two disks moves the drive as its own paths need it to and being left wherever it finished is
not what anybody asked for. So `Copy 1:/a 0:/b` leaves you exactly where you were.
### Bank Numbers Are One Namespace:
A program that wants a device's memory registers it as a bank, and **bank numbers belong to
the whole machine**. Nothing hands them out and nothing refuses a number that is already
spoken for - registering one that is taken does not fail, it succeeds, and whatever held it
before quietly answers to nothing.
| Bank | Whose |
| --- | --- |
| 0 | Program Memory. The machine's. |
| 1 | Data Memory. The machine's. |
| 2 | The bank table. The machine's. |
| 3 | The disk's buffer, given at mount by `sbfsMount`. CosmOS needs it for as long as it is running. |
| 4 and up | Free for a program to use. |
`Grid` learned this the hard way and is the reason the table is here. It asked for 3, took the
disk's buffer, and every read the filesystem made afterwards came out of video memory - so the
shell found an empty disk and could not start anything by name, several commands after the
program that did it had exited. Nothing said a word, because from the controller's point of
view nothing went wrong.
**A program returns a bank by giving it back**, which today means knowing what was there
before. There is no service that hands out a free number, and if this becomes a common thing
for programs to want then that is what should exist rather than a longer table.
## What A Program May Ask The System For: ## What A Program May Ask The System For:
A loaded program is on its own hardware and can do anything the machine can do - it is a fence, not a wall. But the things it usually wants are things the system is already doing, and asking is both shorter and the only way to reach code that was assembled separately. `CALL` needs a label, and a label has to be in the same assembly; `SWI` needs only a number both sides agree on. A loaded program is on its own hardware and can do anything the machine can do - it is a fence, not a wall. But the things it usually wants are things the system is already doing, and asking is both shorter and the only way to reach code that was assembled separately. `CALL` needs a label, and a label has to be in the same assembly; `SWI` needs only a number both sides agree on.
@@ -608,6 +1012,7 @@ Those numbers are written down once, in `Programs/CosmOS/Source/services.asm`, w
| osPrintNumber | A and B together are a number. Prints it in decimal, without leading zeroes. | | osPrintNumber | A and B together are a number. Prints it in decimal, without leading zeroes. |
| osBreak | Stops the program, shows every register as it had them, waits for a key, and carries on. | | osBreak | Stops the program, shows every register as it had them, waits for a key, and carries on. |
| osLastStatus | Q answers what the last program exited with: 0 it did what it was asked, 1 it did not, 2 it was asked wrongly. A program may give its own meanings if it says so. | | osLastStatus | Q answers what the last program exited with: 0 it did what it was asked, 1 it did not, 2 it was asked wrongly. A program may give its own meanings if it says so. |
| osTakeScreen | Says this program is about to use the whole screen and would like what is on it put back when it exits. Q is zero if that was arranged; anything else means it was not, which is the ordinary answer on a machine with no volatile drive. See Giving The Screen Back. |
| osBootState | Q answers how the last start went: 0 settled, 1 trying, 2 fell back. A machine with no disk answers settled, because there is nothing there to be unsettled about. | | osBootState | Q answers how the last start went: 0 settled, 1 trying, 2 fell back. A machine with no disk answers settled, because there is nothing there to be unsettled about. |
| osBootSettle | Puts it back to settled, which is how a machine that fell back is told the situation has changed. Q is zero if the disk took it. **Settling is the only write a program gets** - marking a start as trying or fallen back is the loader's business, and a service that let a program claim either would let it lie about something the loader cannot check. | | osBootSettle | Puts it back to settled, which is how a machine that fell back is told the situation has changed. Q is zero if the disk took it. **Settling is the only write a program gets** - marking a start as trying or fallen back is the loader's business, and a service that let a program claim either would let it lie about something the loader cannot check. |
File diff suppressed because it is too large Load Diff
+628 -7
View File
@@ -136,6 +136,409 @@ sbfsMountTooBig:
ADD ; Q is not zero: not a disk this will mount. ADD ; Q is not zero: not a disk this will mount.
RET RET
; ---- Making a disk into a filesystem ----
;
; The machine hands out blocks and says how many; what they mean is the system's business.
; That is the whole reason this exists here rather than only in the host tool: a machine with
; a drive made of memory comes up with a drive full of zeroes, and zeroes are not a
; filesystem. Somebody has to write the first one, and it should be whoever is going to read
; it - which is also what leaves room for a system that would rather have its own.
;
; Q is zero if it worked. Everything on the drive is lost, which is why nothing calls this
; except on a drive the machine has said is volatile.
sbfsFormat:
; How big it is, which only the drive can say. A superblock would say too, and a disk with
; no superblock is exactly the case this is for.
INA 0x27
SETD.1 SbfsScratch
STA.1
INA 0x28
INCD.1
STA.1
; The buffer, cleared, because everything not written below has to be nought and the
; controller's buffer holds whatever was last read.
SETD.0 SbfsBuffer
RSTB
sbfsFormatClear:
RSTA
STA.0
INCD.0
DECB
BNB sbfsFormatClear ; 256 of them: B wraps from nought to 255 and back to nought.
; "SBFS", and the version. Two, because a disk made now has directories.
SETD.0 SbfsMagic
SETD.1 SbfsBuffer
INIB 0d4
sbfsFormatMagic:
LDA.0
STA.1
INCD.0
INCD.1
DECB
BNB sbfsFormatMagic
INIA 0d2
STA.1 ; Offset 4: the version.
; Offset 6, how many blocks the disk has, as the drive reported it.
SETD.1 SbfsBuffer
DPUP.1 0d06
SETD.0 SbfsScratch
LDA.0
STA.1
INCD.0
INCD.1
LDA.0
STA.1
; Offset 8, where the directory starts: block one, straight after this one.
SETD.1 SbfsBuffer
DPUP.1 0d08
RSTA
STA.1
INCD.1
INIA 0d1
STA.1
; Offset 10, how many blocks of directory. SIXTEEN, WHICH IS 128 NAMES, and chosen rather
; than worked out: a scratch disk runs out of names long before it runs out of room, and
; this machine cannot divide, so a number that fits every size this is used for is worth
; more than arithmetic to find a better one.
SETD.1 SbfsBuffer
DPUP.1 0d10
RSTA
STA.1
INCD.1
INIA 0d16
STA.1
; Block nought, written.
SETD.0 SbfsBlock
RSTA
STA.0
INCD.0
STA.0
SETD.1 SbfsBuffer
CALL sbfsBufferIn
CALL sbfsWriteBlock
BNQ sbfsFormatFailed
; And the directory cleared, so that no entry is in use. The buffer is already nought
; everywhere the superblock did not reach, so it is cleared once more and written sixteen
; times rather than built again each time.
SETD.0 SbfsBuffer
RSTB
sbfsFormatBlank:
RSTA
STA.0
INCD.0
DECB
BNB sbfsFormatBlank
INIA 0d16
SETD.1 SbfsCount
STA.1
sbfsFormatDirectory:
SETD.0 SbfsBlock
INCD.0
LDA.0
INCA
STA.0 ; Blocks one to sixteen. A directory never crosses 255 here.
SETD.1 SbfsBuffer
CALL sbfsBufferIn
CALL sbfsWriteBlock
BNQ sbfsFormatFailed
SETD.1 SbfsCount
LDA.1
DECA
STA.1
BNA sbfsFormatDirectory
RSTA
RSTB
CCF
ADD
RET
sbfsFormatFailed:
RSTA
INIB 0d1
CCF
ADD
RET
; ---- Every drive the machine has ----
;
; Asked for rather than assumed: the controller says how many are plugged in, and each is
; selected and mounted in turn. A drive with nothing in it, or a disk this cannot read, is
; left unmounted rather than stopping the others - a machine with a good disk in drive 0 and
; a blank in drive 1 should start.
;
; Q is zero if drive 0 mounted, because that is the one the system came off and the one the
; shell will be standing in when it gets a prompt.
sbfsMountAll:
RSTA
SETD.1 SbfsMounted
STA.1
SETD.1 SbfsDrive
STA.1
OUTA 0x24 ; Drive 0, whatever the controller was left on.
INIA 0xFF
SETD.1 SbfsScratch1
STA.1 ; No scratch drive until one is found.
; The live record belongs to nobody yet, so every slot starts empty and a drive that fails
; to mount keeps an empty one.
SETD.0 SbfsMountTable
INIB 0d32
sbfsMountClear:
RSTA
STA.0
INCD.0
DECB
BNB sbfsMountClear
INA 0x25
SETD.1 SbfsDriveCount
STA.1
RSTA
SETD.1 SbfsDriveAt
STA.1
sbfsMountEach:
SETD.1 SbfsDriveAt
LDA.1
SETD.1 SbfsDriveCount
LDB.1
CCF
SUB
BRQ sbfsMountAllDone ; Past the last one.
; Selected directly rather than through sbfsUse: there is nothing in the live record worth
; putting back yet, and sbfsUse would copy eight bytes of nothing into a slot.
SETD.1 SbfsDriveAt
LDA.1
OUTA 0x24
SETD.1 SbfsDrive
STA.1
RSTA
SETD.1 SbfsBufferKnown
STA.1
CALL sbfsMount
BRQ sbfsMountGot
; ---- Nothing readable, and whether that is an invitation ----
;
; A drive the machine calls VOLATILE loses everything when the machine stops, so a volatile
; drive with no filesystem on it never had one to lose and bringing it up is the system's
; job. Anything else is somebody's disk: an unformatted floppy is not an invitation, it is
; a blank floppy, and a system that formatted it on sight would be a system you could not
; safely put a disk into.
;
; THE MACHINE SAYS WHAT THE DRIVE IS AND NOTHING ABOUT FILESYSTEMS. A system that would
; rather have its own reads the same bit and writes whatever it likes.
INA 0x26
INIB 0x01 ; VOLATILE
AND
BRQ sbfsMountNext ; Not ours to touch.
CALL sbfsFormat
BNQ sbfsMountNext
CALL sbfsMount
BNQ sbfsMountNext
sbfsMountGot:
; ---- And whether this is the one to keep scratch on ----
;
; A volatile drive is somewhere the system may write without asking, because nothing on it
; outlives the machine. The FIRST one found is the scratch drive; a machine with two has
; made a decision nobody expressed, and taking the lower number is at least predictable.
INA 0x26
INIB 0x01
AND
BRQ sbfsMountNotScratch
SETD.1 SbfsScratch1
LDA.1
INIB 0xFF
CCF
SUB
BNQ sbfsMountNotScratch ; There is one already.
SETD.1 SbfsDriveAt
LDA.1
SETD.1 SbfsScratch1
STA.1
sbfsMountNotScratch:
; Mounted: remember which, and put the live record where it belongs.
SETD.1 SbfsDriveAt
LDA.1
CALL sbfsDriveBit
MVQA ; The bit is in Q, and RET put the old A back. OR reads A and B.
SETD.1 SbfsMounted
LDB.1
OR
STQ.1
SETD.1 SbfsDriveAt
LDA.1
CALL sbfsSlotAt
PSHD.3
POPD.1
SETD.0 SbfsMountLive
CALL sbfsCopyMount
sbfsMountNext:
SETD.1 SbfsDriveAt
LDA.1
INCA
STA.1
BRI sbfsMountEach
sbfsMountAllDone:
; Back to drive 0 and its record, which is where a shell starts. A stays nought throughout,
; which is the drive, the note on the buffer, and the slot to fetch.
RSTA
OUTA 0x24
SETD.1 SbfsDrive
STA.1
SETD.1 SbfsBufferKnown
STA.1
CALL sbfsSlotAt
PSHD.3
POPD.0
SETD.1 SbfsMountLive
CALL sbfsCopyMount
; Is drive 0 one of the ones that mounted?
INIA 0x01
SETD.1 SbfsMounted
LDB.1
AND
BRQ sbfsMountAllNone
RSTA
RSTB
CCF
ADD
RET
sbfsMountAllNone:
RSTA
INIB 0d1
CCF
ADD
RET
; A = a drive number. Q is the bit that stands for it in SbfsMounted.
sbfsDriveBit:
INIB 0x01
sbfsDriveBitStep:
BRA sbfsDriveBitDone
PSHA
RSTA
SHL ; A and B are one register to SHL, so with A nought this is B
POPA ; doubled - which for four drives never reaches the top.
DECA
BRI sbfsDriveBitStep
sbfsDriveBitDone:
RSTA
CCF
ADD ; Q = B, which is the bit.
RET
; ---- Changing which disk is the disk ----
;
; A holds the drive. Q is zero if it is now the one in use.
;
; The eight live bytes go back to the drive they belong to and the wanted drive's come in.
; Everything below this line in the file goes on reading the same four names it always has and
; never learns that more than one disk exists - which is the whole of why this is affordable.
;
; The buffer is FORGOTTEN, and that is not tidiness. The controller has one buffer shared by
; every drive, so the note of which block is in it is wrong the moment the drive changes.
; Leaving it would mean the next read of that block number quietly skipping the disk and
; handing back the other drive's data.
sbfsUse:
SETD.1 SbfsDrive
LDB.1
CCF
SUB
BRQ sbfsUseAlready ; Already there, and swapping would be a long way round to nothing.
PSHA ; The drive that was asked for, kept across the copying.
; The live record back to the drive it belongs to. DP1 is still SbfsDrive, from the
; comparison above.
LDA.1
CALL sbfsSlotAt
PSHD.3
POPD.1
SETD.0 SbfsMountLive
CALL sbfsCopyMount
; And the wanted drive's record into the live eight.
POPA
PSHA
CALL sbfsSlotAt
PSHD.3
POPD.0
SETD.1 SbfsMountLive
CALL sbfsCopyMount
POPA
SETD.1 SbfsDrive
STA.1
OUTA 0x24
; ---- The note on the buffer belongs to the drive that is leaving ----
;
; One buffer serves every drive, so "block 31 is in the buffer" stops being true the moment
; the drive changes, and a read of block 31 that trusted it would hand back the other disk.
;
; IT CANNOT CURRENTLY BE REACHED, and that is worth writing down rather than leaving as an
; implied claim. Only the file read-ahead consults the note - a directory scan deliberately
; does not - and finding a file requires a scan, which overwrites the note on the way past.
; Two disks were built with the same file at the same block to try to catch it and the
; answer was right either way.
;
; Kept because it is three instructions and it holds an invariant rather than patching a
; symptom: the note describes the selected drive. The day something reads two files without
; a directory between them, this is already true instead of being a bug with a story.
RSTA
SETD.1 SbfsBufferKnown
STA.1
sbfsUseAlready:
RSTA
RSTB
CCF
ADD
RET
; DP3 = the eight bytes belonging to the drive in A. Stepped rather than multiplied, because
; this machine cannot multiply and there are at most three steps.
sbfsSlotAt:
SETD.3 SbfsMountTable
BRA sbfsSlotThere
sbfsSlotStep:
DPUP.3 0d08
DECA
BNA sbfsSlotStep
sbfsSlotThere:
RET
; Eight bytes, DP0 to DP1.
sbfsCopyMount:
INIB 0d8
sbfsCopyMountByte:
LDA.0
STA.1
INCD.0
INCD.1
DECB
BNB sbfsCopyMountByte
RET
; ---- Finding something by path ---- ; ---- Finding something by path ----
; ;
; DP0 points at a path ending in a zero byte: names with '/' between them. A path that ; DP0 points at a path ending in a zero byte: names with '/' between them. A path that
@@ -188,12 +591,118 @@ sbfsFindRoot:
ADD ADD
RET RET
; ---- Is there a drive on the front of this path, and can we go there? ----
;
; A digit and a colon. Q is zero if the path is usable, whether or not one was there; Q is one
; if a drive was named and it is not one this machine can read, which makes the whole path
; unfindable - because it is.
;
; NAMING A DRIVE GOES THERE AND STAYS THERE. The alternative was to switch for the operation
; and switch back, which reads better in a listing and cannot work: what a path resolves to is
; a start block and a length, and those mean nothing without the drive they were read from. A
; load that resolved on drive 1 and then read on drive 0 would read the right blocks of the
; wrong disk.
sbfsPathDrive:
SETD.1 SbfsPathAt
LDD.0.1
LDA.0
INIB 0d48 ; '0'
CCF
SUB
BRC sbfsPathNoDrive ; Borrowed, so it is below '0' and not a digit.
MVQA
INIB 0d10
CCF
SUB
BNC sbfsPathNoDrive ; Ten or more, so not a digit either.
; The character after it has to be a colon, or this is a name that begins with a digit.
; DP1 is still SbfsPathAt, from the top of this routine.
LDD.0.1
INCD.0
LDA.0
INIB 0d58 ; ':'
CCF
SUB
BNQ sbfsPathNoDrive
; It is a drive. Is it one this machine has, with something readable in it?
LDD.0.1
LDA.0
INIB 0d48
CCF
SUB
MVQA
SETD.1 SbfsPathWanted
STA.1
CALL sbfsDriveBit
MVQA
SETD.1 SbfsMounted
LDB.1
AND
BRQ sbfsPathBadDrive
SETD.1 SbfsPathWanted
LDA.1
CALL sbfsUse
; Past the digit and the colon. What follows is an ordinary path, and a bare "1:" is an
; empty one - which walks to where that drive already was.
SETD.1 SbfsPathAt
LDD.0.1
INCD.0
INCD.0
STD.0.1
sbfsPathNoDrive:
RSTA
RSTB
CCF
ADD
RET
sbfsPathBadDrive:
RSTA
INIB 0d1
CCF
ADD
RET
; The walk itself. Q is zero if the whole path was walked, and SbfsAt says where it ended - ; The walk itself. Q is zero if the whole path was walked, and SbfsAt says where it ended -
; which may be the root, and that is an answer rather than a failure. ; which may be the root, and that is an answer rather than a failure.
sbfsWalk: sbfsWalk:
SETD.1 SbfsPathAt SETD.1 SbfsPathAt
STD.0.1 STD.0.1
; ---- A drive in front of the path ----
;
; Done here because this is where every path in the system arrives - eight callers between
; the shell, the config reader and this file - so naming a drive works everywhere at once
; rather than in whichever commands somebody remembered.
CALL sbfsPathDrive
BNQ sbfsWalkNoDrive
; ---- And DP0 has to be told ----
;
; sbfsPathDrive moved SbfsPathAt past the digit and the colon, but RET put DP0 back the way
; it found it - so the test below for a leading separator was reading the DIGIT and calling
; every prefixed path relative. It only showed when the drive being named was standing
; somewhere other than its root, because a relative walk from the root is an absolute one.
SETD.1 SbfsPathAt
LDD.0.1
BRI sbfsWalkPath
; A drive that this machine cannot read makes the path unfindable, because it is: there is
; nowhere for the rest of it to be.
sbfsWalkNoDrive:
RSTA
INIB 0d1
CCF
ADD
RET
sbfsWalkPath:
; Where it starts. A path beginning with a separator is measured from the root, which is ; Where it starts. A path beginning with a separator is measured from the root, which is
; zero because a parent is an entry index PLUS ONE and the root is not an entry. ; zero because a parent is an entry index PLUS ONE and the root is not an entry.
; Anything else is measured from wherever the machine already is. ; Anything else is measured from wherever the machine already is.
@@ -1413,10 +1922,37 @@ sbfsBoundsDone:
; First fit, walking the directory, because with files laid down contiguously the ; First fit, walking the directory, because with files laid down contiguously the
; directory already says which blocks are spoken for. There is no allocation table to ; directory already says which blocks are spoken for. There is no allocation table to
; consult and none to keep right. ; consult and none to keep right.
;
; ---- Moving the candidate along without starting again ----
;
; This used to give up the moment it found something in the way: it moved the candidate past
; that one entry and STARTED THE DIRECTORY AGAIN FROM THE FIRST BLOCK. With files laid down
; one after another that is a restart per file, and every restart reads directory blocks off
; the disk until it reaches the next one in the way - which is further in each time. A disk
; of 183 files cost thousands of block reads to place one file, and assembling onto a disk
; with the whole source tree on it took eleven minutes with nearly all of it spent here.
;
; The candidate moves along DURING the pass now, and the pass carries on from where it is.
; Entries later in the directory are then tested against where the candidate has got to, so
; on a disk that has been appended to - which is what a disk mostly is - one pass walks it
; past everything and the next confirms there is nothing left in the way. Two passes rather
; than one per file.
;
; IT IS STILL FIRST FIT. The candidate only ever moves past something that genuinely
; overlaps it, and when it does there is nowhere below that could have held the run: the
; entry in the way covers everything up to its end, and it begins before the candidate ends.
; So nothing is skipped that first fit would have found - and because entries earlier in the
; directory were tested against an earlier candidate, the pass repeats until one goes by with
; the candidate standing still.
sbfsAllocate: sbfsAllocate:
CALL sbfsFirstData CALL sbfsFirstData
sbfsAllocTry: sbfsAllocTry:
; The candidate has not moved yet this time round.
SETD.0 SbfsAllocMoved
RSTA
STA.0
SETD.0 SbfsCandEnd SETD.0 SbfsCandEnd
SETD.2 SbfsCandidate SETD.2 SbfsCandidate
CALL sbfsSetWord CALL sbfsSetWord
@@ -1491,12 +2027,26 @@ sbfsAllocEntry:
CALL sbfsCompareWord CALL sbfsCompareWord
BNC sbfsAllocClear BNC sbfsAllocClear
; They do overlap, so try again from the far end of whatever is in the way. ; They do overlap, so the candidate moves to the far end of what is in the way - and the
POPD.2 ; scan carries on from here rather than beginning again, so whatever comes next is measured
; against where the candidate has got to.
;
; WHERE THIS ENTRY IS STAYS ON THE STACK THROUGHOUT. The work below wants DP2 for its own
; purposes and the scan needs it back on the entry to step to the next one, which is what
; sbfsAllocClear is for.
SETD.0 SbfsCandidate SETD.0 SbfsCandidate
SETD.2 SbfsEntryEnd SETD.2 SbfsEntryEnd
CALL sbfsSetWord CALL sbfsSetWord
BRI sbfsAllocTry SETD.0 SbfsCandEnd
SETD.2 SbfsCandidate
CALL sbfsSetWord
SETD.0 SbfsCandEnd
SETD.2 SbfsWantBlocks
CALL sbfsAddWord
INIA 0x01
SETD.0 SbfsAllocMoved
STA.0
; And on into sbfsAllocClear, which puts the entry back in DP2 and steps to the next one.
sbfsAllocClear: sbfsAllocClear:
POPD.2 POPD.2
@@ -1517,6 +2067,16 @@ sbfsAllocNext:
STA.1 STA.1
BNA sbfsAllocBlock BNA sbfsAllocBlock
; ---- The pass is over ----
;
; Something was in the way, so start again from the far end of the furthest of them. That
; is one jump for however many files the candidate ran into, rather than one jump each.
SETD.0 SbfsAllocMoved
LDA.0
BRA sbfsAllocRoom ; It never moved, so nothing is in the way of where it is.
BRI sbfsAllocTry
sbfsAllocRoom:
; Nothing was in the way, so this is where it goes. ; Nothing was in the way, so this is where it goes.
SETD.0 SbfsFileStart SETD.0 SbfsFileStart
SETD.2 SbfsCandidate SETD.2 SbfsCandidate
@@ -2549,6 +3109,16 @@ sbfsStreamNoTemp:
SETD.2 SbfsFileStart SETD.2 SbfsFileStart
CALL sbfsSetWord CALL sbfsSetWord
; ---- And which disk all of that is on ----
;
; A write stream is the one thing here that lives across service calls, so it is the one
; thing that can have the drive changed underneath it. Copying between two disks is exactly
; that: every osFileBlock re-resolves the SOURCE path and goes to its drive, and then
; osFileWrite has to come back here. The path was walked above, so the drive is right now.
INA 0x24
SETD.0 SbfsStreamDrive
STA.0
INIA 0x01 INIA 0x01
SETD.0 SbfsStreamOpen SETD.0 SbfsStreamOpen
STA.0 STA.0
@@ -2558,6 +3128,13 @@ sbfsStreamNoTemp:
ADD ADD
RET RET
; Back to the disk the open stream belongs to, whatever has been read in between.
sbfsStreamHere:
SETD.0 SbfsStreamDrive
LDA.0
CALL sbfsUse
RET
sbfsStreamNo: sbfsStreamNo:
RSTA RSTA
INIB 0d1 INIB 0d1
@@ -2571,6 +3148,7 @@ sbfsStreamWrite:
SETD.0 SbfsStreamOpen SETD.0 SbfsStreamOpen
LDA.0 LDA.0
BRA sbfsStreamNo BRA sbfsStreamNo
CALL sbfsStreamHere
; Where the file is and how big it is, said again rather than looked up: all three were ; Where the file is and how big it is, said again rather than looked up: all three were
; settled when the temporary was made, and everything since has been describing whatever ; settled when the temporary was made, and everything since has been describing whatever
@@ -2597,6 +3175,7 @@ sbfsStreamFetch:
SETD.0 SbfsStreamOpen SETD.0 SbfsStreamOpen
LDA.0 LDA.0
BRA sbfsStreamNo BRA sbfsStreamNo
CALL sbfsStreamHere
SETD.0 SbfsStreamTo SETD.0 SbfsStreamTo
STD.1.0 STD.1.0
@@ -2633,6 +3212,7 @@ sbfsStreamDone:
SETD.0 SbfsStreamOpen SETD.0 SbfsStreamOpen
LDA.0 LDA.0
BRA sbfsStreamNo BRA sbfsStreamNo
CALL sbfsStreamHere
; What it really came to, put aside before anything walks the disk. ; What it really came to, put aside before anything walks the disk.
SETD.0 SbfsStreamNewBlocks SETD.0 SbfsStreamNewBlocks
@@ -3067,10 +3647,50 @@ SbfsStateWants:
SbfsMagic: SbfsMagic:
"SBFS" "SBFS"
; ---- Which disk this is, in eight bytes ----
;
; THE ORDER AND THE ADJACENCY ARE LOAD BEARING. These four are everything that distinguishes
; one mounted disk from another, and they are together so that changing drives is one copy
; out and one copy in. Nothing else may be put between them.
;
; Everything else in this file is either a constant or scratch for the operation being done
; now, and only one operation is ever being done - which is why a filesystem of 3,300 lines
; needs an eight byte record to know more than one disk. The rest never learns there is more
; than one.
;
; The version is not here. It is checked at mount and thrown away, because a version one
; disk's zero parent already reads as "in the root", which is where all of its files are.
SbfsMountLive:
SbfsDirStart: SbfsDirStart:
0x00 0x00 0x00 0x00
SbfsDirBlocks: SbfsDirBlocks:
0x00 0x00 0x00 0x00
SbfsDiskBlocks:
0x00 0x00
SbfsCwd:
0x00 0x00
; One record a drive, and the live eight above are whichever is selected. Four, because the
; controller has four.
SbfsMountTable:
#Reserve 0d32
; Which drive the live record belongs to, and which drives were found to have a disk on them
; that this can read. A bit a drive, so drive n is bit n.
SbfsDrive:
0x00
SbfsMounted:
0x00
SbfsDriveCount:
0x00
SbfsDriveAt:
0x00
; Which drive the system may write scratch to, or 0xFF for a machine with none. It is a drive
; the machine called volatile, so nothing written there was ever going to survive anyway.
SbfsScratch1:
0xFF
SbfsPathWanted:
0x00
SbfsFileStart: SbfsFileStart:
0x00 0x00 0x00 0x00
SbfsFileBlocks: SbfsFileBlocks:
@@ -3079,14 +3699,15 @@ SbfsFileTail:
0x00 0x00
SbfsBlock: SbfsBlock:
0x00 0x00 0x00 0x00
SbfsDiskBlocks:
0x00 0x00
SbfsWantBlocks: SbfsWantBlocks:
0x00 0x00 0x00 0x00
SbfsCandidate: SbfsCandidate:
0x00 0x00 0x00 0x00
SbfsCandEnd: SbfsCandEnd:
0x00 0x00 0x00 0x00
; Whether the candidate had to move at all during the pass just finished. See sbfsAllocate.
SbfsAllocMoved:
0x00
SbfsEntryStart: SbfsEntryStart:
0x00 0x00 0x00 0x00
SbfsEntryEnd: SbfsEntryEnd:
@@ -3111,8 +3732,6 @@ SbfsLeft:
; thing that resolves a path is here. Keeping it in the shell would mean either handing it ; thing that resolves a path is here. Keeping it in the shell would mean either handing it
; down on every call or having the shell paste it onto the front of every name, and the ; down on every call or having the shell paste it onto the front of every name, and the
; second of those is how a name that is already absolute gets ruined. ; second of those is how a name that is already absolute gets ruined.
SbfsCwd:
0x00 0x00
; ---- What walking a path keeps ---- ; ---- What walking a path keeps ----
; ;
@@ -3221,6 +3840,8 @@ SbfsTempName:
"sbfs.part" "sbfs.part"
; ---- What a file being written a block at a time keeps ---- ; ---- What a file being written a block at a time keeps ----
SbfsStreamDrive:
0x00
SbfsStreamOpen: SbfsStreamOpen:
0x00 0x00
SbfsStreamPath: SbfsStreamPath:
+562
View File
@@ -0,0 +1,562 @@
; Reading shell lines out of a file.
;
; DP0 = the file's name
; CALL scriptOpen Q = 0 and a script is running, or Q says what was wrong:
; 1 there is no such file
; 2 it is not a script - no #! on the front
; 3 too many scripts inside each other
; DP0 = a buffer, B = how much room
; CALL scriptLine Q = 0 and there is a line in the buffer, or nonzero at the end
;
; Written by Anachronaut
;
; ---- Why the shell reads files and not the other way round ----
;
; fileStream.asm does almost this and is deliberately not used. It is application machinery:
; More, Type and Wander each include it and each get their own copy in their own Data
; Segment. The shell reading a script through it would be a fourth copy, and the shell is the
; one place where that copy has to survive running a program - which is exactly the thing
; those programs are. Here the state belongs to the system and nothing a program does can
; reach it.
;
; ---- A block, and a nought on the end of it ----
;
; The reader walks a Data Pointer along the block and stops at a nought. A block is 256 bytes
; and a count of them does not fit in a byte, so every other way of knowing where the block
; ends costs sixteen bit arithmetic on every character. Writing a nought after the last byte
; costs one store per block and turns the whole question into "is this byte zero".
;
; The buffer is 257 bytes for that reason: a full block leaves the nought at the end of it.
; A script cannot contain a nought, which is not a restriction anybody will notice - it is
; text, and the #! on the front is what stops a program being read as one.
#Program
; ---- One script inside another ----
;
; A build script calling a setup script is the first thing anybody tries, so what is saved
; when one script starts another is a POSITION AND NOT A BUFFER. The whole state of a
; running script is its name, which block comes next, how many are left, and where in the
; block it is - seventy bytes, laid out next to each other below so that saving it is one
; copy. The block itself is read again on the way back, which costs one disk read per return
; and saves 257 bytes a level.
;
; Four levels. Deep enough for a script calling a script that calls a helper, and shallow
; enough that a script which runs itself says so instead of filling memory.
scriptPush:
CALL scriptSlotAt
SETD.0 ScriptName
PSHD.3
POPD.1
CALL scriptCopyState
RET
scriptPop:
CALL scriptSlotAt
PSHD.3
POPD.0
SETD.1 ScriptName
CALL scriptCopyState
; ScriptAt points into the block buffer, which now holds somebody else's block. Reading
; it back is what makes the saved pointer mean what it meant.
CALL scriptReread
RET
; DP3 = where the script one level up is remembered. Reached by stepping rather than by
; multiplying, because this machine cannot multiply and the depth is never more than three
; steps. DP3 because RET puts the others back.
scriptSlotAt:
SETD.3 ScriptSaved
SETD.2 ScriptDepth
LDA.2
DECA
BRA scriptSlotDone
scriptSlotStep:
DPUP.3 0d71
DECA
BNA scriptSlotStep
scriptSlotDone:
RET
; Seventy bytes, DP0 to DP1.
scriptCopyState:
INIB 0d71
scriptCopyByte:
LDA.0
STA.1
INCD.0
INCD.1
DECB
BNB scriptCopyByte
RET
; The block that is meant to be in the buffer, back in the buffer. ScriptIndex is the NEXT
; one, so the one being read from is the one before it.
scriptReread:
SETD.1 ScriptIndex
LDA.1
INCD.1
LDB.1
DECB
BNC scriptRereadGo
DECA
scriptRereadGo:
SETD.0 ScriptName
SETD.1 ScriptBlock
SWI osFileBlock
SETD.1 ScriptBlock
PSHD.3
POPB
POPA
DPUW.1
RSTA
STA.1
RET
; ---- Opening ----
;
; The name is COPIED rather than remembered by address. osFileBlock is given the name again
; for every block, and the caller's copy is CommandLine, which the next line typed will
; overwrite. fileStream remembers an address and says in its own comment that the address
; must stay valid; here it cannot, because the thing that reads the next line is the reason
; the name is needed.
scriptOpen:
; ---- Four deep and no further ----
SETD.1 ScriptDepth
LDA.1
INIB 0d4
CCF
SUB
BRQ scriptOpenTooDeep
; ---- The one already running is put somewhere safe FIRST ----
;
; Before anything below overwrites it, and put back again on every way out of here that is
; not success. Opening writes the name into the live state to ask the disk about it, so by
; the time the answer is known the caller's place is already gone.
;
; A still holds the depth from the check above: SUB writes Q and leaves it alone.
BRA scriptOpenOutermost
CALL scriptPush
BRI scriptOpenFirst
scriptOpenOutermost:
; ---- The one place a stale quiet would matter ----
;
; A script started from the prompt begins loud whatever the last one left behind, which is
; what makes this the only place the flag has to be put back. Nested scripts INHERIT
; instead: a build that asked for quiet meant its helpers too.
RSTA
SETD.1 ScriptQuiet
STA.1
scriptOpenFirst:
SETD.1 ScriptName
INIB 0d63
CALL copyText
SETD.0 ScriptName
SWI osFileInfo
BRQ scriptOpenThere
INIA 0x01
BRI scriptOpenFailed
scriptOpenThere:
; DP3 is how many blocks. A file of none is not a script.
PSHD.3
POPB
POPA
SETD.1 ScriptBlocks
STA.1
INCD.1
STB.1
OR
BRQ scriptOpenNotOne
SETD.1 ScriptIndex
RSTA
STA.1
INCD.1
STA.1
; The first block, so that the #! can be looked at before anything is promised.
CALL scriptFill
BNQ scriptOpenNotOne
; ---- What makes a file a script ----
;
; Two bytes, and the rest of the line ignored. A directive rather than a comment, the way
; #Program is in assembly: the shell reads it and refuses the file without it, so calling
; it a comment would be a lie about what it does. What follows the #! is where the name of
; an interpreter goes when there is a second one; today there is one and it is this shell.
SETD.1 ScriptBlock
LDA.1
INIB 0d35 ; '#'
CCF
SUB
BNQ scriptOpenNotOne
INCD.1
LDA.1
INIB 0d33 ; '!'
CCF
SUB
BNQ scriptOpenNotOne
; Past the shebang line, wherever it ends.
CALL scriptSkipLine
SETD.1 ScriptDepth
LDA.1
INCA
STA.1
RSTA
BRI scriptOpenAnswer ; A is nought, which is the answer for "it opened".
scriptOpenTooDeep:
INIA 0x03
BRI scriptOpenAnswer ; Nothing was pushed, so there is nothing to put back.
scriptOpenNotOne:
INIA 0x02
scriptOpenFailed:
; Whatever was running is still running, and its place is in the slot rather than in the
; live state. A is the answer and must survive being put back.
SETD.1 ScriptDepth
LDB.1
BRB scriptOpenAnswer
PSHA
CALL scriptPop
POPA
scriptOpenAnswer:
; Q is the answer, and A holds it. Adding nought is how a register becomes Q.
RSTB
CCF
ADD
RET
; ---- One line ----
;
; Comments and blank lines never reach the shell. The reader drops them, so the echo does not
; print them and the dispatch never sees a line it would have to know to ignore.
scriptLine:
SETD.1 ScriptRoom
STB.1
SETD.1 ScriptInto
STD.0.1
scriptLineAgain:
SETD.1 ScriptLength
RSTA
STA.1
SETD.0 ScriptInto
LDD.0.0
scriptLineNext:
CALL scriptByte
BNQ scriptLineEnded
SETD.1 ScriptChar
LDA.1
INIB 0d10
CCF
SUB
BRQ scriptLineDone
; A still holds the character: SUB writes Q and leaves it alone.
INIB 0d13 ; Carriage return, from a file written on a host that uses them.
CCF
SUB
BRQ scriptLineNext
; Room?
SETD.1 ScriptLength
LDA.1
SETD.1 ScriptRoom
LDB.1
CCF
SUB
BRQ scriptLineNext ; Full. Read on and drop what comes, the way readLine does.
SETD.1 ScriptChar
LDA.1
STA.0
INCD.0
SETD.1 ScriptLength
LDA.1
INCA
STA.1
BRI scriptLineNext
scriptLineDone:
; Terminate it, then decide whether the shell wants to see it.
RSTA
STA.0
SETD.0 ScriptInto
LDD.0.0
LDA.0
BRA scriptLineAgain ; Empty.
INIB 0d59 ; ';' - a comment, the same as everywhere else on this machine.
CCF
SUB
BRQ scriptLineAgain
; ---- A directive, which is about the file rather than for the shell ----
;
; '#' the way the assembler means it. #quiet stops each line being echoed as it runs, for a
; script whose own output is the point and which the prompts get in the way of; #loud puts
; it back.
;
; ANYTHING ELSE BEGINNING WITH # IS HANDED TO THE SHELL, which does not know it and says
; so and stops the script. That is deliberate and it is free: a script that asked for
; something this shell cannot do should not carry on as though it had been given it, and
; the machinery for saying so already exists.
INIB 0d35 ; '#'
CCF
SUB
BNQ scriptLineGive
SETD.1 QuietWord
CALL textSame
BRQ scriptLineQuiet
SETD.0 ScriptInto
LDD.0.0
SETD.1 LoudWord
CALL textSame
BRQ scriptLineLoud
SETD.0 ScriptInto
LDD.0.0
BRI scriptLineGive
scriptLineQuiet:
INIA 0x01
SETD.1 ScriptQuiet
STA.1
BRI scriptLineAgain
scriptLineLoud:
RSTA
SETD.1 ScriptQuiet
STA.1
BRI scriptLineAgain
scriptLineGive:
RSTA ; Q = 0: there is a line.
RSTB
CCF
ADD
RET
scriptLineEnded:
; ---- A last line with no newline on it is still a line ----
;
; Text files do not reliably end with one, and a script whose final command silently did
; not run because somebody's editor left the newline off is a bad way to find that out.
; If anything has been gathered, finish it the ordinary way; the next call comes back here
; with nothing gathered and ends for real.
SETD.1 ScriptLength
LDA.1
BRA scriptLineNoMore
BRI scriptLineDone
scriptLineNoMore:
CALL scriptClose
INIA 0x01
RSTB
CCF
ADD
RET
; ---- One character, or the end ----
;
; Q = 0 and the character is in ScriptChar, or Q is one and there are no more.
;
; IN MEMORY RATHER THAN IN A REGISTER, because RET puts A and B back the way the caller had
; them - only Q and Data Pointer 3 survive a CALL. Handing the character back in A looked
; right, assembled, and returned the caller's own A every time.
scriptByte:
SETD.1 ScriptAt
LDD.1.1
LDA.1
BRA scriptByteRefill ; The nought at the end of the block.
; Step the saved pointer past it.
SETD.1 ScriptChar
STA.1
SETD.1 ScriptAt
LDD.0.1
INCD.0
STD.0.1
RSTA
RSTB
CCF
ADD ; Q = 0.
RET
scriptByteRefill:
CALL scriptFill
BNQ scriptByteNoMore
BRI scriptByte
scriptByteNoMore:
INIA 0x01
RSTB
CCF
ADD
RET
; ---- The next block, with a nought written after it ----
;
; Q = 0 if there is one.
scriptFill:
SETD.1 ScriptBlocks
LDA.1
INCD.1
LDB.1
OR
BRQ scriptFillNoMore
SETD.0 ScriptName
SETD.1 ScriptBlock
SETD.2 ScriptIndex
LDA.2
INCD.2
LDB.2
SWI osFileBlock
BNQ scriptFillNoMore
; DP3 is how many bytes came back. The nought goes after them.
SETD.1 ScriptBlock
PSHD.3
POPB
POPA
DPUW.1
RSTA
STA.1
SETD.1 ScriptAt
SETD.0 ScriptBlock
STD.0.1
; Index++, blocks--.
SETD.1 ScriptIndex
INCD.1
LDA.1
INCA
STA.1
BNC scriptFillCount
DECD.1
LDA.1
INCA
STA.1
scriptFillCount:
SETD.1 ScriptBlocks
INCD.1
LDA.1
BRA scriptFillBorrow
DECA
STA.1
BRI scriptFillGot
scriptFillBorrow:
INIA 0xFF
STA.1
DECD.1
LDA.1
DECA
STA.1
scriptFillGot:
RSTA
RSTB
CCF
ADD
RET
scriptFillNoMore:
INIA 0x01
RSTB
CCF
ADD
RET
; Everything up to and including the next line feed, thrown away. Used for the shebang.
scriptSkipLine:
CALL scriptByte
BNQ scriptSkipDone
SETD.1 ScriptChar
LDA.1
INIB 0d10
CCF
SUB
BNQ scriptSkipLine
scriptSkipDone:
RET
; One script ending. Whatever asked for it carries on, if anything did.
scriptClose:
SETD.1 ScriptDepth
LDA.1
BRA scriptCloseNone
DECA
STA.1
BRA scriptCloseNone
CALL scriptPop
RET
scriptCloseNone:
; ---- Nothing to put back here ----
;
; The flag is only ever READ while a script is running: the console's own path says its
; prompt whatever this holds, and the first script started from the prompt sets it to loud
; on the way in. So a stale quiet cannot be observed, and clearing it here would be a line
; no test could tell the difference about - which is how it was written the first time, with
; a comment claiming it fixed something. It fixed something that a later change had already
; made impossible.
RET
; Every script ending at once, which is what a line that did not work means. A build whose
; helper failed should not carry on in the script that called the helper either.
scriptAbandon:
RSTA
SETD.1 ScriptDepth
STA.1
RET
#Data
QuietWord:
"#quiet"
LoudWord:
"#loud"
ScriptDepth:
0x00
; ---- Seventy bytes, and they are next to each other on purpose ----
;
; Name, blocks left, next block, where in the block, and whether it is echoing: the whole of
; where a script has got to. Saving it is one copy because of this order, and nothing else
; may be put between them.
ScriptName:
#Reserve 0d64
ScriptBlocks:
0x00 0x00
ScriptIndex:
0x00 0x00
ScriptAt:
0x00 0x00
; Saved with the rest, so that a quiet script calling a loud one gets its quiet back when
; the loud one finishes. A new script INHERITS it rather than resetting, because a build
; that asked for quiet meant its helpers too; only the first script started from the prompt
; begins loud.
ScriptQuiet:
0x00
; Three would do - a save happens on the second script and not the first - but four costs
; seventy bytes and removes an off-by-one from the only place it could hide.
ScriptSaved:
#Reserve 0d284
ScriptInto:
0x00 0x00
ScriptRoom:
0x00
ScriptLength:
0x00
ScriptChar:
0x00
ScriptBlock:
#Reserve 0d257
+11
View File
@@ -167,3 +167,14 @@
; print it: a program that failed has already said so in words, and a number beside that ; print it: a program that failed has already said so in words, and a number beside that
; would be noise. This is for the thing that cannot read words. ; would be noise. This is for the thing that cannot read words.
osLastStatus 0d35 osLastStatus 0d35
; ---- Taking the screen, and giving it back ----
;
; Says that this program is about to use the whole screen and would like what is on it now put
; back when it exits. Q is zero if that was arranged; anything else means it was not, which is
; the ordinary answer on a machine with no volatile drive to keep it on - and a program told
; no should carry on regardless, because it was going to before this existed.
;
; NOT AUTOMATIC, and that is the point: dir and Say print and stop, and their output is the
; reason you ran them. Only a program that says it took the screen gets the screen put back.
osTakeScreen 0d36
+142
View File
@@ -0,0 +1,142 @@
; colours.asm
; Every colour the machine wakes up with, and how to change one.
; Written by Anachronaut
;
; ---- What a colour is on this machine ----
;
; The screen draws CELLS, and a cell is two bytes: which tile, and an attribute. A tile is
; eight by eight pixels and every pixel is a byte - a number, not a colour. What colour that
; number means is looked up in the PALETTE, which is 256 entries of four bytes: red, green,
; blue, and one spare. Four rather than three so that entry n begins at n times four, which
; is a shift; three would need a multiply and this machine has none.
;
; A cell's attribute nibble is ADDED to every number in its tile, sixteen at a time. So the
; same tile drawn with attribute 0 reads palette entries 0 and 1, with attribute 1 it reads
; 16 and 17, and so on. Sixteen banks of sixteen.
;
; The console's glyphs are drawn in numbers 0 and 1 - paper and ink - so for text those
; sixteen banks are sixteen INK AND PAPER PAIRS. Writing the attribute register at port 0x06
; says which one to use.
;
; The palette a machine wakes up with is laid out so one bit inverts a pair:
;
; banks 0 to 7 a colour on black
; banks 8 to 15 the same colour AS the background, with black text on it
;
; So attribute XOR 8 highlights, which is all a cursor is.
;
; ---- Reaching the palette ----
;
; Video memory belongs to the screen, not to the program, so the CPU cannot write it with a
; store. It is reached the way every device's memory is reached: registered as a bank, and
; written through the memory controller. That is what the last part of this program does.
#Program
start:
; The console wants sixteen columns for the name and a bit more, so the wide screen is
; not needed. This is the mode the machine wakes up in and is here to be seen.
RSTA
OUTA 0x31
; ---- Sixteen pairs, one line each ----
;
; Counting in memory rather than in a register, because the loop below uses A and B for
; the arithmetic and there is nowhere else to keep it.
SETD.0 Bank
STA.0 ; Still the zero from the mode write above: SETD does not touch A
nextBank:
LDA.0
OUTA 0x06 ; Draw in this pair from now on
SETD.1 SampleText
RCAL say
; The same bank with bit 3 set, which is the same colour inside out.
LDA.0
INIB 0x08
XOR
MVQA
OUTA 0x06
SETD.1 HighlightText
RCAL say
RSTA
OUTA 0x06 ; Back to plain for the newline
INIA 0x0A
OUTA 0x00
LDA.0
INCA
STA.0
INIB 0d8 ; Eight banks; the other eight are their reverses
CCF
SUB
BNQ nextBank
; ---- And one written by hand ----
;
; Bank 2 is green when the machine starts. This makes its ink orange instead, by writing
; three bytes into the palette - which means reaching video memory, which means the
; controller.
; Give the screen's memory a bank number. The screen answers on port 0x30, and bank 3 is
; the first number software is allowed to hand out: 0, 1 and 2 belong to the machine.
INIA 0d3
OUTA 0xE3 ; DestBank: the number being given
INIA 0x30
OUTA 0xE2 ; SourceLow: the port that owns the memory
INIA 0x03
OUTA 0xE8 ; Command: RegisterBank
; The palette sits at the top of video memory, at 0xFC00, and entry n is at n times
; four. Bank 2's ink is entry 2 * 16 + 1, which is 33, and 33 * 4 is 132 - so 0xFC84.
INIA 0xFC
OUTA 0xE4 ; DestHigh
INIA 0x84
OUTA 0xE5 ; DestLow
; Writing the controller's Data port puts a byte at the destination and steps it on, so
; three writes are red, green and blue in order.
INIA 0xF0
OUTA 0xE9 ; red
INIA 0x80
OUTA 0xE9 ; green
INIA 0x20
OUTA 0xE9 ; blue
INIA 0x02
OUTA 0x06 ; That pair again, now that it has been changed
SETD.1 ChangedText
RCAL say
RSTA
OUTA 0x06
HALT
; DP1 names a string. Printing is one byte at a time out of port 0x00, which is the oldest
; thing on this machine and has never changed.
say:
LDA.1
BRA sayDone
OUTA 0x00
INCD.1
BRI say
sayDone:
RRET
#Data
Bank:
0x00
SampleText:
" ordinary "
HighlightText:
" highlighted "
ChangedText:
"
bank 2's ink is orange now, because this program said so
"
#Vectors
Boot start
+97
View File
@@ -0,0 +1,97 @@
; frames.asm
; Waiting for the screen, which is the only regular beat this machine has.
; Written by Anachronaut
;
; ---- There is no clock ----
;
; Nothing on a SplitBit can tell you how long a second is. Every program that wanted to
; happen at a certain speed has counted instructions and hoped - which is why Snake's pause
; quietly halved the day a cycle stopped being an instruction and became a memory access.
; The program was right; the thing it was counting had changed underneath it.
;
; A screen finishes drawing sixty times a second, and that is a real beat. It is counted in
; the machine's own cycles rather than the host's, so this program sees sixty frames a second
; whether the emulator is running at its proper rate or as fast as it possibly can.
;
; ---- Waiting rather than spinning ----
;
; WAIT stops the machine until something interrupts it. That is not the same as looping until
; a flag goes up, even though both take the same time and print the same thing: a machine in
; WAIT is not using memory, so its cycles are counted as idle rather than as bus. Run this
; and the last line says so - nearly every cycle it spent, it spent asleep.
;
; Which is the whole argument for having a frame to wait for. On real hardware that is a
; machine that could be doing something else, or nothing at all and drawing less current.
#Program
start:
SETD.0 Frames
RSTA
STA.0
; Ask the screen to interrupt at each frame, then let interrupts in. The screen does not do
; this unless it is asked: an interrupt with nothing installed to catch it is a fault, so a
; machine that started interrupting on its own would take down every program that had never
; heard of frames.
INIA 0x01
OUTA 0x35
SIF
everyFrame:
; A dot a frame, so there is something to watch.
INIA 0d46
OUTA 0x00
; And nothing at all until the next one.
WAIT
SETD.0 Frames
LDA.0
INIB 0d60 ; One second of them
CCF
SUB
BNQ everyFrame
; Put the screen back the way it was found, and stop asking to be interrupted before
; taking away the thing that would catch it.
CIF
RSTA
OUTA 0x35
SETD.0 Done
RCAL say
HALT
; ---- Called sixty times a second ----
;
; A handler runs between two instructions of whatever was going on, so it saves everything it
; touches - which for an interrupt the machine does itself. RETI puts it all back.
frame:
SETD.0 Frames
LDA.0
INCA
STA.0
RETI
say:
LDA.0
BRA sayDone
OUTA 0x00
INCD.0
BRI say
sayDone:
RRET
#Data
Frames:
0x00
Done:
"
that was a second
"
#Vectors
Boot start
Device 0x30 frame
+5 -10
View File
@@ -14,8 +14,8 @@
start: start:
CALL seedGlider CALL seedGlider
SETD ClearScreen INIA 0x01
CALL printString OUTA 0x05 ; Console command: clear the screen
generationLoop: generationLoop:
CALL renderBoard CALL renderBoard
@@ -48,8 +48,9 @@ seedGlider:
RET RET
renderBoard: renderBoard:
SETD CursorHome RSTA
CALL printString OUTA 0x03
OUTA 0x04 ; Cursor to row 0, column 0
SETD Board SETD Board
DPUP 0d38 DPUP 0d38
INIA 0d16 INIA 0d16
@@ -306,12 +307,6 @@ RowCount:
ColCount: ColCount:
0x00 0x00
ClearScreen:
0x1B
"[2J"
CursorHome:
0x1B
"[H"
; The emulator zero-fills the remainder of Data Memory. Board names the first ; The emulator zero-fills the remainder of Data Memory. Board names the first
; byte of a 648-byte logical allocation (18 * 18 * 2). ; byte of a 648-byte logical allocation (18 * 18 * 2).
@@ -10,8 +10,8 @@
start: start:
CALL seedGlider CALL seedGlider
SETD.0 ClearScreen INIA 0x01
CALL printString OUTA 0x05 ; Console command: clear the screen
generationLoop: generationLoop:
CALL renderBoard CALL renderBoard
@@ -38,8 +38,9 @@ seedGlider:
RET RET
renderBoard: renderBoard:
SETD.0 CursorHome RSTA
CALL printString OUTA 0x03
OUTA 0x04 ; Cursor to row 0, column 0
SETD.1 RowCount SETD.1 RowCount
SETD.2 ColCount SETD.2 ColCount
INIA 0d16 INIA 0d16
@@ -250,12 +251,6 @@ RowCount:
ColCount: ColCount:
0x00 0x00
ClearScreen:
0x1B
"[2J"
CursorHome:
0x1B
"[H"
; 18 by 18 cells with the current and next states interleaved, so 648 bytes. The ; 18 by 18 cells with the current and next states interleaved, so 648 bytes. The
; original leaves this implicit and leans on Data Memory being zero, which works but ; original leaves this implicit and leans on Data Memory being zero, which works but
+105
View File
@@ -0,0 +1,105 @@
; picture.asm
; The other kind of screen: a byte a pixel.
; Written by Anachronaut
;
; ---- Two ways to have a screen ----
;
; A tile mode costs the machine the number of CELLS that changed. Forty by twenty-five is
; two thousand bytes for a whole screen and four bytes for two cells, which is why text on
; this machine is affordable at all.
;
; A bitmap costs it the number of PIXELS. Three hundred and twenty by two hundred is 64,000
; bytes - four frames of work at a megahertz to replace all of it. So this is not the mode to
; animate a whole screen in; it is the mode to draw a picture in and then leave alone, or to
; change a corner of.
;
; It lives over the top of tile memory and the map, because there is nowhere else for it: the
; bank is 65,536 bytes and the picture is 64,000 of them. Going to bitmap mode does not clear
; the text screen, it stops calling it one - and coming back finds the tiles holding whatever
; the picture put there. Taking the screen means taking it.
;
; The palette is the one thing that means the same in both, which is why it sits at the very
; top, out of the way of everything.
#Program
start:
; Video memory is the screen's, not this program's, so it is reached the way every device's
; memory is: given a bank number, then written through the memory controller. Banks 0, 1
; and 2 belong to the machine, so 3 is the first one software may hand out.
INIA 0d3
OUTA 0xE3 ; DestBank: the number being given
INIA 0x30
OUTA 0xE2 ; SourceLow: the port that owns the memory
INIA 0x03
OUTA 0xE8 ; Command: RegisterBank
; ---- Two hundred and fifty six colours ----
;
; Entry n at 0xFC00 plus n times four. Writing the controller's Data port puts a byte at
; the destination and steps it on, so the whole palette is one address and a loop.
INIA 0xFC
OUTA 0xE4
RSTA
OUTA 0xE5
SETD.0 Count
STA.0 ; Still the zero from DestLow above: SETD does not touch A
palette:
LDA.0
OUTA 0xE9 ; red climbs
LDA.0
OUTA 0xE9 ; green with it
LDA.0
INIB 0xFF
XOR
MVQA
OUTA 0xE9 ; and blue falls away, so it runs blue to white to yellow
RSTA
OUTA 0xE9 ; the fourth byte is spare
LDA.0
INCA
STA.0
BNA palette ; A comes back to zero after 256 of them
; ---- The picture ----
;
; Two hundred rows of three hundred and twenty pixels, each row one colour. FILL LEAVES THE
; DESTINATION PAST WHAT IT TOUCHED, so the address is set once here and never worked out
; again - which matters, because working out where row n begins would be n times 320 and
; this machine has no multiply.
RSTA
OUTA 0xE4
OUTA 0xE5 ; Dest 0x0000, the top left corner
INIA 0x01
OUTA 0xE6
INIA 0x40
OUTA 0xE7 ; 320 bytes, which is one row
SETD.0 Count
RSTA
STA.0
rows:
LDA.0
OUTA 0xE2 ; Fill takes its byte from SourceLow: the row number is the colour
INIA 0x02
OUTA 0xE8 ; Command: Fill
LDA.0
INCA
STA.0
INIB 0d200
CCF
SUB
BNQ rows
; And now show it. Nothing above cared which mode the screen was in - the bytes were
; already there, waiting to be called a picture.
INIA 0x02
OUTA 0x31
HALT
#Data
Count:
0x00
#Vectors
Boot start
@@ -1,7 +1,7 @@
; The 16-bit segmented sieve rewritten for SplitBit's four-Data-Pointer ISA. ; The 16-bit segmented sieve rewritten for SplitBit's four-Data-Pointer ISA.
; ;
; This deliberately implements the same algorithm and emits the same text as ; This deliberately implements the same algorithm and emits the same text as
; 16bitSegmentedSieve.asm, making the two versions useful as a direct comparison. ; 16bitSieve.asm, making the two versions useful as a direct comparison.
; DP0 walks PrimeStates, DP1 holds Page, DP2 walks Segment, and volatile DP3 ; DP0 walks PrimeStates, DP1 holds Page, DP2 walks Segment, and volatile DP3
; marks multiples. CALL preserves the first three pointers automatically. ; marks multiples. CALL preserves the first three pointers automatically.
+278
View File
@@ -0,0 +1,278 @@
; tune.asm
; Playing a melody, which needs a sound device and a clock and has neither by halves.
; Written by Anachronaut
;
; ---- Two devices, because one is not enough ----
;
; The sound device knows how to make a note and knows nothing about when. It has no timer and
; does not interrupt, so a program that only had the sound device could play a tune at
; whatever speed the machine happened to run at, which is not a tune.
;
; The screen finishes a frame sixty times a second and will say so. That is the only regular
; beat on this machine, and it is counted in the machine's own cycles, so this plays at the
; same speed whether the emulator is running at a megahertz or as fast as it can go. Every
; duration below is in frames: 30 is half a second.
;
; A programmable timer is the device that ought to be doing this, and it does not exist yet.
; Borrowing the screen's frame costs nothing and works, which is the whole reason to notice
; that a beat is a beat wherever it comes from.
;
; ---- What borrowing it costs ----
;
; The frame is not slow. It is FIXED, and that is the different complaint. Every duration here
; is a whole number of 16.67 ms, so a note worth a third of a beat cannot be written at all -
; and the way round it is to pick a tempo whose subdivisions happen to land on whole frames,
; which is making the tune fit the machine rather than the other way round. The theme below
; was written to the frame and is a few cents of tempo away from what it wants to be.
;
; So what the timer wants is an ARBITRARY tick rather than a faster fixed one, and the reason
; the screen should not be the clock is that a display refresh and a music routine have no
; reason to share a rate.
;
; ---- What a patch costs and what a note costs ----
;
; Setting the sound up is twenty-odd writes, done once before a single note is played. After
; that the inner loop is two: the note, and letting go of it. That split is what the selector
; and value registers are for - see Making A Noise in the Programming Manual.
#Program
start:
; ---- The instrument ----
;
; Channel 0, selected once. Every parameter write below lands on it.
RSTA
OUTA 0x41
; A saw wave, which has all the harmonics and so is the one to hear a filter on. Writing a
; port leaves A alone, so the nothing that selected the channel also selects parameter 0,
; which is oscillator 0's waveform. SplitLint will point out any attempt to put it there
; twice.
OUTA 0x42
INIA 0d2 ; Saw
OUTA 0x43
INIA 0x01 ; Oscillator 0, gain
OUTA 0x42
INIA 0xFF ; All of it. A channel arrives at full gain already, so this is
OUTA 0x43 ; saying so rather than changing it.
; A second oscillator a little out of tune with the first, which is the oldest trick there
; is for making one voice sound like more than one.
;
; SWITCHING IT ON IS A SEPARATE WRITE from setting its gain, and it is the one that matters:
; the two oscillators are averaged rather than added, so `active` is structural. Setting a
; gain on an oscillator that is off does nothing at all, silently, which is how the first
; draft of this program came to have a detune in it that could not be heard.
INIA 0x15 ; Oscillator 1, on
OUTA 0x42
INIA 0x01
OUTA 0x43
INIA 0x11 ; Oscillator 1, gain
OUTA 0x42
INIA 0xC0
OUTA 0x43
INIA 0x13 ; Oscillator 1, detune
OUTA 0x42
INIA 0d129 ; Centred on 128, and a step is about nine cents, so this is
; nine cents sharp - a shimmer rather than a wrong note.
OUTA 0x43
; Plucked: no attack to speak of, most of a second of decay, and nothing held.
;
; A sustain of nothing does NOT end the note. It goes quiet and keeps sounding, because a
; voice holding at nothing is what a held key is. Dropping the gate is the only thing that
; ends a note, which is why the loop below does it whether the sound has faded or not.
INIA 0x20 ; Amplitude envelope, attack
OUTA 0x42
INIA 0d10
OUTA 0x43
INIA 0x21 ; Decay
OUTA 0x42
INIA 0d120
OUTA 0x43
INIA 0x22 ; Sustain: nothing
OUTA 0x42
RSTA
OUTA 0x43
INIA 0x23 ; Release
OUTA 0x42
INIA 0d40
OUTA 0x43
; A low pass with the modulation envelope opening it, so each note starts bright and closes
; down. This is what the second envelope is for, and it can only be spent this way because
; the level is shaped by the first one and not by whichever happens to be wired to the
; output.
INIA 0x40 ; Filter, on
OUTA 0x42
INIA 0x01
OUTA 0x43
INIA 0x42 ; Cutoff, low to start with
OUTA 0x42
INIA 0d90
OUTA 0x43
INIA 0x43 ; A little resonance, to hear it move
OUTA 0x42
INIA 0d150
OUTA 0x43
INIA 0x44 ; What opens it: the modulation envelope
OUTA 0x42
INIA 0d2
OUTA 0x43
INIA 0x45 ; And how far, upwards from centre
OUTA 0x42
INIA 0d220
OUTA 0x43
INIA 0x31 ; That envelope's decay, which is the sweep's length
OUTA 0x42
INIA 0d70
OUTA 0x43
INIA 0x32 ; and it closes all the way
OUTA 0x42
RSTA
OUTA 0x43
INIA 0xC0 ; The device's volume, with room left over the top
OUTA 0x46
; ---- The beat ----
;
; Ask the screen to interrupt at each frame, and let interrupts in. The screen does not do
; this unless it is asked.
INIA 0x01
OUTA 0x35
SIF
; ---- The tune ----
;
; Data Pointer 0 walks the table, and nothing in this loop is a CALL, so it stays where it
; was left without being saved anywhere.
SETD.0 Theme
nextNote:
LDA.0 ; The note. Zero is the end of the tune.
BRA finished
OUTA 0x44 ; Writing the note is what starts it.
INCD.0
LDB.0 ; How many frames it lasts.
INCD.0
holdNote:
WAIT ; Nothing at all until the screen says a frame has gone by.
DECB
BNB holdNote
; Let go. The note is already fading on its own decay, but dropping the gate is what a
; keyboard does and what the release time is waiting for.
RSTA
OUTA 0x45
BRI nextNote
finished:
; Let the last note ring out rather than cutting it off, then put the screen back the way it
; was found - and stop asking to be interrupted before taking away what catches it.
INIB 0d45
lastRing:
WAIT
DECB
BNB lastRing
CIF
RSTA
OUTA 0x35
HALT
; Sixty times a second, and it has nothing to do. WAIT only needs something to have happened,
; and this is the something. A handler still has to exist: an interrupt with nothing installed
; to catch it is a fault.
frame:
RETI
#Data
; ---- Notes and how long they last ----
;
; Pairs: a MIDI note, then a count of frames. 60 is middle C and every 12 is an octave. A zero
; note ends it, which is why there are no rests in here - a rest would want a duration with no
; note, and this table has no way to say that. Adding one is a byte of flag or a note number
; nothing plays, and this program did not need it.
Tune:
0d60 0d15 ; C
0d64 0d15 ; E
0d67 0d15 ; G
0d72 0d30 ; C, an octave up, held twice as long
0d71 0d15 ; B
0d67 0d15 ; G
0d64 0d15 ; E
0d60 0d45 ; and home
0x00
Theme:
0x30 0d15 ; C
0x35 0d15 ; F
0x3C 0d15 ; C+
0x40 0d30 ; E+
0x48 0d30 ; C++
0x45 0d30 ; A+
0x47 0d30 ; B+
0x43 0d45 ; G+
; repeat four times.
0x2A 0d7 ; Gs
0x33 0d7 ; Ef
0x3C 0d7 ; C+
;
0x2A 0d7 ; Gs
0x33 0d7 ; Ef
0x3C 0d7 ; C+
;
0x2A 0d7 ; Gs
0x33 0d7 ; Ef
0x3C 0d7 ; C+
;
0x2A 0d7 ; Gs
0x33 0d7 ; Ef
0x3C 0d7 ; C+
; next chord
0x2E 0d7 ; Bf
0x35 0d7 ; F
0x3E 0d7 ; D+
; next chord
0x2E 0d7 ; Bf
0x35 0d7 ; F
0x3E 0d7 ; D+
;
; next chord
0x2E 0d7 ; Bf
0x35 0d7 ; F
0x3E 0d7 ; D+
;
; next chord
0x2E 0d7 ; Bf
0x35 0d7 ; F
0x3E 0d7 ; D+
; Finally on the C major
0x30 0d7 ; C
0x37 0d7 ; G
0x40 0d7 ; E+
;
0x30 0d7 ; C
0x37 0d7 ; G
0x40 0d7 ; E+
;
0x30 0d7 ; C
0x37 0d7 ; G
0x40 0d7 ; E+
;
0x30 0d7 ; C
0x37 0d7 ; G
0x40 0d7 ; E+
;
0x18 0d60 ; C bass
0x00
#Vectors
Boot start
Device 0x30 frame
-230
View File
@@ -1,230 +0,0 @@
# SplitBit Programs Makefile
# Anachronaut
#
# Builds every SplitBit program into build/, and keeps track of which libraries
# each one includes so that editing a library reassembles whatever depends on it.
#
# make Assemble everything.
# make clean Throw away build/.
# make run-hello Assemble and run one program.
ASM ?= ../Assembler
EMU ?= ../SplitBit
BUILD ?= build
# Libraries are included by bare name, so the assembler is told where to find them.
# CosmOS owns the filesystem library and the service names, so it is a place to look too.
INCLUDES = -I Libraries -I CosmOS/Source
# The programs worth building. Every one lives in a directory that says what kind it is:
# Examples/ is what you read to learn, Loader/ is the standalone loader CosmOS grew out of,
# CosmOS/ is the system. Files in Libraries/ are left out because they have no entry point
# of their own, and the ones in testPrograms/ are covered by 'make test' in the parent
# directory.
PROGRAMS = \
CosmOS/Source/cosmos.asm \
Examples/hello.asm \
Examples/printHello.asm \
Examples/inputTest.asm \
Examples/replCalculator.asm \
Examples/Fibonacci/8bitFibonacci.asm \
Examples/Fibonacci/16bitFibonacci.asm \
Examples/Fibonacci/32bitFibonacci.asm \
Examples/primeSieve/8bitSieve.asm \
Examples/primeSieve/16bitSegmentedSieve.asm \
Examples/primeSieve/16bitSegmentedSieveModern.asm \
Examples/gameOfLife/16x16Life.asm \
Examples/gameOfLife/16x16LifeModern.asm \
Loader/loader.asm \
Loader/loadable.asm
BINARIES = $(PROGRAMS:%.asm=$(BUILD)/%.bin)
DEPENDENCIES = $(BINARIES:.bin=.d)
all: $(BINARIES)
# -M writes out which source files went into the binary, in the form of a make rule.
$(BUILD)/%.bin: %.asm
@mkdir -p $(@D)
$(ASM) $(INCLUDES) -M $(@:.bin=.d) -o $@ $<
# Assemble and run a single program, as in 'make run-hello'.
#
# THE NAME IS THE PROGRAM'S, NOT ITS PATH. This used to be a pattern rule against
# $(BUILD)/%.bin, which worked while every program sat at the top of this directory and
# stopped working the moment they were filed into Examples/ - 'make run-hello' had nothing
# to match. Somebody trying the one command the header advertises should not be the way
# that is discovered, so the name is now looked up among the programs instead.
run-%:
@target=`echo $(BINARIES) | tr ' ' '\n' | grep -E "(^|/)$*\.bin$$" | head -1`; \
if [ -z "$$target" ]; then \
echo "There is no program called '$*'. What there is:"; \
echo $(PROGRAMS) | tr ' ' '\n' | sed 's|.*/||;s|\.asm$$||;s|^| |'; \
exit 1; \
fi; \
$(MAKE) --no-print-directory "$$target" && $(EMU) "$$target"
# ---- CosmOS ----
#
# make cosmos Assemble the system and everything it can load.
# make cosmos-disk ... and put the loadable programs on a disk image.
# make run-cosmos ... and boot the machine with that disk in the drive.
#
# Programs in Apps/ say where they live with #Base, so the assembler writes them out as
# loadable programs rather than as boot images. They are named .sbx to keep that
# difference visible: a .bin is something the machine boots, a .sbx is something a
# running system loads.
DISKTOOL ?= ../SplitDisk
COSMOS = $(BUILD)/CosmOS/Source/cosmos.bin
APPS = $(patsubst CosmOS/Apps/%.asm,$(BUILD)/CosmOS/Apps/%.sbx,$(wildcard CosmOS/Apps/*.asm))
COSMOS_DISK = $(BUILD)/cosmos.img
# ---- What starts the machine ----
#
# Stage two goes into a boot slot as RAW BYTES: stage one reads blocks into Program Memory
# and jumps to the first one, so a sixteen byte header would be sixteen bytes of nonsense
# executed first. Its Data Segment travels with it and it copies that down itself.
#
# Stage one is not here at all. It is the ROM, built into the emulator by the top level
# makefile from the same source, which is what makes it the one part of this that a disk
# cannot replace.
STAGE2 = $(BUILD)/Boot/stage2.raw
DEPENDENCIES += $(BUILD)/Boot/stage2.d
$(STAGE2): Boot/stage2.asm
@mkdir -p $(@D)
$(ASM) $(INCLUDES) -M $(BUILD)/Boot/stage2.d -o $(BUILD)/Boot/stage2.sbx $<
tail -c +17 $(BUILD)/Boot/stage2.sbx > $@
DEPENDENCIES += $(APPS:.sbx=.d)
$(BUILD)/CosmOS/Apps/%.sbx: CosmOS/Apps/%.asm
@mkdir -p $(@D)
$(ASM) $(INCLUDES) -M $(@:.sbx=.d) -o $@ $<
# The assembler that runs on the machine. It is not in Apps/ because it is not one file:
# it has a directory of its own, the way the C assembler does. Its own pieces are found
# beside it without being told, since an include is looked for next to the file that asked
# for it before anywhere else; only services.asm needs the include path.
NATIVE_ASM = $(BUILD)/CosmOS/Assembler/Asm.sbx
DEPENDENCIES += $(NATIVE_ASM:.sbx=.d)
$(NATIVE_ASM): CosmOS/Assembler/Asm.asm
@mkdir -p $(@D)
$(ASM) $(INCLUDES) -M $(@:.sbx=.d) -o $@ $<
cosmos: $(COSMOS) $(APPS) $(NATIVE_ASM)
# Made from scratch every time, so that what is on it is what is in Apps/ now and not
# also whatever used to be.
#
# TWENTY FOUR DIRECTORY BLOCKS, WHICH IS ONE HUNDRED AND NINETY TWO NAMES. It was eight,
# and that is sixty four, of which thirty nine were already spoken for. The two ceilings a
# disk has were nowhere near each other: at the average file on here, twenty six blocks,
# sixty four names run out with the disk forty one per cent full. Names were going to be
# gone long before space was.
#
# A directory block is 256 bytes and holds eight entries, so the difference costs sixteen
# blocks of four thousand and ninety six - three tenths of one per cent - to buy a hundred
# and twenty eight more names. The superblock has carried this number per disk since the
# format was written, so nothing but this line knows what it is.
$(COSMOS_DISK): $(APPS) $(NATIVE_ASM) $(COSMOS) $(STAGE2) testPrograms/stringKeyword.asm \
$(wildcard CosmOS/Apps/*.asm) \
$(wildcard CosmOS/Source/*.asm) $(wildcard CosmOS/Assembler/*.asm)
@mkdir -p $(@D)
rm -f $@
@# A BOOT AREA, so that this is a disk the machine can start itself from rather than
@# one it has to be handed. Forty blocks a slot and two slots: stage two is about
@# eight thousand bytes, and the second slot is what makes replacing it survivable,
@# since raw blocks have no name and so nothing to rename.
$(DISKTOOL) format $@ 4096 24 40
$(DISKTOOL) boot $@ $(STAGE2) 0
@# THREE DIRECTORIES, WHICH IS WHAT A CLEAN INSTALL LOOKS LIKE: what you run, what you
@# assemble, and what those include. It was thirty nine files in one list with
@# cosmos.asm sitting between fileStream.asm and sbfs.asm.
@#
@# The split is by ROLE rather than by which directory the host keeps them in. /Source
@# holds the things you name to the assembler and /Lib the things they pull in, which is
@# a distinction the host makes with -I and the machine now makes with a search path of
@# its own: an include is looked for beside you and then in /Lib. Without that, every
@# source that calls a service would have to sit in the same directory as services.asm
@# and there would be nothing to organise.
$(DISKTOOL) mkdir $@ /Apps
$(DISKTOOL) mkdir $@ /Source
$(DISKTOOL) mkdir $@ /Lib
$(DISKTOOL) mkdir $@ /System
$(DISKTOOL) mkdir $@ /System/Boot
@# The system itself, as a file, which is the whole of what boot.cfg chooses between.
@# No boot.cfg is written: stage two falls back to this name when there is none, and a
@# clean install having nothing to configure is the right default.
$(DISKTOOL) put $@ $(COSMOS) /System/Boot/cosmos.bin
@# What you run. /Apps is the second place the shell looks when a word it does not know
@# turns out to be a program, so anything in here starts by name from anywhere.
@for app in $(APPS); do \
$(DISKTOOL) put $@ $$app /Apps/`basename $$app` >/dev/null || exit 1; done
$(DISKTOOL) put $@ $(NATIVE_ASM) /Apps/Asm.sbx
@# What you assemble. All of it, because an assembler with nothing to assemble is a
@# demonstration of nothing:
@#
@# > cd /Source
@# /Source> Asm cosmos.asm the system it is running on
@# /Source> Asm Asm.asm and the thing that built it
@#
@# Both come out byte for byte what the host tool makes from the same source.
@#
@# Keys.asm brings a vector of its own, so assembling it exercises the version two
@# header and the Vector Segment: the loader installs its handler, the console
@# interrupts into it, and the shell takes the vector back at exit.
@#
@# strings.asm is the odd one out on purpose. It has no #Include and no #Base, so it
@# comes out as a boot image rather than a loadable program, and the difference between
@# the two is visible on one disk.
$(DISKTOOL) put $@ CosmOS/Source/cosmos.asm /Source/cosmos.asm
$(DISKTOOL) put $@ CosmOS/Assembler/Asm.asm /Source/Asm.asm
$(DISKTOOL) put $@ CosmOS/Assembler/readTest.asm /Source/readTest.asm
$(DISKTOOL) put $@ CosmOS/Assembler/tokenTest.asm /Source/tokenTest.asm
$(DISKTOOL) put $@ CosmOS/Apps/hello.asm /Source/hello.asm
$(DISKTOOL) put $@ CosmOS/Apps/Say.asm /Source/Say.asm
$(DISKTOOL) put $@ CosmOS/Apps/Keys.asm /Source/Keys.asm
$(DISKTOOL) put $@ testPrograms/stringKeyword.asm /Source/strings.asm
@# And the loader, so the machine can rebuild what starts it. Assembling stage2.asm on
@# the machine and writing the result into the other boot slot is the whole of a
@# self-hosted boot chain, and everything it includes is already in /Lib.
$(DISKTOOL) put $@ Boot/stage1.asm /Source/stage1.asm
$(DISKTOOL) put $@ Boot/stage2.asm /Source/stage2.asm
@# And what those include. Everything here is named by an #Include somewhere and by
@# nothing else, which is exactly what makes it a library rather than a source.
@for f in CosmOS/Source/console.asm CosmOS/Source/fileStream.asm \
CosmOS/Source/sbfs.asm CosmOS/Source/services.asm CosmOS/Source/text.asm \
CosmOS/Source/config.asm \
CosmOS/Assembler/classify.asm CosmOS/Assembler/labels.asm \
CosmOS/Assembler/numbers.asm CosmOS/Assembler/scratch.asm \
CosmOS/Assembler/source.asm CosmOS/Assembler/table.asm \
CosmOS/Assembler/token.asm CosmOS/Assembler/vectors.asm; do \
$(DISKTOOL) put $@ $$f /Lib/`basename $$f` >/dev/null || exit 1; done
# The system as well as the disk. Building only the image leaves whatever cosmos.bin was
# there before, or none at all, and then the disk is booted with a system that does not
# match the programs on it.
cosmos-disk: $(COSMOS) $(COSMOS_DISK)
# THE MACHINE STARTS ITSELF. No image is named, so the emulator shadows its ROM into
# Program Memory and that reads the disk for everything else - a boot slot, then a loader,
# then whatever /System/Boot/boot.cfg names, or cosmos.bin when it names nothing.
run-cosmos: $(COSMOS_DISK)
$(EMU) --disk $(COSMOS_DISK)
# The same disk with the system handed over directly instead, which is what a debugger
# does: memory is placed from outside and nothing on the disk is consulted about it. Useful
# when the thing being debugged is the boot chain itself, since it skips the boot chain.
run-cosmos-direct: $(COSMOS) $(COSMOS_DISK)
$(EMU) --disk $(COSMOS_DISK) $(COSMOS)
clean:
rm -rf $(BUILD)
# Pull in the dependency rules written by -M above, so that touching a library
# reassembles every program that includes it.
-include $(DEPENDENCIES)
.PHONY: all clean cosmos cosmos-disk run-cosmos run-cosmos-direct
+5
View File
@@ -0,0 +1,5 @@
#! shell
#quiet
clear
echo Welcome to CosmOS.
echo Ready.
+1 -1
View File
@@ -12,7 +12,7 @@
; ;
; There is a fourth bit, for whether the console interrupts on input, and nothing here ; There is a fourth bit, for whether the console interrupts on input, and nothing here
; sets it. Polling and interrupting are the two ways to get a byte and this is the one ; sets it. Polling and interrupting are the two ways to get a byte and this is the one
; about polling; consoleInterruptTest.asm is the other. ; about polling; consoleInterrupt.asm is the other.
; ;
; This runs with input from a file rather than a terminal, so key mode has no terminal to ; This runs with input from a file rather than a terminal, so key mode has no terminal to
; put into another state and the mode bit is the only thing that changes. That is on ; put into another state and the mode bit is the only thing that changes. That is on
+69
View File
@@ -0,0 +1,69 @@
; diskLineTest.asm
; A device's line comes down when its status port is read.
; Written by Anachronaut
;
; ---- The idiom with the race in it ----
;
; This is the shape the manual gives for waiting on a device, and it has a hole. It reads the
; status, branches out if the device has already finished, and only WAITs otherwise. On a disk
; fast enough to finish before the first look - which is every disk here - the WAIT is never
; reached, and WAIT was the only thing that took the line down for a program with no handler.
;
; The line then stood for the rest of the machine's life. Nothing else was going to answer it:
; the program is masked and has nowhere to dispatch to. So the next program to set the
; Interrupt Flag was interrupted on behalf of a read that finished before it was loaded, and
; faulted on the instruction after the SIF.
;
; That is not a story about the disk. It is what happens to any program CosmOS loads, because
; the boot chain reads the disk to load it. Playing Examples/tune.asm through Once is how it
; was found: the program set its whole patch up and died four bytes before its first note.
;
; So reading the status takes the line down, the same way taking the byte takes the console's
; down. This program is the check on that: read the disk without ever waiting, then let
; interrupts in with no handler installed anywhere. If a line were standing, SIF would find it
; and there would be nothing to catch it.
#Program
start:
; Block 0, read.
RSTA
OUTA 0x20
OUTA 0x21
INIA 0x01
OUTA 0x22
waitDisk:
INA 0x23
INIB 0x01
AND
BRQ readDone ; Already finished, so the WAIT below never runs.
WAIT
BRI waitDisk
readDone:
; No handler is installed for anything, and none is installed below either. This is the
; instruction the fault used to land on.
SIF
INIA 0d110
OUTA 0x00
INIA 0d111
OUTA 0x00
INIA 0d32
OUTA 0x00
INIA 0d108
OUTA 0x00
INIA 0d105
OUTA 0x00
INIA 0d110
OUTA 0x00
INIA 0d101
OUTA 0x00
INIA 0d10
OUTA 0x00
HALT
#Vectors
Boot start
+75
View File
@@ -0,0 +1,75 @@
; Choosing which disk the registers mean.
;
; SEVERAL DISKS ARE ONE CONTROLLER AND NOT SEVERAL DEVICES, and the instruction set is the
; reason. A port is an immediate byte inside the OUT that names it, so a program cannot
; compute one - "the disk on port 0x20 plus drive times four" is not a thing this machine can
; say. Two disks as two devices would mean a branch per access in every place the filesystem
; names a disk port.
;
; So the drive is a register, the way a floppy controller has always done it.
;
; Written by Anachronaut
#Program
start:
; How many are plugged in. A fact about the machine, so it is read only.
INA 0x25
INIB 0d48
CCF
ADD
OUTQ 0x00
INIA 0d1
OUTA 0x24
INA 0x24
CCF
ADD ; B is still 48, from the count above.
OUTQ 0x00 ; It took.
; ---- A drive that is not there is refused, not wrapped ----
;
; Wrapping to drive 0 would mean a program asking for a drive this machine does not have
; quietly reading the one it does. That is the same shape of fault as taking a bank number
; somebody else is using: it succeeds, and the wrong disk answers.
INIA 0d9
OUTA 0x24
INA 0x24
CCF
ADD
OUTQ 0x00 ; Still 1.
; ---- And reading follows the selection ----
;
; Drive 1 has no disk in it here, which is not the same as not existing: the controller has
; four drives whether or not there are disks in them, so this selects, reads, and fails.
INIA 0d1
OUTA 0x24
RSTA
OUTA 0x20
OUTA 0x21
INIA 0x01
OUTA 0x22
waitDisk:
INA 0x23
INIB 0x01
AND
BNQ waitDisk
INA 0x23
INIB 0x02
AND
BRQ readWorked
INIA 0d78 ; 'N', which is what an empty drive should give.
OUTA 0x00
BRI done
readWorked:
INIA 0d89
OUTA 0x00
done:
INIA 0d10
OUTA 0x00
HALT
#Vectors
Boot start
+7 -2
View File
@@ -6,10 +6,15 @@
; about, which is the whole point: reading port 0x00 to find out what it is would take ; about, which is the whole point: reading port 0x00 to find out what it is would take
; a character off standard input and wait for one that never comes. ; a character off standard input and wait for one that never comes.
; ;
; Port 0x05 has nothing on it, and reads as class 0. That is the same answer a machine ; Port 0x80 has nothing on it, and reads as class 0. That is the same answer a machine
; with no registry at all would give, so software finds out whether it can enumerate by ; with no registry at all would give, so software finds out whether it can enumerate by
; enumerating. ; enumerating.
; ;
; It used to ask about 0x05, until the console grew cursor registers and took it. The empty
; port has to be one nothing is likely to want: 0x80 is clear of the console below it, the
; disk and the screen, the memory controller at the top, and the sound device that is coming
; to 0x40.
;
; Correct output is: ; Correct output is:
; 00 02 00 the console, class 2, no flags ; 00 02 00 the console, class 2, no flags
; 10 10 00 the test device, class 0x10, no flags ; 10 10 00 the test device, class 0x10, no flags
@@ -27,7 +32,7 @@ start:
CALL reportPort CALL reportPort
INIA 0xFF INIA 0xFF
CALL reportPort CALL reportPort
INIA 0x05 INIA 0x80
CALL reportPort CALL reportPort
HALT HALT
+48
View File
@@ -0,0 +1,48 @@
; The timer interrupting, which is what a music routine actually wants.
;
; 125,000 cycles is a sixteenth note at 120 beats a minute. THE SCREEN CANNOT EXPRESS IT: a
; frame is 16,667 cycles, so that beat is seven and a half of them, and a program timing music
; on frames has to pick a tempo whose subdivisions happen to land on whole ones.
;
; Eight of them is a second, and what is recorded is that it took one - and that the machine
; slept through nearly all of it, which is the difference between waiting for a beat and
; counting up to it.
;
; Written by Anachronaut
#Program
start:
; 125,000 cycles: a sixteenth note at 120 beats a minute, which the screen's frame
; cannot express at all.
INIA 0x01
OUTA 0x52
INIA 0xE8
OUTA 0x53
INIA 0x48
OUTA 0x54 ; 0x01E848 = 125,000
INIA 0x07
OUTA 0x51 ; Run, repeat, interrupt.
SIF
INIB 0d8
everyBeat:
WAIT
INIA 0d46
OUTA 0x00
DECB
BNB everyBeat
CIF
RSTA
OUTA 0x51
INIA 0d10
OUTA 0x00
HALT
beat:
RETI
#Vectors
Boot start
Device 0x50 beat
+93
View File
@@ -0,0 +1,93 @@
; A beat a program sets for itself.
;
; The only regular thing this machine had was the screen finishing a frame, sixty times a
; second and not negotiable. That is a clock a program BORROWS: every duration becomes a
; multiple of 16.67 ms, so a sixteenth note at 120 beats a minute - 125,000 cycles, which is
; seven and a half frames - cannot be asked for at all.
;
; Counted in cycles, because that is what everything else on this machine is counted in.
;
; Written by Anachronaut
#Program
start:
; ---- Repeating, and polled ----
;
; 100,000 cycles, a tenth of a second. Four of them, so the whole thing is 400,000 and the
; recorded cycle count is the check that the period is what it says.
INIA 0x01
OUTA 0x52
INIA 0x86
OUTA 0x53
INIA 0xA0
OUTA 0x54 ; 0x0186A0
INIA 0x03
OUTA 0x51 ; Run, repeat, no interrupt.
INIB 0d4
everyTick:
INA 0x50
PSHB
INIB 0x01
AND
POPB
BRQ everyTick ; Not yet.
INIA 0d46
OUTA 0x00
DECB
BNB everyTick
; ---- And looking is what answered it ----
;
; The bit came down when it was read, so asking again immediately says nothing has happened
; since. A timer whose flag stayed up would look like a beat every time round the loop.
INA 0x50
INIB 0x01
AND
BRQ tickCleared
INIA 0d78 ; 'N'
OUTA 0x00
BRI oneShot
tickCleared:
INIA 0d89 ; 'Y'
OUTA 0x00
oneShot:
; ---- Once, and then stopped ----
;
; Without the repeat bit it runs its period out and turns itself off, which the status port
; says: bit 1 is whether it is running.
RSTA
OUTA 0x51 ; Stop first, so starting below is a start.
INIA 0x01
OUTA 0x51 ; Run, no repeat.
waitOnce:
INA 0x50
INIB 0x01
AND
BRQ waitOnce
INIA 0d46
OUTA 0x00
INA 0x50
INIB 0x02 ; RUNNING
AND
BRQ stopped
INIA 0d78
OUTA 0x00
BRI done
stopped:
INIA 0d89
OUTA 0x00
done:
INIA 0d10
OUTA 0x00
HALT
#Vectors
Boot start
+140 -38
View File
@@ -20,17 +20,81 @@ wrote Asm.sbx: program 7533, data 4099, labels 555
| Directory | What it holds | | Directory | What it holds |
| --- | --- | | --- | --- |
| [`Source/Emulator`](Source/Emulator) | The machine: CPU, memory controller, devices, console, disk | | [`Source/Emulator`](Source/Emulator) | The machine: CPU, memory controller, devices, console, disk - and the two front ends that present it |
| [`Source/Assembler`](Source/Assembler) | The assembler that runs on a host | | [`Source/Assembler`](Source/Assembler) | The assembler that runs on a host |
| [`Source/DiskTool`](Source/DiskTool) | SplitDisk, which reads and writes SplitBit's filesystem | | [`Source/DiskTool`](Source/DiskTool) | SplitDisk, which reads and writes SplitBit's filesystem |
| [`Source/Linter`](Source/Linter) | SplitLint, which points out needlessly long assembly forms | | [`Source/Linter`](Source/Linter) | SplitLint, which points out needlessly long assembly forms |
| [`Programs/Examples`](Programs/Examples) | Programs to read: hello, a calculator, Fibonacci, a prime sieve, Life | | [`Programs/Examples`](Programs/Examples) | Programs to read: hello, a calculator, Fibonacci, a prime sieve, Life, the colours |
| [`Programs/Libraries`](Programs/Libraries) | Code included by name rather than linked, since there is no linker | | [`Programs/Libraries`](Programs/Libraries) | Code included by name rather than linked, since there is no linker |
| [`Programs/Loader`](Programs/Loader) | The standalone loader CosmOS grew out of | | [`Programs/Loader`](Programs/Loader) | The standalone loader CosmOS grew out of |
| [`Programs/CosmOS`](Programs/CosmOS) | The operating system, its applications, and the native assembler | | [`Programs/CosmOS`](Programs/CosmOS) | The operating system, its applications, and the native assembler |
| [`Programs/testPrograms`](Programs/testPrograms) | What the test suite drives | | [`Programs/testPrograms`](Programs/testPrograms) | What the test suite drives |
| [`Tests`](Tests) | The suite: the manifest, the recorded output, and the scripts that check it | | [`Tests`](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: ## 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. - **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.
@@ -72,12 +136,27 @@ Assemble something and run it:
Or boot the operating system, with a disk of programs and all of its own source on it: Or boot the operating system, with a disk of programs and all of its own source on it:
``` ```
cd Programs make run-voyager
make run-cosmos
``` ```
`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.
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. 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 ## Running Programs: SplitBit
``` ```
@@ -131,14 +210,38 @@ anybody could build - and it is the emulator's job to be the thing the hardware
against. against.
The average SplitBit instruction costs 3.72 cycles, measured over the native assembler The average SplitBit instruction costs 3.72 cycles, measured over the native assembler
assembling a program. 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 **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 separate memories, and that is what sets the rate: a move between two of them can overlap
its read and its write, so it settles at a byte a cycle, while a move within one bank cannot its read and its write, while a move within one bank cannot and costs twice as much. A fill
and costs two. A fill has nothing to read and costs one. So a 256 byte block is 257 cycles has nothing to read and goes at the between-banks rate. Against the ten cycles a transfer
between banks and 513 within one, against the ten it used to cost - which was the five port used to cost - the five port writes that set it up, and nothing at all for the quarter of a
writes that set it up and nothing for the quarter of a kilobyte that moved. 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 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 once is left open, the same way pipelining is: the memories are separate, so it plausibly
@@ -481,11 +584,10 @@ A disk is at the lowest version that describes what is on it, so `format` makes
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 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.
`Programs/makefile` does this for the programs in this repository: The makefile does this for the programs in this repository:
``` ```
cd Programs make programs
make
``` ```
The rule it uses is small enough to copy into your own projects: The rule it uses is small enough to copy into your own projects:
@@ -504,45 +606,43 @@ $(BUILD)/%.bin: %.asm
make test make test
``` ```
The suite assembles and runs every program in `Programs/` and compares the results against recorded output. Tests are defined in `Tests/manifest`, one line per program. To record the current output as the expected result, after you have checked that it is correct: Builds the four 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 make bless
``` ```
Programs are 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: Records the current output as the expected result, after you have checked that it is
correct.
```
./Tests/run.sh hello 8bitFibonacci
```
The disk images tests read from are built first by `Tests/makedisks.sh`, using SplitDisk. A
test that reads one is therefore checked against a filesystem written by different code from
the same written specification, rather than against itself.
`Tests/run.sh` drives that comparison. Six more scripts run alongside it, and each exists
because a recorded file cannot answer its question:
- **`Tests/disk.sh`** checks the disk tool on its own: files of every awkward size onto an image and off again, and the things the format says cannot happen refused rather than half done.
- **`Tests/terminal.sh`** checks what a recorded file cannot see. Piped output is buffered and flushed at exit, so a prompt shown before its answer is asked for and one shown an hour late produce identical files; and key mode only touches a terminal when there is one. Both have gone wrong here, and both were found by a person whose terminal stopped working rather than by anything in this suite. So it runs the emulator under a pseudo-terminal and asks directly: that a prompt arrives before input is read, that a keystroke arrives without Return, that the terminal is handed back however the machine dies, and that suspending and resuming leave it as they found it. It also asks the one question about cycles that a recorded file cannot, since the count is stripped from every one: whether a program on a slow disk slept through the wait or spun on it. Both print the same characters and take the same elapsed time, and only the split between idle and bus cycles tells them apart.
- **`Tests/native.sh`** checks the assembler that runs on SplitBit against the one that runs on the host, byte for byte, on a boot image and four loadable programs, and then on CosmOS and on itself, and then on the CosmOS that CosmOS built.
- **`Tests/agree.sh`** checks the two implementations of SBFS against each other rather than each against itself, by building the same disk with SplitDisk and with CosmOS and comparing the images byte for byte. Every field one of them writes and the other only reads is checked there and nowhere else.
- **`Tests/lint.sh`** checks SplitLint against a fixture written so that every line of it trips exactly one rule. It compares which warning came out and at which line rather than how many came out in total: a count stays right while the thing behind it goes wrong, and breaking one rule's message left the total untouched at twenty three.
- **`Tests/docs.sh`** checks the manuals against the code: that every instruction has a row and every row is an instruction, that the counts in the headings are right, that every directive is written down, that every service the system implements is described and every service described is implemented, that every routine the manuals promise exists, that CosmOS still fits in the half of the machine its memory map gives it, and that the worked examples still assemble to the bytes printed beside them.
A cycle count is deliberately **not** part of a recorded result. The last line of the emulator's output has the number taken out before anything is compared, keeping only whether the program stopped on its own or ran into its limit, which is behaviour. Two instructions added to CosmOS used to move that number in six unrelated files at once, so a real difference would have arrived in a crowd of meaningless ones. Anything that wants to measure cycles should say so in a test of its own.
To rebuild all four tools with the address and undefined behaviour sanitizers and run the suite under them:
``` ```
make sanitize make sanitize
``` ```
This catches reads and writes past the end of an array, use after free, leaks, and undefined arithmetic. It also fills fresh allocations with a junk pattern, which turns a read of uninitialised memory from something that quietly works into something the tests notice. It runs everything `make test` runs, takes about twice as long, and puts the ordinary binaries back when it finishes. Rebuilds all four 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](SplitBit%20Test%20Manual.md).
## Documentation: ## Documentation:
Three documents, divided by what they are about rather than by who reads them. Four documents, divided by what they are about rather than by who reads them.
**[SplitBit Programming Manual](SplitBit%20Programming%20Manual.md)** 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 Programming Manual](SplitBit%20Programming%20Manual.md)** 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.
@@ -550,6 +650,8 @@ Three documents, divided by what they are about rather than by who reads them.
[Programs/CosmOS/README.md](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. [Programs/CosmOS/README.md](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 Test Manual](SplitBit%20Test%20Manual.md)** 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: ## License:
Apache License, Version 2.0. You may obtain a copy at [http://www.apache.org/licenses/LICENSE-2.0](http://www.apache.org/licenses/LICENSE-2.0). Apache License, Version 2.0. You may obtain a copy at [http://www.apache.org/licenses/LICENSE-2.0](http://www.apache.org/licenses/LICENSE-2.0).
+147
View File
@@ -12,6 +12,10 @@
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
// For mirroring a host directory onto a disk: walking one, and telling a directory from a
// file. POSIX rather than C, which is why the build asks for POSIX.1-2008 by name.
#include <dirent.h>
#include <sys/stat.h>
// Numbers on a SplitBit disk are most significant byte first, the same as everywhere // Numbers on a SplitBit disk are most significant byte first, the same as everywhere
// else on the machine. // else on the machine.
@@ -1108,6 +1112,137 @@ done:
// disk - so the children of a deleted directory would reappear inside whatever took its // disk - so the children of a deleted directory would reappear inside whatever took its
// place. Emptying it first is the only safe order, and making the caller do that is the // place. Emptying it first is the only safe order, and making the caller do that is the
// smallest way to guarantee it. // smallest way to guarantee it.
// ---- Mirroring a host directory onto a disk ----
//
// So that putting a new program where the others live is all it takes to have it on the
// machine. A list of files in a makefile is a list that goes stale the moment somebody adds
// something and forgets, and the thing they forgot is invisible until they look for it.
//
// EVERY FILE GOES THROUGH put AND EVERY DIRECTORY THROUGH mkdir, which is the point: this
// adds a walk and no filesystem code at all, so anything the format refuses here it refuses
// everywhere, in exactly the same words.
static int compareEntries(const void *left, const void *right) {
return strcmp(*(const char *const *)left, *(const char *const *)right);
}
static int mirrorDirectory(const char *path, const char *hostDir, const char *diskDir,
int skipCount, char *const skips[]) {
DIR *open = opendir(hostDir);
if (open == NULL) {
fprintf(stderr, "Error: Couldn't read the directory \"%s\".\n", hostDir);
return 1;
}
// ---- Read the names first, and sort them ----
//
// readdir hands them back in whatever order the host filesystem feels like, and a disk
// image that comes out different from one run to the next is an image no test can
// compare against another. Sorted, the same tree always makes the same disk.
char **names = NULL;
size_t count = 0, room = 0;
const struct dirent *entry;
while ((entry = readdir(open)) != NULL) {
// Nothing beginning with a dot: that is . and .. and every editor's leavings, and
// none of it is source anybody wants on the machine.
if (entry->d_name[0] == '.') {
continue;
}
int skipped = 0;
for (int i = 0; i < skipCount; i++) {
if (strcmp(entry->d_name, skips[i]) == 0) {
skipped = 1;
}
}
if (skipped) {
continue;
}
if (count == room) {
room = room ? room * 2 : 32;
char **grown = realloc(names, room * sizeof(*names));
if (grown == NULL) {
fprintf(stderr, "Error: Out of memory reading \"%s\".\n", hostDir);
closedir(open);
for (size_t i = 0; i < count; i++) free(names[i]);
free(names);
return 1;
}
names = grown;
}
names[count] = strdup(entry->d_name);
if (names[count] == NULL) {
fprintf(stderr, "Error: Out of memory reading \"%s\".\n", hostDir);
closedir(open);
for (size_t i = 0; i < count; i++) free(names[i]);
free(names);
return 1;
}
count++;
}
closedir(open);
qsort(names, count, sizeof(*names), compareEntries);
int failed = 0;
for (size_t i = 0; i < count; i++) {
char hostChild[1024];
char diskChild[1024];
if (snprintf(hostChild, sizeof(hostChild), "%s/%s", hostDir, names[i])
>= (int)sizeof(hostChild)
|| snprintf(diskChild, sizeof(diskChild), "%s/%s", diskDir, names[i])
>= (int)sizeof(diskChild)) {
fprintf(stderr, "Error: \"%s/%s\" makes a path too long to follow.\n",
hostDir, names[i]);
failed = 1;
continue;
}
// A NAME TOO LONG IS AN ERROR RATHER THAN A SKIP. Leaving it off would mean a build
// that looks like it worked and a disk quietly missing a program, which is the exact
// failure a mirror exists to prevent. Twenty-two bytes is what a directory entry
// holds, and the fix is to call the file something shorter.
if (strlen(names[i]) > SBFS_NAME_BYTES) {
fprintf(stderr, "Error: \"%s\" is %zu characters, and a name holds %d.\n",
names[i], strlen(names[i]), SBFS_NAME_BYTES);
failed = 1;
continue;
}
struct stat about;
if (stat(hostChild, &about) != 0) {
fprintf(stderr, "Error: Couldn't look at \"%s\".\n", hostChild);
failed = 1;
continue;
}
if (S_ISDIR(about.st_mode)) {
if (commandMakeDirectory(path, diskChild)
|| mirrorDirectory(path, hostChild, diskChild, skipCount, skips)) {
failed = 1;
}
} else if (S_ISREG(about.st_mode)) {
if (commandPut(path, hostChild, diskChild)) {
failed = 1;
}
}
// Anything else - a socket, a device, whatever a host has - is not a thing this
// filesystem has a way to be, so it is passed over without comment.
}
for (size_t i = 0; i < count; i++) {
free(names[i]);
}
free(names);
return failed;
}
static int commandMirror(const char *path, const char *hostDir, const char *diskDir,
int skipCount, char *const skips[]) {
struct stat about;
if (stat(hostDir, &about) != 0 || !S_ISDIR(about.st_mode)) {
fprintf(stderr, "Error: \"%s\" is not a directory to mirror.\n", hostDir);
return 1;
}
return mirrorDirectory(path, hostDir, diskDir, skipCount, skips);
}
static int commandRemoveDirectory(const char *path, const char *name) { static int commandRemoveDirectory(const char *path, const char *name) {
FILE *image = openImage(path, "r+b"); FILE *image = openImage(path, "r+b");
if (image == NULL) { if (image == NULL) {
@@ -1162,6 +1297,8 @@ static void printUsage(const char *program) {
printf(" delete <image> <path> Remove a file.\n"); printf(" delete <image> <path> Remove a file.\n");
printf(" mkdir <image> <path> Make a directory.\n"); printf(" mkdir <image> <path> Make a directory.\n");
printf(" rmdir <image> <path> Remove an empty one.\n"); printf(" rmdir <image> <path> Remove an empty one.\n");
printf(" mirror <image> <dir> <path> [skip...] Copy a whole host directory onto it,\n");
printf(" leaving behind anything named in skip.\n");
printf("\n"); printf("\n");
printf("Blocks are %d bytes. A name may be %d characters, and a path is names with\n", printf("Blocks are %d bytes. A name may be %d characters, and a path is names with\n",
SBFS_BLOCK_BYTES, SBFS_NAME_BYTES); SBFS_BLOCK_BYTES, SBFS_NAME_BYTES);
@@ -1256,6 +1393,16 @@ int main(int argc, char *argv[]) {
} }
return commandGet(path, argv[3], (argc > 4) ? argv[4] : argv[3]); return commandGet(path, argv[3], (argc > 4) ? argv[4] : argv[3]);
} }
if (strcmp(command, "mirror") == 0) {
if (argc < 5) {
fprintf(stderr, "Error: mirror needs a directory to copy and somewhere to put"
" it.\n");
return 1;
}
// Anything after those is a name to leave behind, which is how a project keeps what
// it builds out of what it wrote.
return commandMirror(path, argv[3], argv[4], argc - 5, &argv[5]);
}
if (strcmp(command, "delete") == 0) { if (strcmp(command, "delete") == 0) {
if (argc < 4) { if (argc < 4) {
fprintf(stderr, "Error: delete needs the name of a file on the disk.\n"); fprintf(stderr, "Error: delete needs the name of a file on the disk.\n");
+1 -1
View File
@@ -178,7 +178,7 @@ uint8_t loadROM(const unsigned char *bytes, unsigned long length,
return failed; return failed;
} }
uint8_t loadFile(char *path, uint8_t *Program, uint8_t *Data) { uint8_t loadFile(const char *path, uint8_t *Program, uint8_t *Data) {
FILE *file = fopen(path, "rb"); FILE *file = fopen(path, "rb");
if (file == NULL) { if (file == NULL) {
fprintf(stderr, "Error: Couldn't open file: %s\n", path); fprintf(stderr, "Error: Couldn't open file: %s\n", path);
+1 -1
View File
@@ -8,7 +8,7 @@
#include <stdint.h> #include <stdint.h>
uint8_t loadFile(char *path, uint8_t *Program, uint8_t *Data); uint8_t loadFile(const char *path, uint8_t *Program, uint8_t *Data);
// The same, from bytes the emulator carries rather than a file it opens. See loadROM. // The same, from bytes the emulator carries rather than a file it opens. See loadROM.
uint8_t loadROM(const unsigned char *bytes, unsigned long length, uint8_t loadROM(const unsigned char *bytes, unsigned long length,
+171 -170
View File
@@ -14,276 +14,277 @@
#include "../Assembler/assembly.h" // For the fault vector numbers. #include "../Assembler/assembly.h" // For the fault vector numbers.
#include <string.h> #include <string.h>
typedef struct {
uint8_t *memory; // Never published. See the note in controller.h.
uint32_t capacity; // In bytes. A full bank is 65536, which is why this is not 16 bit.
uint8_t flags;
uint8_t ownerPort;
uint16_t guardStart;
uint16_t guardEnd;
} Bank;
static Bank banks[BANK_COUNT];
// Bank 2's contents: the description of every bank, for anything that wants to read it.
static uint8_t bankTable[BANK_TABLE_BYTES];
// The registers, exactly as the ports name them.
static uint8_t sourceBank, destBank, guardBank;
static uint16_t sourceAddress, destAddress, length;
static uint16_t guardStart, guardEnd;
static uint8_t status;
// Writes a bank's description into the table that bank 2 publishes. Called whenever // Writes a bank's description into the table that bank 2 publishes. Called whenever
// anything about a bank changes, so the published table and the real one cannot drift. // anything about a bank changes, so the published table and the real one cannot drift.
static void publishBank(int number) { static void publishBank(Controller *c, int number) {
uint8_t *record = bankTable + number * BANK_RECORD_BYTES; uint8_t *record = c->bankTable + number * BANK_RECORD_BYTES;
record[0] = banks[number].flags; record[0] = c->banks[number].flags;
record[1] = banks[number].ownerPort; record[1] = c->banks[number].ownerPort;
// Zero means the whole 64K, the same convention Length uses, because a capacity of // Zero means the whole 64K, the same convention Length uses, because a capacity of
// nothing is never what anyone meant. // nothing is never what anyone meant.
record[2] = (uint8_t)((banks[number].capacity >> 8) & 0xFF); record[2] = (uint8_t)((c->banks[number].capacity >> 8) & 0xFF);
record[3] = (uint8_t)(banks[number].capacity & 0xFF); record[3] = (uint8_t)(c->banks[number].capacity & 0xFF);
record[4] = (uint8_t)(banks[number].guardStart >> 8); record[4] = (uint8_t)(c->banks[number].guardStart >> 8);
record[5] = (uint8_t)(banks[number].guardStart & 0xFF); record[5] = (uint8_t)(c->banks[number].guardStart & 0xFF);
record[6] = (uint8_t)(banks[number].guardEnd >> 8); record[6] = (uint8_t)(c->banks[number].guardEnd >> 8);
record[7] = (uint8_t)(banks[number].guardEnd & 0xFF); record[7] = (uint8_t)(c->banks[number].guardEnd & 0xFF);
} }
static void defineBank(int number, uint8_t *memory, uint32_t capacity, uint8_t flags, uint8_t owner) { static void defineBank(Controller *c, int number, uint8_t *memory, uint32_t capacity, uint8_t flags, uint8_t owner) {
banks[number].memory = memory; c->banks[number].memory = memory;
banks[number].capacity = capacity; c->banks[number].capacity = capacity;
banks[number].flags = flags | BANK_FLAG_PRESENT; c->banks[number].flags = flags | BANK_FLAG_PRESENT;
banks[number].ownerPort = owner; c->banks[number].ownerPort = owner;
banks[number].guardStart = 0; c->banks[number].guardStart = 0;
banks[number].guardEnd = 0; c->banks[number].guardEnd = 0;
publishBank(number); publishBank(c, number);
} }
void initializeController(uint8_t *programMemory, uint8_t *dataMemory) { void initializeController(Controller *c, uint8_t *programMemory, uint8_t *dataMemory) {
memset(banks, 0, sizeof(banks)); memset(c->banks, 0, sizeof(c->banks));
memset(bankTable, 0, sizeof(bankTable)); memset(c->bankTable, 0, sizeof(c->bankTable));
for (int i = 0; i < BANK_COUNT; i++) { for (int i = 0; i < BANK_COUNT; i++) {
banks[i].ownerPort = BANK_OWNER_MACHINE; c->banks[i].ownerPort = BANK_OWNER_MACHINE;
publishBank(i); publishBank(c, i);
} }
defineBank(BANK_PROGRAM, programMemory, 0x10000, 0, BANK_OWNER_MACHINE); defineBank(c, BANK_PROGRAM, programMemory, 0x10000, 0, BANK_OWNER_MACHINE);
defineBank(BANK_DATA, dataMemory, 0x10000, 0, BANK_OWNER_MACHINE); defineBank(c, BANK_DATA, dataMemory, 0x10000, 0, BANK_OWNER_MACHINE);
// The table describes itself, so a program that walks it finds bank 2 in there along // The table describes itself, so a program that walks it finds bank 2 in there along
// with everything else. It is read only, which is what keeps RegisterBank the only // with everything else. It is read only, which is what keeps RegisterBank the only
// way to change what the controller routes through. // way to change what the controller routes through.
defineBank(BANK_TABLE, bankTable, BANK_TABLE_BYTES, BANK_FLAG_READ_ONLY, BANK_OWNER_MACHINE); defineBank(c, BANK_TABLE, c->bankTable, BANK_TABLE_BYTES, BANK_FLAG_READ_ONLY, BANK_OWNER_MACHINE);
sourceBank = destBank = guardBank = 0; c->sourceBank = c->destBank = c->guardBank = 0;
sourceAddress = destAddress = length = 0; c->sourceAddress = c->destAddress = c->length = 0;
guardStart = guardEnd = 0; c->guardStart = c->guardEnd = 0;
status = 0; c->status = 0;
} }
// Refuses, remembering why so that Status can be read afterwards. // Refuses, remembering why so that Status can be read afterwards.
static void refuse(uint8_t faultVector) { static void refuse(Controller *c, uint8_t faultVector) {
status = faultVector; c->status = faultVector;
refuseAccess(faultVector); refuseAccess(faultVector);
} }
// Is this somewhere the controller can read? A bank has to be there, and the address has // Is this somewhere the controller can read? A bank has to be there, and the address has
// to be inside it. // to be inside it.
static int canRead(uint8_t bank, uint16_t address) { static int canRead(Controller *c, uint8_t bank, uint16_t address) {
if (!(banks[bank].flags & BANK_FLAG_PRESENT) || address >= banks[bank].capacity) { if (!(c->banks[bank].flags & BANK_FLAG_PRESENT) || address >= c->banks[bank].capacity) {
refuse(VECTOR_BANK_FAULT); refuse(c, VECTOR_BANK_FAULT);
return 0; return 0;
} }
return 1; return 1;
} }
// The same, and then the two reasons a write in particular gets turned away. // The same, and then the two reasons a write in particular gets turned away.
static int canWrite(uint8_t bank, uint16_t address) { static int canWrite(Controller *c, uint8_t bank, uint16_t address) {
if (!canRead(bank, address)) { if (!canRead(c, bank, address)) {
return 0; return 0;
} }
if (banks[bank].flags & BANK_FLAG_READ_ONLY) { if (c->banks[bank].flags & BANK_FLAG_READ_ONLY) {
refuse(VECTOR_GUARD_VIOLATION); refuse(c, VECTOR_GUARD_VIOLATION);
return 0; return 0;
} }
if ((banks[bank].flags & BANK_FLAG_GUARDED) if ((c->banks[bank].flags & BANK_FLAG_GUARDED)
&& address >= banks[bank].guardStart && address <= banks[bank].guardEnd) { && address >= c->banks[bank].guardStart && address <= c->banks[bank].guardEnd) {
refuse(VECTOR_GUARD_VIOLATION); refuse(c, VECTOR_GUARD_VIOLATION);
return 0; return 0;
} }
return 1; return 1;
} }
// A length of zero means the whole 64K, because a transfer of no bytes is never what // A c->length of zero means the whole 64K, because a transfer of no bytes is never what
// anyone meant, and 65536 does not fit in the two bytes that carry it. // anyone meant, and 65536 does not fit in the two bytes that carry it.
static uint32_t transferLength(void) { static uint32_t transferLength(Controller *c) {
return (length == 0) ? 0x10000u : (uint32_t)length; return (c->length == 0) ? 0x10000u : (uint32_t)c->length;
} }
// Everything a transfer will touch is checked before any of it moves. A blit that ran // Everything a transfer will touch is checked before any of it moves. A blit that ran
// out of bank halfway would leave memory in a state no program asked for, and the // out of bank halfway would leave memory in a state no program asked for, and the
// diagnostic would arrive after the damage rather than instead of it. So these answer // diagnostic would arrive after the damage rather than instead of it. So these answer
// for the whole range or refuse the whole thing. // for the whole range or refuse the whole thing.
static int rangeReadable(uint8_t bank, uint16_t address, uint32_t count) { static int rangeReadable(Controller *c, uint8_t bank, uint16_t address, uint32_t count) {
if (!(banks[bank].flags & BANK_FLAG_PRESENT) if (!(c->banks[bank].flags & BANK_FLAG_PRESENT)
|| (uint32_t)address + count > banks[bank].capacity) { || (uint32_t)address + count > c->banks[bank].capacity) {
refuse(VECTOR_BANK_FAULT); refuse(c, VECTOR_BANK_FAULT);
return 0; return 0;
} }
return 1; return 1;
} }
static int rangeWritable(uint8_t bank, uint16_t address, uint32_t count) { static int rangeWritable(Controller *c, uint8_t bank, uint16_t address, uint32_t count) {
if (!rangeReadable(bank, address, count)) { if (!rangeReadable(c, bank, address, count)) {
return 0; return 0;
} }
if (banks[bank].flags & BANK_FLAG_READ_ONLY) { if (c->banks[bank].flags & BANK_FLAG_READ_ONLY) {
refuse(VECTOR_GUARD_VIOLATION); refuse(c, VECTOR_GUARD_VIOLATION);
return 0; return 0;
} }
if (banks[bank].flags & BANK_FLAG_GUARDED) { if (c->banks[bank].flags & BANK_FLAG_GUARDED) {
uint32_t last = (uint32_t)address + count - 1; uint32_t last = (uint32_t)address + count - 1;
// Any overlap at all with the fence, not just a write that starts inside it. // Any overlap at all with the fence, not just a write that starts inside it.
if (!(last < banks[bank].guardStart || address > banks[bank].guardEnd)) { if (!(last < c->banks[bank].guardStart || address > c->banks[bank].guardEnd)) {
refuse(VECTOR_GUARD_VIOLATION); refuse(c, VECTOR_GUARD_VIOLATION);
return 0; return 0;
} }
} }
return 1; return 1;
} }
// What the moves below have cost since anybody last asked. // ---- Sixteen bits wide, when the addresses let it be ----
static unsigned long pendingCycles = 0; //
// The controller reaches bank memory two bytes at a time, so an aligned transfer moves two
// bytes in the time a misaligned one moves one. A word is read at an even address and
// written at an even address, which is why the source, the destination AND the c->length must
// all be even: an odd anything would have the controller shifting bytes across word
// boundaries to line them up, and that is a second design rather than this one.
//
// Misaligned falls back to a byte a cycle, which is exactly what the machine did before it
// was widened, so nothing already written got slower.
//
// THE RULE IS VISIBLE ON PURPOSE. A program that cares can align what it moves, and a cost
// a program cannot see is a cost it cannot avoid. It is also the honest thing to model:
// hardware this shape really does behave this way.
static int wideRun(uint32_t addressesAndLength) {
return (addressesAndLength & 1u) == 0;
}
unsigned long controllerTakeCycles(void) { unsigned long controllerTakeCycles(Controller *c) {
unsigned long taken = pendingCycles; unsigned long taken = c->pendingCycles;
pendingCycles = 0; c->pendingCycles = 0;
return taken; return taken;
} }
static void doBlit(void) { static void doBlit(Controller *c) {
uint32_t count = transferLength(); uint32_t count = transferLength(c);
if (!rangeReadable(sourceBank, sourceAddress, count)) { if (!rangeReadable(c, c->sourceBank, c->sourceAddress, count)) {
return; return;
} }
if (!rangeWritable(destBank, destAddress, count)) { if (!rangeWritable(c, c->destBank, c->destAddress, count)) {
return; return;
} }
// memmove rather than memcpy, because source and destination may be the same bank // memmove rather than memcpy, because source and destination may be the same bank
// and may overlap. Sliding a buffer along itself is an ordinary thing to want, and // and may overlap. Sliding a buffer along itself is an ordinary thing to want, and
// getting it silently wrong is exactly the sort of failure this machine keeps // getting it silently wrong is exactly the sort of failure this machine keeps
// designing against. // designing against.
memmove(banks[destBank].memory + destAddress, memmove(c->banks[c->destBank].memory + c->destAddress,
banks[sourceBank].memory + sourceAddress, count); c->banks[c->sourceBank].memory + c->sourceAddress, count);
// A byte read and a byte written. Two banks are two memories and the pair overlaps; // A word read and a word written. Two c->banks are two memories and the pair overlaps;
// one bank is one memory and they do not. The odd cycle is the pipeline filling. // one bank is one memory and they do not. The odd cycle is the pipeline filling.
pendingCycles += (sourceBank == destBank) ? 2 * count + 1 : count + 1; //
sourceAddress = (uint16_t)(sourceAddress + count); // Wide when everything is even, so an aligned move between c->banks settles at two bytes a
destAddress = (uint16_t)(destAddress + count); // cycle and an aligned move within one at a byte a cycle - each twice what it was.
status = 0; unsigned long moves = wideRun(c->sourceAddress | c->destAddress | count) ? count / 2 : count;
c->pendingCycles += (c->sourceBank == c->destBank) ? 2 * moves + 1 : moves + 1;
c->sourceAddress = (uint16_t)(c->sourceAddress + count);
c->destAddress = (uint16_t)(c->destAddress + count);
c->status = 0;
} }
static void doFill(void) { static void doFill(Controller *c) {
uint32_t count = transferLength(); uint32_t count = transferLength(c);
if (!rangeWritable(destBank, destAddress, count)) { if (!rangeWritable(c, c->destBank, c->destAddress, count)) {
return; return;
} }
// A fill has nowhere to read from, only a value, so SourceLow carries the byte and // A fill has nowhere to read from, only a value, so SourceLow carries the byte and the
// the rest of the source registers mean nothing here. // rest of the source registers mean nothing here - including for the alignment, which
pendingCycles += count + 1; // asks only about where the bytes are going and how many there are.
memset(banks[destBank].memory + destAddress, (int)(sourceAddress & 0xFF), count); c->pendingCycles += (wideRun(c->destAddress | count) ? count / 2 : count) + 1;
destAddress = (uint16_t)(destAddress + count); memset(c->banks[c->destBank].memory + c->destAddress, (int)(c->sourceAddress & 0xFF), count);
status = 0; c->destAddress = (uint16_t)(c->destAddress + count);
c->status = 0;
} }
// DestBank is the number being given out, and SourceLow says which port owns the memory. // DestBank is the number being given out, and SourceLow says which port owns the memory.
// The capacity is asked of the device rather than supplied, because how big a bank is // The capacity is asked of the device rather than supplied, because how big a bank is
// was settled when the machine was built. // was settled when the machine was built.
static void doRegisterBank(void) { static void doRegisterBank(Controller *c) {
if (destBank <= BANK_TABLE) { if (c->destBank <= BANK_TABLE) {
// Banks 0 to 2 are the machine's own and are not anybody's to hand out. // Banks 0 to 2 are the machine's own and are not anybody's to hand out.
refuse(VECTOR_BANK_FAULT); refuse(c, VECTOR_BANK_FAULT);
return; return;
} }
uint8_t port = (uint8_t)(sourceAddress & 0xFF); uint8_t port = (uint8_t)(c->sourceAddress & 0xFF);
uint32_t capacity = 0; uint32_t capacity = 0;
uint8_t *memory = deviceMemory(port, &capacity); uint8_t *memory = deviceMemory(port, &capacity);
if (memory == NULL) { if (memory == NULL) {
// Either nothing is on that port or what is there brings no memory. Registering // Either nothing is on that port or what is there brings no memory. Registering
// it would put a bank in the table that leads nowhere. // it would put a bank in the table that leads nowhere.
refuse(VECTOR_BANK_FAULT); refuse(c, VECTOR_BANK_FAULT);
return; return;
} }
// Registering over a bank that already has something in it is allowed. Which number // Registering over a bank that already has something in it is allowed. Which number
// a device's memory answers to is the OS's business, and nothing was allocated that // a device's memory answers to is the OS's business, and nothing was allocated that
// could be lost by changing its mind. // could be lost by changing its mind.
defineBank(destBank, memory, capacity, 0, port); defineBank(c, c->destBank, memory, capacity, 0, port);
status = 0; c->status = 0;
} }
// The guard registers stage a range; this is what commits it. Raising a fence over a // The guard registers stage a range; this is what commits it. Raising a fence over a
// bank that is not there would protect nothing while looking like it protected // bank that is not there would protect nothing while looking like it protected
// something, so it is refused rather than quietly accepted. // something, so it is refused rather than quietly accepted.
static void doGuardOn(void) { static void doGuardOn(Controller *c) {
if (!(banks[guardBank].flags & BANK_FLAG_PRESENT)) { if (!(c->banks[c->guardBank].flags & BANK_FLAG_PRESENT)) {
refuse(VECTOR_BANK_FAULT); refuse(c, VECTOR_BANK_FAULT);
return; return;
} }
if (guardStart > guardEnd) { if (c->guardStart > c->guardEnd) {
// No address can be inside a range that ends before it starts, so this fence // No address can be inside a range that ends before it starts, so this fence
// would catch nothing. A program that raised one would believe it was protected // would catch nothing. A program that raised one would believe it was protected
// and would not be, which is worse than having no fence at all. // and would not be, which is worse than having no fence at all.
refuse(VECTOR_BANK_FAULT); refuse(c, VECTOR_BANK_FAULT);
return; return;
} }
banks[guardBank].guardStart = guardStart; c->banks[c->guardBank].guardStart = c->guardStart;
banks[guardBank].guardEnd = guardEnd; c->banks[c->guardBank].guardEnd = c->guardEnd;
banks[guardBank].flags |= BANK_FLAG_GUARDED; c->banks[c->guardBank].flags |= BANK_FLAG_GUARDED;
publishBank(guardBank); publishBank(c, c->guardBank);
status = 0; c->status = 0;
} }
static void doGuardOff(void) { static void doGuardOff(Controller *c) {
if (!(banks[guardBank].flags & BANK_FLAG_PRESENT)) { if (!(c->banks[c->guardBank].flags & BANK_FLAG_PRESENT)) {
refuse(VECTOR_BANK_FAULT); refuse(c, VECTOR_BANK_FAULT);
return; return;
} }
banks[guardBank].flags &= (uint8_t)~BANK_FLAG_GUARDED; c->banks[c->guardBank].flags &= (uint8_t)~BANK_FLAG_GUARDED;
publishBank(guardBank); publishBank(c, c->guardBank);
status = 0; c->status = 0;
} }
uint8_t controllerWrite(uint8_t value, uint8_t port) { uint8_t controllerWrite(Controller *c, uint8_t value, uint8_t port) {
switch (port) { switch (port) {
case CTRL_SOURCE_BANK: sourceBank = value; break; case CTRL_SOURCE_BANK: c->sourceBank = value; break;
case CTRL_SOURCE_HIGH: sourceAddress = (uint16_t)(value << 8) | (sourceAddress & 0x00FF); break; case CTRL_SOURCE_HIGH: c->sourceAddress = (uint16_t)(value << 8) | (c->sourceAddress & 0x00FF); break;
case CTRL_SOURCE_LOW: sourceAddress = (sourceAddress & 0xFF00) | value; break; case CTRL_SOURCE_LOW: c->sourceAddress = (c->sourceAddress & 0xFF00) | value; break;
case CTRL_DEST_BANK: destBank = value; break; case CTRL_DEST_BANK: c->destBank = value; break;
case CTRL_DEST_HIGH: destAddress = (uint16_t)(value << 8) | (destAddress & 0x00FF); break; case CTRL_DEST_HIGH: c->destAddress = (uint16_t)(value << 8) | (c->destAddress & 0x00FF); break;
case CTRL_DEST_LOW: destAddress = (destAddress & 0xFF00) | value; break; case CTRL_DEST_LOW: c->destAddress = (c->destAddress & 0xFF00) | value; break;
case CTRL_LENGTH_HIGH: length = (uint16_t)(value << 8) | (length & 0x00FF); break; case CTRL_LENGTH_HIGH: c->length = (uint16_t)(value << 8) | (c->length & 0x00FF); break;
case CTRL_LENGTH_LOW: length = (length & 0xFF00) | value; break; case CTRL_LENGTH_LOW: c->length = (c->length & 0xFF00) | value; break;
case CTRL_GUARD_BANK: guardBank = value; break; case CTRL_GUARD_BANK: c->guardBank = value; break;
case CTRL_GUARD_START_HIGH: guardStart = (uint16_t)(value << 8) | (guardStart & 0x00FF); break; case CTRL_GUARD_START_HIGH: c->guardStart = (uint16_t)(value << 8) | (c->guardStart & 0x00FF); break;
case CTRL_GUARD_START_LOW: guardStart = (guardStart & 0xFF00) | value; break; case CTRL_GUARD_START_LOW: c->guardStart = (c->guardStart & 0xFF00) | value; break;
case CTRL_GUARD_END_HIGH: guardEnd = (uint16_t)(value << 8) | (guardEnd & 0x00FF); break; case CTRL_GUARD_END_HIGH: c->guardEnd = (uint16_t)(value << 8) | (c->guardEnd & 0x00FF); break;
case CTRL_GUARD_END_LOW: guardEnd = (guardEnd & 0xFF00) | value; break; case CTRL_GUARD_END_LOW: c->guardEnd = (c->guardEnd & 0xFF00) | value; break;
case CTRL_DATA: case CTRL_DATA:
// A byte into the destination, and the address steps on so that writing a // A byte into the destination, and the address steps on so that writing a
// run of bytes is a loop over one instruction rather than four. // run of bytes is a loop over one instruction rather than four.
if (canWrite(destBank, destAddress)) { if (canWrite(c, c->destBank, c->destAddress)) {
pendingCycles++; // The byte itself, beyond reaching the port. c->pendingCycles++; // The byte itself, beyond reaching the port.
banks[destBank].memory[destAddress] = value; c->banks[c->destBank].memory[c->destAddress] = value;
if (destBank == BANK_TABLE) { if (c->destBank == BANK_TABLE) {
// Unreachable while the table is read only, and here so that it stays // Unreachable while the table is read only, and here so that it stays
// true if that ever changes: the published bytes are a description, // true if that ever changes: the published bytes are a description,
// and nothing may write through them into a real bank. // and nothing may write through them into a real bank.
publishBank(BANK_TABLE); publishBank(c, BANK_TABLE);
} }
destAddress++; c->destAddress++;
status = 0; c->status = 0;
} }
break; break;
@@ -291,16 +292,16 @@ uint8_t controllerWrite(uint8_t value, uint8_t port) {
// Both leave the addresses past whatever they touched and Length as it was, // Both leave the addresses past whatever they touched and Length as it was,
// so asking again carries straight on from where the last one stopped. // so asking again carries straight on from where the last one stopped.
switch (value) { switch (value) {
case COMMAND_BLIT: doBlit(); break; case COMMAND_BLIT: doBlit(c); break;
case COMMAND_FILL: doFill(); break; case COMMAND_FILL: doFill(c); break;
case COMMAND_REGISTER_BANK: doRegisterBank(); break; case COMMAND_REGISTER_BANK: doRegisterBank(c); break;
case COMMAND_GUARD_ON: doGuardOn(); break; case COMMAND_GUARD_ON: doGuardOn(c); break;
case COMMAND_GUARD_OFF: doGuardOff(); break; case COMMAND_GUARD_OFF: doGuardOff(c); break;
default: default:
// Refusing an unknown command is better than ignoring it, since a // Refusing an unknown command is better than ignoring it, since a
// program that asked for something is entitled to find out that it // program that asked for something is entitled to find out that it
// did not happen. // did not happen.
refuse(VECTOR_BANK_FAULT); refuse(c, VECTOR_BANK_FAULT);
break; break;
} }
break; break;
@@ -312,33 +313,33 @@ uint8_t controllerWrite(uint8_t value, uint8_t port) {
return 0; return 0;
} }
uint8_t controllerRead(uint8_t port) { uint8_t controllerRead(Controller *c, uint8_t port) {
switch (port) { switch (port) {
case CTRL_SOURCE_BANK: return sourceBank; case CTRL_SOURCE_BANK: return c->sourceBank;
case CTRL_SOURCE_HIGH: return (uint8_t)(sourceAddress >> 8); case CTRL_SOURCE_HIGH: return (uint8_t)(c->sourceAddress >> 8);
case CTRL_SOURCE_LOW: return (uint8_t)(sourceAddress & 0xFF); case CTRL_SOURCE_LOW: return (uint8_t)(c->sourceAddress & 0xFF);
case CTRL_DEST_BANK: return destBank; case CTRL_DEST_BANK: return c->destBank;
case CTRL_DEST_HIGH: return (uint8_t)(destAddress >> 8); case CTRL_DEST_HIGH: return (uint8_t)(c->destAddress >> 8);
case CTRL_DEST_LOW: return (uint8_t)(destAddress & 0xFF); case CTRL_DEST_LOW: return (uint8_t)(c->destAddress & 0xFF);
case CTRL_LENGTH_HIGH: return (uint8_t)(length >> 8); case CTRL_LENGTH_HIGH: return (uint8_t)(c->length >> 8);
case CTRL_LENGTH_LOW: return (uint8_t)(length & 0xFF); case CTRL_LENGTH_LOW: return (uint8_t)(c->length & 0xFF);
case CTRL_STATUS: return status; case CTRL_STATUS: return c->status;
case CTRL_GUARD_BANK: return guardBank; case CTRL_GUARD_BANK: return c->guardBank;
case CTRL_GUARD_START_HIGH: return (uint8_t)(guardStart >> 8); case CTRL_GUARD_START_HIGH: return (uint8_t)(c->guardStart >> 8);
case CTRL_GUARD_START_LOW: return (uint8_t)(guardStart & 0xFF); case CTRL_GUARD_START_LOW: return (uint8_t)(c->guardStart & 0xFF);
case CTRL_GUARD_END_HIGH: return (uint8_t)(guardEnd >> 8); case CTRL_GUARD_END_HIGH: return (uint8_t)(c->guardEnd >> 8);
case CTRL_GUARD_END_LOW: return (uint8_t)(guardEnd & 0xFF); case CTRL_GUARD_END_LOW: return (uint8_t)(c->guardEnd & 0xFF);
case CTRL_DATA: { case CTRL_DATA: {
// A byte out of the source, stepping on the same way a write does. // A byte out of the source, stepping on the same way a write does.
if (!canRead(sourceBank, sourceAddress)) { if (!canRead(c, c->sourceBank, c->sourceAddress)) {
return 0; return 0;
} }
pendingCycles++; // As above, the other way round. c->pendingCycles++; // As above, the other way round.
uint8_t value = banks[sourceBank].memory[sourceAddress]; uint8_t value = c->banks[c->sourceBank].memory[c->sourceAddress];
sourceAddress++; c->sourceAddress++;
status = 0; c->status = 0;
return value; return value;
} }
} }
+48 -7
View File
@@ -76,6 +76,17 @@
// a program that could write one would be setting a host address, which means nothing on // a program that could write one would be setting a host address, which means nothing on
// hardware and everything to the emulator running it. // hardware and everything to the emulator running it.
#define BANK_RECORD_BYTES 8 #define BANK_RECORD_BYTES 8
// What the controller knows about one bank. The memory pointer is never published - see the
// note above the record layout.
typedef struct {
uint8_t *memory;
uint32_t capacity; // In bytes. A full bank is 65536, which is why this is not 16 bit.
uint8_t flags;
uint8_t ownerPort;
uint16_t guardStart;
uint16_t guardEnd;
} Bank;
#define BANK_TABLE_BYTES (BANK_COUNT * BANK_RECORD_BYTES) #define BANK_TABLE_BYTES (BANK_COUNT * BANK_RECORD_BYTES)
#define BANK_FLAG_PRESENT 0x01 #define BANK_FLAG_PRESENT 0x01
@@ -93,21 +104,51 @@
// work look like ten cycles. // work look like ten cycles.
// //
// Banks are separate memories, which is what decides the rate. A move between two of them // Banks are separate memories, which is what decides the rate. A move between two of them
// can overlap its read and its write - fetch the next byte while the last one is stored - // can overlap its read and its write - fetch the next word while the last one is stored -
// so it settles at a byte a cycle. A move WITHIN one bank cannot, and costs two. A fill has // while a move WITHIN one bank cannot and costs twice as much. A fill has nothing to read
// nothing to read and costs one whatever the banks are. // and costs the same as a move between banks.
//
// AND THE PATH IS SIXTEEN BITS WIDE, so 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 the byte a cycle this had before it was widened: lining up bytes across word
// boundaries is a second design, and this is not it. See wideRun in controller.c.
// //
// Returned and cleared, so the caller adds it to whatever it is charging for. The CPU picks // Returned and cleared, so the caller adds it to whatever it is charging for. The CPU picks
// it up after each port access, which makes the transfer a stall: the machine issues a blit // it up after each port access, which makes the transfer a stall: the machine issues a blit
// and waits for it. Whether real hardware would let the two run at once is a live question - // and waits for it. Whether real hardware would let the two run at once is a live question -
// the memories are separate, so it plausibly could - and the answer wants measuring before // the memories are separate, so it plausibly could - and the answer wants measuring before
// it is designed. // it is designed.
unsigned long controllerTakeCycles(void); // ---- One of these to a bus ----
//
// A controller is the most stateful thing on this machine: a source bank and address, a
// destination, a length, a command, five guard registers and a table describing every bank
// it can reach. TWO PROCESSORS SHARING ONE WOULD INTERLEAVE INTO NONSENSE - one sets a
// source, the other sets a destination, the first issues a blit and moves the wrong bytes
// somewhere else again. No amount of arbitration fixes that, because there is nothing to
// arbitrate: both writes were legal and the result belongs to neither of them.
//
// So a peripheral core gets its own, describing its own memories. The fields are here rather
// than hidden in the source file because a device that contains a core has to be able to hold
// one; nothing outside reaches into them.
typedef struct {
Bank banks[BANK_COUNT];
// Bank 2's contents: the description of every bank, for anything that wants to read it.
uint8_t bankTable[BANK_TABLE_BYTES];
// The registers, exactly as the ports name them.
uint8_t sourceBank, destBank, guardBank;
uint16_t sourceAddress, destAddress, length;
uint16_t guardStart, guardEnd;
uint8_t status;
// What the moves have cost since anybody last asked.
unsigned long pendingCycles;
} Controller;
void initializeController(uint8_t *programMemory, uint8_t *dataMemory); unsigned long controllerTakeCycles(Controller *c);
uint8_t controllerWrite(uint8_t value, uint8_t port); void initializeController(Controller *c, uint8_t *programMemory, uint8_t *dataMemory);
uint8_t controllerRead(uint8_t port); uint8_t controllerWrite(Controller *c, uint8_t value, uint8_t port);
uint8_t controllerRead(Controller *c, uint8_t port);
#endif // CONTROLLER_H #endif // CONTROLLER_H
+30 -13
View File
@@ -8,8 +8,6 @@
#include "controller.h" #include "controller.h"
#include "../Assembler/assembly.h" // For the vector table layout, which both tools share. #include "../Assembler/assembly.h" // For the vector table layout, which both tools share.
uint16_t shiftRegister;
// Reads one entry out of a vector table. Most significant byte first, matching the // Reads one entry out of a vector table. Most significant byte first, matching the
// branch instructions and both file formats. // branch instructions and both file formats.
// ---- Every touch of memory, and what it costs ---- // ---- Every touch of memory, and what it costs ----
@@ -38,16 +36,20 @@ static inline void writeData(CPURegisters *cpu, uint16_t at, uint8_t value) {
// otherwise. // otherwise.
static inline void portOut(CPURegisters *cpu, uint8_t value, uint8_t port) { static inline void portOut(CPURegisters *cpu, uint8_t value, uint8_t port) {
cpu->busCycles++; cpu->busCycles++;
OutputHandler(value, port); cpu->bus->out(value, port);
// And whatever memory that made the controller move. The machine waits for it, which // And whatever memory that made the controller move. The machine waits for it, which
// is the conservative reading: a blit stalls the program that asked for one. // is the conservative reading: a blit stalls the program that asked for one.
cpu->busCycles += controllerTakeCycles(); cpu->busCycles += cpu->bus->takeStall();
} }
static inline uint8_t portIn(CPURegisters *cpu, uint8_t port) { static inline uint8_t portIn(CPURegisters *cpu, uint8_t port) {
cpu->busCycles++; cpu->busCycles++;
uint8_t value = InputHandler(port); uint8_t value = cpu->bus->in(port);
cpu->busCycles += controllerTakeCycles(); cpu->busCycles += cpu->bus->takeStall();
// And whatever time went by while the device kept the machine waiting. Idle rather than
// bus, because a machine stopped on a port is not using memory - the same distinction
// WAIT makes, arrived at from the other direction.
cpu->idleCycles += cpu->bus->takeIdle();
return value; return value;
} }
@@ -127,6 +129,9 @@ static uint8_t answerRefusal(CPURegisters *cpu, uint16_t site) {
} }
void initializeCPU(CPURegisters *cpu, uint8_t *programMemory, uint8_t *dataMemory) { void initializeCPU(CPURegisters *cpu, uint8_t *programMemory, uint8_t *dataMemory) {
// The machine's own, which is what every processor here was on when there could only be
// one. Anything that wants a processor somewhere else changes this afterwards.
cpu->bus = machineBus();
cpu->A = 0; cpu->A = 0;
cpu->B = 0; cpu->B = 0;
cpu->Q = 0; cpu->Q = 0;
@@ -245,17 +250,29 @@ uint8_t executeOperation(uint8_t Instruction, CPURegisters *cpu) {
break; break;
case 0x17: case 0x17:
// SHL - Shift AB left. // SHL - Shift AB left.
shiftRegister = ((uint16_t)cpu->A << 8) | cpu->B; //
// A local, and it always was one in effect: written and read inside this one
// instruction and never carried to the next. It sat at file scope until there
// was a second processor to share it with, which is a poor time to find out.
{
uint16_t shiftRegister = ((uint16_t)cpu->A << 8) | cpu->B;
shiftRegister = (shiftRegister << 1) | (shiftRegister >> 15); shiftRegister = (shiftRegister << 1) | (shiftRegister >> 15);
cpu->A = shiftRegister >> 8; cpu->A = shiftRegister >> 8;
cpu->B = shiftRegister & 0xFF; cpu->B = shiftRegister & 0xFF;
}
break; break;
case 0x18: case 0x18:
// SHR - Shift AB right. // SHR - Shift AB right.
shiftRegister = ((uint16_t)cpu->A << 8) | cpu->B; //
// A local, and it always was one in effect: written and read inside this one
// instruction and never carried to the next. It sat at file scope until there
// was a second processor to share it with, which is a poor time to find out.
{
uint16_t shiftRegister = ((uint16_t)cpu->A << 8) | cpu->B;
shiftRegister = (shiftRegister >> 1) | (shiftRegister << 15); shiftRegister = (shiftRegister >> 1) | (shiftRegister << 15);
cpu->A = shiftRegister >> 8; cpu->A = shiftRegister >> 8;
cpu->B = shiftRegister & 0xFF; cpu->B = shiftRegister & 0xFF;
}
break; break;
// //
// 1x - Branch Operations: // 1x - Branch Operations:
@@ -821,7 +838,7 @@ uint8_t executeOperation(uint8_t Instruction, CPURegisters *cpu) {
// makes the ordinary idiom race-free: a program tests its device, finds it // makes the ordinary idiom race-free: a program tests its device, finds it
// busy, and waits. If the device finished in between, the line is standing // busy, and waits. If the device finished in between, the line is standing
// and this does nothing at all rather than sleeping through the answer. // and this does nothing at all rather than sleeping through the answer.
if (nextPendingInterrupt() < 0) { if (cpu->bus->nextInterrupt() < 0) {
cpu->Waiting = 1; cpu->Waiting = 1;
} }
break; break;
@@ -854,7 +871,7 @@ void stepCPU(CPURegisters *cpu) {
// handler for and simply read its status afterwards, which is the whole reason // handler for and simply read its status afterwards, which is the whole reason
// this is worth having and is what the filesystem does with it. // this is worth having and is what the filesystem does with it.
if (cpu->Waiting) { if (cpu->Waiting) {
if (nextPendingInterrupt() < 0) { if (cpu->bus->nextInterrupt() < 0) {
cpu->idleCycles++; cpu->idleCycles++;
return; return;
} }
@@ -868,7 +885,7 @@ void stepCPU(CPURegisters *cpu) {
// handler faults there the way it always has. Waiting changes what the CPU // handler faults there the way it always has. Waiting changes what the CPU
// does between instructions; it does not change interrupt policy. // does between instructions; it does not change interrupt policy.
if (!(cpu->Status & STATUS_INTERRUPT)) { if (!(cpu->Status & STATUS_INTERRUPT)) {
clearInterrupt((uint8_t)nextPendingInterrupt()); cpu->bus->clearInterrupt((uint8_t)cpu->bus->nextInterrupt());
} }
} }
// A device asking for attention is answered between instructions and never // A device asking for attention is answered between instructions and never
@@ -878,9 +895,9 @@ void stepCPU(CPURegisters *cpu) {
// A line that is up while the Interrupt Flag is clear stays up. Masking holds a // A line that is up while the Interrupt Flag is clear stays up. Masking holds a
// device off; it does not lose what the device was asking for. // device off; it does not lose what the device was asking for.
if (cpu->Status & STATUS_INTERRUPT) { if (cpu->Status & STATUS_INTERRUPT) {
int port = nextPendingInterrupt(); int port = cpu->bus->nextInterrupt();
if (port >= 0) { if (port >= 0) {
clearInterrupt((uint8_t)port); cpu->bus->clearInterrupt((uint8_t)port);
if (enterInterrupt(cpu, HARDWARE_VECTOR_BASE, (uint8_t)port, cpu->ProgramCounter)) { if (enterInterrupt(cpu, HARDWARE_VECTOR_BASE, (uint8_t)port, cpu->ProgramCounter)) {
// The device asked and nobody was listening. enterInterrupt has // The device asked and nobody was listening. enterInterrupt has
// already stopped the machine; correct the cause, because the empty // already stopped the machine; correct the cause, because the empty
+26
View File
@@ -51,6 +51,29 @@ typedef enum {
FAULT_DEVICE_REFUSED // A device refused, and nothing was installed to catch it. FAULT_DEVICE_REFUSED // A device refused, and nothing was installed to catch it.
} FaultCause; } FaultCause;
// ---- What a CPU is plugged into ----
//
// Five things a CPU asks of the world outside itself, and every one of them was a call to a
// function there was exactly one of. That is fine for a machine with one processor and wrong
// for a machine with two: A PERIPHERAL CORE'S BUS IS ITS OWN. It sees the devices its own
// device gives it, raises its own interrupt lines, and stalls on its own controller - none
// of which are the host's.
//
// Gathered here rather than threaded through as a bus number, because a bus is a thing a
// device provides, and a device that provides one should hand over the answers rather than
// be looked up by an index somebody has to keep right.
typedef struct {
uint8_t (*out)(uint8_t value, uint8_t port);
uint8_t (*in)(uint8_t port);
// What the memory controller on this bus has just spent moving memory, and what the
// machine spent stopped waiting on a device. Both are taken and cleared.
unsigned long (*takeStall)(void);
unsigned long (*takeIdle)(void);
// The lowest port with its line up, or below zero for none, and putting one down.
int (*nextInterrupt)(void);
void (*clearInterrupt)(uint8_t port);
} Bus;
// The struct containing the CPU registers. // The struct containing the CPU registers.
typedef struct { typedef struct {
uint8_t A; uint8_t A;
@@ -62,6 +85,9 @@ typedef struct {
uint16_t StackPointer; uint16_t StackPointer;
uint8_t *Program; uint8_t *Program;
uint8_t *Data; uint8_t *Data;
// Which bus this processor is on. initializeCPU puts the machine's own here, which is
// what every CPU had before there could be more than one.
const Bus *bus;
// ---- What the machine has cost so far ---- // ---- What the machine has cost so far ----
// //
+26 -218
View File
@@ -4,87 +4,23 @@
// Small 8-Bit Harvard Architecture CPU // Small 8-Bit Harvard Architecture CPU
// Written by Anachronaut // Written by Anachronaut
// 10/15/2024 // 10/15/2024
#include "rom.h"
#include "bootstrap.h"
#include "../Assembler/assembly.h"
#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
#include "cpu.h"
#include "controller.h"
#include "io.h"
#include "utility.h"
#include <string.h>
#include <getopt.h>
#include <time.h>
// nanoseconds per second
#define NS_PER_SEC 1000000000LL
#define CYCLE_RATE 1000000
typedef struct {
long long cycles_per_sec; // e.g. 1000000 for 1 MHz
long long accumulator_ns; // unspent nanoseconds
struct timespec prev;
} CycleTimer;
static inline long long timespec_diff_ns(struct timespec a, struct timespec b) {
return (a.tv_sec - b.tv_sec) * NS_PER_SEC + (a.tv_nsec - b.tv_nsec);
}
void cycle_timer_init(CycleTimer *t, long long cycles_per_sec) {
t->cycles_per_sec = cycles_per_sec;
t->accumulator_ns = 0;
clock_gettime(CLOCK_MONOTONIC, &t->prev);
}
// Call once per host frame. Returns how many SplitBit cycles to execute.
int cycle_timer_tick(CycleTimer *t) {
struct timespec now;
clock_gettime(CLOCK_MONOTONIC, &now);
long long elapsed = timespec_diff_ns(now, t->prev);
t->prev = now;
// optional: clamp to avoid spiral-of-death on hitches
if (elapsed > NS_PER_SEC / 10) elapsed = NS_PER_SEC / 10;
t->accumulator_ns += elapsed;
long long period_ns = NS_PER_SEC / t->cycles_per_sec;
int cycles = (int)(t->accumulator_ns / period_ns);
t->accumulator_ns %= period_ns;
return cycles;
}
// How many cycles to run between glances at the wall clock. In fast mode there is
// no clock to keep pace with, so run a large batch before looking up.
#define FAST_BATCH 65536
unsigned long cycleCount = 0;
char *programFile = NULL;
// Memory Banks:
uint8_t Program[0x10000], Data[0x10000];
// How the run is reported. The idle half is mentioned only when there is one, so that
// every program written before WAIT existed prints exactly the line it always did.
// //
// THE TWO ARE NOT THE SAME KIND OF TIME. A bus cycle is the machine using memory; an idle // ---- The machine with a terminal attached ----
// cycle is the machine stopped in a WAIT while a device catches up. Added together they //
// are elapsed time, which is what a cycle limit measures; told apart they say whether a // This is a front end and nothing else. The machine itself is in machine.c, shared with
// program was working or waiting. // Voyager, which is the same machine with a screen and a speaker instead of a terminal.
static void reportCycles(const CPURegisters *cpu, unsigned long cycleCount) { //
if (cpu->idleCycles > 0) { // Keeping this file small is the point rather than a side effect: anything that ends up
printf("Execution halted after %lu cycles, %lu of them waiting.\n", // here is behaviour Voyager does not have, and the two are supposed to differ only in
cycleCount, cpu->idleCycles); // what they present. This builds and runs anywhere, with no graphics library, which is
} else { // what keeps the whole toolchain and the whole suite dependency free.
printf("Execution halted after %lu cycles.\n", cycleCount);
}
}
int main (int argc, char *argv[]) { #include "machine.h"
#include "utility.h"
#include <stdio.h>
#include <getopt.h>
int main(int argc, char *argv[]) {
EmulatorOptions options; EmulatorOptions options;
uint8_t result = parseOptions(argc, argv, &options); uint8_t result = parseOptions(argc, argv, &options);
if (result == OPTIONS_HELP) { if (result == OPTIONS_HELP) {
@@ -94,6 +30,7 @@ int main (int argc, char *argv[]) {
// Bad command line, don't execute. // Bad command line, don't execute.
return 1; return 1;
} }
char *programFile = NULL;
if (optind < argc) { if (optind < argc) {
programFile = argv[optind]; programFile = argv[optind];
optind++; optind++;
@@ -102,150 +39,21 @@ int main (int argc, char *argv[]) {
fprintf(stderr, "Error: Unexpected argument: %s\n", argv[optind]); fprintf(stderr, "Error: Unexpected argument: %s\n", argv[optind]);
return 1; return 1;
} }
// ---- Where the machine's first instruction comes from ----
// Machine machine;
// Named an image, it is placed into memory and started - which is what a debugger uint8_t started = machineStart(&machine, &options, programFile);
// does, and is how every test here runs. That path is not a shortcut to apologise if (started == MACHINE_NOTHING_TO_RUN) {
// for: placing memory from outside is a real thing real machines allow.
//
// Named none, the machine starts the way hardware would: the ROM is shadowed into
// Program Memory and it reads the disk for the rest. There has to be a disk for that
// to mean anything, and no image and no disk is a machine with nothing to run.
if (programFile == NULL && options.disk == NULL) {
fprintf(stderr, "Error: No boot image and no disk, so there is nothing to run.\n"); fprintf(stderr, "Error: No boot image and no disk, so there is nothing to run.\n");
printHelp(argv[0]); printHelp(argv[0]);
return 1; return 1;
} } else if (started != MACHINE_OK) {
if (programFile != NULL) {
if (loadFile(programFile, Program, Data)) {
fprintf(stderr, "Error: Couldn't read file: %s\n", programFile);
return 1; return 1;
} }
} else if (loadROM(bootROM, bootROMBytes, Program, Data)) {
fprintf(stderr, "Error: The boot ROM is not a boot image.\n");
return 1;
}
if (options.disk != NULL && attachDisk(options.disk, options.writeProtect)) {
return 1;
}
CPURegisters cpu;
// The controller has to know where the memories are before anything can reach
// them through it. Banks 0 and 1 are those two arrays.
initializeController(Program, Data);
initializeCPU(&cpu, Program, Data);
if(options.debug) {
printRegisters(&cpu, Program, Data);
}
CycleTimer timer; while (machineRunning(&machine)) {
setDiskLatency(options.diskCycles); machineRunSlice(&machine);
cycle_timer_init(&timer, CYCLE_RATE); }
uint8_t limitReached = 0; machineStop(&machine);
while (!(cpu.Status & STATUS_HALT) && !limitReached) { return machineReport(&machine);
if (options.debug) {
// Wait before advancing, not after, so that a keypress is what moves the
// machine on rather than something that happens once it already has.
// Through the console rather than getchar, so that everything reading standard
// input reads it the same way and the console's pushback stays the only place
// a byte can be sitting.
consoleReadByte();
}
int cycles;
if (options.debug) {
// Debug mode advances one instruction per keypress, so the wall clock
// has no say in how many cycles to run.
cycles = 1;
} else if (options.fast) {
cycles = FAST_BATCH;
} else {
cycles = cycle_timer_tick(&timer);
}
// ---- Spending a budget of cycles, not running a count of instructions ----
//
// An instruction costs what it touches, so a batch is finished when the cycles are
// gone rather than after so many steps. In debug mode the budget is one, and any
// instruction costs at least the fetch of its own opcode, so one step still runs.
for (long spent = 0; spent < cycles; ) {
// Both kinds of cycle, because both are time passing. A step that waits
// spends no bus at all, and a budget measured only in bus cycles would never
// be spent - the machine would sit inside one batch forever and the device it
// was waiting for would never be given a moment to finish.
unsigned long before = cpu.busCycles + cpu.idleCycles;
stepCPU(&cpu);
unsigned long took = (cpu.busCycles + cpu.idleCycles) - before;
spent += (long)took;
cycleCount += took;
// Time has passed, so anything waiting on it may be finished.
deviceTick(cycleCount);
// ---- Starting over ----
//
// Between instructions, which is the only place it can happen: a device cannot
// restart the machine from inside the instruction that asked for it.
//
// WHAT A RESET REPEATS IS HOW THIS MACHINE STARTED. Named an image, it is
// placed 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.
//
// The disk is not unplugged and its image keeps everything written to it. That
// is what warm means: the machine starts again, the world it starts into does
// not.
if (takeResetRequest()) {
// The vector table goes, and that is a deliberate departure from leaving
// memory alone. A vector points into whatever installed it, and after this
// 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.
memset(Program + SOFTWARE_VECTOR_BASE, 0,
(size_t)(0x10000 - SOFTWARE_VECTOR_BASE));
uint8_t failed = (programFile != NULL)
? loadFile(programFile, Program, Data)
: loadROM(bootROM, bootROMBytes, Program, Data);
if (failed) {
fprintf(stderr, "Error: The machine could not be started again.\n");
return 1;
}
initializeCPU(&cpu, Program, Data);
break; // Out of this batch; the loop above carries on with a new CPU.
}
if (cpu.Status & STATUS_HALT) {
// We've halted.
break;
}
if (options.cycles && cycleCount >= options.cycles) {
limitReached = 1;
break;
}
}
if (options.debug) {
printRegisters(&cpu, Program, Data);
printf("Cycle: %lu\n", cycleCount);
}
}
detachDisk();
if (limitReached) {
printf("Execution stopped after %lu cycles. (cycle limit reached)\n", cycleCount);
} else if (cpu.Status & STATUS_FAULT) {
// The Program Counter is still pointing at whatever the CPU could not get past.
reportCycles(&cpu, cycleCount);
if (cpu.Fault == FAULT_NO_HANDLER) {
fprintf(stderr, "Fault: Software vector %u, dispatched from Program Address 0x%04X, has no handler installed.\n",
cpu.FaultVector, cpu.ProgramCounter);
} else if (cpu.Fault == FAULT_DEVICE_REFUSED) {
fprintf(stderr, "Fault: The device on port %u refused the access at Program Address 0x%04X, and nothing is installed to deal with it.\n",
cpu.FaultVector, cpu.ProgramCounter);
} else if (cpu.Fault == FAULT_NO_DEVICE_HANDLER) {
fprintf(stderr, "Fault: The device on port %u interrupted at Program Address 0x%04X, and hardware vector %u has no handler installed.\n",
cpu.FaultVector, cpu.ProgramCounter, cpu.FaultVector);
} else {
fprintf(stderr, "Fault: 0x%02X at Program Address 0x%04X is not an instruction.\n",
Program[cpu.ProgramCounter], cpu.ProgramCounter);
}
return 1;
} else {
reportCycles(&cpu, cycleCount);
}
return 0;
} }
+157
View File
@@ -0,0 +1,157 @@
// font.c
// GENERATED ONCE from the Hatchet-GPU sprite sheet, and vendored here on purpose.
//
// Hatchet was an earlier attempt at a graphics system for this machine and is not part of
// this repository. What survives of it is this font: an 8x8 sheet 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 and is done.
//
// One bit a pixel, most significant bit leftmost, eight bytes a glyph. The screen wants
// eight bits a pixel, so the machine expands this into tile memory at reset rather than
// storing it expanded: 1,088 bytes here against 16 kilobytes there.
//
// Index 0 is ASCII 32, the space, and is blank. Everything through ASCII 126 is where
// ASCII says it is; after that come the drawn extras - box corners and junctions, arrows,
// the card suits - which have no ASCII to be in order with.
#include "font.h"
const unsigned char consoleFont[CONSOLE_FONT_GLYPHS * CONSOLE_FONT_BYTES] = {
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // 32 space
0x00, 0x30, 0x30, 0x30, 0x30, 0x30, 0x00, 0x30, // 33 '!'
0x00, 0x36, 0x36, 0x12, 0x00, 0x00, 0x00, 0x00, // 34 '"'
0x00, 0x24, 0x7E, 0x24, 0x24, 0x7E, 0x24, 0x00, // 35 '#'
0x00, 0x18, 0x3E, 0x58, 0x3C, 0x1A, 0x7C, 0x18, // 36 '$'
0x00, 0x62, 0x66, 0x0C, 0x18, 0x30, 0x66, 0x06, // 37 '%'
0x00, 0x38, 0x44, 0x48, 0x30, 0x4A, 0x44, 0x3A, // 38 '&'
0x00, 0x18, 0x18, 0x08, 0x00, 0x00, 0x00, 0x00, // 39 '''
0x00, 0x1E, 0x38, 0x70, 0x70, 0x70, 0x38, 0x1E, // 40 '('
0x00, 0x78, 0x1C, 0x0E, 0x0E, 0x0E, 0x1C, 0x78, // 41 ')'
0x00, 0x00, 0x5A, 0x3C, 0x7E, 0x3C, 0x5A, 0x00, // 42 '*'
0x00, 0x00, 0x18, 0x18, 0x7E, 0x7E, 0x18, 0x18, // 43 '+'
0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x30, 0x10, // 44 ','
0x00, 0x00, 0x00, 0x00, 0x7F, 0x7F, 0x00, 0x00, // 45 '-'
0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x30, 0x00, // 46 '.'
0x00, 0x03, 0x07, 0x0E, 0x1C, 0x38, 0x70, 0x60, // 47 '/'
0x00, 0x3C, 0x66, 0x4E, 0x5A, 0x72, 0x66, 0x3C, // 48 '0'
0x00, 0x18, 0x38, 0x18, 0x18, 0x18, 0x18, 0x7E, // 49 '1'
0x00, 0x3C, 0x66, 0x46, 0x0C, 0x18, 0x30, 0x7E, // 50 '2'
0x00, 0x3C, 0x66, 0x06, 0x0C, 0x06, 0x66, 0x3C, // 51 '3'
0x00, 0x3C, 0x6C, 0x6C, 0x7E, 0x7E, 0x0C, 0x0C, // 52 '4'
0x00, 0x7E, 0x60, 0x7C, 0x0E, 0x66, 0x6E, 0x3C, // 53 '5'
0x00, 0x3C, 0x66, 0x60, 0x7C, 0x66, 0x66, 0x3C, // 54 '6'
0x00, 0x7E, 0x66, 0x66, 0x0E, 0x1C, 0x38, 0x30, // 55 '7'
0x00, 0x3C, 0x66, 0x66, 0x3C, 0x66, 0x66, 0x3C, // 56 '8'
0x00, 0x3E, 0x66, 0x66, 0x3E, 0x06, 0x06, 0x06, // 57 '9'
0x00, 0x00, 0x30, 0x30, 0x00, 0x30, 0x30, 0x00, // 58 ':'
0x00, 0x00, 0x30, 0x30, 0x00, 0x30, 0x30, 0x10, // 59 ';'
0x00, 0x0E, 0x1C, 0x38, 0x70, 0x38, 0x1C, 0x0E, // 60 '<'
0x00, 0x00, 0x3E, 0x3E, 0x00, 0x3E, 0x3E, 0x00, // 61 '='
0x00, 0x70, 0x38, 0x1C, 0x0E, 0x1C, 0x38, 0x70, // 62 '>'
0x00, 0x3C, 0x66, 0x06, 0x1C, 0x18, 0x00, 0x18, // 63 '?'
0x00, 0x3C, 0x66, 0x4E, 0x4E, 0x40, 0x60, 0x3C, // 64 '@'
0x00, 0x3E, 0x36, 0x63, 0x63, 0x7F, 0x63, 0x63, // 65 'A'
0x00, 0x7C, 0x66, 0x66, 0x7C, 0x66, 0x66, 0x7C, // 66 'B'
0x00, 0x3E, 0x73, 0x60, 0x60, 0x60, 0x73, 0x3E, // 67 'C'
0x00, 0x7C, 0x66, 0x63, 0x63, 0x63, 0x66, 0x7C, // 68 'D'
0x00, 0x7E, 0x7E, 0x60, 0x78, 0x60, 0x7E, 0x7E, // 69 'E'
0x00, 0x7E, 0x7E, 0x60, 0x7C, 0x7C, 0x60, 0x60, // 70 'F'
0x00, 0x3E, 0x73, 0x60, 0x67, 0x63, 0x73, 0x3E, // 71 'G'
0x00, 0x63, 0x63, 0x63, 0x7F, 0x63, 0x63, 0x63, // 72 'H'
0x00, 0x7E, 0x18, 0x18, 0x18, 0x18, 0x18, 0x7E, // 73 'I'
0x00, 0x7F, 0x6C, 0x0C, 0x0C, 0x6C, 0x6C, 0x38, // 74 'J'
0x00, 0x67, 0x6E, 0x7C, 0x78, 0x7E, 0x66, 0x67, // 75 'K'
0x00, 0x60, 0x60, 0x60, 0x60, 0x60, 0x7E, 0x7E, // 76 'L'
0x00, 0x63, 0x77, 0x7F, 0x7F, 0x6B, 0x63, 0x63, // 77 'M'
0x00, 0x63, 0x73, 0x7B, 0x7B, 0x6F, 0x67, 0x67, // 78 'N'
0x00, 0x3C, 0x66, 0x66, 0x66, 0x66, 0x66, 0x3C, // 79 'O'
0x00, 0x7E, 0x67, 0x63, 0x67, 0x7E, 0x60, 0x60, // 80 'P'
0x00, 0x3E, 0x77, 0x63, 0x63, 0x77, 0x3E, 0x07, // 81 'Q'
0x00, 0x7E, 0x67, 0x63, 0x66, 0x7C, 0x6E, 0x67, // 82 'R'
0x00, 0x3E, 0x77, 0x70, 0x3E, 0x07, 0x77, 0x3E, // 83 'S'
0x00, 0x7F, 0x6C, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, // 84 'T'
0x00, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x3E, // 85 'U'
0x00, 0x63, 0x63, 0x63, 0x77, 0x3E, 0x1C, 0x08, // 86 'V'
0x00, 0x63, 0x63, 0x63, 0x6B, 0x6B, 0x7F, 0x36, // 87 'W'
0x00, 0x66, 0x66, 0x3C, 0x18, 0x3C, 0x66, 0x66, // 88 'X'
0x00, 0x63, 0x77, 0x3E, 0x1C, 0x38, 0x70, 0x60, // 89 'Y'
0x00, 0x7F, 0x7F, 0x0E, 0x1C, 0x38, 0x7F, 0x7F, // 90 'Z'
0x00, 0x3E, 0x30, 0x30, 0x30, 0x30, 0x30, 0x3E, // 91 '['
0x00, 0x60, 0x70, 0x38, 0x1C, 0x0E, 0x07, 0x03, // 92 '\'
0x00, 0x7C, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x7C, // 93 ']'
0x00, 0x08, 0x1C, 0x3E, 0x77, 0x63, 0x00, 0x00, // 94 '^'
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7F, 0x7F, // 95 '_'
0x00, 0x30, 0x38, 0x18, 0x00, 0x00, 0x00, 0x00, // 96 '`'
0x00, 0x00, 0x38, 0x06, 0x3E, 0x66, 0x66, 0x3B, // 97 'a'
0x00, 0x60, 0x60, 0x7C, 0x66, 0x66, 0x66, 0x7C, // 98 'b'
0x00, 0x00, 0x3C, 0x66, 0x60, 0x60, 0x66, 0x3C, // 99 'c'
0x00, 0x06, 0x06, 0x3E, 0x66, 0x66, 0x66, 0x3E, // 100 'd'
0x00, 0x00, 0x3C, 0x66, 0x7C, 0x60, 0x62, 0x3C, // 101 'e'
0x00, 0x1E, 0x30, 0x60, 0x7C, 0x60, 0x60, 0x60, // 102 'f'
0x00, 0x00, 0x3C, 0x66, 0x66, 0x3E, 0x06, 0x7C, // 103 'g'
0x00, 0x60, 0x60, 0x7C, 0x66, 0x66, 0x66, 0x66, // 104 'h'
0x00, 0x18, 0x00, 0x18, 0x18, 0x18, 0x18, 0x18, // 105 'i'
0x00, 0x06, 0x00, 0x06, 0x06, 0x66, 0x66, 0x3C, // 106 'j'
0x00, 0x60, 0x66, 0x6C, 0x78, 0x7C, 0x64, 0x66, // 107 'k'
0x00, 0x38, 0x38, 0x18, 0x18, 0x18, 0x18, 0x18, // 108 'l'
0x00, 0x00, 0xC2, 0x66, 0x7E, 0x7E, 0x66, 0x66, // 109 'm'
0x00, 0x00, 0xEC, 0x76, 0x66, 0x66, 0x66, 0x66, // 110 'n'
0x00, 0x00, 0x3C, 0x66, 0x66, 0x66, 0x66, 0x3C, // 111 'o'
0x00, 0x00, 0x7C, 0x66, 0x66, 0x7C, 0x60, 0x60, // 112 'p'
0x00, 0x00, 0x3C, 0x66, 0x3C, 0x04, 0x19, 0x3E, // 113 'q'
0x00, 0x00, 0x60, 0x7C, 0x6C, 0x60, 0x60, 0x60, // 114 'r'
0x00, 0x00, 0x3C, 0x66, 0x30, 0x0C, 0x66, 0x3C, // 115 's'
0x00, 0x18, 0x18, 0x7E, 0x58, 0x18, 0x18, 0x18, // 116 't'
0x00, 0x00, 0x66, 0x66, 0x66, 0x66, 0x7E, 0x3D, // 117 'u'
0x00, 0x00, 0x42, 0x66, 0x66, 0x76, 0x3C, 0x18, // 118 'v'
0x00, 0x00, 0x42, 0x66, 0x66, 0x7E, 0x7E, 0x24, // 119 'w'
0x00, 0x00, 0x66, 0x76, 0x38, 0x1C, 0x6E, 0x66, // 120 'x'
0x00, 0x00, 0x66, 0x66, 0x76, 0x3E, 0x06, 0x7C, // 121 'y'
0x00, 0x00, 0x7E, 0x66, 0x0C, 0x18, 0x32, 0x7E, // 122 'z'
0x00, 0x0E, 0x38, 0x30, 0x18, 0x30, 0x38, 0x0E, // 123 '{'
0x00, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, // 124 '|'
0x00, 0x70, 0x1C, 0x0C, 0x18, 0x0C, 0x1C, 0x78, // 125 '}'
0x00, 0x00, 0x03, 0x3B, 0x6E, 0x60, 0x00, 0x00, // 126 '~'
0x00, 0x40, 0x60, 0x70, 0x78, 0x7C, 0x10, 0x00, // 127
0x00, 0x00, 0x00, 0x0F, 0x1F, 0x1C, 0x18, 0x18, // 128
0x00, 0x00, 0x00, 0xF0, 0xF8, 0x38, 0x18, 0x18, // 129
0x00, 0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00, 0x00, // 130
0x00, 0x00, 0x00, 0xFF, 0xFF, 0x3C, 0x18, 0x18, // 131
0x18, 0x18, 0x38, 0xF8, 0xF8, 0x38, 0x18, 0x18, // 132
0xFF, 0xFF, 0x3C, 0x18, 0x18, 0x18, 0x18, 0x18, // 133
0x03, 0x03, 0x07, 0xFF, 0xFF, 0x07, 0x03, 0x03, // 134
0x01, 0x03, 0x06, 0x0C, 0x18, 0x30, 0x60, 0xC0, // 135
0x80, 0xC0, 0x60, 0x30, 0x18, 0x0C, 0x06, 0x03, // 136
0xFF, 0xFF, 0xC0, 0xC0, 0xC0, 0xC0, 0xC0, 0xC0, // 137
0xFF, 0xFF, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, // 138
0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // 139
0x18, 0x18, 0x1C, 0x1F, 0x0F, 0x00, 0x00, 0x00, // 140
0x18, 0x18, 0x38, 0xF8, 0xF0, 0x00, 0x00, 0x00, // 141
0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, // 142
0x18, 0x18, 0x1C, 0x1F, 0x1F, 0x1C, 0x18, 0x18, // 143
0x18, 0x18, 0x3C, 0xFF, 0xFF, 0x00, 0x00, 0x00, // 144
0xC0, 0xC0, 0xE0, 0xFF, 0xFF, 0xE0, 0xC0, 0xC0, // 145
0x18, 0x18, 0x18, 0x18, 0x18, 0x3C, 0xFF, 0xFF, // 146
0xC0, 0x60, 0x30, 0x18, 0x0C, 0x06, 0x03, 0x01, // 147
0x03, 0x06, 0x0C, 0x18, 0x30, 0x60, 0xC0, 0x80, // 148
0xC0, 0xC0, 0xC0, 0xC0, 0xC0, 0xC0, 0xFF, 0xFF, // 149
0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0xFF, 0xFF, // 150
0xC0, 0xC0, 0xC0, 0xC0, 0xC0, 0xC0, 0xC0, 0xC0, // 151
0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, // 152
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF, // 153
0x00, 0x36, 0x7F, 0x7F, 0x7F, 0x3E, 0x1C, 0x08, // 154
0x00, 0x08, 0x1C, 0x3E, 0x7F, 0x3E, 0x08, 0x3E, // 155
0x00, 0x08, 0x1C, 0x3E, 0x7F, 0x3E, 0x1C, 0x08, // 156
0x00, 0x1C, 0x1C, 0x7F, 0x7F, 0x7F, 0x08, 0x3E, // 157
0x00, 0x18, 0x24, 0x24, 0x42, 0x42, 0x7E, 0x00, // 158
0x00, 0x3C, 0x66, 0x42, 0x42, 0x66, 0x3C, 0x00, // 159
0x00, 0x7E, 0x42, 0x42, 0x42, 0x42, 0x7E, 0x00, // 160
0x00, 0x42, 0x24, 0x18, 0x18, 0x24, 0x42, 0x00, // 161
0x00, 0x02, 0x04, 0x44, 0x28, 0x28, 0x10, 0x00, // 162
0x08, 0x0C, 0x0E, 0xFF, 0xFF, 0x0E, 0x0C, 0x08, // 163
0x18, 0x18, 0x18, 0x18, 0xFF, 0x7E, 0x3C, 0x18, // 164
0x10, 0x30, 0x70, 0xFF, 0xFF, 0x70, 0x30, 0x10, // 165
0x18, 0x3C, 0x7E, 0xFF, 0x18, 0x18, 0x18, 0x18, // 166
};
+17
View File
@@ -0,0 +1,17 @@
// font.h
// The console's character generator.
// Written by Anachronaut
#ifndef FONT_H
#define FONT_H
// Eight bytes a glyph, one bit a pixel. See font.c for where it came from.
#define CONSOLE_FONT_BYTES 8
#define CONSOLE_FONT_GLYPHS 135
// The first character the font has, so a byte maps to a glyph by subtracting this.
#define CONSOLE_FONT_FIRST 32
extern const unsigned char consoleFont[CONSOLE_FONT_GLYPHS * CONSOLE_FONT_BYTES];
#endif // FONT_H
+939 -70
View File
File diff suppressed because it is too large Load Diff
+248 -2
View File
@@ -8,6 +8,7 @@
#include <stdint.h> #include <stdint.h>
#include "cpu.h" #include "cpu.h"
#include "controller.h"
// ---- Ports ---- // ---- Ports ----
// //
@@ -18,11 +19,30 @@
// does not change: writing sends a byte, reading takes one and waits for it. The other two // does not change: writing sends a byte, reading takes one and waits for it. The other two
// are additions, so a program written before they existed cannot notice them. // are additions, so a program written before they existed cannot notice them.
#define PORT_CONSOLE 0x00 #define PORT_CONSOLE 0x00
#define PORT_CONSOLE_TOP 0x02 #define PORT_CONSOLE_TOP 0x06
#define CONSOLE_DATA 0x00 #define CONSOLE_DATA 0x00
#define CONSOLE_STATUS 0x01 #define CONSOLE_STATUS 0x01
#define CONSOLE_CONTROL 0x02 #define CONSOLE_CONTROL 0x02
// ---- Where the cursor is, as registers ----
//
// Read as well as written, which is the thing an escape sequence cannot do without a query
// and a parse. A program that wants to put something back where it found it asks.
#define CONSOLE_CURSOR_ROW 0x03
#define CONSOLE_CURSOR_COLUMN 0x04
// Written, and it happens at once - the same shape as the memory controller's Command port
// rather than a bit in a register that otherwise holds state.
#define CONSOLE_COMMAND 0x05
#define CONSOLE_COMMAND_CLEAR 0x01
// ---- What colour to write in ----
//
// The attribute given to every cell the console draws from now on. Its low nibble picks one
// of sixteen ink and paper pairs, and the default palette is arranged so that XOR 8 turns
// any of them inside out - which is highlighting, and is also how the cursor is drawn.
#define CONSOLE_ATTRIBUTE 0x06
#define PORT_TEST 0x10 #define PORT_TEST 0x10
#define PORT_REFUSE 0x11 #define PORT_REFUSE 0x11
#define PORT_MEMORY 0x12 #define PORT_MEMORY 0x12
@@ -46,11 +66,81 @@
// that spans more than one port raises its line on its base, which is the rule the // that spans more than one port raises its line on its base, which is the rule the
// machine has not needed until now: the controller spans sixteen and never interrupts. // machine has not needed until now: the controller spans sixteen and never interrupts.
#define PORT_DISK 0x20 #define PORT_DISK 0x20
#define PORT_DISK_TOP 0x23 #define PORT_DISK_TOP 0x28
#define DISK_BLOCK_HIGH 0x20 #define DISK_BLOCK_HIGH 0x20
#define DISK_BLOCK_LOW 0x21 #define DISK_BLOCK_LOW 0x21
#define DISK_COMMAND 0x22 #define DISK_COMMAND 0x22
#define DISK_STATUS 0x23 #define DISK_STATUS 0x23
// ---- Several disks, one controller ----
//
// NOT SEVERAL DEVICES, and the instruction set is why. A port is an immediate byte inside the
// OUT that names it, so a program cannot compute one - "the disk on port 0x20 + drive * 4" is
// not something this machine can say. Two disks as two devices would mean a branch per access
// in every one of the eleven places the filesystem names a disk port.
//
// So it is one controller with a drive register, which is what the machines this one is
// pretending to be actually had: one floppy controller and four drives behind it. The block,
// command and status registers all refer to whichever drive was last selected, and so does
// the single buffer - which is honest, and which means a program that changes drives knows
// the buffer no longer holds what it thought.
#define DISK_DRIVE 0x24
#define DISK_DRIVES 0x25
// ---- What a drive IS, which is the machine's business ----
//
// Not what is on it, which is the system's. A drive backed by memory loses everything when
// the machine stops, and that is the one fact about it a system cannot work out for itself -
// an empty disk and a volatile disk look identical from the outside.
//
// It matters because it is the difference between a drive a system may FORMAT on sight and
// one it must not. An unformatted floppy somebody put in deliberately is not an invitation;
// an unformatted RAM disk is, because there was never anything there to lose. The machine
// says which kind it is and says nothing about filesystems, so a system that would rather
// have its own reads the same bit and does as it likes.
#define DISK_FLAGS 0x26
#define DISK_FLAG_VOLATILE 0x01
// How many blocks the selected drive has. A fact about the drive rather than about what is
// on it - and the one a system needs before it can put anything on it, since the size a
// superblock states is no use on a disk that has not got one yet.
#define DISK_SIZE_HIGH 0x27
#define DISK_SIZE_LOW 0x28
// Four is a floppy controller's worth. The cost of another is a file handle.
#define DISK_DRIVE_COUNT 4
// ---- The screen ----
//
// Sixteen ports, like the controller, and it interrupts on its base the way the disk
// established for a device that spans more than one. The registers themselves are in
// video.h, with the memory layout they describe.
#define PORT_VIDEO 0x30
#define PORT_VIDEO_TOP 0x3F
// ---- The timer ----
//
// The only regular beat this machine had was the screen finishing a frame, which is fixed at
// sixty a second. That is a clock a program borrows rather than one it sets: every duration
// becomes a multiple of 16.67 ms, so a note worth a third of a beat cannot be asked for and
// the way round it is to choose a tempo whose subdivisions happen to land on whole frames -
// which is making the music fit the hardware.
#define PORT_TIMER 0x50
#define PORT_TIMER_TOP 0x54
#define TIMER_STATUS 0x50
#define TIMER_CONTROL 0x51
#define TIMER_PERIOD_HIGH 0x52
#define TIMER_PERIOD_MID 0x53
#define TIMER_PERIOD_LOW 0x54
#define TIMER_STATUS_TICKED 0x01
#define TIMER_STATUS_RUNNING 0x02
#define TIMER_STATUS_INTERRUPT 0x04
#define TIMER_CONTROL_RUN 0x01
#define TIMER_CONTROL_REPEAT 0x02
#define TIMER_CONTROL_INTERRUPT 0x04
#define PORT_REGISTRY 0xFF #define PORT_REGISTRY 0xFF
// ---- The console ---- // ---- The console ----
@@ -93,6 +183,10 @@
// That is not especially useful, but a control bit that quietly did nothing depending on // That is not especially useful, but a control bit that quietly did nothing depending on
// another control bit would be worse than a burst of interrupts somebody asked for. // another control bit would be worse than a burst of interrupts somebody asked for.
#define CONSOLE_CONTROL_INTERRUPT 0x02 #define CONSOLE_CONTROL_INTERRUPT 0x02
// Show a cursor where the next character will go. Off when the machine starts, because a
// machine draws what it is told to and a program painting its own screen does not want one
// blinking in the middle of it. A system that reads lines from a person turns it on.
#define CONSOLE_CONTROL_CURSOR 0x04
// Set when there is a byte to be had. NOT set at the end of input, although a read would // Set when there is a byte to be had. NOT set at the end of input, although a read would
// answer at once there: what it answers is 0xFF standing in for nothing, and calling that // answer at once there: what it answers is 0xFF standing in for nothing, and calling that
@@ -109,6 +203,57 @@
// Whether the console is set to interrupt, for the same reason: everything a program can // Whether the console is set to interrupt, for the same reason: everything a program can
// ask the console to be, it can also ask the console what it currently is. // ask the console to be, it can also ask the console what it currently is.
#define CONSOLE_STATUS_INTERRUPT 0x08 #define CONSOLE_STATUS_INTERRUPT 0x08
// And whether a cursor is being shown, for the same reason as the rest: everything a program
// can ask the console to be, it can also ask the console what it currently is.
#define CONSOLE_STATUS_CURSOR 0x10
// ---- Keys that are not characters ----
//
// An arrow key is not a letter and there is no byte for it, which is why it has never
// reached this machine at all: a window threw it away for want of anywhere to put it, and
// a terminal sent an escape sequence that arrived in a command line and made it
// unrecognisable.
//
// So the console names them. These are the values it delivers, one byte each, and they are
// the console's own: NOT ASCII, and deliberately above it, so nothing that existed before
// them can collide. A program reads one the same way it reads a letter.
//
// THE CONSOLE NORMALISES, WHICH IS WHAT IT ALREADY DOES. Behind a window it turns the key
// somebody pressed into a byte; on a terminal it turns the sequence the terminal sent into
// the same byte. That is the same act it has always performed on Return and Backspace, one
// layer further along, and it is why a program does not have to know which it is talking to.
//
// WHAT IT DOES NOT DO is decide what they mean. Where the cursor goes, what a line looks
// like afterwards and what was typed before are the system's business - see the shell,
// which edits its own line - exactly as what is on a disk is the system's business and what
// a drive IS belongs to the machine.
#define CONSOLE_KEY_UP 0x80
#define CONSOLE_KEY_DOWN 0x81
#define CONSOLE_KEY_LEFT 0x82
#define CONSOLE_KEY_RIGHT 0x83
#define CONSOLE_KEY_HOME 0x84
#define CONSOLE_KEY_END 0x85
// Forward delete, which is the character UNDER the cursor and not the one before it.
// Backspace is 0x08 and always has been; these two are different keys that do different
// things, and a terminal has always sent different bytes for them.
#define CONSOLE_KEY_DELETE 0x86
// The range, so that anything wanting to know whether a byte is one of these can ask
// without naming them all. Room is left above DELETE on purpose: function keys and the
// paging keys are the obvious next ones, and adding one should disturb nothing.
#define CONSOLE_KEY_FIRST 0x80
#define CONSOLE_KEY_LAST 0x8F
// ---- Only in key mode ----
//
// LINE MODE DELIVERS CHARACTERS, and these are not characters. A program in line mode is
// being handed a line that something else has already finished editing, so a key that means
// "move the cursor left" arrived too late to mean anything and putting it in the line would
// only corrupt it - which is precisely what an untranslated escape sequence used to do.
//
// So the console drops them in line mode, wherever it is reading from. That is also what a
// real terminal does: canonical mode gives a program backspace and line kill, and has never
// given it arrow keys.
// Puts the terminal back the way it was found. Registered with atexit and called from a // Puts the terminal back the way it was found. Registered with atexit and called from a
// handler for every signal that can end this process and be caught, because a machine that // handler for every signal that can end this process and be caught, because a machine that
@@ -124,6 +269,28 @@ void consoleRestore(void);
// of its own behind that would make the status port lie about what is waiting. // of its own behind that would make the status port lie about what is waiting.
uint8_t consoleReadByte(void); uint8_t consoleReadByte(void);
// Puts the cursor back in the corner. Called when the machine starts, since the screen is
// cleared then too and a cursor left where the last program stopped would be a cursor
// pointing into something that is gone.
void consoleHome(void);
// ---- How a front end with a window feeds the console ----
//
// Called while the console has nothing to give. It returns a byte, or one of the two
// answers below. They have to be told apart: a window with nobody typing yet is the normal
// case and happens sixty times a second, while a window that has gone is the end of input.
// One value for both would have made the first keystroke look like a closed machine.
//
// Without a hook the console reads standard input, which is what it has always done.
#define CONSOLE_NOTHING_YET (-1)
#define CONSOLE_GONE (-2)
//
// mayWait says which question is being asked. Zero is the status port looking, and must not
// present or sleep: a program polling in a loop would otherwise run at the frame rate. 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.
void consoleSetInputHook(int (*hook)(int mayWait));
// ---- Device classes ---- // ---- Device classes ----
// //
// What kind of thing is plugged into a port. Class 0 is not a device: reading an // What kind of thing is plugged into a port. Class 0 is not a device: reading an
@@ -144,6 +311,9 @@ uint8_t consoleReadByte(void);
#define DEVICE_REFUSE 0x11 #define DEVICE_REFUSE 0x11
#define DEVICE_MEMORY 0x12 #define DEVICE_MEMORY 0x12
#define DEVICE_DISK 0x13 #define DEVICE_DISK 0x13
#define DEVICE_VIDEO 0x14
#define DEVICE_SOUND 0x15
#define DEVICE_TIMER 0x16
// What a device brings besides itself. This means memory that somebody has to register // What a device brings besides itself. This means memory that somebody has to register
// with the controller, so the controller's own bank 2 does not count: it is already there. // with the controller, so the controller's own bank 2 does not count: it is already there.
@@ -193,6 +363,18 @@ uint8_t consoleReadByte(void);
// display that refreshes, a port that waits on the host - wants exactly this shape. // display that refreshes, a port that waits on the host - wants exactly this shape.
void deviceTick(unsigned long now); void deviceTick(unsigned long now);
// ---- Time that passed while the machine was stopped ----
//
// A console waiting on a key it has not been given has stopped the machine, and time is
// still going by: the cursor still blinks, a disk still turns. That is exactly what idle
// cycles are for, and without them the machine's clock froze the moment somebody was asked
// a question - so the cursor stopped blinking precisely when there was a person looking at
// it and waiting to type.
//
// Returned and cleared, the way the controller's cycles are, and picked up in the same
// place: after a port access, by the CPU that was stopped.
unsigned long takeIdleCycles(void);
// How many cycles a block read or write takes. Zero means the answer is there before the // How many cycles a block read or write takes. Zero means the answer is there before the
// next instruction is, which is what this machine has always done and what every recorded // next instruction is, which is what this machine has always done and what every recorded
// test assumes. // test assumes.
@@ -203,6 +385,10 @@ void setDiskLatency(unsigned long cycles);
// equivalent of the tab on the side of a floppy. Returns 1 if it could not attach. // equivalent of the tab on the side of a floppy. Returns 1 if it could not attach.
uint8_t attachDisk(const char *path, uint8_t writeProtect); uint8_t attachDisk(const char *path, uint8_t writeProtect);
// A drive of that many blocks, backed by memory rather than by a file. It comes up as
// zeroes, which is not a filesystem - bringing it up is the system's job.
uint8_t attachRamDisk(uint32_t blocks);
void detachDisk(void); void detachDisk(void);
// How many bytes a device's entry in the registry runs to. Reading past the end gives // How many bytes a device's entry in the registry runs to. Reading past the end gives
@@ -237,13 +423,73 @@ void serviceDevices(void);
// the instruction that asked - the CPU is mid-step and its state is not yet consistent. // the instruction that asked - the CPU is mid-step and its state is not yet consistent.
int takeResetRequest(void); int takeResetRequest(void);
// Whether one is waiting, without taking it. For anything that has to behave differently
// while a restart is on its way but is not the thing that performs it.
int resetIsPending(void);
// ---- The button on the front of the case ----
//
// A machine has one, and a window is the case. Writing MACHINE_RESET is how a PROGRAM asks;
// this is how a person does, without needing a program that is willing to listen - which is
// the whole point of a reset button and the reason the port exists at all.
void requestReset(void);
// Puts the console's input back to how a machine starts: nothing pushed back, no line half
// gathered, and NOT at the end of input. Called when the machine starts over, because a
// console that had run out of input would still have run out afterwards - and a reset that
// left the keyboard dead would be a reset nobody could use twice.
void consoleResetInput(void);
// ---- The lines on one bus ----
//
// One bit a port, so a device can ask for attention without anything having to poll it.
// Eight ports to the byte, low bit first.
//
// A MACHINE WITH TWO PROCESSORS HAS TWO SETS OF THESE. A peripheral core's devices raise
// lines on the core's bus and the host's devices on the host's, and they are not the same
// lines: one array indexed by port for the whole machine would have let each see the other's,
// so a disk finishing would interrupt a sound core and a sound core's tick would interrupt
// the shell. Both would arrive at a handler for something else entirely.
#define INTERRUPT_LINE_BYTES 32
typedef struct {
uint8_t bits[INTERRUPT_LINE_BYTES];
} InterruptLines;
void linesRaise(InterruptLines *lines, uint8_t port);
void linesClear(InterruptLines *lines, uint8_t port);
// The lowest numbered port with its line up, or below zero if none has. A scan rather than
// a priority encoder, so there is no arbitration to explain and a programmer can work out
// what happens next by reading the port numbers.
int linesNext(const InterruptLines *lines);
// The machine's own controller, which is what every device on this bus means when it moves
// memory. A peripheral core's device holds its own.
Controller *machineController(void);
// The machine's own lines, which is what every device on this bus means.
void raiseInterrupt(uint8_t port); void raiseInterrupt(uint8_t port);
void clearInterrupt(uint8_t port); void clearInterrupt(uint8_t port);
// Every line down at once, for a reset. The vector table is cleared when the machine starts
// over because a handler left behind would aim an interrupt into a program that is no longer
// running; a LINE left behind does exactly the same thing, and arrives at a program that
// never asked the device for anything.
void clearAllInterrupts(void);
// The timer, which counts the machine's own cycles.
void timerReset(void);
void timerTick(unsigned long now);
// The lowest numbered port with its line up, or -1 if none of them are. // The lowest numbered port with its line up, or -1 if none of them are.
int nextPendingInterrupt(void); int nextPendingInterrupt(void);
// The bus this machine's own processor is on: its devices, its lines, its controller. A
// peripheral core is given a different one by whatever device contains it.
const Bus *machineBus(void);
// ---- Refusing ---- // ---- Refusing ----
// //
// A device can refuse what it was asked to do. Interrupting is a device asking for // A device can refuse what it was asked to do. Interrupting is a device asking for
+359
View File
@@ -0,0 +1,359 @@
// machine.c
// The SplitBit machine: everything both front ends share.
// Written by Anachronaut
#include "machine.h"
#include "rom.h"
#include "bootstrap.h"
#include "cpu.h"
#include "controller.h"
#include "io.h"
#include "video.h"
#include "sound.h"
#include "utility.h"
#include "../Assembler/assembly.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
// nanoseconds per second
#define NS_PER_SEC 1000000000LL
static inline long long timespec_diff_ns(struct timespec a, struct timespec b) {
return (a.tv_sec - b.tv_sec) * NS_PER_SEC + (a.tv_nsec - b.tv_nsec);
}
void cycle_timer_init(CycleTimer *t, long long cycles_per_sec) {
t->cycles_per_sec = cycles_per_sec;
t->accumulator_ns = 0;
clock_gettime(CLOCK_MONOTONIC, &t->prev);
}
// Call once per host frame. Returns how many SplitBit cycles to execute.
int cycle_timer_tick(CycleTimer *t) {
struct timespec now;
clock_gettime(CLOCK_MONOTONIC, &now);
long long elapsed = timespec_diff_ns(now, t->prev);
t->prev = now;
// optional: clamp to avoid spiral-of-death on hitches
if (elapsed > NS_PER_SEC / 10) elapsed = NS_PER_SEC / 10;
t->accumulator_ns += elapsed;
long long period_ns = NS_PER_SEC / t->cycles_per_sec;
int cycles = (int)(t->accumulator_ns / period_ns);
t->accumulator_ns %= period_ns;
return cycles;
}
// How many cycles to run between glances at the wall clock. In fast mode there is
// no clock to keep pace with, so run a large batch before looking up.
#define FAST_BATCH 65536
// Memory Banks. Static, because a front end has no business reaching into them: what it
// needs to know about the machine it asks the machine.
static uint8_t Program[0x10000], Data[0x10000];
// How the run is reported. The idle half is mentioned only when there is one, so that
// every program written before WAIT existed prints exactly the line it always did.
//
// THE TWO ARE NOT THE SAME KIND OF TIME. A bus cycle is the machine using memory; an idle
// cycle is the machine stopped in a WAIT while a device catches up. Added together they
// are elapsed time, which is what a cycle limit measures; told apart they say whether a
// program was working or waiting.
static void reportCycles(const CPURegisters *cpu, unsigned long cycleCount) {
if (cpu->idleCycles > 0) {
printf("Execution halted after %lu cycles, %lu of them waiting.\n",
cycleCount, cpu->idleCycles);
} else {
printf("Execution halted after %lu cycles.\n", 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;
}
// ---- A zero is a moment of nobody typing ----
//
// The commonest thing that happens behind a window is NOTHING: sixty times a second the
// console asks and is told to come back later, and everything that goes on while that is
// true - the clock advancing, a cursor blinking, a disk finishing - was unreachable from
// here, because a file always has another byte. A zero is a byte no keyboard sends, so it
// is free to mean the one thing a file otherwise cannot say.
if (byte == 0x00) {
return CONSOLE_NOTHING_YET;
}
return byte & 0xFF;
}
// ---- Starting over ----
//
// WHAT A RESET REPEATS IS HOW THIS MACHINE STARTED. Named an image, it is placed 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.
//
// The disk is not unplugged and its image keeps everything written to it. That is what warm
// means: the machine starts again, the world it starts into does not.
//
// The vector table goes, and that is a deliberate departure from leaving memory alone. A
// vector points into whatever installed it, and after this 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.
static int machineRestart(Machine *m) {
memset(Program + SOFTWARE_VECTOR_BASE, 0,
(size_t)(0x10000 - SOFTWARE_VECTOR_BASE));
uint8_t failed = (m->programFile != NULL)
? loadFile(m->programFile, Program, Data)
: loadROM(bootROM, bootROMBytes, Program, Data);
if (failed) {
fprintf(stderr, "Error: The machine could not be started again.\n");
m->restartFailed = 1;
return 0;
}
videoReset();
soundReset();
timerReset();
consoleHome();
consoleResetInput();
// ---- And every line down ----
//
// The same reasoning that clears the vector table. A handler left behind would aim an
// interrupt into a program that is no longer running; a line left behind arrives at one
// that never asked the device for anything. The devices reset above take their own down,
// and this is the rest of them - the disk in particular, which is not unplugged by a
// reset and keeps whatever it was doing.
clearAllInterrupts();
initializeCPU(&m->cpu, Program, Data);
// A machine that had stopped is running again, which is the entire point of asking from
// outside: the interesting time to restart something is when it is not going anywhere.
m->limitReached = 0;
return 1;
}
int machineTakeReset(Machine *m) {
if (!takeResetRequest()) {
return 0;
}
return machineRestart(m);
}
uint8_t machineStart(Machine *m, const EmulatorOptions *options, const char *programFile) {
m->options = *options;
m->programFile = programFile;
m->cycleCount = 0;
m->limitReached = 0;
m->restartFailed = 0;
// ---- Where the machine's first instruction comes from ----
//
// Named an image, it is placed into memory and started - which is what a debugger
// does, and is how every test here runs. That path is not a shortcut to apologise
// for: placing memory from outside is a real thing real machines allow.
//
// Named none, the machine starts the way hardware would: the ROM is shadowed into
// Program Memory and it reads the disk for the rest. There has to be a disk for that
// to mean anything, and no image and no disk is a machine with nothing to run.
if (programFile == NULL && options->disk == NULL) {
return MACHINE_NOTHING_TO_RUN;
}
if (programFile != NULL) {
if (loadFile(programFile, Program, Data)) {
fprintf(stderr, "Error: Couldn't read file: %s\n", programFile);
return MACHINE_ERROR;
}
} else if (loadROM(bootROM, bootROMBytes, Program, Data)) {
fprintf(stderr, "Error: The boot ROM is not a boot image.\n");
return MACHINE_ERROR;
}
// Every drive named, in the order it was named. Write protection is the machine's rather
// than a drive's for now: a tab on one floppy and not another is a thing to add when
// somebody wants it, and pretending otherwise here would be a promise the option cannot
// keep.
for (int at = 0; at < options->diskCount; at++) {
if (attachDisk(options->disks[at], options->writeProtect)) {
return MACHINE_ERROR;
}
}
// After the images, so the drive numbers a command line asks for are the order it asks
// in. A disk made of memory is still a drive and still has to be brought up by whatever
// system is running; the machine only supplies the blocks.
if (options->ramDisk > 0 && attachRamDisk((uint32_t)options->ramDisk)) {
return MACHINE_ERROR;
}
// The screen starts blank, and starts blank again on a warm restart: video memory is
// the device's, and a reset that left last program's screen up would be a reset that
// did not happen.
videoReset();
soundReset();
timerReset();
if (options->sound != NULL) {
soundKeepSamples();
}
consoleHome();
// The controller has to know where the memories are before anything can reach
// them through it. Banks 0 and 1 are those two arrays.
initializeController(machineController(), Program, Data);
initializeCPU(&m->cpu, Program, Data);
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;
}
int machineRunning(const Machine *m) {
return !(m->cpu.Status & STATUS_HALT) && !m->limitReached && !m->restartFailed;
}
void machineRunSlice(Machine *m) {
if (m->options.debug) {
// Wait before advancing, not after, so that a keypress is what moves the
// machine on rather than something that happens once it already has.
// Through the console rather than getchar, so that everything reading standard
// input reads it the same way and the console's pushback stays the only place
// a byte can be sitting.
consoleReadByte();
}
int cycles;
if (m->options.debug) {
// Debug mode advances one instruction per keypress, so the wall clock
// has no say in how many cycles to run.
cycles = 1;
} else if (m->options.fast) {
cycles = FAST_BATCH;
} else {
cycles = cycle_timer_tick(&m->timer);
}
// ---- Spending a budget of cycles, not running a count of instructions ----
//
// An instruction costs what it touches, so a batch is finished when the cycles are
// gone rather than after so many steps. In debug mode the budget is one, and any
// instruction costs at least the fetch of its own opcode, so one step still runs.
for (long spent = 0; spent < cycles; ) {
// Both kinds of cycle, because both are time passing. A step that waits
// spends no bus at all, and a budget measured only in bus cycles would never
// be spent - the machine would sit inside one batch forever and the device it
// was waiting for would never be given a moment to finish.
unsigned long before = m->cpu.busCycles + m->cpu.idleCycles;
stepCPU(&m->cpu);
unsigned long took = (m->cpu.busCycles + m->cpu.idleCycles) - before;
spent += (long)took;
m->cycleCount += took;
// Time has passed, so anything waiting on it may be finished.
deviceTick(m->cycleCount);
// ---- Starting over ----
//
// Between instructions, which is the only place it can happen: a device cannot
// restart the machine from inside the instruction that asked for it.
//
// WHAT A RESET REPEATS IS HOW THIS MACHINE STARTED. Named an image, it is
// placed 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.
//
// The disk is not unplugged and its image keeps everything written to it. That
// is what warm means: the machine starts again, the world it starts into does
// not.
if (machineTakeReset(m)) {
break; // Out of this batch; the loop above carries on with a new CPU.
}
if (m->cpu.Status & STATUS_HALT) {
// We've halted.
break;
}
if (m->options.cycles && m->cycleCount >= m->options.cycles) {
m->limitReached = 1;
break;
}
}
if (m->options.debug) {
printRegisters(&m->cpu, Program, Data);
printf("Cycle: %lu\n", m->cycleCount);
}
}
void machineStop(Machine *m) {
// ---- Saving the screen ----
//
// Written when the machine stops, and it is what makes the screen testable at all: a
// suite has no display, so the only way to check what was drawn is to be handed it. A
// picture out of a headless run is also the quickest way for a person to see what a
// program actually put on the screen without sitting and watching it happen.
if (m->options.screen != NULL) {
videoWriteImage(m->options.screen);
}
// Every sample the machine made, for the same reason a picture is saved: there is no
// speaker on a machine running tests, and a sound nothing can hear is a sound nothing
// can check.
if (m->options.sound != NULL) {
soundWriteSamples(m->options.sound);
}
if (keyboardFile != NULL) {
consoleSetInputHook(NULL);
fclose(keyboardFile);
keyboardFile = NULL;
}
detachDisk();
}
int machineReport(const Machine *m) {
if (m->restartFailed) {
return 1;
}
if (m->limitReached) {
printf("Execution stopped after %lu cycles. (cycle limit reached)\n", m->cycleCount);
} else if (m->cpu.Status & STATUS_FAULT) {
// The Program Counter is still pointing at whatever the CPU could not get past.
reportCycles(&m->cpu, m->cycleCount);
if (m->cpu.Fault == FAULT_NO_HANDLER) {
fprintf(stderr, "Fault: Software vector %u, dispatched from Program Address 0x%04X, has no handler installed.\n",
m->cpu.FaultVector, m->cpu.ProgramCounter);
} else if (m->cpu.Fault == FAULT_DEVICE_REFUSED) {
fprintf(stderr, "Fault: The device on port %u refused the access at Program Address 0x%04X, and nothing is installed to deal with it.\n",
m->cpu.FaultVector, m->cpu.ProgramCounter);
} else if (m->cpu.Fault == FAULT_NO_DEVICE_HANDLER) {
fprintf(stderr, "Fault: The device on port %u interrupted at Program Address 0x%04X, and hardware vector %u has no handler installed.\n",
m->cpu.FaultVector, m->cpu.ProgramCounter, m->cpu.FaultVector);
} else {
fprintf(stderr, "Fault: 0x%02X at Program Address 0x%04X is not an instruction.\n",
Program[m->cpu.ProgramCounter], m->cpu.ProgramCounter);
}
return 1;
} else {
reportCycles(&m->cpu, m->cycleCount);
}
return 0;
}
+84
View File
@@ -0,0 +1,84 @@
// machine.h
// The SplitBit machine, with nothing attached to look at it.
// Written by Anachronaut
#ifndef MACHINE_H
#define MACHINE_H
#include <stdint.h>
#include <time.h>
#include "cpu.h"
#include "utility.h"
// ---- Why the machine is not a main ----
//
// There are two front ends: SplitBit, which is a terminal, and Voyager, which is a window
// and a speaker. THEY ARE THE SAME MACHINE. Keeping it here means the difference between
// them is presentation and nothing else - neither can quietly acquire behaviour the other
// lacks, and the suite can hold them to it by running a program through both and comparing
// what came out.
//
// It also decides where the devices live. A video device that only existed in the windowed
// binary would be untestable, because the suite has no display; here it is part of the
// machine, advances on emulated cycles, and Raylib only presents what it already produced.
// How fast the machine runs when it is not being told to hurry.
#define CYCLE_RATE 1000000
typedef struct {
long long cycles_per_sec;
long long accumulator_ns;
struct timespec prev;
} CycleTimer;
void cycle_timer_init(CycleTimer *t, long long cycles_per_sec);
// Call once per host frame. Returns how many SplitBit cycles to execute.
int cycle_timer_tick(CycleTimer *t);
typedef struct {
CPURegisters cpu;
CycleTimer timer;
unsigned long cycleCount;
uint8_t limitReached;
uint8_t restartFailed;
const char *programFile;
EmulatorOptions options;
} Machine;
#define MACHINE_OK 0
#define MACHINE_ERROR 1
// No image and no disk. Told apart from an ordinary failure so that the front end can
// print its own help, which is the one thing here that knows what it is called.
#define MACHINE_NOTHING_TO_RUN 2
uint8_t machineStart(Machine *m, const EmulatorOptions *options, const char *programFile);
// Whether there is any more running to do. False once the machine has halted, faulted, or
// spent the cycle limit it was given.
int machineRunning(const Machine *m);
// ---- One slice, not the whole run ----
//
// A front end with a window has to get a turn: run a slice, present a frame, run another.
// A terminal simply runs slices until the machine stops. Both loops are three lines, which
// is the point of cutting it here rather than anywhere else.
void machineRunSlice(Machine *m);
// ---- Asking from outside, when nothing inside is asking ----
//
// A reset is normally noticed between instructions, which works when there are instructions.
// A HALTED MACHINE RUNS NONE, so a program that has finished - or faulted, or is a bare metal
// demo that ended with HALT - could ask to be restarted for ever and nothing would come along
// to hear it. That is exactly the machine somebody wants to restart.
//
// So a front end calls this every time round its own loop, whether the machine is running or
// not. Returns 1 if it started over.
int machineTakeReset(Machine *m);
void machineStop(Machine *m);
// Says how the run went and returns what the process should exit with.
int machineReport(const Machine *m);
#endif // MACHINE_H
+311
View File
@@ -0,0 +1,311 @@
// sound.c
// The Voyager's sound device.
// Written by Anachronaut
#include "sound.h"
#include "synth.h"
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <math.h>
// A megahertz, matching the machine. Kept here rather than reaching for machine.h, which
// would drag the whole front end into a device.
#define SOUND_CYCLE_RATE 1000000
static Synth synth;
static uint8_t channel;
static uint8_t parameter;
// Where the machine's clock was when the device started, and how many samples have been made
// since. The next sample is due at start + count * rate / samples, worked out in whole
// numbers each time rather than by adding an approximation over and over - twenty and five
// sixths does not add up to anything exact, and a drift of one part in a thousand is four
// seconds an hour.
static unsigned long startedAt;
static unsigned long samplesMade;
// ---- What has been made and not yet played ----
//
// A ring, written by the machine and read by whatever is playing it. One writer and one
// reader, which is the only sharing that needs no lock at all.
//
// IT DROPS WHEN IT IS FULL, and full means nobody is listening: a headless run makes
// forty-eight thousand samples a second of emulated time and there is nothing to take them.
// Dropping is right there. What must not drop is the COUNT, because that is the clock.
#define SOUND_RING 16384
static int16_t ring[SOUND_RING];
static int ringHead, ringTail;
// And a copy of everything, for --sound. Only kept when a file was asked for, because a long
// run makes millions of samples and a machine that hoarded them by default would be a machine
// that ran out of memory for no reason anybody asked for.
static int16_t *keeping = NULL;
static size_t keptCount, keptRoom;
void soundReset(void) {
synthInit(&synth, (float)SOUND_SAMPLE_RATE);
// ---- The device's own power-on state ----
//
// synthInit leaves soundThing's defaults, which are a patch EDITOR's: one voice set up to
// be heard and seven silent behind it, waiting for the edited patch to be copied over
// them. That is right for a program with one instrument on screen and wrong for a device
// whose four channels are four independent things.
//
// Two consequences if it were left alone, both of which the tests caught. Channels 1 to 3
// would be silent whatever gain was written to them, because their oscillators are not
// switched on. And channel 0's first oscillator would arrive at full gain while every
// other one arrived at nothing - an asymmetry with no reason a programmer could work out.
//
// So: EVERY CHANNEL ARRIVES ABLE TO MAKE A SOUND. Oscillator 0 on, at full gain;
// oscillator 1 off, because two oscillators is a choice and one is the plain case. A
// program that writes a note number hears that note, which is the shortest useful thing
// this device can be asked to do.
for (int i = 0; i < SOUND_CHANNELS; i++) {
synth.voices[i].oscillators[0].active = 1;
synth.voices[i].oscillators[0].gain = OSC_MAX_GAIN;
// Off rather than on-and-silent, because the two oscillators are AVERAGED and not
// added: a second one that is switched on halves the first whatever its gain is.
// "Active" is structural, and there is no setting of it that costs nothing.
synth.voices[i].oscillators[1].active = 0;
synth.voices[i].oscillators[1].gain = 0.0f;
}
channel = 0;
parameter = 0;
startedAt = 0;
samplesMade = 0;
ringHead = 0;
ringTail = 0;
keptCount = 0;
}
void soundKeepSamples(void) {
keptRoom = 1 << 16;
keeping = malloc(keptRoom * sizeof(*keeping));
keptCount = 0;
}
static void pushSample(int16_t sample) {
const int next = (ringTail + 1) % SOUND_RING;
if (next != ringHead) {
ring[ringTail] = sample;
ringTail = next;
}
if (keeping != NULL) {
if (keptCount == keptRoom) {
size_t bigger = keptRoom * 2;
int16_t *grown = realloc(keeping, bigger * sizeof(*keeping));
if (grown == NULL) {
return;
}
keeping = grown;
keptRoom = bigger;
}
keeping[keptCount++] = sample;
}
}
void soundTick(unsigned long now) {
if (startedAt == 0 && samplesMade == 0) {
startedAt = now;
}
for (;;) {
// When the next one is due, in whole numbers: no accumulated fraction to drift.
// Sample n is due n periods after the device started, so sample nought is due the
// moment it starts. Making the first one a period late would put every sample after
// it a period late too, which is a whole sample of lag for nothing.
const unsigned long due = startedAt
+ (unsigned long)(samplesMade * (uint64_t)SOUND_CYCLE_RATE
/ SOUND_SAMPLE_RATE);
if (now < due) {
return;
}
int16_t sample;
synthFillBuffer(&synth, &sample, 1);
pushSample(sample);
samplesMade++;
}
}
// ---- A byte, and what it means ----
//
// Everything on this machine is a byte, and a synthesizer wants seconds, hertz and ratios. So
// each parameter says how its 0 to 255 becomes what the engine needs, and the shapes are
// chosen for where the USEFUL part of the range is rather than for arithmetic convenience.
//
// Times are squared, because the difference between five and fifty milliseconds is the whole
// character of a percussive sound and the difference between three and four seconds is
// nothing anybody can hear. Cutoff is exponential for the same reason: pitch is logarithmic
// and so is where a filter sounds like it is.
static float overRange(uint8_t value, float lowest, float highest) {
return lowest + (highest - lowest) * ((float)value / 255.0f);
}
static float squared(uint8_t value, float highest) {
const float part = (float)value / 255.0f;
return part * part * highest;
}
static float exponential(uint8_t value, float lowest, float highest) {
const float part = (float)value / 255.0f;
return lowest * powf(highest / lowest, part);
}
// Centred on 128, so that half of nothing is no change and either side of it is a direction.
static float signedRange(uint8_t value, float reach) {
return ((float)value - 128.0f) / 128.0f * reach;
}
static ModSource sourceFor(uint8_t value) {
return (value <= MOD_SOURCE_LFO2) ? (ModSource)value : MOD_SOURCE_NONE;
}
static void setOscillator(Oscillator *o, uint8_t which, uint8_t value) {
switch (which) {
case SP_OSC_WAVE: o->waveform = (Waveform)(value % WAVE_COUNT); break;
case SP_OSC_GAIN: o->gain = overRange(value, 0.0f, OSC_MAX_GAIN); break;
case SP_OSC_DUTY: o->dutyCycle = overRange(value, 0.05f, 0.95f); break;
// An octave either way, so a step of the byte is 1200/128, about nine cents. Fine
// enough for the shimmer of two oscillators just apart, which is what detune is
// mostly for, and wide enough to transpose one of them a whole octave.
case SP_OSC_DETUNE: o->detune = signedRange(value, 1200.0f); break;
case SP_OSC_OCTAVE: o->octave = (int)value - 128 < -2 ? -2
: ((int)value - 128 > 2 ? 2 : (int)value - 128); break;
case SP_OSC_ACTIVE: o->active = value != 0; break;
case SP_OSC_PWM_SRC: o->modRouting[0] = sourceFor(value); break;
case SP_OSC_PWM_DEPTH: o->modDepth[0] = signedRange(value, 0.5f); break;
case SP_OSC_DET_SRC: o->modRouting[1] = sourceFor(value); break;
case SP_OSC_DET_DEPTH: o->modDepth[1] = signedRange(value, 1200.0f); break;
case SP_OSC_GAIN_SRC: o->modRouting[2] = sourceFor(value); break;
case SP_OSC_GAIN_DEPTH: o->modDepth[2] = signedRange(value, OSC_MAX_GAIN); break;
default: break;
}
}
static void setEnvelope(Envelope *e, uint8_t which, uint8_t value) {
switch (which) {
case SP_ENV_ATTACK: e->attackSec = squared(value, 4.0f); break;
case SP_ENV_DECAY: e->decaySec = squared(value, 4.0f); break;
case SP_ENV_SUSTAIN: e->sustainLevel = overRange(value, 0.0f, 1.0f); break;
case SP_ENV_RELEASE: e->releaseSec = squared(value, 4.0f); break;
default: break;
}
}
static void setFilter(Filter *f, uint8_t which, uint8_t value) {
switch (which) {
case SP_FILTER_ACTIVE: f->active = value != 0; break;
case SP_FILTER_TYPE: f->type = (FilterType)(value % FILTER_COUNT); break;
case SP_FILTER_CUTOFF: f->cutoff = exponential(value, 20.0f, 20000.0f); break;
case SP_FILTER_RES: f->resonance = overRange(value, 0.0f, 0.99f); break;
case SP_FILTER_CUT_SRC: f->modRouting = sourceFor(value); break;
case SP_FILTER_CUT_DEP: f->modDepth = signedRange(value, 8000.0f); break;
case SP_FILTER_RES_SRC: f->resModRouting = sourceFor(value); break;
case SP_FILTER_RES_DEP: f->resModDepth = signedRange(value, 0.99f); break;
default: break;
}
}
static void setLfo(LFO *l, uint8_t which, uint8_t value) {
switch (which) {
case SP_LFO_ACTIVE: l->active = value != 0; break;
case SP_LFO_WAVE: l->waveform = (Waveform)(value % WAVE_COUNT); break;
case SP_LFO_RATE: l->rate = exponential(value, 0.05f, 20.0f); break;
default: break;
}
}
static void soundParameter(uint8_t value) {
Voice *v = &synth.voices[channel];
const uint8_t group = parameter & 0xF0;
const uint8_t which = parameter & 0x0F;
switch (group) {
case SP_OSC0: setOscillator(&v->oscillators[0], which, value); break;
case SP_OSC1: setOscillator(&v->oscillators[1], which, value); break;
case SP_AMPENV: setEnvelope(&v->ampEnv, which, value); break;
case SP_MODENV: setEnvelope(&v->modEnv, which, value); break;
case SP_FILTER: setFilter(&v->filter, parameter, value); break;
case SP_LEVEL_SOURCE:
if (parameter == SP_LEVEL_SOURCE) {
v->levelSource = sourceFor(value);
}
break;
// The LFOs belong to the device rather than to a channel, so whichever channel is
// selected makes no difference to these.
case SP_LFO0: setLfo(&synth.lfos[0], which, value); break;
case SP_LFO1: setLfo(&synth.lfos[1], which, value); break;
default:
// A parameter number nothing answers to does nothing. A sound device is a poor
// place to stop the machine, the same as a screen.
break;
}
}
uint8_t soundWrite(uint8_t value, uint8_t port) {
switch (port) {
case SOUND_CHANNEL: channel = value % SOUND_CHANNELS; break;
case SOUND_PARAMETER: parameter = value; break;
case SOUND_VALUE: soundParameter(value); break;
case SOUND_NOTE: synthChannelOn(&synth, channel, value); break;
case SOUND_GATE:
if (value) {
synthChannelOn(&synth, channel, synth.voices[channel].midiNote);
} else {
synthChannelOff(&synth, channel);
}
break;
case SOUND_VOLUME: synth.volume = overRange(value, 0.0f, 1.0f); break;
default: break;
}
return 0;
}
uint8_t soundRead(uint8_t port) {
switch (port) {
case SOUND_STATUS: {
uint8_t status = 0;
for (int i = 0; i < SOUND_CHANNELS; i++) {
if (synth.voices[i].active) {
status |= SOUND_STATUS_SOUNDING;
}
}
return status;
}
case SOUND_CHANNEL: return channel;
case SOUND_PARAMETER: return parameter;
case SOUND_NOTE: return (uint8_t)synth.voices[channel].midiNote;
default: return 0;
}
}
int soundTake(int16_t *into, int wanted) {
int taken = 0;
while (taken < wanted && ringHead != ringTail) {
into[taken++] = ring[ringHead];
ringHead = (ringHead + 1) % SOUND_RING;
}
return taken;
}
int soundWriteSamples(const char *path) {
// Nothing was kept, which happens if the file was asked for after the machine ran. An
// empty file is the honest answer: the run made no sound anybody asked to hear.
if (keeping == NULL) {
keptCount = 0;
}
FILE *file = fopen(path, "wb");
if (file == NULL) {
fprintf(stderr, "Error: Couldn't write the sound to: %s\n", path);
return 1;
}
const size_t written = keptCount == 0
? 0 : fwrite(keeping, sizeof(*keeping), keptCount, file);
fclose(file);
if (written != keptCount) {
fprintf(stderr, "Error: The sound was not written whole to: %s\n", path);
return 1;
}
return 0;
}
+121
View File
@@ -0,0 +1,121 @@
// sound.h
// The Voyager's sound device.
// Written by Anachronaut
#ifndef SOUND_H
#define SOUND_H
#include <stdint.h>
// ---- What this is ----
//
// Four channels, each one a full soundThing voice: two oscillators, two envelopes and a
// filter. A channel is asked for by number and keeps its patch between notes, which is what
// makes it hardware rather than a keyboard - channel two is channel two.
//
// ---- Why it is not thirty ports ----
//
// A voice has some forty parameters and the machine has 256 ports, so giving each one a port
// of its own would spend a sixth of the whole address space on one device. Instead there is a
// SELECTOR AND A VALUE: say which channel, say which parameter, write it. Three writes to
// change one thing, which is the right price for something a program does when it loads a
// patch and not when it plays a note.
//
// What a program does per NOTE is cheap on purpose, because that happens in a music routine's
// inner loop: select the channel, write the note, write the gate. Three writes and no
// parameter machinery at all.
#define PORT_SOUND 0x40
#define PORT_SOUND_TOP 0x4F
#define SOUND_STATUS 0x40
#define SOUND_CHANNEL 0x41
#define SOUND_PARAMETER 0x42
#define SOUND_VALUE 0x43
#define SOUND_NOTE 0x44
#define SOUND_GATE 0x45
#define SOUND_VOLUME 0x46
// Set while any channel is still sounding, so a routine can wait for a note to finish
// rather than counting.
#define SOUND_STATUS_SOUNDING 0x01
#define SOUND_CHANNELS 4
// ---- The parameters ----
//
// Grouped so that the number says which part of a voice it belongs to: the high nibble picks
// the part and the low one picks the setting. Everything is a byte, because everything on
// this machine is - what each byte means is in the manual and in soundParameter below.
#define SP_OSC0 0x00 // 0x00-0x0F, and 0x10-0x1F for the second oscillator
#define SP_OSC1 0x10
#define SP_OSC_WAVE 0x00
#define SP_OSC_GAIN 0x01
#define SP_OSC_DUTY 0x02
#define SP_OSC_DETUNE 0x03
#define SP_OSC_OCTAVE 0x04
#define SP_OSC_ACTIVE 0x05
#define SP_OSC_PWM_SRC 0x06
#define SP_OSC_PWM_DEPTH 0x07
#define SP_OSC_DET_SRC 0x08
#define SP_OSC_DET_DEPTH 0x09
#define SP_OSC_GAIN_SRC 0x0A
#define SP_OSC_GAIN_DEPTH 0x0B
#define SP_AMPENV 0x20 // 0x20-0x2F amp, 0x30-0x3F mod
#define SP_MODENV 0x30
#define SP_ENV_ATTACK 0x00
#define SP_ENV_DECAY 0x01
#define SP_ENV_SUSTAIN 0x02
#define SP_ENV_RELEASE 0x03
#define SP_FILTER 0x40
#define SP_FILTER_ACTIVE 0x40
#define SP_FILTER_TYPE 0x41
#define SP_FILTER_CUTOFF 0x42
#define SP_FILTER_RES 0x43
#define SP_FILTER_CUT_SRC 0x44
#define SP_FILTER_CUT_DEP 0x45
#define SP_FILTER_RES_SRC 0x46
#define SP_FILTER_RES_DEP 0x47
// Which source shapes the channel's level: 0 none, 1 envelope 0, 2 envelope 1, 3 and 4 the
// LFOs. Nought is the one that could not be said before - see synth.h.
#define SP_LEVEL_SOURCE 0x50
// The LFOs belong to the whole device rather than to a channel, so these ignore whichever
// channel is selected.
#define SP_LFO0 0x60 // 0x60-0x6F and 0x70-0x7F
#define SP_LFO1 0x70
#define SP_LFO_ACTIVE 0x00
#define SP_LFO_WAVE 0x01
#define SP_LFO_RATE 0x02
// ---- Samples come from the machine's clock ----
//
// Forty-eight thousand a second against a million cycles: one sample every twenty and five
// sixths, worked out in whole numbers so it never drifts. THE HOST'S CLOCK IS NOT INVOLVED,
// which is what makes a recorded sound something a test can compare - the same program makes
// the same samples in the same cycles however fast anything really ran.
#define SOUND_SAMPLE_RATE 48000
void soundReset(void);
// Asks the device to keep every sample it makes, for soundWriteSamples. Off unless something
// wants a file, because a long run makes millions of them.
void soundKeepSamples(void);
// Called with the machine's clock, and generates whatever samples are due by now.
void soundTick(unsigned long now);
uint8_t soundWrite(uint8_t value, uint8_t port);
uint8_t soundRead(uint8_t port);
// Takes up to `wanted` samples for something that is going to play them, and says how many
// there were. A front end with a speaker calls this; nothing else has to.
int soundTake(int16_t *into, int wanted);
// Writes every sample generated so far to a file, as raw signed 16 bit. What --screen is for
// a picture: the only way to check a sound on a machine with no speaker.
int soundWriteSamples(const char *path);
#endif // SOUND_H
+590
View File
@@ -0,0 +1,590 @@
// synth.c
// The Voyager's sound, vendored from soundThing.
//
// ---- Where this came from ----
//
// soundThing is a polyphonic subtractive synthesizer written by Anachronaut, and lives in its
// own repository. What is here is its VOICE ENGINE and nothing else: synth.c pulls in maths,
// stdlib, stdint and stdio and knows nothing about Raylib, MIDI, patches or the user
// interface, which is what made it liftable at all.
//
// It is copied rather than submoduled. Two files against tying this build to another
// repository's history is not a close call, and what a copy costs is that changes have to be
// carried across on purpose. So the list below is the whole of the difference, kept current.
//
// ---- What was changed ----
//
// 1. A VOICE'S LEVEL IS A ROUTING. Envelope 0 multiplied the output and there was no way to
// say otherwise, so routing it to a filter or an oscillator meant it shaped the volume as
// well whether that was wanted or not - which is most of the trouble with making
// percussion. Every other destination in this synth chooses its source; now this one does
// too, and MOD_SOURCE_NONE means the level is simply full.
//
// 2. NOISE COMES FROM A SEEDED GENERATOR. It drew from rand(), which is global state shared
// with the whole process and varies between libraries - so the same program would sound
// different on different machines and every recorded result would be worthless. It is a
// generator inside the Synth now, and a machine that starts the same way sounds the same
// way.
//
// 3. CHANNELS ARE NAMED, NOT ALLOCATED. synthNoteOn hunts for a free voice and steals
// round-robin, which is what a keyboard wants. A hardware channel is asked for by number.
// The old calls are still here and still do what they did.
//
// Written by Anachronaut
#include "synth.h"
#include <math.h>
#include <stdlib.h>
#include <stdint.h>
#include <stdio.h>
#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif
void synthInit(Synth *s, float sampleRate)
{
s->sampleRate = sampleRate;
s->pitchBend = 0.0f;
s->pitchBendRange = 2.0f;
s->lastStolenVoice = 0;
s->volume = 0.8f;
for (int i = 0; i < VOICE_COUNT; i++) {
s->voices[i].freqHz = 440.0f;
s->voices[i].active = 0;
s->voices[i].midiNote = -1;
oscillatorInit(&s->voices[i].oscillators[0], WAVE_TRIANGLE, 0.5f, 0.0f, OSC_MAX_GAIN);
oscillatorInit(&s->voices[i].oscillators[1], WAVE_TRIANGLE, 0.5f, 0.0f, 0.0f);
// A seed each, so that two noise oscillators sounding together are two noises rather
// than one heard twice. Any spread will do as long as none of them is zero.
s->voices[i].oscillators[0].noiseState = 0x9E3779B9u + (uint32_t)i * 2654435761u;
s->voices[i].oscillators[1].noiseState = 0x7F4A7C15u + (uint32_t)i * 2246822519u;
// Envelope 0 shapes the level, which is what it always did - the difference is that
// this now says so, and can be told not to.
s->voices[i].levelSource = MOD_SOURCE_AMP_ENV;
envelopeInit(&s->voices[i].ampEnv,
0.005f, // attack
0.10f, // decay
0.70f, // sustain
0.50f); // release
envelopeInit(&s->voices[i].modEnv,
0.005f, // attack
0.50f, // decay
0.0f, // sustain
0.10f); // release
s->voices[i].filter.cutoff = 8000.0f;
s->voices[i].filter.resonance = 0.0f;
s->voices[i].filter.type = FILTER_LOWPASS;
s->voices[i].filter.active = 0;
s->voices[i].filter.low = 0.0f;
s->voices[i].filter.band = 0.0f;
s->voices[i].filter.modRouting = MOD_SOURCE_NONE;
s->voices[i].filter.modDepth = 0.0f;
s->voices[i].filter.resModRouting = MOD_SOURCE_NONE;
s->voices[i].filter.resModDepth = 0.0f;
}
s->voices[0].oscillators[0].active = 1;
for (int l = 0; l < LFO_COUNT; l++) {
s->lfos[l].phase = 0.0f;
s->lfos[l].rate = 1.0f;
s->lfos[l].waveform = WAVE_SINE;
s->lfos[l].active = 0;
s->lfos[l].noiseHeld = 0.0f;
s->lfos[l].noisePhase = 0.0f;
s->lfos[l].noiseState = 0x2545F491u + (uint32_t)l * 3266489917u;
}
}
void synthResetPatch(Synth *s)
{
Voice *v = &s->voices[0];
// The level is shaped by envelope 0 unless a patch says otherwise, which is what it
// always was - the difference is only that it can now be said otherwise.
v->levelSource = MOD_SOURCE_AMP_ENV;
oscillatorInit(&v->oscillators[0], WAVE_TRIANGLE, 0.5f, 0.0f, OSC_MAX_GAIN);
v->oscillators[0].active = 1;
for (int m = 0; m < 3; m++) {
v->oscillators[0].modRouting[m] = MOD_SOURCE_NONE;
v->oscillators[0].modDepth[m] = 0.0f;
}
oscillatorInit(&v->oscillators[1], WAVE_TRIANGLE, 0.5f, 0.0f, 0.0f);
v->oscillators[1].active = 0;
for (int m = 0; m < 3; m++) {
v->oscillators[1].modRouting[m] = MOD_SOURCE_NONE;
v->oscillators[1].modDepth[m] = 0.0f;
}
envelopeInit(&v->ampEnv, 0.005f, 0.10f, 0.70f, 0.50f);
envelopeInit(&v->modEnv, 0.005f, 0.50f, 0.0f, 0.10f);
v->filter.cutoff = 8000.0f;
v->filter.resonance = 0.0f;
v->filter.type = FILTER_LOWPASS;
v->filter.active = 0;
v->filter.low = 0.0f;
v->filter.band = 0.0f;
v->filter.modRouting = MOD_SOURCE_NONE;
v->filter.modDepth = 0.0f;
v->filter.resModRouting = MOD_SOURCE_NONE;
v->filter.resModDepth = 0.0f;
for (int l = 0; l < LFO_COUNT; l++) {
s->lfos[l].phase = 0.0f;
s->lfos[l].rate = 1.0f;
s->lfos[l].waveform = WAVE_SINE;
s->lfos[l].active = 0;
s->lfos[l].noiseHeld = 0.0f;
s->lfos[l].noisePhase = 0.0f;
s->lfos[l].noiseState = 0x2545F491u + (uint32_t)l * 3266489917u;
}
s->volume = 0.8f;
s->pitchBendRange = 2.0f;
}
void oscillatorInit(Oscillator *o, Waveform waveform, float dutyCycle, float detune, float gain)
{
o->phase = 0.0f;
o->waveform = waveform;
o->dutyCycle = dutyCycle;
o->detune = detune;
o->noiseHeld = 0.0f;
o->noisePhase = 0.0f;
o->noiseState = 0x9E3779B9u; // Non-zero, or xorshift stays at zero and makes silence.
o->gain = gain;
o->octave = 0;
}
// ---- Noise ----
//
// A plain 32-bit xorshift, which is all a noise source needs: it has to be the same sequence
// every run and it does not have to be a good one. A state of zero stays at zero and makes
// silence rather than noise, so every seed below is non-zero on purpose.
static float nextNoise(uint32_t *state) {
*state ^= *state << 13;
*state ^= *state >> 17;
*state ^= *state << 5;
return (float)(*state / 4294967296.0) * 2.0f - 1.0f;
}
static float getModValue(float ampEnv, float modEnv, float lfo0, float lfo1, ModSource source)
{
switch (source) {
case MOD_SOURCE_AMP_ENV: return ampEnv;
case MOD_SOURCE_MOD_ENV: return modEnv;
case MOD_SOURCE_LFO: return lfo0;
case MOD_SOURCE_LFO2: return lfo1;
default: return 0.0f;
}
}
float lfoTick(LFO *l, float sampleRate)
{
if (!l->active) return 0.0f;
l->phase += l->rate / sampleRate;
if (l->phase >= 1.0f) l->phase -= 1.0f;
if (l->waveform == WAVE_NOISE) {
l->noisePhase += l->rate / sampleRate;
if (l->noisePhase >= 1.0f) {
l->noisePhase -= 1.0f;
l->noiseHeld = nextNoise(&l->noiseState);
}
}
return waveformSample(l->waveform, l->phase, 0.5f, l->noiseHeld);
}
float filterTick(Filter *f, float input, float cutoff, float resonance, float sampleRate)
{
if (!f->active) return input;
if (cutoff < 20.0f) cutoff = 20.0f;
if (cutoff > sampleRate * 0.499f) cutoff = sampleRate * 0.499f;
if (resonance < 0.0f) resonance = 0.0f;
if (resonance > 0.99f) resonance = 0.99f;
// Andy Simper TPT SVF (bilinear integration - unconditionally stable)
float g = tanf((float)M_PI * cutoff / sampleRate);
float Q = 0.5f + resonance * 9.5f; // resonance 0..0.99 -> Q 0.5..10.0
float k = 1.0f / Q;
float a1 = 1.0f / (1.0f + g * (g + k));
float a2 = g * a1;
float a3 = g * a2;
// f->band = s1, f->low = s2 (integrator states)
float v3 = input - f->low;
float v1 = a1 * f->band + a2 * v3;
float v2 = f->low + a2 * f->band + a3 * v3;
f->band = 2.0f * v1 - f->band;
f->low = 2.0f * v2 - f->low;
switch (f->type) {
case FILTER_LOWPASS: return v2;
case FILTER_HIGHPASS: return input - k * v1 - v2;
case FILTER_BANDPASS: return v1;
default: return v2;
}
}
const char *filterTypeName(FilterType t)
{
switch (t) {
case FILTER_LOWPASS: return "LP";
case FILTER_HIGHPASS: return "HP";
case FILTER_BANDPASS: return "BP";
default: return "??";
}
}
// float oscillatorTick(Oscillator *o, float freqHz, float bendMultiplier, float sampleRate,
// float dutyCycle, float detune, float gain)
// {
// float detuneMultiplier = powf(2.0f, o->detune / 1200.0f);
// float freq = freqHz * detuneMultiplier * bendMultiplier;
//
// // Advance phase
// o->phase += freq / sampleRate;
// if (o->phase >= 1.0f) o->phase -= 1.0f;
//
// // Clocked noise - draw a new random value once per cycle
// if (o->waveform == WAVE_NOISE) {
// o->noisePhase += freq / sampleRate;
// if (o->noisePhase >= 1.0f) {
// o->noisePhase -= 1.0f;
// o->noiseHeld = nextNoise(&o->noiseState);
// }
// }
//
// return waveformSample(o->waveform, o->phase, o->dutyCycle, o->noiseHeld) * o->gain;
// }
float oscillatorTick(Oscillator *o, float freqHz, float bendMultiplier, float sampleRate,
float dutyCycle, float detune, float gain)
{
float detuneMultiplier = powf(2.0f, detune / 1200.0f);
float freq = freqHz * detuneMultiplier * bendMultiplier;
// Advance phase
o->phase += freq / sampleRate;
if (o->phase >= 1.0f) o->phase -= 1.0f;
// Clocked noise - draw a new random value once per cycle
if (o->waveform == WAVE_NOISE) {
o->noisePhase += freq / sampleRate;
if (o->noisePhase >= 1.0f) {
o->noisePhase -= 1.0f;
o->noiseHeld = nextNoise(&o->noiseState);
}
}
return waveformSample(o->waveform, o->phase, dutyCycle, o->noiseHeld) * gain;
}
float waveformSample(Waveform w, float phase, float dutyCycle, float noiseHeld)
{
switch (w) {
case WAVE_SINE:
return sinf(2.0f * (float)M_PI * phase);
case WAVE_TRIANGLE:
return (phase < 0.5f)
? ( 4.0f * phase - 1.0f)
: (-4.0f * phase + 3.0f);
case WAVE_SAW:
return 2.0f * phase - 1.0f;
case WAVE_RAMP:
return 1.0f - 2.0f * phase;
case WAVE_PULSE:
return (phase < dutyCycle) ? 1.0f : -1.0f;
case WAVE_NOISE:
return noiseHeld;
default:
return 0.0f;
}
}
const char *waveformName(Waveform w)
{
switch (w) {
case WAVE_SINE: return "Sine";
case WAVE_TRIANGLE: return "Triangle";
case WAVE_SAW: return "Saw";
case WAVE_RAMP: return "Ramp";
case WAVE_PULSE: return "Pulse";
case WAVE_NOISE: return "Noise";
default: return "???";
}
}
void synthNoteOn(Synth *s, int midiNote)
{
float hz = 440.0f * powf(2.0f, (midiNote - 69) / 12.0f);
for (int i = 0; i < VOICE_COUNT; i++) {
if (!s->voices[i].active) {
s->voices[i].freqHz = hz;
s->voices[i].midiNote = midiNote;
s->voices[i].active = 1;
s->voices[i].filter.low = 0.0f;
s->voices[i].filter.band = 0.0f;
envelopeNoteOn(&s->voices[i].ampEnv);
envelopeNoteOn(&s->voices[i].modEnv);
return;
}
}
// Steal round-robin
int i = s->lastStolenVoice % VOICE_COUNT;
s->lastStolenVoice++;
s->voices[i].freqHz = hz;
s->voices[i].midiNote = midiNote;
s->voices[i].active = 1;
s->voices[i].filter.low = 0.0f;
s->voices[i].filter.band = 0.0f;
envelopeNoteOn(&s->voices[i].ampEnv);
envelopeNoteOn(&s->voices[i].modEnv);
}
void synthNoteOff(Synth *s, int midiNote) {
for (int i = 0; i < VOICE_COUNT; i++) {
if (s->voices[i].active && s->voices[i].midiNote == midiNote) {
envelopeNoteOff(&s->voices[i].ampEnv);
envelopeNoteOff(&s->voices[i].modEnv);
}
}
}
// ---- A channel is the channel you asked for ----
//
// synthNoteOn hunts for a free voice and steals round-robin, which is what a keyboard wants:
// eight fingers and no say in which voice serves which. A hardware channel is not like that.
// Channel two is channel two, it holds its patch between notes, and a program driving it
// knows perfectly well what it is doing - so these say which one and nothing is stolen.
//
// The two above are left exactly as they were, because the standalone synthesizer still wants
// them and a keyboard has not stopped being a keyboard.
void synthChannelOn(Synth *s, int channel, int midiNote)
{
if (channel < 0 || channel >= VOICE_COUNT) {
return;
}
Voice *v = &s->voices[channel];
v->freqHz = 440.0f * powf(2.0f, (midiNote - 69) / 12.0f);
v->midiNote = midiNote;
v->active = 1;
// The filter's memory of the last note is not this note's business. A note beginning
// where the last one left off is how a click gets into the front of every sound.
v->filter.low = 0.0f;
v->filter.band = 0.0f;
envelopeNoteOn(&v->ampEnv);
envelopeNoteOn(&v->modEnv);
}
void synthChannelOff(Synth *s, int channel)
{
if (channel < 0 || channel >= VOICE_COUNT) {
return;
}
// Released rather than stopped: what happens next is the envelope's business, and a note
// that ended the instant a key came up would have no release at all.
envelopeNoteOff(&s->voices[channel].ampEnv);
envelopeNoteOff(&s->voices[channel].modEnv);
}
void synthFillBuffer(Synth *s, int16_t *out, int frames) {
const float sr = s->sampleRate;
const float bendMultiplier = powf(2.0f, (s->pitchBend * s->pitchBendRange) / 12.0f);
for (int i = 0; i < frames; i++) {
float mix = 0.0f;
float lfo0 = lfoTick(&s->lfos[0], sr);
float lfo1 = lfoTick(&s->lfos[1], sr);
for (int v = 0; v < VOICE_COUNT; v++) {
Voice *vv = &s->voices[v];
if (!vv->active) continue;
float amp = envelopeTick(&vv->ampEnv, sr);
float mod = envelopeTick(&vv->modEnv, sr);
if (vv->ampEnv.stage == ENV_IDLE) {
vv->active = 0;
continue;
}
float oscMix = 0.0f;
int activeOscs = 0;
for (int o = 0; o < OSC_COUNT; o++) {
Oscillator *osc = &vv->oscillators[o];
if (!osc->active) continue;
float dutyCycle = osc->dutyCycle +
getModValue(amp, mod, lfo0, lfo1, osc->modRouting[0]) * osc->modDepth[0];
float detune = osc->detune + (float)osc->octave * 1200.0f +
getModValue(amp, mod, lfo0, lfo1, osc->modRouting[1]) * osc->modDepth[1];
float gain = osc->gain +
getModValue(amp, mod, lfo0, lfo1, osc->modRouting[2]) * osc->modDepth[2];
if (dutyCycle < 0.05f) dutyCycle = 0.05f;
if (dutyCycle > 0.95f) dutyCycle = 0.95f;
if (gain < 0.0f) gain = 0.0f;
if (gain > OSC_MAX_GAIN) gain = OSC_MAX_GAIN;
oscMix += oscillatorTick(osc, vv->freqHz, bendMultiplier, sr, dutyCycle, detune, gain);
activeOscs++;
}
if (activeOscs > 0) oscMix /= activeOscs;
float cutoff = vv->filter.cutoff +
getModValue(amp, mod, lfo0, lfo1, vv->filter.modRouting) * vv->filter.modDepth;
float resonance = vv->filter.resonance +
getModValue(amp, mod, lfo0, lfo1, vv->filter.resModRouting) * vv->filter.resModDepth;
oscMix = filterTick(&vv->filter, oscMix, cutoff, resonance, sr);
// ---- How loud this voice is ----
//
// Envelope 0 used to be multiplied in here unconditionally, so routing it
// anywhere else meant it shaped the volume as well. Now the voice says which
// source shapes its level, and MOD_SOURCE_NONE means nothing does.
//
// Clamped at nothing, because an LFO swings either side of zero and the far side
// is not a negative volume, it is silence. Which makes an LFO here tremolo.
float level = 1.0f;
if (vv->levelSource != MOD_SOURCE_NONE) {
level = getModValue(amp, mod, lfo0, lfo1, vv->levelSource);
if (level < 0.0f) level = 0.0f;
}
mix += oscMix * level;
}
mix *= (0.2f / VOICE_COUNT) * 4.0f * s->volume;
int32_t sample = (int32_t)lrintf(mix * 32767.0f);
if (sample > 32767) sample = 32767;
if (sample < -32768) sample = -32768;
out[i] = (int16_t)sample;
}
}
void envelopeInit(Envelope *e, float attackSec, float decaySec, float sustainLevel, float releaseSec)
{
e->stage = ENV_IDLE;
e->value = 0.0f;
e->attackSec = attackSec;
e->decaySec = decaySec;
e->sustainLevel = sustainLevel;
e->releaseSec = releaseSec;
}
void envelopeNoteOn(Envelope *e)
{
e->value = 0.0f;
e->stage = ENV_ATTACK;
}
void envelopeNoteOff(Envelope *e)
{
// Only trigger release if we're actually playing
if (e->stage != ENV_IDLE)
e->stage = ENV_RELEASE;
}
float envelopeTick(Envelope *e, float sampleRate)
{
switch (e->stage) {
case ENV_ATTACK: {
float inc = (e->attackSec <= 0.0f) ? 1.0f : (1.0f / (e->attackSec * sampleRate));
e->value += inc;
if (e->value >= 1.0f) {
e->value = 1.0f;
e->stage = ENV_DECAY;
}
break;
}
case ENV_DECAY: {
float inc = (e->decaySec <= 0.0f) ? 1.0f : (1.0f / (e->decaySec * sampleRate));
e->value -= inc;
if (e->value <= e->sustainLevel) {
e->value = e->sustainLevel;
e->stage = ENV_SUSTAIN;
}
break;
}
case ENV_SUSTAIN:
e->value = e->sustainLevel;
break;
case ENV_RELEASE: {
float inc = (e->releaseSec <= 0.0f) ? 1.0f : (1.0f / (e->releaseSec * sampleRate));
e->value -= inc;
if (e->value <= 0.0f) {
e->value = 0.0f;
e->stage = ENV_IDLE;
}
break;
}
case ENV_IDLE:
e->value = 0.0f;
break;
}
return e->value;
}
void synthSyncVoices(Synth *s)
{
for (int v = 1; v < VOICE_COUNT; v++) {
// Sync oscillator settings
for (int o = 0; o < OSC_COUNT; o++) {
s->voices[v].oscillators[o].waveform = s->voices[0].oscillators[o].waveform;
s->voices[v].oscillators[o].dutyCycle = s->voices[0].oscillators[o].dutyCycle;
s->voices[v].oscillators[o].detune = s->voices[0].oscillators[o].detune;
s->voices[v].oscillators[o].gain = s->voices[0].oscillators[o].gain;
s->voices[v].oscillators[o].active = s->voices[0].oscillators[o].active;
s->voices[v].oscillators[o].octave = s->voices[0].oscillators[o].octave;
s->voices[v].oscillators[o].modRouting[0] = s->voices[0].oscillators[o].modRouting[0];
s->voices[v].oscillators[o].modRouting[1] = s->voices[0].oscillators[o].modRouting[1];
s->voices[v].oscillators[o].modRouting[2] = s->voices[0].oscillators[o].modRouting[2];
s->voices[v].oscillators[o].modDepth[0] = s->voices[0].oscillators[o].modDepth[0];
s->voices[v].oscillators[o].modDepth[1] = s->voices[0].oscillators[o].modDepth[1];
s->voices[v].oscillators[o].modDepth[2] = s->voices[0].oscillators[o].modDepth[2];
}
// Sync filter params but not state (low/band are per-voice)
s->voices[v].filter.cutoff = s->voices[0].filter.cutoff;
s->voices[v].filter.resonance = s->voices[0].filter.resonance;
s->voices[v].filter.type = s->voices[0].filter.type;
s->voices[v].filter.active = s->voices[0].filter.active;
s->voices[v].filter.modRouting = s->voices[0].filter.modRouting;
s->voices[v].filter.modDepth = s->voices[0].filter.modDepth;
s->voices[v].filter.resModRouting = s->voices[0].filter.resModRouting;
s->voices[v].filter.resModDepth = s->voices[0].filter.resModDepth;
// Sync envelope settings but NOT runtime state
// Each voice needs its own stage, value - just copy the parameters
s->voices[v].ampEnv.attackSec = s->voices[0].ampEnv.attackSec;
s->voices[v].ampEnv.decaySec = s->voices[0].ampEnv.decaySec;
s->voices[v].ampEnv.sustainLevel = s->voices[0].ampEnv.sustainLevel;
s->voices[v].ampEnv.releaseSec = s->voices[0].ampEnv.releaseSec;
// Sync the mod envelope, too.
s->voices[v].modEnv.attackSec = s->voices[0].modEnv.attackSec;
s->voices[v].modEnv.decaySec = s->voices[0].modEnv.decaySec;
s->voices[v].modEnv.sustainLevel = s->voices[0].modEnv.sustainLevel;
s->voices[v].modEnv.releaseSec = s->voices[0].modEnv.releaseSec;
}
}
+208
View File
@@ -0,0 +1,208 @@
// synth.h
// The Voyager's sound, vendored from soundThing.
//
// ---- Where this came from ----
//
// soundThing is a polyphonic subtractive synthesizer written by Anachronaut, and lives in its
// own repository. What is here is its VOICE ENGINE and nothing else: synth.c pulls in maths,
// stdlib, stdint and stdio and knows nothing about Raylib, MIDI, patches or the user
// interface, which is what made it liftable at all.
//
// It is copied rather than submoduled. Two files against tying this build to another
// repository's history is not a close call, and what a copy costs is that changes have to be
// carried across on purpose. So the list below is the whole of the difference, kept current.
//
// ---- What was changed ----
//
// 1. A VOICE'S LEVEL IS A ROUTING. Envelope 0 multiplied the output and there was no way to
// say otherwise, so routing it to a filter or an oscillator meant it shaped the volume as
// well whether that was wanted or not - which is most of the trouble with making
// percussion. Every other destination in this synth chooses its source; now this one does
// too, and MOD_SOURCE_NONE means the level is simply full.
//
// 2. NOISE COMES FROM A SEEDED GENERATOR. It drew from rand(), which is global state shared
// with the whole process and varies between libraries - so the same program would sound
// different on different machines and every recorded result would be worthless. It is a
// generator inside the Synth now, and a machine that starts the same way sounds the same
// way.
//
// 3. CHANNELS ARE NAMED, NOT ALLOCATED. synthNoteOn hunts for a free voice and steals
// round-robin, which is what a keyboard wants. A hardware channel is asked for by number.
// The old calls are still here and still do what they did.
//
// Written by Anachronaut
#ifndef SYNTH_H
#define SYNTH_H
#include <stdint.h>
#define VOICE_COUNT 8
#define OSC_COUNT 2
#define LFO_COUNT 2
#define OSC_MAX_GAIN 4.0f
typedef enum {
WAVE_SINE,
WAVE_TRIANGLE,
WAVE_SAW,
WAVE_RAMP,
WAVE_PULSE,
WAVE_NOISE,
WAVE_COUNT // handy for the modulo wrap on waveform switching
} Waveform;
// Envelope structures:
typedef enum {
ENV_IDLE,
ENV_ATTACK,
ENV_DECAY,
ENV_SUSTAIN,
ENV_RELEASE
} EnvStage;
typedef enum {
MOD_SOURCE_NONE = 0,
MOD_SOURCE_AMP_ENV = 1,
MOD_SOURCE_MOD_ENV = 2,
MOD_SOURCE_LFO = 3,
MOD_SOURCE_LFO2 = 4
} ModSource;
typedef struct {
EnvStage stage;
float value; // current output value, 0.0 to 1.0
float attackSec;
float decaySec;
float sustainLevel;
float releaseSec;
} Envelope;
typedef struct {
float phase;
float rate; // Hz
Waveform waveform;
int active;
float noiseHeld;
float noisePhase;
// Its own noise, seeded at init. rand() is global state shared with the whole process and
// varies between C libraries, so the same program sounded different on different machines
// and no recorded result could mean anything. One generator EACH rather than one shared,
// because two noise sources drawing from the same stream are not two noise sources.
uint32_t noiseState;
} LFO;
typedef enum {
FILTER_LOWPASS,
FILTER_HIGHPASS,
FILTER_BANDPASS,
FILTER_COUNT
} FilterType;
typedef struct {
float cutoff; // Hz
float resonance; // 0.0 (flat) to 0.99 (near self-oscillation)
FilterType type;
int active;
float low, band; // TPT integrator states s1, s2
int modRouting;
float modDepth; // Hz
int resModRouting;
float resModDepth; // resonance units (-0.99..0.99)
} Filter;
typedef struct {
float phase;
float dutyCycle;
Waveform waveform;
float detune; // cents, 0 = no detune
float noiseHeld; // last drawn random value for clocked noise
float noisePhase; // tracks when to draw a new noise value
// Its own noise, seeded at init. rand() is global state shared with the whole process and
// varies between C libraries, so the same program sounded different on different machines
// and no recorded result could mean anything. One generator EACH rather than one shared,
// because two noise sources drawing from the same stream are not two noise sources.
uint32_t noiseState;
float gain;
int active; // whether this oscillator contributes to output
int octave; // transposition in octaves, -2 to +2
// Modulation routing: one source per destination by design.
// Each parameter (pwm, detune, gain) has exactly one mod source and one depth.
int modRouting[3]; // 0=off, 1=env0, 2=env1, 3=lfo0, 4=lfo1
float modDepth[3]; // Depth of modulation parameter
} Oscillator;
typedef struct {
Oscillator oscillators[OSC_COUNT];
float freqHz;
int active;
int midiNote;
Envelope ampEnv;
Envelope modEnv;
Filter filter;
// ---- What shapes how loud this voice is ----
//
// Envelope 0 used to, always, with no way to say otherwise - so routing it to a filter or
// an oscillator meant it shaped the volume too, whether that was wanted or not. Every
// other destination here names its source; this one does now as well.
//
// NOT the base-and-depth pair the others use, because a level is not a deviation from a
// resting value - it is a shape from nothing to full, and multiplying is what an amplitude
// envelope does. So this names a source outright, and MOD_SOURCE_NONE means the voice is
// simply at full and whatever env0 is doing is somebody else's business.
//
// It also makes two things possible that were not: envelope 1 shaping the volume, and an
// LFO doing it, which is tremolo.
ModSource levelSource;
} Voice;
typedef struct {
float sampleRate;
float pitchBend;
float pitchBendRange;
int lastStolenVoice;
Voice voices[VOICE_COUNT];
float volume; // 0.0 to 1.0, master output level
LFO lfos[LFO_COUNT];
} Synth;
// Envelope functions:
void envelopeInit(Envelope *e, float attackSec, float decaySec, float sustainLevel, float releaseSec);
void envelopeNoteOn(Envelope *e);
void envelopeNoteOff(Envelope *e);
float envelopeTick(Envelope *e, float sampleRate);
// Oscillator functions:
void oscillatorInit(Oscillator *o, Waveform waveform, float dutyCycle, float detune, float gain);
float oscillatorTick(Oscillator *o, float freqHz, float bendMultiplier, float sampleRate,
float dutyCycle, float detune, float gain);
// Synth functions:
void synthInit(Synth *s, float sampleRate);
void synthResetPatch(Synth *s);
void synthNoteOn(Synth *s, int midiNote);
void synthNoteOff(Synth *s, int midiNote);
// ---- 3. A channel is asked for by number ----
//
// The two above hunt for a free voice and steal round-robin, which is what a keyboard wants
// and what the standalone synthesizer still does. A hardware channel is not allocated: it is
// the third one, and it is the third one every time.
void synthChannelOn(Synth *s, int channel, int midiNote);
void synthChannelOff(Synth *s, int channel);
void synthFillBuffer(Synth *s, int16_t *out, int frames);
void synthSyncVoices(Synth *s);
// LFO functions:
float lfoTick(LFO *l, float sampleRate);
// Filter functions:
float filterTick(Filter *f, float input, float cutoff, float resonance, float sampleRate);
const char *filterTypeName(FilterType t);
// Waveform functions:
float waveformSample(Waveform w, float phase, float dutyCycle, float noiseHeld);
const char *waveformName(Waveform w);
#endif
+56 -7
View File
@@ -27,6 +27,16 @@ void printHelp(const char *programName) {
printf(" default, finishes before the next instruction starts.\n"); printf(" default, finishes before the next instruction starts.\n");
printf(" -W, --write-protect Attach the disk read only. A disk the host will not let\n"); printf(" -W, --write-protect Attach the disk read only. A disk the host will not let\n");
printf(" you write is read only whether you ask for this or not.\n"); printf(" you write is read only whether you ask for this or not.\n");
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(" -N, --sound FILE Save every sample the machine made, as raw signed 16 bit\n");
printf(" at 48kHz. What --screen is for a picture: the only way to\n");
printf(" check a sound on a machine with no speaker.\n");
printf(" -h, --help Display this help message.\n"); printf(" -h, --help Display this help message.\n");
} }
@@ -36,23 +46,32 @@ uint8_t parseOptions(int argc, char *argv[], EmulatorOptions *options) {
{"cycles", required_argument, 0, 'c'}, {"cycles", required_argument, 0, 'c'},
{"fast", no_argument, 0, 'f'}, {"fast", no_argument, 0, 'f'},
{"disk", required_argument, 0, 'D'}, {"disk", required_argument, 0, 'D'},
{"ram-disk", required_argument, 0, 'R'},
{"write-protect", no_argument, 0, 'W'}, {"write-protect", no_argument, 0, 'W'},
{"disk-cycles", required_argument, 0, 'L'}, {"disk-cycles", required_argument, 0, 'L'},
{"screen", required_argument, 0, 'S'},
{"keyboard", required_argument, 0, 'K'},
{"sound", required_argument, 0, 'N'},
{"help", no_argument, 0, 'h'}, {"help", no_argument, 0, 'h'},
{0, 0, 0, 0 } {0, 0, 0, 0 }
}; };
int opt; int opt;
int option_index = 0; int option_index = 0;
options->debug = 0; // ---- Everything off, in one line rather than nine ----
options->fast = 0; //
options->cycles = 0; // This was a list of assignments, one per field, and a list beside a struct drifts from
options->disk = NULL; // the struct: adding `disks` and `diskCount` left them holding whatever was on the stack,
options->writeProtect = 0; // so a machine given one disk was told it already had four drives. The same struct
options->diskCycles = 0; // growing a field once before left Voyager linking against an object that disagreed
// about its size.
//
// Every default here is nought or nothing, and a default that is not can be written
// below this line where it will be read as the exception it is.
*options = (EmulatorOptions){0};
// Parse options // Parse options
while ((opt = getopt_long(argc, argv, "dc:fhD:WL:", long_options, &option_index)) != -1) { while ((opt = getopt_long(argc, argv, "dc:fhD:WL:S:K:N:R:", long_options, &option_index)) != -1) {
switch (opt) { switch (opt) {
case 'd': case 'd':
options->debug = 1; options->debug = 1;
@@ -74,7 +93,28 @@ uint8_t parseOptions(int argc, char *argv[], EmulatorOptions *options) {
options->fast = 1; options->fast = 1;
break; break;
case 'D': case 'D':
// Each one is the next drive. The first is also left in `disk`, because a
// machine with one disk is what almost every caller means and reading it
// that way keeps them all unchanged.
if (options->diskCount >= DISK_DRIVE_COUNT) {
fprintf(stderr, "Error: This machine has %d drives.\n",
DISK_DRIVE_COUNT);
return 1;
}
options->disks[options->diskCount++] = optarg;
if (options->disk == NULL) {
options->disk = optarg; options->disk = optarg;
}
break;
case 'R': {
char *end;
const unsigned long blocks = strtoul(optarg, &end, 10);
if (*optarg == '\0' || *end != '\0' || blocks == 0) {
fprintf(stderr, "Error: --ram-disk wants a number of blocks.\n");
return 1;
}
options->ramDisk = blocks;
}
break; break;
case 'W': case 'W':
options->writeProtect = 1; options->writeProtect = 1;
@@ -82,6 +122,15 @@ uint8_t parseOptions(int argc, char *argv[], EmulatorOptions *options) {
case 'L': case 'L':
options->diskCycles = strtoul(optarg, NULL, 0); options->diskCycles = strtoul(optarg, NULL, 0);
break; break;
case 'S':
options->screen = optarg;
break;
case 'K':
options->keyboard = optarg;
break;
case 'N':
options->sound = optarg;
break;
case 'h': case 'h':
printHelp(argv[0]); printHelp(argv[0]);
return OPTIONS_HELP; return OPTIONS_HELP;
+15 -1
View File
@@ -8,6 +8,7 @@
#define UTILITY_H #define UTILITY_H
#include <stdint.h> #include <stdint.h>
#include "io.h"
#include "cpu.h" #include "cpu.h"
// Results of reading the command line. // Results of reading the command line.
@@ -20,15 +21,28 @@ typedef struct {
uint8_t fast; // Ignore the cycle rate and run as fast as the host allows. uint8_t fast; // Ignore the cycle rate and run as fast as the host allows.
unsigned long cycles; // Stop after this many cycles. Zero means run until the program halts. unsigned long cycles; // Stop after this many cycles. Zero means run until the program halts.
unsigned long diskCycles; // How long a block move takes. Zero is instant, and the default. unsigned long diskCycles; // How long a block move takes. Zero is instant, and the default.
// ---- The drives, in the order they were named ----
//
// --disk given more than once fills them in turn, so the first is drive 0 and the machine
// has as many as were asked for. One name is the ordinary case and reads exactly as it
// did when there could only be one.
const char *disk; // Disk image to attach, or NULL for a machine with no disk. const char *disk; // Disk image to attach, or NULL for a machine with no disk.
const char *disks[DISK_DRIVE_COUNT];
int diskCount;
// A drive of this many blocks made of memory, taking the next drive number after the
// images above. Zero for a machine without one.
unsigned long ramDisk;
uint8_t writeProtect; // Attach the disk read only, the way a tab on a floppy would. 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.
const char *sound; // Where to save the samples the machine made, as raw 16 bit.
} EmulatorOptions; } EmulatorOptions;
uint8_t parseOptions(int argc, char *argv[], EmulatorOptions *options); uint8_t parseOptions(int argc, char *argv[], EmulatorOptions *options);
void printHelp(const char *programName); void printHelp(const char *programName);
uint8_t loadFile(char *path, uint8_t *Program, uint8_t *Data); uint8_t loadFile(const char *path, uint8_t *Program, uint8_t *Data);
void bootStrap(uint8_t *Program, uint8_t *Data); void bootStrap(uint8_t *Program, uint8_t *Data);
+404
View File
@@ -0,0 +1,404 @@
// video.c
// The Voyager's video device.
// Written by Anachronaut
#include "video.h"
#include "font.h"
#include "io.h"
#include <stdio.h>
#include <string.h>
// The bank the device brings. Registered by whoever enumerates the hardware, reached only
// through the memory controller, and never by the CPU directly - the same arrangement the
// disk's buffer has always had.
static uint8_t videoRAM[VIDEO_MEMORY_BYTES];
static uint8_t mode;
// Which map row is drawn at the top. THE MAP IS A RING: rendering row r reads map row
// (scroll + r) wrapped, so scrolling a screen moves this byte and moves no memory at all.
//
// That is worth more than it looks. Blitting a 40 by 25 screen up one line is 1,920 bytes
// inside one bank, which is 1,920 cycles even with the controller widened - twelve percent
// of a frame, every line. A program printing one page would spend six frames shuffling
// memory. Here it costs one port write, and the rows that scrolled off are still there,
// which is where the console gets scrollback it never had.
static uint8_t scroll;
// The column origin, and the pixel remainder for each axis. Kept apart from the row origin
// above rather than folded into it, because they are read at different moments: the origins
// decide which cell a program's writes land in, and the fine offsets decide nothing at all
// except where the finished picture sits. See videoPutCell.
static uint8_t scrollColumn;
static uint8_t fineX, fineY;
static uint8_t pixels[VIDEO_MAX_WIDTH * VIDEO_MAX_HEIGHT * 3];
static int renderedWidth = 0;
static int renderedHeight = 0;
// Zero in bitmap mode, where there are no characters. Everything that draws one checks, so
// this is the single place the answer lives rather than a mode test in each of them.
static int columnsFor(uint8_t m) {
if (m == VIDEO_MODE_BITMAP) return 0;
return m == VIDEO_MODE_80x50 ? 80 : 40;
}
static int rowsFor(uint8_t m) {
if (m == VIDEO_MODE_BITMAP) return 0;
return m == VIDEO_MODE_80x50 ? 50 : 25;
}
int videoTextRows(void) { return rowsFor(mode); }
int videoColumns(void) { return columnsFor(mode); }
int videoRows(void) { return rowsFor(mode); }
// ---- Sixteen schemes a machine wakes up with ----
//
// A glyph is drawn in palette indices 0 and 1, paper and ink, and a cell's attribute nibble
// adds sixteen to both. So bank n colours text with entries n*16 and n*16+1, and SIXTEEN
// BANKS IS SIXTEEN INK AND PAPER PAIRS - a text attribute system that costs one nibble and
// no hardware at all.
//
// The arrangement is a convention rather than a rule of the machine, and it is chosen so
// that HIGHLIGHTING IS ONE BIT. Banks 0 to 7 are colours on black; banks 8 to 15 are the
// same colours as paper with black ink. Attribute XOR 8 therefore turns any of them inside
// out, which is what a cursor and a selected line both want, and a program that disagrees
// writes its own palette over the top.
//
// Bank 0 is grey on black, which is what the machine has always woken up as.
//
// BLACK IS BLACK AND GREY IS GREY. These were tinted towards green to begin with, on the
// theory that a phosphor never was neutral, and on a real screen it read as a fault rather
// than as character - a background that is nearly black looks like a background that failed
// to be black.
static const uint8_t defaultInks[8][3] = {
{ 0xD8, 0xD8, 0xD8 }, // grey, which is what plain text has always been
{ 0xD0, 0x40, 0x38 }, // red
{ 0x50, 0xC0, 0x50 }, // green
{ 0xD8, 0xC0, 0x48 }, // yellow
{ 0x58, 0x80, 0xE0 }, // blue
{ 0xC8, 0x60, 0xC0 }, // magenta
{ 0x50, 0xC0, 0xC8 }, // cyan
{ 0xF0, 0xF0, 0xF0 }, // white
};
static const uint8_t defaultPaper[3] = { 0x00, 0x00, 0x00 };
// Where the cursor is, whether it is wanted, and what the clock says - which is what makes
// it blink without anything having to remember when it last did.
static int cursorAtRow = 0;
static int cursorAtColumn = 0;
static int cursorVisible = 0;
static unsigned long videoNow = 0;
// When the last frame boundary went by, whether one has gone by unnoticed, and whether the
// screen is meant to say so out loud.
static unsigned long lastFrame = 0;
static int frameWaiting = 0;
static int frameInterrupts = 0;
void videoSetCursor(int row, int column, int visible) {
cursorAtRow = row;
cursorAtColumn = column;
cursorVisible = visible;
}
void videoTick(unsigned long now) {
videoNow = now;
// ---- Caught up rather than counted ----
//
// A loop, because more than one frame can go by between two looks: the machine runs in
// batches, and a slow host or a --fast run can cover several frames before anything asks.
// The flag and the line are each ONE THING, so several frames at once still mean one of
// each - a missed frame is missed, which is what missing one is.
while (now - lastFrame >= VIDEO_FRAME_CYCLES) {
lastFrame += VIDEO_FRAME_CYCLES;
frameWaiting = 1;
if (frameInterrupts) {
raiseInterrupt(PORT_VIDEO);
}
}
}
void videoLoadFont(void) {
// One bit a pixel becomes one byte a pixel: index 1 where the font has a dot and 0
// where it does not, which is what makes the two palette entries below mean ink and
// paper. Glyphs the font does not have are left blank rather than left as whatever was
// in tile memory.
memset(videoRAM + VIDEO_TILE_BASE, 0, (size_t)VIDEO_TILE_COUNT * VIDEO_TILE_BYTES);
for (int glyph = 0; glyph < CONSOLE_FONT_GLYPHS && glyph < VIDEO_TILE_COUNT; glyph++) {
uint8_t *tile = videoRAM + VIDEO_TILE_BASE + glyph * VIDEO_TILE_BYTES;
for (int y = 0; y < CONSOLE_FONT_BYTES; y++) {
const unsigned char row = consoleFont[glyph * CONSOLE_FONT_BYTES + y];
for (int x = 0; x < VIDEO_CELL_PIXELS; x++) {
tile[y * VIDEO_CELL_PIXELS + x] = (row & (0x80u >> x)) ? 1 : 0;
}
}
}
uint8_t *palette = videoRAM + VIDEO_PALETTE_BASE;
for (int bank = 0; bank < 8; bank++) {
// Colour on black, and then the same colour as paper with black ink, sixteen banks
// apart so that one bit turns either into the other.
memcpy(palette + (bank * 16 + 0) * VIDEO_PALETTE_BYTES, defaultPaper, 3);
memcpy(palette + (bank * 16 + 1) * VIDEO_PALETTE_BYTES, defaultInks[bank], 3);
memcpy(palette + ((bank + 8) * 16 + 0) * VIDEO_PALETTE_BYTES, defaultInks[bank], 3);
memcpy(palette + ((bank + 8) * 16 + 1) * VIDEO_PALETTE_BYTES, defaultPaper, 3);
}
}
void videoPutCell(int screenRow, int column, uint8_t tile, uint8_t attribute) {
if (screenRow < 0 || screenRow >= rowsFor(mode)) return;
if (column < 0 || column >= columnsFor(mode)) return;
// ---- Where the caller means, not where the map begins ----
//
// Both origins, because a caller says "row three, column five OF THE SCREEN" and the
// screen is a window onto the map. The row origin has always been applied here - it is
// what makes the console's scrollback free - and the column origin has to be for the
// same reason, or text lands in the wrong cell the moment anything scrolls sideways.
//
// THE FINE OFFSETS ARE NOT APPLIED and must not be. They move the finished picture by
// less than a cell, and there is no such thing as less than a cell to write into.
const int mapRow = (scroll + screenRow) % VIDEO_MAP_ROWS;
const int mapColumn = (scrollColumn + column) % VIDEO_MAP_COLUMNS;
uint8_t *cell = videoRAM + VIDEO_MAP_BASE + mapRow * VIDEO_MAP_STRIDE
+ mapColumn * VIDEO_CELL_BYTES;
cell[0] = tile;
cell[1] = attribute;
}
void videoScrollUp(void) {
scroll = (uint8_t)((scroll + 1) % VIDEO_MAP_ROWS);
// The row now at the bottom held whatever was there a ring ago, so it is cleared. The
// rows that went off the top are NOT cleared, which is the whole of the scrollback: a
// hundred rows of what has already been said, still sitting in the map.
const int bottom = rowsFor(mode) - 1;
const int mapRow = (scroll + bottom) % VIDEO_MAP_ROWS;
memset(videoRAM + VIDEO_MAP_BASE + mapRow * VIDEO_MAP_STRIDE, 0, VIDEO_MAP_STRIDE);
}
void videoReset(void) {
memset(videoRAM, 0, sizeof(videoRAM));
mode = VIDEO_MODE_40x25;
scroll = 0;
scrollColumn = 0;
fineX = 0;
fineY = 0;
renderedWidth = 0;
renderedHeight = 0;
lastFrame = videoNow;
frameWaiting = 0;
frameInterrupts = 0;
clearInterrupt(PORT_VIDEO);
// A machine wakes up able to show text. Everything here is ordinary video memory that a
// program may overwrite the moment it wants the screen for something else.
videoLoadFont();
}
uint8_t *videoMemory(uint32_t *capacity) {
*capacity = VIDEO_MEMORY_BYTES;
return videoRAM;
}
uint8_t videoWrite(uint8_t value, uint8_t port) {
switch (port) {
case VIDEO_MODE:
// A mode that does not exist is not taken. Refusing outright would be the other
// choice, but a screen is not the place to stop the machine: a program that
// asked for something impossible still has the screen it had.
if (value < VIDEO_MODE_COUNT) {
mode = value;
}
break;
case VIDEO_CONTROL:
frameInterrupts = (value & VIDEO_CONTROL_FRAME) != 0;
if (!frameInterrupts) {
// Asking to stop being interrupted takes down whatever was already asked
// for. A request that outlived the setting that made it would arrive at a
// program which had just said it did not want it - the same reasoning the
// console's interrupt bit is written under.
clearInterrupt(PORT_VIDEO);
}
break;
case VIDEO_SCROLL:
// Wrapped rather than clipped, because the map is a ring and every byte names a
// row that exists.
scroll = (uint8_t)(value % VIDEO_MAP_ROWS);
break;
case VIDEO_SCROLL_COLUMN:
// The same ring the other way. A map row is 256 bytes and a cell is two, so
// there are 128 columns whatever the mode shows.
scrollColumn = (uint8_t)(value % VIDEO_MAP_COLUMNS);
break;
case VIDEO_FINE_X:
// The low three bits and nothing else. Eight is not one cell along, it is zero
// again - see the note by the port numbers about why this does not carry.
fineX = (uint8_t)(value & VIDEO_FINE_MASK);
break;
case VIDEO_FINE_Y:
fineY = (uint8_t)(value & VIDEO_FINE_MASK);
break;
default:
// Everything else is read only or not there yet. Writing does nothing rather
// than refusing: a port block reserved for later should be quiet, not fatal.
break;
}
return 0;
}
uint8_t videoRead(uint8_t port) {
switch (port) {
case VIDEO_STATUS: {
uint8_t status = 0;
if (frameWaiting) {
status |= VIDEO_STATUS_FRAME;
}
if (frameInterrupts) {
status |= VIDEO_STATUS_INTERRUPT;
}
// Looking is what answers it. A frame that has been noticed is not still
// waiting to be, and a program polling in a loop would otherwise see the first
// frame for ever.
//
// The line goes with the flag, and for the stronger reason: a program that polls
// this port is not going to be the one that answers an interrupt, so a line left
// standing here is one nothing will ever take down.
frameWaiting = 0;
clearInterrupt(PORT_VIDEO);
return status;
}
case VIDEO_CONTROL:
// Write only. Everything it sets is reported by the status port, and one fact
// wants one place to live.
return 0;
case VIDEO_MODE: return mode;
case VIDEO_SCROLL_COLUMN: return scrollColumn;
case VIDEO_FINE_X: return fineX;
case VIDEO_FINE_Y: return fineY;
// Asked rather than assumed. A program that wants to know how wide the screen is
// should be able to find out, the same way it asks the console what mode it is in.
case VIDEO_COLUMNS: return (uint8_t)columnsFor(mode);
case VIDEO_ROWS: return (uint8_t)rowsFor(mode);
case VIDEO_SCROLL: return scroll;
default: return 0;
}
}
void videoRender(void) {
if (mode == VIDEO_MODE_BITMAP) {
// ---- A byte a pixel, and nothing in the way ----
//
// No tile to look up and no attribute to add: the byte IS the palette index. Which
// is the whole difference between the two kinds of screen - a tile mode costs the
// CPU the number of cells that changed, and this costs it the number of pixels.
const uint8_t *palette = videoRAM + VIDEO_PALETTE_BASE;
const uint8_t *from = videoRAM + VIDEO_BITMAP_BASE;
uint8_t *out = pixels;
for (int at = 0; at < VIDEO_BITMAP_WIDTH * VIDEO_BITMAP_HEIGHT; at++) {
const uint8_t *entry = palette + from[at] * VIDEO_PALETTE_BYTES;
*out++ = entry[0];
*out++ = entry[1];
*out++ = entry[2];
}
renderedWidth = VIDEO_BITMAP_WIDTH;
renderedHeight = VIDEO_BITMAP_HEIGHT;
return;
}
const int columns = columnsFor(mode);
const int rows = rowsFor(mode);
const int width = columns * VIDEO_CELL_PIXELS;
const int height = rows * VIDEO_CELL_PIXELS;
// ---- One more row and one more column than fit ----
//
// With a fine offset the screen no longer starts on a cell boundary, so the first cell
// of each axis is partly above or left of the picture and one extra is needed at the far
// end to fill what that uncovered. Both are drawn and clipped, which is why every write
// below is guarded rather than trusted: the two edge cells are the only ones that can
// fall outside, but they fall outside on every frame that is not cell aligned.
for (int row = 0; row <= rows; row++) {
// The ring. Rows that scrolled off the top are still in the map, which is what
// makes scrollback free rather than something the console has to keep itself.
const int mapRow = (scroll + row) % VIDEO_MAP_ROWS;
const uint8_t *cells = videoRAM + VIDEO_MAP_BASE + mapRow * VIDEO_MAP_STRIDE;
for (int column = 0; column <= columns; column++) {
const int mapColumn = (scrollColumn + column) % VIDEO_MAP_COLUMNS;
const uint8_t tile = cells[mapColumn * VIDEO_CELL_BYTES];
uint8_t attribute = cells[mapColumn * VIDEO_CELL_BYTES + 1];
// ---- The cursor, turned inside out ----
//
// Not a glyph of its own, because a block drawn over a cell hides what is in it
// and a person editing a line wants to see the character they are standing on.
// XOR 8 swaps a bank for its reverse, which is what the default palette is laid
// out to make possible.
//
// The phase comes from the machine's clock, so a screen saved at a given cycle
// count is the same screen every time.
if (cursorVisible && row == cursorAtRow && column == cursorAtColumn
&& ((videoNow / VIDEO_BLINK_CYCLES) & 1) == 0) {
attribute ^= 0x08;
}
// ---- The additive nibble ----
//
// The low nibble of the attribute is added to every palette index in the tile,
// sixteen at a time. A tile drawn in indices 0 to 15 therefore appears in any
// of sixteen colour schemes without a second copy of it in tile memory, and a
// tile that wants all 256 colours simply leaves the nibble at zero and gets
// them. One adder in hardware, and neither use costs the other anything.
const uint8_t bank = (uint8_t)((attribute & 0x0F) << 4);
const uint8_t *art = videoRAM + VIDEO_TILE_BASE + tile * VIDEO_TILE_BYTES;
for (int y = 0; y < VIDEO_CELL_PIXELS; y++) {
// Where this row of the cell lands once the view has been slid up by the
// fine offset. Negative means it is the part of the top cell that is off
// the screen, which is the whole point of drawing it.
const int atY = row * VIDEO_CELL_PIXELS + y - fineY;
if (atY < 0 || atY >= height) {
continue;
}
for (int x = 0; x < VIDEO_CELL_PIXELS; x++) {
const int atX = column * VIDEO_CELL_PIXELS + x - fineX;
if (atX < 0 || atX >= width) {
continue;
}
// Wrapping, because a byte plus a byte is a byte. A tile using the
// high end of the palette with a nibble set comes round the bottom,
// which is what an adder does and what the manual says it does.
const uint8_t index = (uint8_t)(art[y * VIDEO_CELL_PIXELS + x] + bank);
const uint8_t *entry = videoRAM + VIDEO_PALETTE_BASE
+ index * VIDEO_PALETTE_BYTES;
uint8_t *out = pixels + (atY * width + atX) * 3;
out[0] = entry[0];
out[1] = entry[1];
out[2] = entry[2];
}
}
}
}
renderedWidth = width;
renderedHeight = rows * VIDEO_CELL_PIXELS;
}
const uint8_t *videoPixels(int *width, int *height) {
*width = renderedWidth;
*height = renderedHeight;
return pixels;
}
// A binary PPM, because it is the smallest format that needs no library to write and no
// library to read - which matters when the thing reading it is a test script.
int videoWriteImage(const char *path) {
videoRender();
FILE *file = fopen(path, "wb");
if (file == NULL) {
fprintf(stderr, "Error: Couldn't write the screen to: %s\n", path);
return 1;
}
fprintf(file, "P6\n%d %d\n255\n", renderedWidth, renderedHeight);
size_t bytes = (size_t)renderedWidth * (size_t)renderedHeight * 3;
size_t written = fwrite(pixels, 1, bytes, file);
fclose(file);
if (written != bytes) {
fprintf(stderr, "Error: The screen was not written whole to: %s\n", path);
return 1;
}
return 0;
}
+210
View File
@@ -0,0 +1,210 @@
// video.h
// The Voyager's video device.
// Written by Anachronaut
#ifndef VIDEO_H
#define VIDEO_H
#include <stdint.h>
// ---- What this is ----
//
// A tile engine. The CPU writes cell indices and the device expands them into pixels, which
// is the difference between a screen costing 2,000 bytes a frame and 64,000 - and at a
// megahertz that is the difference between a screen and no screen at all.
//
// It follows that COLOUR DEPTH IS FREE AT FRAME TIME. The map is the same size whether the
// tiles behind it are one bit deep or eight, because the depth lives in tile memory, which
// is written once when a program loads and not sixty times a second. So the tiles are eight
// bits: an 8x8 cell is 64 pixels and each one picks independently out of 256 colours, with
// no per-cell limit of the kind that made a Spectrum two and C64 multicolour four.
//
// ---- The device brings memory ----
//
// One bank, registered the way the disk's buffer is, so it costs a program nothing in Data
// Memory and keeps what is in it between frames. A program blits the region that changed
// and the rest stays as it was, which is the whole reason this is a bank rather than a
// window onto a port.
#define VIDEO_MEMORY_BYTES 0x10000
// Tile memory: 256 tiles of 8x8, one byte a pixel.
#define VIDEO_TILE_BASE 0x0000
#define VIDEO_TILE_BYTES 64
#define VIDEO_TILE_COUNT 256
// ---- The map, one page a row ----
//
// A row is padded to exactly 256 bytes whether the mode uses all of it or not, and that is
// not waste, it is arithmetic. THE MACHINE HAS NO MULTIPLY. On a 40 column screen every
// cursor move would otherwise need row times 40 in software, which is a tax on the most
// common operation in the whole system. At a page a row the address needs no arithmetic at
// all: the row number IS the high byte and the doubled column IS the low byte.
//
// It also frees the geometry from having to be a power of two, which is what lets the
// pixel resolution be whatever looks right.
#define VIDEO_MAP_BASE 0x4000
#define VIDEO_MAP_STRIDE 256
#define VIDEO_MAP_ROWS 128
#define VIDEO_MAP_COLUMNS (VIDEO_MAP_STRIDE / 2)
// Two bytes to a cell: which tile, and how to colour it.
#define VIDEO_CELL_BYTES 2
// ---- A bitmap, over the top of the tiles and the map ----
//
// THE SAME MEMORY MEANING DIFFERENT THINGS IN DIFFERENT MODES, which is what shared video
// memory has always been. There is no room for it to be anywhere else: 320 by 200 at a byte
// a pixel is 64,000 bytes and the whole bank is 65,536, so a bitmap that sat beside the
// tiles rather than on top of them would need a second bank for no reason except tidiness.
//
// What it costs is that the two do not coexist. Going to bitmap mode does not clear the text
// screen; it stops calling it a text screen. Coming back finds the tiles and the map holding
// whatever the picture put there, which is what taking the screen means.
#define VIDEO_BITMAP_BASE 0x0000
#define VIDEO_BITMAP_WIDTH 320
#define VIDEO_BITMAP_HEIGHT 200
// ---- The palette ----
//
// Four bytes an entry rather than three, for the same reason a map row is a page: entry n
// begins at n times four, which is a shift. Three would need a multiply the machine does
// not have. The fourth byte is unused and reads as whatever was put there.
//
// At the TOP of video memory, clear of everything else, because it is the one thing that has
// to mean the same in every mode - a bitmap needs colours as much as a tile does, and 64,000
// bytes of picture leaves nowhere in the middle for it to hide.
#define VIDEO_PALETTE_BASE 0xFC00
#define VIDEO_PALETTE_BYTES 4
#define VIDEO_PALETTE_SIZE 256
// ---- Modes ----
//
// Both are 8x8 cells over the same engine; only how many of them differ. The pixel count
// costs the CPU nothing, because it only ever writes the map - which is why the larger mode
// is affordable at all.
#define VIDEO_MODE_40x25 0
#define VIDEO_MODE_80x50 1
#define VIDEO_MODE_BITMAP 2
#define VIDEO_MODE_COUNT 3
#define VIDEO_CELL_PIXELS 8
#define VIDEO_MAX_WIDTH (80 * VIDEO_CELL_PIXELS)
#define VIDEO_MAX_HEIGHT (50 * VIDEO_CELL_PIXELS)
// How many characters across and down the screen is, and ZERO IN BITMAP MODE, where there is
// no such thing. The console asks, and a console told there are no columns has nowhere to
// put a glyph and does not try.
int videoTextRows(void);
// ---- Ports ----
//
// Sixteen, like the controller, and it interrupts on its base the way the disk established.
// Nothing interrupts yet; the frame interrupt is the next rung.
#define VIDEO_STATUS 0x30
#define VIDEO_MODE 0x31
#define VIDEO_COLUMNS 0x32
#define VIDEO_ROWS 0x33
#define VIDEO_SCROLL 0x34
#define VIDEO_CONTROL 0x35
// ---- The other three quarters of scrolling ----
//
// 0x34 moves the view a whole cell at a time and only downwards, which is a scrolling text
// screen and not a scrolling picture. These are the rest of it: a column origin so the map
// can be wider than the screen as well as taller, and a pixel remainder for each axis so the
// step can be one pixel rather than eight.
//
// COARSE AND FINE DO NOT CARRY INTO EACH OTHER. Fine is the low three bits of what is
// written and nothing else, so a program that scrolls past a cell edge advances the coarse
// register itself. That is what the machines this one is pretending to be did, it keeps each
// register meaning exactly one thing, and it means a program always knows where it is
// without reading anything back off the screen.
#define VIDEO_SCROLL_COLUMN 0x36
#define VIDEO_FINE_X 0x37
#define VIDEO_FINE_Y 0x38
// Eight pixels to a cell, so three bits say where inside one the view begins.
#define VIDEO_FINE_MASK 0x07
// ---- The frame ----
//
// A screen finishes drawing sixty times a second and then has a moment before it starts
// again, and that moment is the one safe time to change what it is drawing. It is also the
// only regular beat this machine has: there is no clock, and every program that wanted to
// happen at a certain speed has until now counted instructions and hoped.
//
// Sixty a second at a megahertz. On the MACHINE'S clock rather than the host's, so a program
// runs the same number of frames in the same number of cycles however fast anything really
// went - which is what makes a frame something a test can count.
#define VIDEO_FRAME_CYCLES 16667
// Set when a frame has gone by, and cleared by reading the status port. A program with no
// handler installed can wait on this instead, the way a program can poll the console rather
// than being interrupted by it.
#define VIDEO_STATUS_FRAME 0x01
// Whether the screen is set to interrupt, so that a program can ask what it asked for.
#define VIDEO_STATUS_INTERRUPT 0x02
// Asks to be interrupted at each frame, on hardware vector 0x30. OFF WHEN THE MACHINE
// STARTS, because an interrupt with nothing installed to catch it is a fault, and a machine
// that began interrupting the moment it was switched on would take any program that had not
// thought about frames down with it.
#define VIDEO_CONTROL_FRAME 0x01
void videoReset(void);
// ---- What the console needs to draw with ----
//
// The Voyager's console is a display controller: it takes a byte stream and puts glyphs on
// the screen, the way a video terminal's character generator does. That is a real kind of
// chip rather than an emulator convenience - but it does mean the console and a program
// drawing graphics are writing one screen, because a machine has one screen.
//
// The font is expanded into tile memory at reset rather than stored expanded: 1,088 bytes
// of one-bit rows against 16 kilobytes of tiles.
void videoLoadFont(void);
// ---- The cursor ----
//
// Drawn by the device rather than by whatever is presenting, because on a machine with a
// screen the cursor IS a hardware feature - a display controller blinks it from a counter,
// and one drawn by the window would not be in a picture the machine saved.
//
// It blinks on the machine's own clock, so the phase is a pure function of the cycle count
// and a screen saved at a given cycle is the same screen every time.
#define VIDEO_BLINK_CYCLES 500000
void videoSetCursor(int row, int column, int visible);
// The machine's clock, for anything that has to know time has passed.
void videoTick(unsigned long now);
int videoColumns(void);
int videoRows(void);
// Screen coordinates, not map coordinates. The ring is the device's business, and a caller
// that had to know where the origin was would have to be told every time it moved.
void videoPutCell(int screenRow, int column, uint8_t tile, uint8_t attribute);
// Moves the origin on by a row and clears the one that has just come into view at the
// bottom - which is holding whatever was there 128 rows ago, since the map is a ring.
void videoScrollUp(void);
uint8_t *videoMemory(uint32_t *capacity);
uint8_t videoWrite(uint8_t value, uint8_t port);
uint8_t videoRead(uint8_t port);
// Turns what is in video memory into pixels. A pure function of that memory, so the same
// contents give the same picture with nobody watching - which is what lets the suite check
// a screen on a machine that has no display.
void videoRender(void);
// The pixels the last render produced, three bytes each, red then green then blue.
const uint8_t *videoPixels(int *width, int *height);
// Renders and writes a binary PPM. Returns 0 if it worked.
int videoWriteImage(const char *path);
#endif // VIDEO_H
+445
View File
@@ -0,0 +1,445 @@
// 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 "sound.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;
// ---- Keys are kept until they are asked for ----
//
// RAYLIB CLEARS ITS CHARACTER QUEUE ON EVERY POLL, and a poll happens inside EndDrawing, so
// a key survives exactly one frame unless something takes it. That is fine for a game that
// reads input every frame and wrong for everything else: Snake looks about ten times a
// second, so five keys in six were being thrown away by the next present before it ever
// glanced at them. The shell worked the whole time, because a blocking read presents and
// then looks immediately.
//
// So the window keeps its own queue, drained from Raylib at every present and emptied only
// when the console actually takes a byte. That is what the machine already promises - Snake's
// own comment says "the console keeps the next key until it is asked for" - and it makes the
// console's timing nobody else's business.
#define KEY_QUEUE 64
static unsigned char keyQueue[KEY_QUEUE];
static int keyHead = 0;
static int keyTail = 0;
static void keyPush(unsigned char byte) {
const int next = (keyTail + 1) % KEY_QUEUE;
if (next == keyHead) {
// Full, so the oldest goes. Somebody leaning on the keyboard while a program ignores
// it should not be able to push out what they typed most recently.
keyHead = (keyHead + 1) % KEY_QUEUE;
}
keyQueue[keyTail] = byte;
keyTail = next;
}
static int keyTake(void) {
if (keyHead == keyTail) {
return CONSOLE_NOTHING_YET;
}
const int byte = keyQueue[keyHead];
keyHead = (keyHead + 1) % KEY_QUEUE;
return byte;
}
// Everything Raylib has, taken before it can throw any of it away.
static void drainKeyboard(void) {
int character;
while ((character = GetCharPressed()) > 0) {
if (character < 128) {
keyPush((unsigned char)character);
}
}
int key;
while ((key = GetKeyPressed()) > 0) {
// Only the keys a character queue does not carry, because they are not characters.
// Everything else has already arrived above, and taking it again would double it.
switch (key) {
case KEY_ENTER: case KEY_KP_ENTER: keyPush('\n'); break;
case KEY_BACKSPACE: keyPush(0x08); break;
case KEY_TAB: keyPush('\t'); break;
case KEY_ESCAPE: keyPush(0x1B); break;
// ---- And the keys that are not characters at all ----
//
// These used to fall through the default below and vanish, because there was no
// byte to turn them into. There is now, and it is the console's rather than
// this window's - a terminal reaches the same values by a different road, and a
// program is entitled not to know which of the two it is talking to.
case KEY_UP: keyPush(CONSOLE_KEY_UP); break;
case KEY_DOWN: keyPush(CONSOLE_KEY_DOWN); break;
case KEY_LEFT: keyPush(CONSOLE_KEY_LEFT); break;
case KEY_RIGHT: keyPush(CONSOLE_KEY_RIGHT); break;
case KEY_HOME: keyPush(CONSOLE_KEY_HOME); break;
case KEY_END: keyPush(CONSOLE_KEY_END); break;
case KEY_DELETE: keyPush(CONSOLE_KEY_DELETE); break;
default: break;
}
}
}
// ---- The reset button ----
//
// 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 pokes the same flag a program pokes
// through the machine port. When those are designed, a keyboard controller will have to see
// this gesture and pull reset REGARDLESS OF WHAT THE CPU IS DOING - which is the property
// that matters and the one a port write cannot have, since a port write needs a program
// willing and able to make it.
//
// The shape of that is already visible here. A reset is normally noticed between
// instructions, and a halted machine runs none - so the window asks every frame rather than
// leaving it to the machine to notice, which is what real hardware would do with a line.
//
// ON REAL HARDWARE THIS 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. So the gesture wants two properties a single
// key does not have.
//
// It must not be a key SOFTWARE MIGHT WANT. A machine with a keyboard has function keys on
// it, and something will eventually have a use for F12 - which is where this was, and which
// would have meant taking it away again later.
//
// And it must not be reachable BY ACCIDENT. Restarting the machine throws away everything in
// memory, and a single key that does that sits one mistake away from losing work. Three keys
// together are not pressed by mistake.
//
// Control, Shift and R. It was Control, Alt and Delete, which has meant this since 1981 and
// is the one gesture nobody has to be told the meaning of - AND WHICH CANNOT BE USED.
//
// It is a secure attention key. Every serious operating system reserves it so that it always
// reaches the system and never an application, precisely so that a program cannot imitate a
// login screen; on Windows an application cannot see it at all without a kernel driver, and
// on Linux the desktop takes it. That is not an oversight to work around - it is the same
// guarantee a reset button wants, being enforced one layer further down, and there is no
// call this program can make that would win the argument.
//
// So the gesture has to be one the host has no opinion about. Control and Shift with a
// letter is about as free as a combination gets: it is not window management, not a virtual
// terminal switch, and not a shortcut any desktop claims by default.
//
// If a platform does send a character for it, nothing comes of that either - whatever
// arrives is in memory that is about to be thrown away.
//
// What it does is what writing MACHINE_RESET does: the machine starts the way it started, so
// the boot chain runs again and finds whatever the disk now says to run. Which is what makes
// a bare metal program escapable - Once puts a demo in front of the next start and deletes
// the request before jumping, so a demo that has taken the whole machine is one gesture from
// the system coming back, rather than closing the window and opening it again.
static void checkResetButton(void) {
const int control = IsKeyDown(KEY_LEFT_CONTROL) || IsKeyDown(KEY_RIGHT_CONTROL);
const int shift = IsKeyDown(KEY_LEFT_SHIFT) || IsKeyDown(KEY_RIGHT_SHIFT);
if (control && shift && IsKeyPressed(KEY_R)) {
requestReset();
}
}
// ---- The speaker ----
//
// The device makes its samples on emulated cycles and puts them in a ring; this takes them
// out and hands them to Raylib. Nothing here decides what a sound is, the same as nothing in
// presentFrame decides what the screen looks like - which is why the headless binary and this
// one make the same sound, and why Tests/sound.sh can check a device with no speaker in it.
//
// A sub-buffer at a time, because that is the unit Raylib hands back when it has finished
// playing one. At 48,000 a second and sixty frames, a frame of machine time is 800 samples,
// so 1,024 is a little over one and there are two of them.
#define SOUND_BUFFER 1024
static AudioStream soundStream;
static int speakerOn;
// ---- When the machine cannot keep up, and when it runs away ----
//
// Both directions happen and neither should be a crash. The machine runs a slice per frame
// against the wall clock, so a host that stalls leaves the ring short and a host running
// --fast fills it faster than anything can play it. The device drops when full, which is the
// runaway case. This is the other one: what is missing is filled by HOLDING THE LAST SAMPLE
// rather than by zeroes, because a jump to silence and back is a click and a held level is
// not. It is still a glitch; it is the quieter kind.
static void feedSpeaker(void) {
static int16_t buffer[SOUND_BUFFER];
static int16_t lastSample;
while (IsAudioStreamProcessed(soundStream)) {
const int taken = soundTake(buffer, SOUND_BUFFER);
if (taken > 0) {
lastSample = buffer[taken - 1];
}
for (int i = taken; i < SOUND_BUFFER; i++) {
buffer[i] = lastSample;
}
UpdateAudioStream(soundStream, buffer, SOUND_BUFFER);
}
}
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();
// EndDrawing has just polled, which is the one moment Raylib's queues hold anything.
drainKeyboard();
checkResetButton();
}
// 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;
}
// ---- The button has to reach a machine that is waiting ----
//
// A reset is acted on between instructions, and a machine blocked on a key is part way
// through one - so pressing the button while a program sits waiting would set the flag
// and nothing would ever come along to notice it. Which is precisely the moment a reset
// button earns its keep: a program that is stuck is the one you want to get out of.
//
// So the wait ends. The console treats that as the end of input, which it is for the
// machine that is about to stop existing, and the reset puts the console's input back.
//
// ASKED OF THE REQUEST ITSELF rather than remembered here. A flag of its own outlived
// the reset it belonged to: a program that never read the console - picture.bin, say,
// which draws and halts - left it set, and the NEXT machine's first read came back as
// the end of input. CosmOS booted and stopped immediately, having been told there was
// nobody there. There is one fact and it lives in one place.
if (resetIsPending()) {
return CONSOLE_GONE;
}
// Whatever is already waiting, however long ago it was typed. This is the answer to
// both questions, and asking it first is what makes a program that polls rarely see
// every key rather than one in six.
const int waiting = keyTake();
if (waiting != CONSOLE_NOTHING_YET) {
return waiting;
}
if (!mayWait) {
// A poll is a poll. Presenting here would charge a frame for every glance, and a
// program that looks in a loop would run at the frame rate.
return CONSOLE_NOTHING_YET;
}
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. It drains the
// keyboard on the way out, so anything just typed is here now.
presentFrame();
return keyTake();
}
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.
//
// And presented in step with the display. Without the hint the frame limiter sleeps
// towards sixty a second on its own clock, which beats against a screen refreshing on
// its own - some frames shown twice, some skipped, and the machine handed an uneven
// number of cycles each time because it takes them from the wall clock. The target
// stays as well, for a driver that ignores the hint.
SetConfigFlags(FLAG_WINDOW_RESIZABLE | FLAG_VSYNC_HINT);
InitWindow(VIDEO_MAX_WIDTH * SCREEN_SCALE, VIDEO_MAX_HEIGHT * SCREEN_SCALE,
"Segan Voyager");
SetTargetFPS(60);
// ---- Escape is a byte, not a way out ----
//
// 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 - which is a poor way
// to find out that a default was left as it was found.
SetExitKey(KEY_NULL);
// ---- And a speaker, if the host has one ----
//
// Asked for rather than assumed: a machine with no audio device is a perfectly good
// machine to look at, and a front end that refused to start without one would make
// the window depend on something the picture does not need.
InitAudioDevice();
if (IsAudioDeviceReady()) {
SetAudioStreamBufferSizeDefault(SOUND_BUFFER);
soundStream = LoadAudioStream(SOUND_SAMPLE_RATE, 16, 1);
PlayAudioStream(soundStream);
speakerOn = 1;
}
// 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.
while (windowOpen && !WindowShouldClose()) {
// Before the running check, not after it: a machine that has stopped is the one
// worth restarting, and it is the one that cannot notice a reset by itself.
machineTakeReset(&machine);
if (machineRunning(&machine)) {
machineRunSlice(&machine);
}
presentFrame();
// After the slice, so what the machine just made is what gets played.
if (speakerOn) {
feedSpeaker();
}
}
windowOpen = 0;
// Taken back before the machine stops, so nothing can ask a window that has gone.
consoleSetInputHook(NULL);
if (speakerOn) {
UnloadAudioStream(soundStream);
speakerOn = 0;
}
CloseAudioDevice();
UnloadTexture(screenTexture);
CloseWindow();
}
machineStop(&machine);
return machineReport(&machine);
}
+1 -1
View File
@@ -436,7 +436,7 @@ $(BUILD)/%.bin: %.asm
-include $(BINARIES:.bin=.d) -include $(BINARIES:.bin=.d)
``` ```
Programs/makefile in this repository builds every program that way, if you would like a longer example to copy. The makefile in this repository builds every program that way, if you would like a longer example to copy.
## An Example SplitBit Assembly Program: ## An Example SplitBit Assembly Program:
+593 -7
View File
@@ -204,6 +204,9 @@ waitForDisk:
ready: ready:
``` ```
Notice that on a device quick enough to finish before the first look, the `WAIT` in that loop
never runs at all. That is fine, and it is why the next section exists.
Test the device, then wait. If the device finishes in the gap between the two, its line is Test the device, then wait. If the device finishes in the gap between the two, its line is
standing when WAIT runs and the wait is skipped rather than slept through. standing when WAIT runs and the wait is skipped rather than slept through.
@@ -261,13 +264,16 @@ Nothing in that program names a Data Pointer, so all of it runs through Data Poi
## The Console: ## The Console:
Port 0x00 is the oldest thing on this machine and it has not changed: writing sends a byte out, reading takes one in and waits until there is one. Every program ever written for SplitBit uses it that way and still does. What is new is that a program can say what it wants a keypress to mean, can ask whether a read would have to wait, and can arrange to be told when a byte arrives instead of having to ask at all. Port 0x00 is the oldest thing on this machine and it has not changed: writing sends a byte out, reading takes one in and waits until there is one.
**On a machine with a screen, the console draws.** It is a display controller as well as a port: it owns a font, keeps a cursor, and scrolls - which is what a video terminal's character generator did, and is why a program written before there was a screen puts text on one without being changed. See Writing On The Screen below.
Every program ever written for SplitBit uses it that way and still does. What is new is that a program can say what it wants a keypress to mean, can ask whether a read would have to wait, and can arrange to be told when a byte arrives instead of having to ask at all.
| Port | Register | | Port | Register |
| --- | --- | | --- | --- |
| 0x00 | Data. Writing sends a byte out, reading takes one in and waits for it. | | 0x00 | Data. Writing sends a byte out, reading takes one in and waits for it. |
| 0x01 | Status. Bit 0 a byte is waiting, bit 1 input has ended, bit 2 the console is in key mode, bit 3 the console is set to interrupt. | | 0x01 | Status. Bit 0 a byte is waiting, bit 1 input has ended, bit 2 the console is in key mode, bit 3 the console is set to interrupt, bit 4 a cursor is being shown. |
| 0x02 | Control. Bit 0 asks for key mode, bit 1 asks the console to interrupt when a byte arrives. Writing 0x00 asks for neither, which is how the console starts. | | 0x02 | Control. Bit 0 asks for key mode, bit 1 asks the console to interrupt when a byte arrives, bit 2 asks for a cursor. Writing 0x00 asks for none of them, which is how the console starts. |
The control port's two bits are independent, and one write sets both. Everything the control port can ask for, the status port reports, so a program can put the console back the way it found it instead of assuming it knows. The control port's two bits are independent, and one write sets both. Everything the control port can ask for, the status port reports, so a program can put the console back the way it found it instead of assuming it knows.
@@ -275,10 +281,36 @@ The control port's two bits are independent, and one write sets both. Everything
In **line mode**, which is how the machine starts, the terminal holds what is typed until Return and does the echoing and the backspacing on the way. A program reading the data port gets a finished line, one byte at a time. This is what the machine has always done and what a shell wants. In **line mode**, which is how the machine starts, the terminal holds what is typed until Return and does the echoing and the backspacing on the way. A program reading the data port gets a finished line, one byte at a time. This is what the machine has always done and what a shell wants.
In **key mode** the terminal stops holding the line. Keys arrive as they are pressed, and nothing echoes them, so a program that wants them seen has to send them back out itself. The editing goes with the echo: there is no backspace, because backspace was the terminal's doing and the terminal is no longer involved. That is not a choice this machine makes, it is what asking for keys means, and a program that wants keys is expected to want it. In **key mode** the terminal stops holding the line. Keys arrive as they are pressed, and nothing echoes them, so a program that wants them seen has to send them back out itself. This is also the only mode in which the keys that are not characters arrive at all - see below. The editing goes with the echo: there is no backspace, because backspace was the terminal's doing and the terminal is no longer involved. That is not a choice this machine makes, it is what asking for keys means, and a program that wants keys is expected to want it.
A program is expected to put the console back in line mode before it finishes. CosmOS also does it whenever a program returns, because a program that stops early would otherwise hand back a shell with no echo, and a shell has no way to find out that happened. A program is expected to put the console back in line mode before it finishes. CosmOS also does it whenever a program returns, because a program that stops early would otherwise hand back a shell with no echo, and a shell has no way to find out that happened.
### Keys That Are Not Characters:
An arrow key is not a letter, and for a long time there was no byte for one, so it did not reach this machine at all: a window threw it away for want of anywhere to put it, and a terminal sent an escape sequence which arrived in the middle of whatever was being read and made it unrecognisable.
The console names them now. Each arrives as one byte, above ASCII so that nothing written before them can collide:
| Byte | Key |
| --- | --- |
| 0x80 | Up |
| 0x81 | Down |
| 0x82 | Left |
| 0x83 | Right |
| 0x84 | Home |
| 0x85 | End |
| 0x86 | Delete, meaning the character under the cursor |
Backspace is 0x08 and always has been. It is a different key from Delete and does a different thing, which is why they are two values and not one.
0x80 to 0x8F belong to the console, so a program can tell a key from a character by testing that range. The values above 0x86 are not used yet.
**The console normalises, which is what it has always done.** Behind a window it turns the key somebody pressed into a byte; on a terminal it turns `ESC [ A` and its neighbours into the same byte. That is the same act it performs on Return and Backspace, and it is why a program does not have to know which of the two it is talking to. The translation happens only when there really is a terminal: a file or a pipe holds exactly the bytes somebody put in it, and a program reading one gets those bytes untouched - which is also how a test presses an arrow key.
**These arrive in key mode only.** Line mode delivers characters, and a program in line mode is being handed a line that something else has already finished editing, so a key meaning "move the cursor left" arrived too late to mean anything. The console drops them there. This is what a terminal does too: it has always given a program in line mode backspace and line kill, and has never given it arrow keys.
**What a key means is not the console's business.** Where the cursor goes, what the line looks like afterwards and what was typed before are all decisions, and decisions belong to whatever is reading - which on this machine is usually CosmOS, whose shell edits its own line. The console says which key was pressed and stops there, exactly as the disk says what a drive is and says nothing about what should be on it.
### Reading Without Waiting: ### Reading Without Waiting:
Reading the data port waits in **both** modes. The status port is how a program declines to wait, and keeping that in one place is deliberate: a read that sometimes blocked and sometimes did not, depending on a mode set somewhere else, would be a program that works until it does not. Reading the data port waits in **both** modes. The status port is how a program declines to wait, and keeping that in one place is deliberate: a read that sometimes blocked and sometimes did not, depending on a mode set somewhere else, would be a program that works until it does not.
@@ -387,6 +419,40 @@ One last thing. An interrupt arriving while the Stack Pointer is somewhere unusu
# When Something Else Wants Attention # When Something Else Wants Attention
## Answering A Line:
A device raises its line when it has something to say, and something has to take it down
again. There are three things that do, and between them they cover every way a program can
find out that a device is finished.
**Being interrupted takes it down.** The dispatch does it, before the handler runs, which is
why a handler does not have to and why a handler that forgets does not spin.
**Being woken from `WAIT` with the Interrupt Flag down takes it down**, because nobody else is
going to. A masked program has nowhere to dispatch to, and a line left standing would be found
by the next `WAIT`, and the one after that, and the program would spin exactly as it did
before while appearing to sleep.
**And reading the port that answers the device takes it down.** For the console that is the
data port, because taking the byte is what answers the console. For the disk and the screen it
is the status port: the operation finished, and whether it worked is what Status is for.
That third one is the one to have in mind, because without it the loop above has a hole in it.
A program that polls, finds the device already done and never reaches its `WAIT` has used none
of the first two. The line stands - and it stands for the rest of the machine's life, because
nothing is ever going to come along and answer it.
**What that costs is not paid by the program that leaves it.** That program never set the
Interrupt Flag; it was masked throughout. The bill arrives later, at whoever does. The boot
chain reads the disk to load a program, leaves the line up, and hands over - and the loaded
program is interrupted on behalf of a read that finished before it existed, through a vector
table that has no entry for a device it never touched. It faults on the instruction after its
`SIF`. `Programs/Examples/tune.asm` is how this was found: run through `Once`, it set up its
whole sound and then died four bytes before playing a note.
So: a status read is an acknowledgement, and a program that wants to be interrupted by a
device should not poll it.
## Interrupts: ## Interrupts:
An interrupt is an involuntary transfer of control. A subroutine call is agreed to by the code that makes it, so CALL can leave Q and Data Pointer 3 alone and let a subroutine pass results back through them. An interrupt arrives in code that has never heard of it, where Q and DP3 are ordinary working registers, so it saves everything: An interrupt is an involuntary transfer of control. A subroutine call is agreed to by the code that makes it, so CALL can leave Q and Data Pointer 3 alone and let a subroutine pass results back through them. An interrupt arrives in code that has never heard of it, where Q and DP3 are ordinary working registers, so it saves everything:
@@ -503,15 +569,409 @@ If nothing is installed for the vector a device refused with, the machine stops
| Port | Device | Class | | Port | Device | Class |
| --- | --- | --- | | --- | --- | --- |
| 0x00 - 0x02 | The console. See The Console. Writing to 0x00 sends a byte to standard output, reading takes one from standard input. It interrupts on 0x00, its base port, when asked to. | 0x02 | | 0x00 - 0x05 | The console. See The Console. Writing to 0x00 sends a byte to standard output, reading takes one from standard input. It interrupts on 0x00, its base port, when asked to. | 0x02 |
| 0x10 | A test device. Writing anything to it puts its own line up, so that interrupt handling can be exercised without waiting on anything. The byte written is ignored. | 0x10 | | 0x10 | A test device. Writing anything to it puts its own line up, so that interrupt handling can be exercised without waiting on anything. The byte written is ignored. | 0x10 |
| 0x11 | A device that refuses everything, in both directions, so that refusal can be exercised without the memory controller. | 0x11 | | 0x11 | A device that refuses everything, in both directions, so that refusal can be exercised without the memory controller. | 0x11 |
| 0x20 - 0x23 | The disk. See Storage. It interrupts on 0x20, its base port. | 0x13 | | 0x20 - 0x28 | The disk. See Storage. It interrupts on 0x20, its base port. | 0x13 |
| 0x13 | The machine itself. Writing 1 asks it to start over: whatever put the first instruction in memory does it again, and the CPU begins where the boot vector points. A port rather than a service, because a reset has to work when the system does not - and a program that owns the whole machine has no system to ask. The disk is not unplugged and keeps what was written to it; the vector table is cleared, because a handler left behind would aim an interrupt into a program that is no longer running. | 0x04 | | 0x13 | The machine itself. Writing 1 asks it to start over: whatever put the first instruction in memory does it again, and the CPU begins where the boot vector points. A port rather than a service, because a reset has to work when the system does not - and a program that owns the whole machine has no system to ask. The disk is not unplugged and keeps what was written to it; the vector table is cleared, because a handler left behind would aim an interrupt into a program that is no longer running. | 0x04 |
| 0x12 | A device that owns 256 bytes of memory. Writing to its port fills that memory with the byte written, standing in for a disk controller reading a sector. Its memory is unreachable until it is registered as a bank. | 0x12 | | 0x12 | A device that owns 256 bytes of memory. Writing to its port fills that memory with the byte written, standing in for a disk controller reading a sector. Its memory is unreachable until it is registered as a bank. | 0x12 |
| 0x30 - 0x3F | The screen. See The Screen. It brings video memory, which is unreachable until it is registered as a bank. | 0x14 |
| 0x40 - 0x4F | The sound device. See Making A Noise. Four channels, played by writing to ports; it brings no memory. | 0x15 |
| 0x50 - 0x54 | The timer. See Keeping Time. Counts the machine's cycles and says when a period has gone by. | 0x16 |
| 0xE0 - 0xEF | The memory controller. See The Memory Controller. | 0x03 | | 0xE0 - 0xEF | The memory controller. See The Memory Controller. | 0x03 |
| 0xFF | The bus registry. See Asking What Is There. | 0x01 | | 0xFF | The bus registry. See Asking What Is There. | 0x01 |
## The Screen:
A tile engine, on ports 0x30 to 0x3F. The CPU writes cell indices and the device turns them into pixels.
That indirection is the whole reason a screen is affordable here. At a megahertz a frame is 16,667 cycles, and pushing a full 320 by 200 picture a byte at a time is 64,000 bytes - four frames of work for one frame of screen. A 40 by 25 map is 2,000 bytes, and a program that changes two cells writes four. **The cost of a screen becomes the number of cells that changed rather than the number of pixels on it.**
It follows that colour depth is free. The map is the same size whatever is behind it, so the tiles are eight bits deep: an 8 by 8 cell is 64 pixels and each one picks independently out of 256 colours. There is no limit of two to a cell, or four, or sixteen.
### Video Memory:
One bank, brought by the device and reached only through the memory controller, like the disk's buffer. It keeps what is in it between frames, so a program writes the part that changed and the rest stays as it was.
| Address | Holds |
| --- | --- |
| 0x0000 - 0x3FFF | Tile memory. 256 tiles of 8 by 8, one byte a pixel, so tile n begins at n times 64. |
| 0x4000 - 0xBFFF | The map. 128 rows of 256 bytes. |
| 0x0000 - 0xF9FF | In bitmap mode, the picture instead: 64,000 bytes, one to a pixel. |
| 0xFC00 - 0xFFFF | The palette. 256 entries of four bytes: red, green, blue, and one unused. |
**The bitmap is the same memory as the tiles and the map**, which is what shared video memory has always been, and there is nowhere else it could be: 64,000 bytes of picture in a 65,536 byte bank leaves room for nothing beside it. Going to bitmap mode does not clear the text screen - it stops calling it one, and coming back finds the tiles and the map holding whatever the picture put there.
The palette is at the top, out of the way of both, because it is the one thing that means the same in every mode.
**A map row is a page whether the mode fills it or not**, and that is arithmetic rather than waste. This machine has no multiply, so on a 40 column screen every cursor move would otherwise cost a `row times 40` in software - a tax on the most common operation in the system. At a page a row there is no arithmetic at all: the row number is the high byte of the address and the doubled column is the low byte.
A palette entry is four bytes for the same reason. Entry n begins at n times four, which is a shift; three bytes would need a multiply.
### Cells:
Two bytes. The first says which tile, the second how to colour it.
The low nibble of the second byte is **added to every palette index in the tile, sixteen at a time**. A tile drawn in indices 0 to 15 therefore appears in any of sixteen colour schemes without a second copy of it in tile memory. A tile that wants all 256 colours leaves the nibble at zero and gets them. The addition wraps, because a byte plus a byte is a byte.
The high nibble is reserved and should be left at zero, so that a meaning can be given to it later without changing what already-written programs mean.
### Registers:
| Port | Register |
| --- | --- |
| 0x30 | Status. Bit 0 a frame has gone by, bit 1 the screen is set to interrupt. |
| 0x31 | Mode. |
| 0x32 | Columns, read only. |
| 0x33 | Rows, read only. |
| 0x34 | Scroll row. Which of the map's 128 rows is drawn at the top. |
| 0x35 | Control. Bit 0 asks to be interrupted at each frame. |
| 0x36 | Scroll column. Which of the map's 128 columns is drawn at the left. |
| 0x37 | Fine X. How many pixels into that column the screen begins, 0 to 7. |
| 0x38 | Fine Y. How many pixels into that row the screen begins, 0 to 7. |
| Mode | Screen | Cells |
| --- | --- | --- |
| 0 | 320 by 200 | 40 by 25 |
| 1 | 640 by 400 | 80 by 50 |
| 2 | 320 by 200 | none: a byte a pixel |
The first two are 8 by 8 cells over the same engine, and the pixel count costs a program nothing, because it only ever writes the map.
**Mode 2 is the other kind of screen**, where a byte is a palette index and there is no tile to look it up in and no attribute to add. What it costs is the other way round: a whole picture is 64,000 bytes, four frames of work at a megahertz, so it is the mode to draw in and leave alone or to change a corner of, not the mode to animate all of. `Programs/Examples/picture.asm` fills one in 127 bytes of program.
**A bitmap has no columns and no rows**, and asking says so: both registers read zero, which is the true answer rather than a leftover from the last mode. The console asks, and a console told there is no character screen has nowhere to put a glyph and draws nothing - it still says everything down the serial line. The alternative is what a machine with shared video memory really does, which is scribble marks nobody can read across somebody's picture. A mode that does not exist is not taken, and is not a fault either: a screen is a poor place to stop the machine, and a program that asked for something impossible still has the screen it had.
How big the screen is, is asked for rather than assumed. A program written once can find out what it is running on.
### The Frame:
A screen finishes drawing sixty times a second and then has a moment before it starts again. That moment is the one safe time to change what it is drawing - and it is also **the only regular beat this machine has.** There is no clock here. Every program that wanted to happen at a certain speed has until now counted instructions and hoped, which is why Snake's pause quietly halved the day a cycle stopped being an instruction and became a memory access.
Sixty a second, counted in the machine's own cycles rather than the host's. So a program sees the same number of frames in the same number of cycles however fast anything really ran, which is what makes a frame something a test can count and a recorded result can contain.
**Status bit 0 goes up when a frame has gone by, and reading the status port puts it down.** Looking is what answers it: a frame that has been noticed is not still waiting to be noticed, and a program polling in a loop would otherwise see the first frame for ever.
**Control bit 0 asks to be interrupted instead**, on hardware vector 0x30, which is the screen's base port. It is **off when the machine starts**, and that is not caution for its own sake: an interrupt with nothing installed to catch it is a fault, so a screen that began interrupting the moment it was switched on would take down every program written before frames existed. Asking to stop takes down any request already standing, for the same reason the console's interrupt bit does.
More than one frame can go by between two looks - the machine runs in batches, and a slow host covers several at once. The flag and the line are each one thing, so several frames still mean one of each. **A missed frame is missed**, which is what missing one means.
This is what `WAIT` was built for. A program does its work, waits, and is woken:
```
INIA 0x01
OUTA 0x35 ; Interrupt me at each frame
SIF
loop:
; ... draw ...
WAIT ; Nothing to do until the screen says so
BRI loop
```
A machine doing that is asleep between frames rather than spinning, and the difference is visible: the cycles it spent are counted as idle rather than as bus, so a program that waited properly and one that polled in a loop can be told apart even though they print the same thing and take the same time.
### Scrolling:
**The map is a ring, and the Scroll register says which of its 128 rows is drawn at the top.** Screen row *r* shows map row *scroll + r*, wrapped.
Scrolling therefore moves a register and no memory at all. That is not a small saving. Moving a 40 by 25 screen up one line is 1,920 bytes inside one bank, which is 1,920 cycles even with the controller widened - twelve percent of a frame, for one line. A program printing a single page would spend six frames shuffling memory. Here it is one write to a port.
And the rows that scrolled off are still in the map, which is where a terminal on this machine gets scrollback without having to keep any.
**The columns are the same ring the other way.** A map row is 256 bytes and a cell is two, so there are 128 of them whatever the mode shows - 88 more than a 40 column screen displays, and 48 more than an 80. Scroll column says which one is at the left, and screen column *c* shows map column *scroll column + c*, wrapped. A map wider than the screen costs nothing to have, because the map is that wide already.
### Scrolling By Less Than A Cell:
The two registers above move the view a whole cell at a time, which is a scrolling text screen rather than a scrolling picture: eight pixels is a long way to jump sixty times a second. **Fine X and Fine Y are the remainder** - how far into the cell at the origin the screen actually starts. Together the four registers place the view anywhere in the map to the pixel.
The screen no longer begins on a cell boundary when a fine register is not zero, so the cells at two edges are partly off it. That is the device's problem and not a program's: it draws one more row and one more column than fit and clips them.
**Fine does not carry into coarse.** Writing 8 to a fine register is writing 0, because only the low three bits of it mean anything - it is not one cell along. A program scrolling past a cell edge advances the coarse register itself:
```asm
; One pixel to the left, carrying when it runs out of cell.
SETD.0 FineX
LDA.0
INCA
INIB 0x07
AND
STQ.0
BNQ scrolled ; Still inside the cell.
SETD.0 CoarseX
LDA.0
INCA
STA.0
OUTA 0x36
scrolled:
```
The alternative was to let a write of 8 step the column and set the fine part to zero, and it was rejected for one reason: a program that scrolls has to know where it has got to, and if the hardware carries then the only way to find out is to read the register back. Keeping them apart means the program already knows, because it did the arithmetic.
**The fine registers move the picture and nothing else.** Writing a character still lands in a whole cell, because there is no such thing as less than a cell to write into - so a program may scroll to any pixel and the console's idea of where row three, column five is does not move underneath it. The coarse registers are the ones the console follows, and it has always followed the row.
**None of the four does anything in bitmap mode**, which has no map to slide.
### Writing On The Screen:
A console on a machine with a screen sends every byte to both, because a machine with a screen and a serial line is an ordinary machine and there is one console driving both.
At reset the font is expanded into tile memory and the palette is given sixteen ink and paper pairs. See Colour below.
The font is in ASCII order, so a byte becomes a glyph by subtracting 32. Bytes below that have no glyph and are not drawn; three of them do something instead.
| Byte | Does |
| --- | --- |
| 0x0A | Newline. The cursor goes to the start of the next row, and at the last row the screen scrolls instead. |
| 0x0D | Carriage return. The cursor goes to the start of the row it is on. |
| 0x08 | Backspace. The cursor steps back and rubs out what was there. |
Writing past the last column wraps to the next row, the same as a newline.
### Colour:
A glyph is drawn in palette indices 0 and 1 - paper and ink - and a cell's attribute nibble adds sixteen to both. **So sixteen banks is sixteen ink and paper pairs**, and a text attribute system costs one nibble and no hardware at all.
Which pair the console draws in is the Attribute register, 0x06. Everything written after it is drawn that way, until it changes.
The palette a machine wakes up with is arranged so that **highlighting is one bit**:
| Attribute | Paper | Ink |
| --- | --- | --- |
| 0 | Black | Grey |
| 1 to 7 | Black | Red, green, yellow, blue, magenta, cyan, white |
| 8 to 15 | The same seven and grey | Black |
So `attribute XOR 8` turns any pair inside out, which is what a highlighted line wants and how the cursor is drawn. Bank 0 is grey on black, which is what plain text has always been.
**That arrangement is a convention rather than a rule of the machine.** A program that wants different colours writes its own palette, and one that wants thirty-two of something rather than sixteen pairs can have that too - the device only ever adds the nibble and looks the answer up.
The palette lives at 0xFC00 in video memory, four bytes an entry - red, green, blue, and one spare - so entry *n* begins at 0xFC00 plus *n* times four. Video memory belongs to the screen rather than to the program, so it is written the way every device's memory is written: registered as a bank, and reached through the memory controller.
`Programs/Examples/colours.asm` does all of that in eighty lines and prints the result. It shows the sixteen pairs, shows what XOR 8 does to each, and then changes one of them by writing three bytes into the palette, so that the difference between using the colours a machine wakes up with and choosing your own is visible in one program.
### Moving The Cursor:
Three more registers, because that is how this machine talks to everything else.
| Port | Register |
| --- | --- |
| 0x03 | Cursor row. Read and write. |
| 0x04 | Cursor column. Read and write. |
| 0x05 | Command. Write 1 to clear the screen, which also puts the cursor at the top left. |
| 0x06 | Attribute. Read and write. |
**A cursor is shown only when it is asked for**, with bit 2 of the Control port, and status bit 4 says whether one is being shown. Off is the right default for a machine: a program painting its own screen does not want something blinking in the middle of it, and a system that reads lines from a person turns it on.
It is drawn by turning its cell inside out rather than by putting a block over it, so the character underneath stays readable - which matters to somebody editing a line. And it blinks **on the machine's own clock**, half a second on and half a second off, so the picture at a given cycle count is the same picture every time and a saved screen is not a matter of luck.
Both counted from zero, and both **readable**, which is the thing worth having: a routine that wants to put the cursor back where it found it asks where that was.
A cursor sent past the edge is clamped rather than refused. It has an obvious place to be, and stopping the machine over one would be a poor trade.
Clearing does not touch the scrollback. It clears what is on the screen, and what has already gone off the top is still in the map where the Scroll register can find it.
**There is no escape sequence here, and there should not be.** 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, and reaching it by sending characters for a parser to take apart is a middleman for something the machine does better - clearing by writing 1 to a port costs one command, against a thousand cells walked one at a time.
What a program on the other end of an actual serial line sees is a different question, and the answer is that the console sends it the escapes it needs. That is the emulator bridging to a host terminal, the same job it does reading standard input, and it is not part of this machine.
**Scrolling moves the video device's Scroll register and no memory at all.** The row that comes into view at the bottom is cleared, because the map is a ring and it is holding whatever was there 128 rows ago. The rows that go off the top are *not* cleared, and that is the point: a hundred rows of what has already been said are still in the map, so a machine has scrollback without anything having to keep it.
**It is one screen.** A program that writes its own tiles and its own map has taken the screen, and a console still writing characters into it will scribble on what that program drew. This is not an oversight to be worked around - it is what one screen means, and it is why a program that wants the screen takes it.
## Making A Noise:
Four channels on ports 0x40 to 0x4F. Each one is a whole voice - two oscillators, two
envelopes, a filter and the routing between them - and it keeps its settings between notes.
Channel two is channel two: a program sets up a sound once and then plays it, the same way it
sets up a tile once and then places it.
### Why It Is Six Ports And Not Forty:
A voice has around forty settings and there are four of them, so a port for each would spend
more than half of the machine's whole port space on one device. Instead there is a **selector
and a value**: say which channel, say which setting, write it. Three writes to change one
thing.
That is the right price because of *when* a program pays it. Patches are loaded; notes are
played. Changing a setting happens when a program starts or when an instrument changes, and
three writes there costs nothing anybody can hear. Playing a note happens in the inner loop of
a music routine, and that is two writes with no selector machinery at all.
### Registers:
| Port | Register |
| --- | --- |
| 0x40 | Status. Bit 0, some channel is still sounding. |
| 0x41 | Channel, 0 to 3. Anything larger wraps, so a program cannot select a channel that is not there. |
| 0x42 | Which setting the next write to 0x43 means. |
| 0x43 | The value of that setting, for the selected channel. |
| 0x44 | Note. Writing a MIDI note number **starts it**: 60 is middle C, and every 12 is an octave. |
| 0x45 | Gate. Writing zero releases the note and lets it fade; writing anything else starts the last note again. |
| 0x46 | Volume, for the whole device. |
Reading 0x41, 0x42 and 0x44 gives back what is in them, so a routine can save and restore the
selection around an interrupt.
### The Shortest Program That Makes A Sound:
```
RSTA
OUTA 0x41 ; Channel 0
INIA 0d60
OUTA 0x44 ; Middle C, which starts it
```
**Every channel arrives able to make a sound**: one oscillator switched on at full gain, a
plain triangle wave, an envelope that fades in and holds. Writing a note number is the whole
of playing a note, and a program only reaches for the settings when it wants a different
sound rather than a sound at all.
The second oscillator arrives switched off, and that is not the same as arriving silent. **The
two oscillators are averaged rather than added**, so switching the second one on halves the
first whatever gain it has - which is what keeps two of them from clipping, and which means
there is no setting of `active` that costs nothing. One oscillator is the plain case, and
asking for two is something a program says out loud:
```
INIA 0x15
OUTA 0x42 ; Oscillator 1, on
INIA 0x01
OUTA 0x43
INIA 0x11
OUTA 0x42 ; and how loud
INIA 0xC0
OUTA 0x43
```
### Settings:
The high nibble says which part of the voice, the low nibble which setting of it.
| Number | Part |
| --- | --- |
| 0x00 - 0x0F | Oscillator 0. |
| 0x10 - 0x1F | Oscillator 1. |
| 0x20 - 0x2F | The amplitude envelope. |
| 0x30 - 0x3F | The modulation envelope. |
| 0x40 - 0x4F | The filter. |
| 0x50 | What shapes the channel's level. |
| 0x60 - 0x6F | LFO 0. |
| 0x70 - 0x7F | LFO 1. |
| Oscillator | Setting |
| --- | --- |
| 0 | Waveform: 0 sine, 1 triangle, 2 saw, 3 ramp, 4 pulse, 5 noise. Anything larger wraps. |
| 1 | Gain. Silent at zero, which is where it starts. |
| 2 | Pulse width, for the pulse wave. |
| 3 | Detune, centred on 128, an octave either way. A step is about nine cents. |
| 4 | Octave, centred on 128, two either way. |
| 5 | On, or off at zero. |
| 6, 7 | What modulates the pulse width, and how much. |
| 8, 9 | What modulates the detune, and how much. |
| 10, 11 | What modulates the gain, and how much. |
| Envelope | Setting |
| --- | --- |
| 0 | Attack. |
| 1 | Decay. |
| 2 | Sustain, the level it holds at while the note is held. |
| 3 | Release. |
| Filter | Setting |
| --- | --- |
| 0x40 | On, or off at zero. |
| 0x41 | Type: 0 low pass, 1 high pass, 2 band pass. Anything larger wraps. |
| 0x42 | Cutoff. |
| 0x43 | Resonance. |
| 0x44, 0x45 | What modulates the cutoff, and how much. |
| 0x46, 0x47 | What modulates the resonance, and how much. |
| LFO | Setting |
| --- | --- |
| 0 | On, or off at zero. |
| 1 | Waveform, from the same six. |
| 2 | Rate. |
Anywhere a setting asks *what modulates* something, the answer is one of these:
| Value | Source |
| --- | --- |
| 0 | Nothing. |
| 1 | The amplitude envelope. |
| 2 | The modulation envelope. |
| 3 | LFO 0. |
| 4 | LFO 1. |
**The two LFOs belong to the device and not to a channel**, so writing 0x60 to 0x7F ignores
whichever channel is selected. That is what makes them useful: a vibrato that every voice
shares is one wobble rather than four that drift apart.
### What A Byte Means:
Everything here is a byte, and a synthesizer wants seconds and hertz. How the one becomes the
other is chosen for **where the useful part of the range is**, not for whatever arithmetic is
tidiest.
| Kind of setting | 0 to 255 becomes |
| --- | --- |
| Times: attack, decay, release | Nought to four seconds, squared. |
| Levels: gain, sustain, resonance, volume | Nought to the most there is, evenly. |
| Cutoff, LFO rate | 20 Hz to 20 kHz, and 0.05 Hz to 20 Hz: exponential. |
| Detune, octave, and every modulation depth | Centred on 128, so half is no change and either side is a direction. |
Times are squared because the difference between five and fifty milliseconds is the whole
character of a percussive sound, and the difference between three seconds and four is nothing
anybody can hear. A byte spread evenly over four seconds would spend nine tenths of itself on
the part that does not matter. Cutoff and rate are exponential for the same reason, since
pitch is logarithmic and so is where a filter sounds like it is.
### Level:
Setting 0x50 says what shapes the channel's level, out of the same list of sources. It is
normally the amplitude envelope, which is what an amplitude envelope is for, and it can be set
to **nothing** - a channel whose level nothing shapes plays flat out until it is gated off.
That sounds like a small thing and is not. Without it the amplitude envelope is welded to the
output, so an envelope routed somewhere useful - opening the filter, bending a pitch - still
has to be shaped like something you would want to hear, and a snare that wants a click of
filter sweep and a flat body cannot have both.
### Knowing When It Has Finished:
The status port's bit 0 is set while any channel is still sounding, so a routine can wait for
a sound to end rather than counting cycles.
There is one rule about when a note ends, and it is worth stating on its own because the
obvious guess is wrong. **A note sounds until the gate is dropped.** What the envelope is
doing does not come into it.
In particular, a note whose sustain is nothing goes quiet and *keeps sounding*. Silence and
being finished look identical from outside and are not the same thing: the voice is holding at
nothing, which is exactly what a held key does on any instrument. A program that plays such a
note and then waits for the status bit waits for ever.
So a routine that means to wait for a sound does this, in this order: play the note, wait
however long the note is meant to last, write nothing to the gate at 0x45, and *then* wait for
the bit to come down - which it does when the release has finished. `Programs/Examples/tune.asm`
is that loop with the waiting done on the screen's frame.
### The Sound Comes From The Machine's Clock:
Samples are made against cycles, not against however fast the host really ran: forty-eight
thousand a second of *emulated* time, worked out in whole numbers so it never drifts. Three
million cycles make exactly one hundred and forty-four thousand samples.
This is the same decision as the screen writing a picture out, and it buys the same thing. A
sound is **something a test can compare**: the same program makes the same samples every time,
on any host, at any speed, and `Tests/sound.sh` reads them back and measures the pitch. It
also means a machine that is paused makes no sound rather than a held note, which is right - a
stopped machine's oscillators are stopped too.
**The device does not interrupt.** Nothing about a note finishing needs the CPU's attention
urgently enough to be worth a line, and a program that wants to play in time has the screen's
frame interrupt, which is 60 a second and already there. A programmable timer is the proper
answer and is a device that does not exist yet.
## Asking What Is There: ## Asking What Is There:
A program that only ever runs on one machine can be told where everything is. A program meant to run on more than one has to ask, and the bus registry on port 0xFF is what it asks. A program that only ever runs on one machine can be told where everything is. A program meant to run on more than one has to ask, and the bus registry on port 0xFF is what it asks.
@@ -553,7 +1013,60 @@ One thing to be careful of: the registry remembers which port it was asked about
| 0x11 | Test device, which refuses everything. | | 0x11 | Test device, which refuses everything. |
| 0x12 | Test device, which owns memory. | | 0x12 | Test device, which owns memory. |
| 0x13 | Disk. | | 0x13 | Disk. |
| 0x14 - 0xFF | Peripherals. | | 0x14 | Screen. |
| 0x15 | Sound. |
| 0x16 | Timer. |
| 0x17 - 0xFF | Peripherals. |
## Keeping Time:
A period, in cycles, and a bit that says when one has gone by.
Before this the only regular beat on the machine was the screen finishing a frame, and that is
a clock a program **borrows** rather than one it sets. A frame is 16,667 cycles and not
negotiable, so every duration becomes a multiple of it - and a sixteenth note at 120 beats a
minute is 125,000 cycles, which is seven and a half frames. It cannot be asked for at all. The
way round it is to choose a tempo whose subdivisions happen to land on whole frames, which is
making the music fit the machine.
| Port | Register |
| --- | --- |
| 0x50 | Status. Bit 0 a period has gone by, bit 1 it is running, bit 2 it is set to interrupt. |
| 0x51 | Control. Bit 0 run, bit 1 repeat, bit 2 interrupt. |
| 0x52 - 0x54 | The period, in cycles, most significant byte first. |
**The period is in cycles**, because that is what everything else here is counted in: it is
what the cost model counts and what a frame is measured in, so a timer counting anything else
would be a second unit to remember. Twenty four bits reaches from one cycle to sixteen and a
half seconds, and 120 beats a minute sits at 500,000 in the middle of it. There is no
prescaler, because there is no range left for one to buy.
**Starting it loads the period.** Writing the control byte with the run bit already set does
not, so a program that turns interrupts on half way through a period does not silently move
the beat it was keeping.
**With the repeat bit it reloads; without it, it stops** and the status port says so. What is
left over carries into the next period, so a timer asked for 1,000 cycles ticks every 1,000
and not every 1,000 plus however late anybody looked.
**Reading the status is what answers it**: the tick comes down when it is read, and the line
with it. A program that polls is not one that will answer a handler.
```asm
; A sixteenth note at 120 beats a minute, waited for rather than counted.
INIA 0x01
OUTA 0x52
INIA 0xE8
OUTA 0x53
INIA 0x48
OUTA 0x54 ; 0x01E848, which is 125,000
INIA 0x07
OUTA 0x51 ; Run, repeat, interrupt
SIF
WAIT
```
Eight of those is one second, and a machine doing it spends 999,720 of those cycles asleep.
## The Memory Controller: ## The Memory Controller:
@@ -604,6 +1117,26 @@ Everything a transfer would touch is checked before any of it moves. A transfer
Filling is worth reaching for. Clearing a page with one Fill instead of a store and a loop takes about a tenth off the running time of the segmented sieve, which spends most of its life zeroing its window. Filling is worth reaching for. Clearing a page with one Fill instead of a store and a loop takes about a tenth off the running time of the segmented sieve, which spends most of its life zeroing its window.
### What A Transfer Costs:
A transfer does not wait on anything, but it is not free. The controller is charged for every byte it moves, and the program that asked stalls until it is done, so these are cycles out of that program's budget.
Two things set the rate. **Banks are separate memories**, so a move between two of them can fetch the next word while the last one is stored, and a move within a single bank cannot and costs twice as much. And **the controller's path to memory is sixteen bits wide**, so it moves two bytes at a time when the addresses allow.
They allow it when the source, the destination and the length are **all even**. A word is read at an even address and written at an even address; an odd anything would mean shifting bytes across word boundaries to line them up, which is a different machine. A misaligned transfer falls back to a byte a cycle, which is what this cost before the path was widened.
| Moving 256 bytes | Aligned | Not aligned |
| --- | --- | --- |
| Between two banks | 129 | 257 |
| Within one bank | 257 | 513 |
| Fill | 129 | 257 |
The odd cycle in each is the pipeline filling. A fill has nothing to read, so it goes at the between-banks rate whatever bank it writes, and only its destination and length decide whether it can be paired - the byte it writes lives in SourceLow and is a value rather than an address.
**The rule is visible so that a program can act on it.** Aligning a buffer costs nothing and halves what moving it costs, and a cost a program cannot see is a cost it cannot avoid.
None of this changes the CPU. It still sees eight bits, a Data Pointer still addresses a byte, and no instruction means anything different than it did. What got wider is the controller's own path to the memories it moves between.
### Banks: ### Banks:
Memory the controller can reach is divided into banks of up to 64K each, numbered 0 to 255. Program and Data are banks like any other; being 0 and 1 is the only thing special about them. Memory the controller can reach is divided into banks of up to 64K each, numbered 0 to 255. Program and Data are banks like any other; being 0 and 1 is the only thing special about them.
@@ -730,6 +1263,59 @@ The disk owns one block of memory, its buffer. Reading fills it and writing take
A device that answers on more than one port raises its line on the first of them, so the disk interrupts on 0x20. A device that answers on more than one port raises its line on the first of them, so the disk interrupts on 0x20.
### Several Disks:
**One controller with four drives, not four devices**, and the instruction set is the reason.
A port is an immediate byte inside the `OUT` that names it, so a program cannot compute one -
*the disk on port 0x20 plus drive times four* is not something this machine can say. Two disks
as two devices would mean a branch on the drive number in every place a program touches a
disk port. So the drive is a register, which is what a floppy controller has always been.
| Port | Register |
| --- | --- |
| 0x24 | Drive. Which one the block, command and status registers refer to. Reads back. |
| 0x25 | Drives, read only. How many are plugged in. |
| 0x26 | What the selected drive is, read only. Bit 0: its contents do not survive the machine stopping. |
| 0x27, 0x28 | How many blocks the selected drive has, read only. |
### A Drive Made Of Memory:
A drive may have memory behind it instead of a file. It selects, reads, writes and has a size
like any other, and a filesystem on it is a filesystem - **a program cannot tell the
difference except by how fast it was.** What it has not got is anything that survives the
machine stopping.
That difference is the one thing a system cannot work out for itself, because an empty disk
and a volatile disk look identical from outside. So the machine says it, in bit 0 of 0x26, and
says nothing whatever about filesystems.
**Which is the whole point of saying it that way.** The bit is what separates a drive a system
may format on sight from one it must not: an unformatted floppy somebody put in deliberately
is not an invitation, while an unformatted drive made of memory never had anything to lose. A
system reads the bit and draws its own conclusion - and a system that would rather have a
different filesystem entirely reads the same bit and writes whatever it likes. **The machine
supplies blocks. Bringing them up is the system's job.**
The size registers exist for the same reason. A superblock states a disk's size too, and that
is no use at all on a disk which has not got one yet.
The block, command and status registers, **and the single buffer**, all belong to whichever
drive is selected. A program that changes drives is holding a buffer that no longer contains
what it thought, and has to say so to itself - the controller cannot know what the program
believed.
A drive that is not there is **refused rather than wrapped**: writing 9 to the drive register
leaves the selection where it was, and reading the register says so. Wrapping would mean a
program asking for a drive this machine does not have quietly reading the one it does.
**Selecting an empty drive is allowed**, because a controller has its drives whether or not
there are disks in them. Reads from one fail with the error bit, which is what an empty drive
should do. `Drives` says how many have disks; the drive register accepts any of the four.
Changing drives finishes whatever the drive being left was in the middle of. A transfer waits
for the clock, so one may be owed at any moment, and running it against the disk that is
arriving instead of the one that asked for it would be a fault with no owner.
### Waiting: ### Waiting:
A command returns at once and the line goes up when the block has moved. Status bit 0 says the disk is still working. A command returns at once and the line goes up when the block has moved. Status bit 0 says the disk is still working.
+584
View File
@@ -0,0 +1,584 @@
# The SplitBit Test Manual
This describes the test suite: what it is for, what each part of it can and cannot answer,
how to add to it, and where it is blind. It is the fourth of the manuals, and the only one
about the repository rather than about the machine.
The other three describe things a program can rely on. This one describes the reason to
believe them.
## What The Suite Claims:
The suite is not one thing. It is eleven scripts making five different kinds of claim, and
knowing which claim you are relying on is the whole point of this document. A recorded
transcript and a byte-for-byte comparison against a second implementation both print
`[ok ]`, and they are worth wildly different amounts.
The strongest thing the suite says is this: **on the source it is given, the assembler that
runs on SplitBit and the one that runs on the host produce identical bytes; and on the
operations it performs, the filesystem written by the tool and the filesystem written by the
machine are identical disks.** Those are two programs written from one specification,
sharing no code, checking each other.
Note the qualifiers, because they are the whole difference between a strong claim and an
untrue one. This is evidence about a corpus and a sequence of operations, not a proof about
every program that could be assembled or every disk that could be built. No suite says more
than that. But nothing else here is even that strong.
The weakest thing it says is that a program prints what it printed last time. That is worth
having and it is worth having a lot of, but it only ever catches change. It cannot catch a
thing that was wrong the first time it was recorded.
Everything in between is somewhere on that line.
## Running It:
```
make test
```
Builds the four tools - and Voyager, where Raylib is installed - checks they compile under
strict ISO C, and runs the scripts in order. Takes a few seconds. Everything must pass; there are no expected failures at the
level of the suite, only tests that record an expected failure of the assembler.
```
make sanitize
```
The same suite with the four tools rebuilt under AddressSanitizer and
UndefinedBehaviorSanitizer. See [The Sanitizer Run](#the-sanitizer-run).
Individual scripts can be run on their own, from anywhere:
```
./Tests/run.sh Every program in the manifest.
./Tests/run.sh hello waitTest Only the named ones.
./Tests/run.sh --bless Record current output as expected. See below.
./Tests/voyager.sh The same manifest, through the other front end.
./Tests/disk.sh The disk tool against the format.
./Tests/cycles.sh What the memory controller charges.
./Tests/video.sh What the video device draws.
./Tests/sound.sh What the sound device makes.
./Tests/terminal.sh The things a recorded file cannot see.
./Tests/native.sh The two assemblers against each other.
./Tests/agree.sh The two filesystems against each other.
./Tests/lint.sh SplitLint against its fixture and the corpus baseline.
./Tests/lint.sh --bless Record the corpus baseline. See below.
./Tests/docs.sh The manuals against the code.
```
`Tests/makedisks.sh` is not in that list because it checks nothing. It builds the disk
images the other scripts read, and `run.sh` calls it.
Everything is built into `Tests/build`, which is removed and remade at the start of every
run. **The suite never writes into `Programs/`.** A binary sitting next to its source came
from `make`, not from here.
## The Five Kinds Of Check:
### 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`. 189 tests, of which 127 run, 35
only assemble, 16 are expected to fail to assemble, and 11 boot from ROM with no image
given at all.
This is the bulk of the suite and the shallowest part of it. It answers "did this change"
and nothing else. A recorded file is only as correct as the day somebody looked at it,
which is why blessing has a protocol.
The 16 `xfail` tests are worth calling out separately: each one is a piece of source the
assembler is supposed to refuse, and the recorded result is the refusal message. They catch
two things at once - an error that stops being detected, and an error message that changes
without anybody meaning it to.
### 2. A second implementation
`Tests/native.sh`, `Tests/agree.sh` and `Tests/voyager.sh` are the checks that do not
compare the code against a memory of itself.
`native.sh` assembles the same source with the host assembler and with `Asm.sbx` running on
the emulated machine, and compares the two binaries byte for byte. **The only honest test of
an assembler is the bytes it produces:** "it ran" and "the sizes look right" both pass for a
binary with a label one byte out, which is a program that jumps into the middle of an
instruction. It does a boot image, four loadable programs, CosmOS, and the assembler
itself - and then the CosmOS that CosmOS built, which is where it stops, because a second
generation that matches the first is a fixed point.
`agree.sh` builds the same directory tree twice, once with SplitDisk and once by driving
CosmOS, and compares the images byte for byte. Every field one implementation writes and
the other only reads is checked here and nowhere else: which entry a file lands in, which
block, what a directory's unused fields hold, the version in the superblock, the free
count. A disagreement in any of those is a disk one of them can read and the other cannot,
and the usual way that gets discovered is somebody's file coming back wrong months later.
`native.sh` and `agree.sh` both depend on the two sides being driven in the same order,
because both allocate first fit and both take the first free entry. Given the same
operations in the same sequence they should reach the same bytes, and any difference is real
rather than an artefact of the script.
`Tests/voyager.sh` is the cheapest of the three, because it reuses the runner rather than
repeating it. SplitBit and Voyager share every line of the machine and differ only in what
they present - a terminal, or a window and a speaker - and the way to keep that true is to
make the claim testable. So it runs the **whole manifest** through Voyager with
`--headless`, held to the same recorded results SplitBit is held to. The claim is not that
the two look alike: it is that one satisfies every recording the other does, byte for byte,
exit status included.
`Tests/run.sh` takes the machine to run from `SPLITBIT_EMULATOR`, which is what makes that
possible without a second copy of the runner. A copy would drift, and the first thing to go
would be whichever awkward case got added to only one of them.
**Voyager not being built is not a failure.** It needs Raylib and nothing else here does,
which is the whole point of there being two binaries; a suite that failed on a machine with
no graphics library would be enforcing exactly the dependency the split exists to avoid. The
script says it was skipped, and passes.
### 3. Named properties
`Tests/disk.sh` and `Tests/lint.sh` do not compare transcripts. Each check is a named
assertion that either holds or does not, and the name is the failure message.
`disk.sh` puts files of every awkward size onto an image and takes them off again -
nothing at all, less than a block, exactly a block, a part block, an exact multiple - and
then asks for the things the format says cannot happen and requires them to be refused
rather than half done. Roughly half of its checks are `refuses`, which is the shape
worth copying: **a tool that never says no is not finished.**
`Tests/video.sh` belongs here too, and exists for the same reason as the two above: the
suite has no display, and a screen nothing can look at is a screen nothing checks. The
device renders into a buffer that is a pure function of video memory, and the machine can be
asked to save it with `--screen`, so every check runs a program, saves the picture and reads
pixels back out of it. No window, no display server, and the same answer every time.
**It checks named behaviours rather than a recorded image**, which for a screen matters more
than usual. A recorded image would say "something changed" and leave which of the palette,
the tile, the attribute, the map or the scroll register broke to be found by hand. Instead
each check is one claim: that a tile lands where it is put and stops at the cell edge, that
the palette is what colours it, that the attribute nibble adds sixteen, that scrolling moves
which row is on top, that the map wraps, and that an impossible mode is refused without
stopping the machine.
`Tests/sound.sh` is the same argument again, one device along: the suite has no speaker, and
a sound nothing can hear is a sound nothing checks. Its samples come from the machine's clock
rather than the host's, so `--sound` writes a file that is a pure function of the program and
the cycle count - a million cycles is exactly forty-eight thousand samples, on any host, at
any speed. The checks read that file back and measure it: that a note is the note that was
asked for, that twelve of them is an octave, that gain is a level and volume is over the top
of it, and that two runs are identical byte for byte.
**It found three real defects the first time it ran**, all of them the same shape - a
synthesizer written for a patch editor, wired up as hardware and inheriting the editor's
assumptions. Only one voice had an oscillator switched on, so three of the four channels could
not make a sound whatever was written to them. That voice's oscillator arrived at full gain
while every other one arrived at nothing. And a note with no sustain is silent but not over,
so the obvious way to wait for a sound to end waits for ever.
The last of those had already caught the person writing the device, an hour before the check
existed, and had been written into the manual as advice. That is the argument for a suite in
one sentence: **the check disagreed with the documentation, and the check was right.**
**Half of `video.sh` is about the console rather than the device.** Those programs ask the video
device for nothing at all: they write bytes to port 0x00, the way every SplitBit program
always has, and the picture is what is checked. A character lands at the cursor and the
cursor moves along, a newline starts the next row, backspace rubs out, the line wraps at the
last column, and the screen scrolls by moving the origin rather than by moving 1,920 bytes -
which is checked by reading the register back, because a console that blitted rows instead
would look identical on the screen and cost twelve percent of a frame a line.
Breaking the additive nibble fails exactly one check. Breaking the scroll origin fails
exactly two. Moving every cell one pixel sideways fails the four about placement. That is
what a screen test is supposed to do.
`lint.sh` builds a fixture in which every line trips exactly one rule, and checks which
warning came out at which line. It used to compare a total, and a total is a number that
stays right while the thing behind it goes wrong: a change that stopped one rule firing and
made another fire twice would have passed without a murmur. The fixture is written inside
the script rather than kept as a file because a reader wants the pattern and the
expectation side by side.
### 4. What a recorded file cannot see
`Tests/terminal.sh` runs the emulator under a pseudo-terminal, which is what makes its
questions askable at all. Two whole classes of behaviour are invisible to every other check
here:
**When something is printed.** Piped output is fully buffered and flushed when the process
ends, so a prompt shown before its answer is read and a prompt shown an hour late produce
byte-identical files. A prompt printed *after* the thing it was asking for is invisible to
the entire rest of the suite.
**What happens to the terminal.** Key mode only touches a terminal when there is one, so
with input from a file there is nothing to put into another state and nothing to put back.
A machine that leaves the terminal with echo off passes every other test in this
repository.
Both of those went wrong here, and both were found by a person whose terminal stopped
working rather than by anything in this suite. That is why the script exists. It checks
that a prompt arrives before input is read, that a keystroke arrives without Return, that
the terminal is handed back however the machine dies - SIGHUP, SIGINT, SIGQUIT, SIGABRT,
SIGSEGV, SIGTERM - and that suspending and resuming leave it as they found it.
**And it is the only place an escape sequence is ever read as one.** A terminal sends
`ESC [ A` for the Up key and the console turns that into a byte of its own, but only when
standard input really is a terminal - everywhere else in this suite the input is a file,
which holds exactly the bytes somebody put in it and goes straight past the translation. So
three checks here type at a pseudo-terminal: that the sequences arrive as keys, that Escape
pressed on its own is still Escape, and that an ordinary character typed straight after an
escape is held rather than swallowed with the sequence that never was. The recorded tests
cover the other half - what a program does with the key values - by writing them into the
input file directly.
It also asks the one question about *waiting* that nothing else can, since the count is
stripped from every recorded result: whether a program on a slow disk slept through the wait
or spun on it. Both print the same characters and take the same elapsed time. Only the split
between idle and bus cycles tells them apart.
`Tests/cycles.sh` is the other half of the same bargain, and exists because the determinism
rules below throw the cycle count away. It measures what the memory controller charges for
moving memory - which is real time out of a program's budget, and is invisible everywhere
else in this suite.
**It pins the rate rather than a total.** Each case runs twice, from programs whose
instructions are identical except for the byte written to the Command port: once asking for
the transfer, and once asking for `GuardOff`, which lowers a fence that was never raised and
costs nothing beyond the port write. The difference between the two runs is the transfer and
nothing else - no instruction count, no setup, no startup - so the check survives every
change to the machine that is not a change to what a transfer costs.
### 5. The documents against the code
`Tests/docs.sh` settles, every time the suite runs, every claim in the manuals that can be
settled by looking at the source. Documentation goes stale quietly: an instruction added
without a table row, or a count in a heading that nobody updated, is wrong in a way nothing
notices until somebody trusts it.
It checks that every tracked file is plain ASCII; that every link in every document goes
somewhere, and carries `%20` rather than a raw space; that every instruction has a row and
every row is an instruction; that the counts in the group headings are right, and the count
of instructions that take a Data Pointer; that every device class has a row in the Devices
table; that the vector ranges the manuals quote are the ones the assembler uses; that the
loadable header table matches the offsets the assembler writes; that every console status
bit is described; that every service CosmOS implements has a row and every row is
implemented; that every application the CosmOS README describes exists; that the monitor's
instruction table is the assembler's, generated rather than typed; that every directive is
written down; that every routine the manuals promise is defined; that CosmOS still fits in
the half of the machine its memory map gives it; that the sizes quoted for applications are
their real sizes; and that the worked examples still assemble to the bytes printed beside
them.
It also checks this document: that every script the suite runs has a bullet here saying why
it exists, and that the number quoted is the number there are.
**A manual is a claim somebody may check by typing it.** Everything here is one that gets
checked automatically instead.
## Determinism:
A test that passes four times out of five is worse than no test, because the failure gets
attributed to the suite rather than to the code. Several rules exist only to keep results
identical from one run to the next, and each of them is a bug that already happened.
**The cycle count is stripped from every recorded result.** The last line of the emulator's
output has the number taken out before anything is compared, keeping only *whether* the
program stopped on its own or ran into its limit, which is behaviour. Two instructions
added to CosmOS used to move that number in six unrelated files at once, so a real
difference would have arrived in a crowd of meaningless ones and had to be picked out by
hand. Anything that genuinely wants to measure cycles should say so out loud in a test of
its own, the way `terminal.sh` does.
The substitution is deliberately **not anchored to the start of a line**. A program whose
last output has no newline on it leaves the cursor mid-line and the halt message is printed
there, so the count sat inside a line rather than at the head of one and survived.
`replCalculator` is the program that does that, and it was the only test to churn when the
machine started charging for memory accesses instead of counting instructions.
**Every program runs with `--fast`.** The emulated clock rate has no bearing on what a
program prints, and waiting out real time makes the suite slower for nothing.
**A program that never halts is bounded by `--cycles`, not by wall clock.** A cycle limit
produces the same output on a fast machine and a slow one. A time limit does not.
**Every run has a ten second timeout** on top of that, which is a failure rather than a
result. A test that hangs says so instead of hanging the suite.
**A scratch disk is removed before every run**, so nothing a test writes can be seen by the
next one. The emulator makes a blank image in its place.
**The build directory is removed and remade at the start of every run**, so a stale binary
cannot pass for a fresh one.
**The exit status is recorded with the output.** What a program exits with is part of what
it does. A program that faults is supposed to exit non-zero, and that should be as pinned
down as what it printed.
## The Manifest:
`Tests/manifest` is one test per line, fields separated by `|`. Blank lines and lines
starting with `#` are ignored, and the file is heavily commented by section.
```
name | source | mode | stdin | limit | disk
```
**name** is what the test is called and what `Tests/expected/<name>.out` is named after.
**source** is relative to `Programs/`. Everything assembles from there with `Libraries/`
and `CosmOS/Source/` on the include path. CosmOS is on the path because it owns the
filesystem library and the service names, which test programs outside it include.
**mode** is one of:
| Mode | What happens |
| --- | --- |
| `run` | Assemble, execute, compare all output against the recorded result |
| `assemble` | Assemble only, for library files with no entry point to run |
| `xfail` | Assembly is expected to fail, and the message is recorded |
| `rom` | As `run`, but no image is given, so the machine boots from its ROM |
`xfail` records a known breakage, so that fixing one is noticed and so that an accidental
new one is too. `rom` is what a machine with no debugger attached does: the emulator
shadows its built-in stage one into Program Memory and reads the disk for everything else.
**stdin** names a file in `Tests/input`, or `-` for none.
**limit** is a cycle count for programs that never halt on their own, or `-`.
**disk** names an image to attach, or `-` for a machine with no disk, which is most of
them. Four forms:
| Form | Meaning |
| --- | --- |
| `name.img` | Scratch. Removed before the run, so the emulator makes a blank one |
| `disks/name.img` | A fixture built by `makedisks.sh`, used as it stands |
| `...:ro` | Attached write protected, so a test can check the *device* bars writes |
| `...@N` | Given a latency of N cycles, so a test can check the filesystem waits |
The `@N` form deserves a note. Every other test runs with the disk's answer available
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 27 images with SplitDisk before anything runs, into
`Tests/build/disks`. **That is the point of them.** A SplitBit program reading one of these
is being checked against a filesystem written by different code from the same written
specification, rather than against itself.
They are shaped to be awkward on purpose. `sbfs.img` has two directory blocks and eight
files that fill the first one exactly, so everything after that lands in the second and the
walk from block to block gets exercised rather than assumed. It carries a file longer than
a block whose pattern repeats every twenty-six bytes, which makes a misplaced block obvious
to read; an empty file; and a name of exactly the maximum length. `deep.img` has eight
directory blocks. `chain.img` carries a different payload in each of its two boot slots, so
a test reading the right word is reading the slot that was chosen rather than whatever
happened to be left in Program Memory, and `chainAlt.img` is the same disk with the other
slot selected. `selfboot.img` has stage two in a boot slot and the system as an ordinary
file, which is the whole chain end to end, and `nosystem.img` has the second stage with
nothing for it to find, so that a loader which cannot start anything says so rather than
jumping somewhere.
`cfgbare.img`, `cfgbroken.img` and `cfgfallback.img` are the same disk three ways,
differing only in `/System/Boot/boot.cfg`. Nothing else about any of them changes, which is
what makes those tests of the **file** rather than of the machinery under it. That is the
shape to copy when a fixture is added: change one thing, and keep everything else
identical to a disk that already works.
The script makes its paths absolute before doing anything else, because it changes
directory partway down. When that was wrong the failure was not an error - it was a disk
quietly missing some of the files it was supposed to have, which is a much worse thing to
debug.
## Recording Expected Output:
```
make bless
./Tests/run.sh --bless
```
This overwrites every file in `Tests/expected` with the current output.
**Blessing unexamined output records a bug as correct, permanently and silently.** It is
the one operation in this repository that can make the suite confidently wrong, and it does
it without printing anything alarming. The protocol is:
1. Run the suite and read the diffs. `run.sh` prints the first twenty lines of each.
2. Account for every changed file. Not "that looks like the change I made" - know why each
one moved, and know why the ones that did not move did not.
3. Bless.
4. Look at `git diff Tests/expected` before committing, which is the last chance to see a
recorded result that should not have changed.
A single test can be re-run by name to see one diff on its own, which is usually faster
than reading all of them:
```
./Tests/run.sh cosmosCwd
```
## The Lint Baseline:
`Tests/lint-baseline.txt` records how many warnings of each rule SplitLint finds in each
file across the whole corpus - 34 file-and-rule pairs. It is checked on every run, and it
is checked **in both directions.**
A new warning appearing is a regression. A recorded warning *disappearing* is also
reported, and that is the half people do not expect: it means either that somebody fixed
something and did not record it, which is fine and takes one command, or that a rule
stopped firing, which is not fine at all and is otherwise completely silent.
```
./Tests/lint.sh --bless
```
records the corpus as it stands, for when warnings have been deliberately fixed or
deliberately accepted. Same shape as `run.sh --bless`, and it wants the same care.
A warning that is correct but deliberate should be suppressed in the source rather than
carried in the baseline, with a comment saying `splitlint: <reason>` or
`splitlint[rule]: <reason>`. The reason is required, so that a deliberate exception says
what makes it one. A marker that silences nothing is itself reported, as `dead-suppression`,
which stops the markers outliving the code they were about.
## The Sanitizer Run:
```
make sanitize
```
Rebuilds all four tools with `-fsanitize=address,undefined` and runs **the whole suite**
under them. What it reliably catches is invalid access: reads and writes off the end of an
array, use after free, leaks, and arithmetic the standard does not define.
AddressSanitizer also fills fresh heap allocations with a junk pattern, and it is worth
knowing why that buys almost nothing here. It is a default of the toolchain rather than
anything this build configures, so it is not something to rely on; and there are **six heap
allocations in the whole repository**, all of them in the assembler, the largest a
deliberate `calloc`. The machine's Program and Data memories are static arrays, which the
sanitizers neither fill nor bound-check - which is the same fact, seen from a
different side, as the overrun blind spot below.
It runs everything because it used to not. It built all four tools sanitized and then ran
only `run.sh` and `terminal.sh`, so SplitDisk was compiled with the sanitizers and never
exercised, and `native.sh` - which drives the assembler and the emulator harder than
anything else here - was skipped entirely. Those are exactly where block arithmetic on disk
images and buffer indexing in two assemblers live. Adding the other scripts cost about six
seconds.
If the suite fails, the sanitizer binaries are deliberately left in place so the failing
case can be run again by hand. Plain `make` puts the normal ones back.
Worth running before a release, and after anything that touches memory handling.
## The Strict Build:
`make test` depends on `make strict`, which compiles every source file with
`-std=c11 -pedantic -Wall -Wextra -Werror` **and `-D_XOPEN_SOURCE=700`**, then throws away
the object.
That last flag is part of the check rather than a hole in it, and the distinction matters:
this is strict C11 with the POSIX interfaces the code actually uses explicitly selected, not
freestanding ISO C. The sources call `realpath`, `strdup`, `dirname` and `getopt`, and
asking for POSIX.1-2008 by name is what makes a strict C11 build declare them rather than
guess.
This exists because the README says "the sources are ISO C and build clean under
`-std=c11 -pedantic` with `-Wall -Wextra`", and that is a claim somebody may check by
typing it. It was false when the target was written: `realpath` went undeclared under a
feature test macro that did not reach far enough, which the ordinary `-Os` build never saw.
## Adding A Test:
**A program.** Put the source in `Programs/`, add a line to `Tests/manifest`, run
`./Tests/run.sh <name>` and read what it printed. If it is right, `make bless` - which
records every test, so make sure the rest of the suite is clean first. If the program needs
input, put a file in `Tests/input` and name it in the manifest. If it needs a
disk, name a scratch image; if it needs a disk with something already on it, add it to
`makedisks.sh` and name it as `disks/whatever.img`.
**A property of a host tool.** Add a `check` or a `refuses` line to `Tests/disk.sh`. The
name is the failure message, so write it as a statement of what should be true: "no
removing an occupied one", not "test 47".
**Something a recorded file cannot see.** `Tests/terminal.sh` is the place, and it is the
hardest script to add to because each check has to arrange a situation and then observe it
from outside. Small assembly programs are written inline in the script for this, and only
this - everything else assembles from the repository.
**A linter rule.** Add its line to the fixture in `Tests/lint.sh` next to the other lines
for its family, with the expected line number and message. Every line of the fixture exists
to trip exactly one rule; keep it that way, or a rule that stops working will be masked by
the one next to it.
**A claim in a manual.** If you write a number, a count, or a list into any of the four
documents, consider whether `Tests/docs.sh` can settle it from the source. Most of them
can. The ones already there are all claims that were true when written and quietly stopped
being.
## Checking A Check:
**A new check is not finished until it has been seen to fail.** The discipline is to break
the thing on purpose, run the check, watch it report, and put the thing back. This has
caught several checks in this repository that passed for the wrong reason and would have
passed for ever.
Two of them are worth remembering:
An agreement test passed because both of its cases were on one disk, and the first save ate
the file that was the second case's *source*. It compared two things that were both empty.
Splitting them onto separate disks made it fail, which is when it started being a test.
The lint fixture passed with a rule's message broken, because the total was still
twenty-three.
A check that has only ever been seen to pass is a check you are trusting on faith. That is
the same category of thing as an unread recorded result.
## What The Suite Does Not Catch:
Written down because a document that lists only strengths teaches the wrong lesson.
**Buffer overruns into an adjacent variable.** This has happened three times: the shell's
prompt into its command name table, the assembler's ceilings, and the line editor's `Entry`
buffer into `TextHead` and `ArenaFree`. **All three were found by a person using the
machine, and none of them by the suite.** They are hard for it structurally: the write
succeeds, nothing faults, and the damage shows up somewhere else entirely, at a time that
depends on the memory map. The sanitizers cannot see them either, because the overrun is in
emulated Data Memory, which is one legitimate host array from end to end. The only defences
are reading every `#Reserve` that something copies into, and bounding the loop that fills
it.
**Anything that needs somebody at the keyboard.** `terminal.sh` gets closer than a recorded
file can, but it drives a pseudo-terminal on a script. Nobody types slowly, changes their
mind, resizes the window, or holds a key down. The worst bugs of the last stretch - the
editor misreading a source file, the machine wedging afterwards, and both terminal failures
above - all came from somebody sitting down and using CosmOS for an hour.
**Performance.** Cycle counts are deliberately excluded from every recorded result, so
nothing here notices a program becoming four times slower. That is the correct trade for
now and it is a real gap.
**The emulator being wrong about the machine.** Every check here compares SplitBit against
SplitBit. `native.sh` and `agree.sh` are second opinions about the *assembler* and the
*filesystem*, and there is no second opinion about the CPU at all. Timing, bus behaviour,
what an undriven port reads as, and what happens when two things want memory at once are
all decided by one implementation and checked against nothing.
**Whether the design is any good.** The suite can say that CosmOS still fits in its half of
the machine. It cannot say that the memory map is the right one.
+4 -1
View File
@@ -169,7 +169,10 @@ if cmp -s toCopy.dat copiedBack.dat; then
else else
report FAIL "large native copy" "the host read back different bytes" report FAIL "large native copy" "the host read back different bytes"
fi fi
if grep -q '^> the same$' copied.txt; then # Not anchored to a prompt any more. The shell echoes the line it was given and then ends
# it, so what a command prints now starts at the beginning of a line instead of following
# the "> " that asked for it.
if grep -q '^the same$' copied.txt; then
report ok "native compare" "the streamed files agree" report ok "native compare" "the streamed files agree"
else else
report FAIL "native compare" "Compare did not call the copied files equal" report FAIL "native compare" "Compare did not call the copied files equal"
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env bash
# Checks what the memory controller charges for moving memory.
#
# EVERY OTHER TEST HERE IS BLIND TO THIS. run.sh strips the cycle count out of every
# recorded result on purpose, because a number that moves whenever anything changes turns
# real differences into a crowd of meaningless ones. The Test Manual says the other half of
# that bargain out loud: anything that genuinely wants to measure cycles has to say so in a
# test of its own. This is that test.
#
# What it pins is the RATE rather than a total. Each case runs twice, from programs whose
# instructions are identical except for the byte written to the Command port: once asking
# for the transfer, and once asking for GuardOff, which lowers a fence that was never raised
# and costs nothing beyond the port write. The difference between the two runs is therefore
# the transfer and nothing else - no instruction count, no setup, no startup.
#
# Written by Anachronaut
set -u
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
BUILD="$ROOT/Tests/build/cycles"
ASM="$ROOT/Assembler"
EMU="$ROOT/SplitBit"
for tool in "$ASM" "$EMU"; do
[ -x "$tool" ] || { echo "$(basename "$tool") is not built."; exit 1; }
done
rm -rf "$BUILD"; mkdir -p "$BUILD"
PASS=0
FAIL=0
FAILED_NAMES=()
GREEN=$'\033[32m'; RED=$'\033[31m'; RESET=$'\033[0m'
[ -t 1 ] || { GREEN=""; RED=""; RESET=""; }
# A program that sets the controller up and then writes one byte to the Command port.
# The eight registers are written the same way every time, so two programs built from this
# differ by exactly one immediate.
program() {
# program <sourceBank> <srcHigh> <srcLow> <destBank> <dstHigh> <dstLow> <lenHigh> <lenLow> <command>
cat <<ASM
#Program
start:
INIA $1
OUTA 0xE0
INIA $2
OUTA 0xE1
INIA $3
OUTA 0xE2
INIA $4
OUTA 0xE3
INIA $5
OUTA 0xE4
INIA $6
OUTA 0xE5
INIA $7
OUTA 0xE6
INIA $8
OUTA 0xE7
INIA $9
OUTA 0xE8
HALT
#Vectors
Boot start
ASM
}
# Runs one program and says how many cycles the machine used.
cycles() {
local name="$1"; shift
program "$@" > "$BUILD/$name.asm"
"$ASM" "$BUILD/$name.asm" -o "$BUILD/$name.bin" >/dev/null 2>&1 || {
echo " could not assemble $name"; return 1; }
"$EMU" --fast "$BUILD/$name.bin" 2>&1 | grep -oE 'after [0-9]+ cycles' | grep -oE '[0-9]+'
}
# The cost of one transfer: the same program asking for it, less the same program asking
# for GuardOff instead. 0x01 is Blit, 0x02 is Fill, 0x11 is GuardOff.
charged() {
# charged <name> <command> <sourceBank..lenLow>
local name="$1" command="$2"; shift 2
local withIt withoutIt
withIt="$(cycles "$name" "$@" "$command")" || return 1
withoutIt="$(cycles "$name-idle" "$@" 0x11)" || return 1
[ -n "$withIt" ] && [ -n "$withoutIt" ] || { echo " no cycle count for $name"; return 1; }
echo $((withIt - withoutIt))
}
check() {
# check <name> <expected> <command> <registers...>
local name="$1" expected="$2"; shift 2
local got
got="$(charged "$name" "$@")" || {
FAIL=$((FAIL + 1)); FAILED_NAMES+=("$name")
printf " [%sFAIL%s] %-34s could not measure it\n" "$RED" "$RESET" "$name"
return
}
if [ "$got" = "$expected" ]; then
PASS=$((PASS + 1))
printf " [%sok %s] %-34s %s cycles\n" "$GREEN" "$RESET" "$name" "$got"
else
FAIL=$((FAIL + 1)); FAILED_NAMES+=("$name")
printf " [%sFAIL%s] %-34s %s cycles, expected %s\n" "$RED" "$RESET" "$name" "$got" "$expected"
fi
}
echo "Checking what the memory controller charges."
# ---- Between two banks ----
#
# Data Memory to Program Memory, well above where the program itself sits and well below
# the vector table. 256 bytes: 128 words and the cycle the pipeline takes to fill.
check "256 bytes between banks" 129 0x01 0d1 0x10 0x00 0d0 0x80 0x00 0x01 0x00
# The same move with an odd source. Nothing about it can be paired, so it runs at the byte
# a cycle the machine had before the path was widened.
check "and again from an odd address" 257 0x01 0d1 0x10 0x01 0d0 0x80 0x00 0x01 0x00
# An odd destination is just as disqualifying, and so is an odd length: all three have to
# line up or none of it does.
check "an odd destination is the same" 257 0x01 0d1 0x10 0x00 0d0 0x80 0x01 0x01 0x00
check "so is an odd length" 256 0x01 0d1 0x10 0x00 0d0 0x80 0x00 0x00 0xFF
# ---- Within one bank ----
#
# One memory cannot overlap its own read and its own write, so it costs twice what the same
# move between two banks costs - widened or not.
check "256 bytes within one bank" 257 0x01 0d1 0x10 0x00 0d1 0x20 0x00 0x01 0x00
check "and misaligned, within one" 513 0x01 0d1 0x10 0x01 0d1 0x20 0x00 0x01 0x00
# ---- Filling ----
#
# Nothing to read, so it goes at the between-banks rate whatever the banks are. SourceLow
# carries the byte rather than an address, which is why only the destination and the length
# decide whether it can be paired.
check "256 bytes filled" 129 0x02 0d0 0x00 0xAA 0d1 0x30 0x00 0x01 0x00
check "and filled at an odd address" 257 0x02 0d0 0x00 0xAA 0d1 0x30 0x01 0x01 0x00
echo
if [ "$FAIL" -eq 0 ]; then
echo "All $PASS controller cost checks passed."
exit 0
fi
echo "$PASS passed, $FAIL failed: ${FAILED_NAMES[*]}"
exit 1
+41 -1
View File
@@ -76,7 +76,7 @@ for f in empty.bin one.bin exact.bin part.bin whole.bin; do
check "$f comes back byte for byte" roundTrip "$f" check "$f comes back byte for byte" roundTrip "$f"
done done
refuses "refuse a name of 29 characters" "$TOOL" put work.img part.bin 16bitSegmentedSieveModern.asm refuses "refuse a name of 29 characters" "$TOOL" put work.img part.bin twentyNineCharactersLong.asm
refuses "refuse a duplicate name" "$TOOL" put work.img one.bin refuses "refuse a duplicate name" "$TOOL" put work.img one.bin
refuses "refuse a file that is not there" "$TOOL" get work.img nosuch.bin out.bin refuses "refuse a file that is not there" "$TOOL" get work.img nosuch.bin out.bin
check "delete" "$TOOL" delete work.img one.bin check "delete" "$TOOL" delete work.img one.bin
@@ -228,6 +228,46 @@ check "a fresh disk is settled" python3 -c "
import sys import sys
sys.exit(0 if open('plain.img','rb').read()[17] == 0 else 1)" sys.exit(0 if open('plain.img','rb').read()[17] == 0 else 1)"
# ---- Mirroring a host directory ----
#
# What the system disk is built with. Every file goes through put and every directory
# through mkdir, so this is a walk over machinery already checked above - what wants
# checking is the walk: that it goes all the way down, that it leaves behind what it was
# told to, and that it REFUSES a name the format cannot hold rather than skipping it, since
# a disk quietly missing a file is the failure a mirror exists to prevent.
mkdir -p tree/inner/deeper tree/leave
printf 'top' > tree/top.txt
printf 'inner' > tree/inner/middle.txt
printf 'deep' > tree/inner/deeper/bottom.txt
printf 'not this' > tree/leave/ignored.txt
: > tree/.hidden
"$TOOL" format mirror.img 256 8 >/dev/null
check "mirror a directory tree" "$TOOL" mirror mirror.img tree /
"$TOOL" list mirror.img > mirrored.txt 2>&1
grep -q '/inner/deeper/bottom.txt' mirrored.txt \
&& { PASS=$((PASS + 1)); printf " [%sok %s] %s\n" "$GREEN" "$RESET" "it goes all the way down"; } \
|| { FAIL=$((FAIL + 1)); FAILED_NAMES+=("depth"); printf " [%sFAIL%s] %s\n" "$RED" "$RESET" "it goes all the way down"; }
grep -q 'hidden' mirrored.txt \
&& { FAIL=$((FAIL + 1)); FAILED_NAMES+=("hidden"); printf " [%sFAIL%s] %s\n" "$RED" "$RESET" "and leaves dotfiles behind"; } \
|| { PASS=$((PASS + 1)); printf " [%sok %s] %s\n" "$GREEN" "$RESET" "and leaves dotfiles behind"; }
# Named on the command line, which is how a project keeps what it builds out of what it
# wrote.
"$TOOL" format skipped.img 256 8 >/dev/null
check "mirror with something left out" "$TOOL" mirror skipped.img tree / leave
"$TOOL" list skipped.img > skipped.txt 2>&1
grep -q 'ignored.txt' skipped.txt \
&& { FAIL=$((FAIL + 1)); FAILED_NAMES+=("skip"); printf " [%sFAIL%s] %s\n" "$RED" "$RESET" "and the skipped one is not there"; } \
|| { PASS=$((PASS + 1)); printf " [%sok %s] %s\n" "$GREEN" "$RESET" "and the skipped one is not there"; }
# Twenty-three characters, one more than a directory entry holds.
printf 'too long' > tree/aNameOfTwentyThreeChars
"$TOOL" format refused.img 256 8 >/dev/null
refuses "a name too long stops the mirror" "$TOOL" mirror refused.img tree /
rm -f tree/aNameOfTwentyThreeChars
echo echo
if [ "$FAIL" -eq 0 ]; then if [ "$FAIL" -eq 0 ]; then
echo "All $PASS disk tool checks passed." echo "All $PASS disk tool checks passed."
+100 -24
View File
@@ -510,7 +510,15 @@ else:
# The minimal application in the same section is what somebody copies, so it is the # The minimal application in the same section is what somebody copies, so it is the
# part of the map most worth being right. It went stale across the doubling while the # part of the map most worth being right. It went stale across the doubling while the
# table above it was corrected. # table above it was corrected.
example = re.search(r"```asm\n(.*?)```", readme, re.S) # ---- Found by its section, not by being first ----
#
# This took the first asm block in the file, which was the minimal application right up
# until somebody documented a program with an assembly example above it - and then this
# said the minimal application had no #Base, about a block that was never claiming to be
# one. The example lives under System Services; that is what identifies it.
services = readme.split("### System Services:", 1)
example = (re.search(r"```asm\n(.*?)```", services[1], re.S)
if len(services) > 1 else None)
if not example: if not example:
problems.append("the CosmOS README no longer shows a minimal application") problems.append("the CosmOS README no longer shows a minimal application")
else: else:
@@ -581,42 +589,110 @@ else:
" 0x%s - the buffers are on top of the variables" " 0x%s - the buffers are on top of the variables"
% (ends - 1, first.group(1).upper())) % (ends - 1, first.group(1).upper()))
# ---- Every test script the suite runs has a bullet saying why it exists ---- # ---- The Test Manual against the suite it describes ----
# #
# Two claims in the README went stale at once and neither was noticed: it said FIVE more # The suite documents itself, and a document about the suite goes stale the same way every
# scripts run alongside run.sh when there were six, and "all three tools" when there were # other one does. Two claims in the README went stale at once before this check existed and
# four. Both are the kind of number that is written once, is true for months, and is then # neither was noticed: it said FIVE more scripts run alongside run.sh when there were six,
# quietly wrong - which is the entire subject of this file. # and "all three tools" when there were four. Both are the kind of number that is written
# once, is true for months, and is then quietly wrong - which is the entire subject of this
# file.
# #
# run.sh is the driver rather than one of the others, and makedisks.sh makes the images # The bullets now live in the Test Manual rather than the README, so that is what is read.
# rather than checking anything, so neither is counted. # makedisks.sh is not counted, because it builds the images rather than checking anything;
# The repository README rather than CosmOS's, which is what `readme` above holds. # run.sh is counted, because the manual describes it alongside the rest.
rootReadme = open("README.md").read() rootReadme = open("README.md").read()
manual = open("SplitBit Test Manual.md").read()
# THE MANUAL IS WRAPPED, so a number and the noun it counts are regularly on two different
# lines. Every pattern below runs against a copy with its whitespace flattened.
flat = re.sub(r"\s+", " ", manual)
scripts = sorted(os.path.basename(p) for p in glob.glob("Tests/*.sh") scripts = sorted(os.path.basename(p) for p in glob.glob("Tests/*.sh")
if os.path.basename(p) not in ("run.sh", "makedisks.sh")) if os.path.basename(p) != "makedisks.sh")
words = {"three": 3, "four": 4, "five": 5, "six": 6, "seven": 7, "eight": 8, "nine": 9} # Spelled out, because that is how the documents say them. Kept a few ahead of the count so
said = re.search(r"([A-Za-z]+) more scripts run alongside it", rootReadme) # that adding a script fails on the number being wrong rather than on the word being unknown,
# which is a much less helpful thing to be told.
words = {"three": 3, "four": 4, "five": 5, "six": 6, "seven": 7, "eight": 8, "nine": 9,
"ten": 10, "eleven": 11, "twelve": 12, "thirteen": 13, "fourteen": 14}
said = re.search(r"It is ([a-z]+) scripts making", flat)
if not said: if not said:
problems.append("the README no longer says how many scripts run alongside run.sh") problems.append("the Test Manual no longer says how many scripts the suite is")
elif words.get(said.group(1).lower()) != len(scripts): elif words.get(said.group(1)) != len(scripts):
problems.append("the README says %s scripts run alongside run.sh, and there are %d: %s" problems.append("the Test Manual says the suite is %s scripts, and there are %d: %s"
% (said.group(1), len(scripts), ", ".join(scripts))) % (said.group(1), len(scripts), ", ".join(scripts)))
for name in scripts: for name in scripts:
if ("`Tests/%s`" % name) not in rootReadme: if ("`Tests/%s`" % name) not in manual:
problems.append("Tests/%s runs in the suite and the README does not say what it is" problems.append("Tests/%s runs in the suite and the Test Manual does not say what"
" for" % name) " it is for" % name)
# ---- The shape of the manifest, which the manual states outright ----
#
# Five numbers in one sentence, all of them countable from the file they describe. This is
# the most quotable thing in the manual and the least likely to be recounted by hand.
modes = {}
total = 0
for line in open("Tests/manifest"):
line = line.strip()
if not line or line.startswith("#"):
continue
total += 1
fields = line.split("|")
if len(fields) > 2:
modes[fields[2].strip()] = modes.get(fields[2].strip(), 0) + 1
said = re.search(r"(\d+) tests, of which (\d+) run, (\d+) only assemble,"
r" (\d+) are expected to fail to assemble, and (\d+) boot from ROM", flat)
if not said:
problems.append("the Test Manual no longer states the shape of the manifest")
else:
for index, (what, count) in enumerate((("tests", total),
("run tests", modes.get("run", 0)),
("assemble-only tests", modes.get("assemble", 0)),
("xfail tests", modes.get("xfail", 0)),
("rom tests", modes.get("rom", 0)))):
if int(said.group(index + 1)) != count:
problems.append("the Test Manual says there are %s %s, and there are %d"
% (said.group(index + 1), what, count))
said = re.search(r"The (\d+) `xfail` tests", flat)
if said and int(said.group(1)) != modes.get("xfail", 0):
problems.append("the Test Manual says %s xfail tests in one place and %d in another"
% (said.group(1), modes.get("xfail", 0)))
# ---- And the fixtures and the baseline ----
disks = len(re.findall(r'format "\$DISKS/', open("Tests/makedisks.sh").read()))
said = re.search(r"builds (\d+) images with SplitDisk", flat)
if not said:
problems.append("the Test Manual no longer says how many fixture disks are built")
elif int(said.group(1)) != disks:
problems.append("the Test Manual says %s fixture disks are built, and makedisks.sh"
" builds %d" % (said.group(1), disks))
pairs = sum(1 for line in open("Tests/lint-baseline.txt") if line.strip())
said = re.search(r"(\d+) file-and-rule pairs", flat)
if not said:
problems.append("the Test Manual no longer says how large the lint baseline is")
elif int(said.group(1)) != pairs:
problems.append("the Test Manual says the lint baseline holds %s file and rule pairs,"
" and it holds %d" % (said.group(1), pairs))
# ---- And the tool count is the number of things the makefile builds ---- # ---- And the tool count is the number of things the makefile builds ----
#
# Claimed in both documents, so both are read.
makefile = open("makefile").read() makefile = open("makefile").read()
built = re.search(r"^all:(.*)$", makefile, re.M) # TOOLS rather than the all target, which now depends on whether Raylib is installed.
# What "the tools" means should be a fact in one place, not read off a conditional.
built = re.search(r"^TOOLS = (.*)$", makefile, re.M)
if not built: if not built:
problems.append("the makefile no longer has an all target this can count") problems.append("the makefile no longer has a TOOLS list this can count")
else: else:
tools = len(built.group(1).split()) tools = len(built.group(1).split())
for said in re.findall(r"(?:build|rebuild) (?:all )?(?:the )?([a-z]+) tools", rootReadme): for where, text in (("README", rootReadme), ("Test Manual", manual)):
if words.get(said) != tools: for said in re.findall(r"(?:build|rebuild)s? (?:all )?(?:the )?([a-z]+) tools",
problems.append("the README says the %s tools and the makefile builds %d" text, re.I):
% (said, tools)) if words.get(said.lower()) != tools:
problems.append("the %s says the %s tools and the makefile builds %d"
% (where, said, tools))
# ---- The sizes the CosmOS README quotes for its own programs ---- # ---- The sizes the CosmOS README quotes for its own programs ----
# #
+29 -29
View File
@@ -1,4 +1,4 @@
 #  #
# #
### ###
@@ -14,7 +14,7 @@
 
# # # #
## ##
# #
@@ -30,7 +30,7 @@
 
# #
# # # #
## ##
@@ -46,7 +46,7 @@
 
# #
## ##
## ##
@@ -62,7 +62,7 @@
 
# #
# #
### ###
@@ -78,7 +78,7 @@
 
# # # #
## ##
@@ -94,7 +94,7 @@
 
# #
# # # #
@@ -110,7 +110,7 @@
 
# #
## ##
@@ -126,7 +126,7 @@
 
# #
# #
@@ -142,7 +142,7 @@
 
# # # #
@@ -158,7 +158,7 @@
 
# #
@@ -174,7 +174,7 @@
 
# #
@@ -190,7 +190,7 @@
 
# #
@@ -206,7 +206,7 @@
 
@@ -222,7 +222,7 @@
 
@@ -238,7 +238,7 @@
 
@@ -254,7 +254,7 @@
 
@@ -270,7 +270,7 @@
 
@@ -286,7 +286,7 @@
 
@@ -302,7 +302,7 @@
 
@@ -318,7 +318,7 @@
 
@@ -334,7 +334,7 @@
 
@@ -350,7 +350,7 @@
 
@@ -366,7 +366,7 @@
 
@@ -382,7 +382,7 @@
 
@@ -398,7 +398,7 @@
 
@@ -414,7 +414,7 @@
 
@@ -430,7 +430,7 @@
 
@@ -446,7 +446,7 @@
 
+30 -30
View File
@@ -1,4 +1,4 @@
 #  #
# #
### ###
@@ -14,7 +14,7 @@
 
# # # #
## ##
# #
@@ -30,7 +30,7 @@
 
# #
# # # #
## ##
@@ -46,7 +46,7 @@
 
# #
## ##
## ##
@@ -62,7 +62,7 @@
 
# #
# #
### ###
@@ -78,7 +78,7 @@
 
# # # #
## ##
@@ -94,7 +94,7 @@
 
# #
# # # #
@@ -110,7 +110,7 @@
 
# #
## ##
@@ -126,7 +126,7 @@
 
# #
# #
@@ -142,7 +142,7 @@
 
# # # #
@@ -158,7 +158,7 @@
 
# #
@@ -174,7 +174,7 @@
 
# #
@@ -190,7 +190,7 @@
 
# #
@@ -206,7 +206,7 @@
 
@@ -222,7 +222,7 @@
 
@@ -238,7 +238,7 @@
 
@@ -254,7 +254,7 @@
 
@@ -270,7 +270,7 @@
 
@@ -286,7 +286,7 @@
 
@@ -302,7 +302,7 @@
 
@@ -318,7 +318,7 @@
 
@@ -334,7 +334,7 @@
 
@@ -350,7 +350,7 @@
 
@@ -366,7 +366,7 @@
 
@@ -382,7 +382,7 @@
 
@@ -398,7 +398,7 @@
 
@@ -414,7 +414,7 @@
 
@@ -430,7 +430,7 @@
 
@@ -446,7 +446,7 @@
 
@@ -462,7 +462,7 @@
 
+9 -4
View File
@@ -2,15 +2,20 @@ stage two
no /System/Boot/missing.bin no /System/Boot/missing.bin
trying the fallback trying the fallback
CosmOS CosmOS
> saved it > Files
saved it
read it back, 22 bytes: read it back, 22 bytes:
a file kept by asking a file kept by asking
renamed it renamed it
deleted it deleted it
and it is gone and it is gone
finished finished
> made > mkdir Notes
> /Notes> 0 files made
/Notes> halted > cd Notes
/Notes> dir
0 files
/Notes> exit
halted
Execution halted. Execution halted.
[exit 0] [exit 0]
+12
View File
@@ -0,0 +1,12 @@
ordinary highlighted
ordinary highlighted
ordinary highlighted
ordinary highlighted
ordinary highlighted
ordinary highlighted
ordinary highlighted
ordinary highlighted
bank 2's ink is orange now, because this program said so
Execution halted.
[exit 0]
+12 -4
View File
@@ -1,17 +1,22 @@
CosmOS CosmOS
> dir list what is on the disk > help
dir list what is on the disk
load <file> read a program off the disk load <file> read a program off the disk
run [words] start what was loaded, and tell it those words run [words] start what was loaded, and tell it those words
<name> [words] look where you are and then in /Apps, and start that <name> [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 cd [path] go to a directory, or to the root with nothing after it
mkdir <path> make a directory mkdir <path> make a directory
rmdir <path> remove an empty one rmdir <path> remove an empty one
do <file> run the lines in a file, which must start with #!
echo [words] say them
clear empty the screen
delete <file> take it off the disk delete <file> take it off the disk
rename <file> <to> call it something else rename <file> <to> call it something else
monitor look at memory, change it, and jump into it monitor look at memory, change it, and jump into it
help this help this
exit stop, or leave the monitor if you are in it exit stop, or leave the monitor if you are in it
> greeting.txt 17 > dir
greeting.txt 17
filler1.txt 8 filler1.txt 8
filler2.txt 8 filler2.txt 8
filler3.txt 8 filler3.txt 8
@@ -24,7 +29,10 @@ across.txt 700
empty.txt 0 empty.txt 0
aName22CharactersLong! 22 aName22CharactersLong! 22
12 files 12 files
> > I do not know: frobnicate >
> halted > frobnicate
I do not know: frobnicate
> exit
halted
Execution halted. Execution halted.
[exit 0] [exit 0]
+4 -2
View File
@@ -1,6 +1,8 @@
CosmOS CosmOS
no filesystem on the disk no filesystem on the disk
> no filesystem on the disk > dir
> halted no filesystem on the disk
> exit
halted
Execution halted. Execution halted.
[exit 0] [exit 0]
+9
View File
@@ -0,0 +1,9 @@
CosmOS
> drive 1
drive: nothing this can read is in that drive
> drive
0
> exit
halted
Execution halted.
[exit 0]
+6 -3
View File
@@ -1,6 +1,8 @@
CosmOS CosmOS
> loaded, starting at 4000 > load Break.sbx
> two stops, and what the registers were at each loaded, starting at 4000
> run
two stops, and what the registers were at each
break at 4016 break at 4016
A 11 B 22 Q 00 status 00 A 11 B 22 Q 00 status 00
DP0 2030 DP1 2000 DP2 2037 DP3 4000 SP FFFF DP0 2030 DP1 2000 DP2 2037 DP3 4000 SP FFFF
@@ -11,6 +13,7 @@ DP0 2000 DP1 2037 DP2 2030 DP3 4000 SP FFF5
press a key press a key
carried on to the end carried on to the end
finished finished
> halted > exit
halted
Execution halted. Execution halted.
[exit 0] [exit 0]
+43 -20
View File
@@ -1,27 +1,50 @@
CosmOS CosmOS
> 0 files > dir
> made 0 files
> made > mkdir Apps
> made made
> Apps <dir> > mkdir Apps/Deep
made
> mkdir Notes
made
> dir
Apps <dir>
Notes <dir> Notes <dir>
0 files, 2 directories 0 files, 2 directories
> /Apps> Deep <dir> > cd Apps
/Apps> dir
Deep <dir>
0 files, 1 directory 0 files, 1 directory
/Apps> cannot make that: check the path, the name, and whether it is taken /Apps> mkdir Deep
/Apps> made cannot make that: check the path, the name, and whether it is taken
/Apps> /Notes> Deep <dir> /Apps> mkdir /Notes/Deep
made
/Apps> cd /Notes
/Notes> dir
Deep <dir>
0 files, 1 directory 0 files, 1 directory
/Notes> cannot remove that: it must be a directory, and empty /Notes> rmdir /Apps
/Notes> removed cannot remove that: it must be a directory, and empty
/Notes> that is a directory /Notes> rmdir /Apps/Deep
/Notes> removed removed
/Notes> > cannot make that: check the path, the name, and whether it is taken /Notes> delete /Apps
> cannot remove that: it must be a directory, and empty that is a directory
> cannot remove that: it must be a directory, and empty /Notes> rmdir /Notes/Deep
> removed removed
> removed /Notes> cd /
> 0 files > mkdir Apps/Deep/Inner
> halted cannot make that: check the path, the name, and whether it is taken
> rmdir Apps/Deep/Inner
cannot remove that: it must be a directory, and empty
> rmdir Apps/Deep
cannot remove that: it must be a directory, and empty
> rmdir Apps
removed
> rmdir Notes
removed
> dir
0 files
> exit
halted
Execution halted. Execution halted.
[exit 0] [exit 0]
+6 -3
View File
@@ -1,10 +1,13 @@
CosmOS CosmOS
> claiming more than was reserved was refused > Claim
claiming more than was reserved was refused
and the size it really came to was taken and the size it really came to was taken
finished finished
> Claim.sbx 580 > dir
Claim.sbx 580
claim.dat 266 claim.dat 266
2 files 2 files
> halted > exit
halted
Execution halted. Execution halted.
[exit 0] [exit 0]
+28 -14
View File
@@ -1,30 +1,44 @@
CosmOS CosmOS
> copied > Copy /Input/empty.dat /Output/empty.dat
copied
finished finished
> the same > Compare /Input/empty.dat /Output/empty.dat
the same
finished finished
> copied > Copy /Input/exact.dat /Output/exact.dat
copied
finished finished
> the same > Compare /Input/exact.dat /Output/exact.dat
the same
finished finished
> copied > Copy /Input/tail.dat /Output/tail.dat
copied
finished finished
> the same > Compare /Input/tail.dat /Output/tail.dat
the same
finished finished
> copied > Copy /Input/large.dat /Output/large.dat
copied
finished finished
> the same > Compare /Input/large.dat /Output/large.dat
the same
finished finished
> different > Compare /Input/large.dat /Input/different.dat
different
finished finished
> copy: cannot find the source > Copy /Input/missing.dat /Output/missing.dat
copy: cannot find the source
finished finished
> compare: cannot find the second file > Compare /Input/large.dat /Input/missing.dat
compare: cannot find the second file
finished finished
> copy: give me a source and destination > Copy
copy: give me a source and destination
finished finished
> compare: give me two files > Compare /Input/large.dat
compare: give me two files
finished finished
> halted > exit
halted
Execution halted. Execution halted.
[exit 0] [exit 0]
+39
View File
@@ -0,0 +1,39 @@
CosmOS
> drive 1
> Copy 1:/twoblocks.txt 0:/crossed.txt
copied
finished
> drive
1
> Say from drive one
it says: from drive one
finished
> drive
1
> cd 0:/
> dir
greet.sbx 211
hello.sbx 53
Life.sbx 1396
Snake.sbx 2164
Keys.sbx 664
Say.sbx 156
Break.sbx 149
Grid.sbx 559
Press.sbx 872
notes.txt 21
Apps <dir>
hi.script 121
bad.script 45
plain.script 24
cross.script 280
nonl.script 38
outer.script 376
inner.script 44
loop.script 35
crossed.txt 560
19 files, 1 directory
> exit
halted
Execution halted.
[exit 0]
+42 -22
View File
@@ -1,36 +1,56 @@
CosmOS CosmOS
> Apps <dir> > dir
Apps <dir>
A <dir> A <dir>
B <dir> B <dir>
0 files, 3 directories 0 files, 3 directories
> /A> Say.sbx 53 > cd /A
/A> dir
Say.sbx 53
notes.txt 25 notes.txt 25
2 files 2 files
/A> these are the notes in A /A> Type notes.txt
finished
/A> /B> and these are the very different notes in B
finished
/B> it says: reached the one in Apps
finished
/B> /A> Hello, World!
finished
/A> > Apps <dir>
A <dir>
B <dir>
0 files, 3 directories
> /B> these are the notes in A
finished
/B> moved, and reading a bare name from there:
these are the notes in A these are the notes in A
finished finished
/B> and these are the very different notes in B /A> cd /B
/B> Type notes.txt
and these are the very different notes in B
finished finished
/B> no such file /B> Say reached the one in Apps
/B> that is not a directory it says: reached the one in Apps
/B> > Apps <dir> finished
/B> cd /A
/A> Say reached the one in A
Hello, World!
finished
/A> cd ..
> dir
Apps <dir>
A <dir> A <dir>
B <dir> B <dir>
0 files, 3 directories 0 files, 3 directories
> halted > cd /B
/B> Type ../A/notes.txt
these are the notes in A
finished
/B> Wander /A
moved, and reading a bare name from there:
these are the notes in A
finished
/B> Type notes.txt
and these are the very different notes in B
finished
/B> cd nosuchplace
no such file
/B> cd notes.txt
that is not a directory
/B> cd /
> dir
Apps <dir>
A <dir>
B <dir>
0 files, 3 directories
> exit
halted
Execution halted. Execution halted.
[exit 0] [exit 0]
+16 -2
View File
@@ -1,16 +1,30 @@
CosmOS CosmOS
> /abcdefghijklmnopqrst01> /abcdefghijklmnopqrst01/abcdefghijklmnopqrst02> /abcdefghijklmnopqrst01/abcdefghijklmnopqrst02/abcdefghijklmnopqrst03> /abcdefghijklmnopqrst01/abcdefghijklmnopqrst02/abcdefghijklmnopqrst03/abcdefghijklmnopqrst04> /abcdefghijklmnopqrst01/abcdefghijklmnopqrst02/abcdefghijklmnopqrst03/abcdefghijklmnopqrst04/abcdefghijklmnopqrst05> ...opqrst01/abcdefghijklmnopqrst02/abcdefghijklmnopqrst03/abcdefghijklmnopqrst04/abcdefghijklmnopqrst05/abcdefghijklmnopqrst06> ...opqrst02/abcdefghijklmnopqrst03/abcdefghijklmnopqrst04/abcdefghijklmnopqrst05/abcdefghijklmnopqrst06/abcdefghijklmnopqrst07> ...opqrst03/abcdefghijklmnopqrst04/abcdefghijklmnopqrst05/abcdefghijklmnopqrst06/abcdefghijklmnopqrst07/abcdefghijklmnopqrst08> dir list what is on the disk > cd abcdefghijklmnopqrst01
/abcdefghijklmnopqrst01> cd abcdefghijklmnopqrst02
/abcdefghijklmnopqrst01/abcdefghijklmnopqrst02> cd abcdefghijklmnopqrst03
/abcdefghijklmnopqrst01/abcdefghijklmnopqrst02/abcdefghijklmnopqrst03> cd abcdefghijklmnopqrst04
/abcdefghijklmnopqrst01/abcdefghijklmnopqrst02/abcdefghijklmnopqrst03/abcdefghijklmnopqrst04> cd abcdefghijklmnopqrst05
/abcdefghijklmnopqrst01/abcdefghijklmnopqrst02/abcdefghijklmnopqrst03/abcdefghijklmnopqrst04/abcdefghijklmnopqrst05> cd abcdefghijklmnopqrst06
...opqrst01/abcdefghijklmnopqrst02/abcdefghijklmnopqrst03/abcdefghijklmnopqrst04/abcdefghijklmnopqrst05/abcdefghijklmnopqrst06> cd abcdefghijklmnopqrst07
...opqrst02/abcdefghijklmnopqrst03/abcdefghijklmnopqrst04/abcdefghijklmnopqrst05/abcdefghijklmnopqrst06/abcdefghijklmnopqrst07> cd abcdefghijklmnopqrst08
...opqrst03/abcdefghijklmnopqrst04/abcdefghijklmnopqrst05/abcdefghijklmnopqrst06/abcdefghijklmnopqrst07/abcdefghijklmnopqrst08> help
dir list what is on the disk
load <file> read a program off the disk load <file> read a program off the disk
run [words] start what was loaded, and tell it those words run [words] start what was loaded, and tell it those words
<name> [words] look where you are and then in /Apps, and start that <name> [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 cd [path] go to a directory, or to the root with nothing after it
mkdir <path> make a directory mkdir <path> make a directory
rmdir <path> remove an empty one rmdir <path> remove an empty one
do <file> run the lines in a file, which must start with #!
echo [words] say them
clear empty the screen
delete <file> take it off the disk delete <file> take it off the disk
rename <file> <to> call it something else rename <file> <to> call it something else
monitor look at memory, change it, and jump into it monitor look at memory, change it, and jump into it
help this help this
exit stop, or leave the monitor if you are in it exit stop, or leave the monitor if you are in it
...opqrst03/abcdefghijklmnopqrst04/abcdefghijklmnopqrst05/abcdefghijklmnopqrst06/abcdefghijklmnopqrst07/abcdefghijklmnopqrst08> > halted ...opqrst03/abcdefghijklmnopqrst04/abcdefghijklmnopqrst05/abcdefghijklmnopqrst06/abcdefghijklmnopqrst07/abcdefghijklmnopqrst08> cd
> exit
halted
Execution halted. Execution halted.
[exit 0] [exit 0]
+20
View File
@@ -0,0 +1,20 @@
CosmOS
> cd 1:/notes
/notes> drive
1
/notes> cd 0:/
> drive
0
> cd 1:/2things
/2things> drive
1
/2things> cd /
> cd 2things
/2things> drive
1
/2things> cd 9:/
no such file
/2things> exit
halted
Execution halted.
[exit 0]
+42
View File
@@ -0,0 +1,42 @@
CosmOS
> drive
0
> dir
greet.sbx 211
hello.sbx 53
Life.sbx 1396
Snake.sbx 2164
Keys.sbx 664
Say.sbx 156
Break.sbx 149
Grid.sbx 559
Press.sbx 872
notes.txt 21
Apps <dir>
hi.script 121
bad.script 45
plain.script 24
cross.script 280
nonl.script 38
outer.script 376
inner.script 44
loop.script 35
18 files, 1 directory
> drive 1
> dir
other.txt 28
notes <dir>
2things <dir>
twoblocks.txt 560
2 files, 2 directories
> cd /notes
/notes> drive 0
> drive 1
/notes> drive 0
> drive 1
/notes> drive 3
drive: this machine has no such drive
/notes> exit
halted
Execution halted.
[exit 0]
+8 -4
View File
@@ -1,6 +1,8 @@
CosmOS CosmOS
> loaded, starting at 4000 > load Edit.sbx
> poem.txt, 0 lines loaded, starting at 4000
> run poem.txt
poem.txt, new file
> : : : : > : : > 1: alpha > : : : : > : : > 1: alpha
2: INSERTED 2: INSERTED
3: beta 3: beta
@@ -10,12 +12,14 @@ CosmOS
3: beta 3: beta
> written, 22 bytes > written, 22 bytes
> finished > finished
> poem.txt, 3 lines > run poem.txt
poem.txt, 3 lines
> 1: CHANGED > 1: CHANGED
2: INSERTED 2: INSERTED
3: beta 3: beta
> there is no such line > there is no such line
> finished > finished
> halted > exit
halted
Execution halted. Execution halted.
[exit 0] [exit 0]
+17
View File
@@ -0,0 +1,17 @@
CosmOS
> eco okecho ok 
ok
> Xecho okecho ok 
ok
> echo ok
ok
> echo okX 
ok
> echo kecho ok 
ok
> Xecho aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaecho aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa !
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!
> exit
halted
Execution halted.
[exit 0]
+12 -6
View File
@@ -1,14 +1,20 @@
CosmOS CosmOS
> hello.asm, 31 lines > Edit hello.asm
hello.asm, 31 lines
> finished > finished
> hello.asm, 31 lines > Edit hello.asm
hello.asm, 31 lines
> finished > finished
> the last program left 0, which is: it did what it was asked > Status
the last program left 0, which is: it did what it was asked
finished finished
> a line in it is longer than this can edit, so it has not been opened > Edit long.txt
a line in it is longer than this can edit, so it has not been opened
finished finished
> the last program left 1, which is: it did not > Status
the last program left 1, which is: it did not
finished finished
> halted > exit
halted
Execution halted. Execution halted.
[exit 0] [exit 0]
+18 -9
View File
@@ -1,17 +1,26 @@
CosmOS CosmOS
> one.txt 13 > dir
one.txt 13
two.txt 14 two.txt 14
2 files 2 files
> renamed > rename one.txt first.txt
> first.txt 13 renamed
> dir
first.txt 13
two.txt 14 two.txt 14
2 files 2 files
> gone > delete first.txt
> two.txt 14 gone
> dir
two.txt 14
1 file 1 file
> no such file > delete first.txt
> rename what to what? no such file
> there is no such file, or that name is taken > rename two.txt
> halted rename what to what?
> rename two.txt two.txt
there is no such file, or that name is taken
> exit
halted
Execution halted. Execution halted.
[exit 0] [exit 0]
+35
View File
@@ -0,0 +1,35 @@
CosmOS
> Say before Grid
it says: before Grid
finished
> Grid
finished
>
> Say after Grid
it says: after Grid
finished
> dir
greet.sbx 211
hello.sbx 53
Life.sbx 1396
Snake.sbx 2164
Keys.sbx 664
Say.sbx 156
Break.sbx 149
Grid.sbx 559
Press.sbx 872
notes.txt 21
Apps <dir>
hi.script 121
bad.script 45
plain.script 24
cross.script 280
nonl.script 38
outer.script 376
inner.script 44
loop.script 35
18 files, 1 directory
> exit
halted
Execution halted.
[exit 0]
+8 -4
View File
@@ -1,9 +1,13 @@
CosmOS CosmOS
> loaded, starting at 4000 > load hello.sbx
> Hello, World! loaded, starting at 4000
> run
Hello, World!
finished finished
> Hello, World! > run
Hello, World!
finished finished
> halted > exit
halted
Execution halted. Execution halted.
[exit 0] [exit 0]
+27 -12
View File
@@ -1,33 +1,48 @@
CosmOS CosmOS
> it says: hello there > Say hello there
it says: hello there
finished finished
> it says: spelled out in full > Say.sbx spelled out in full
it says: spelled out in full
finished finished
> it says: once more > run once more
it says: once more
finished finished
> Say.sbx 156 > dir
Say.sbx 156
dir.sbx 156 dir.sbx 156
notes.txt 21 notes.txt 21
notes.sbx 21 notes.sbx 21
4 files 4 files
> not a program > notes
> not a program not a program
> I do not know: notes.txt > notes.sbx
> I do not know: nosuchprogram not a program
> I do not know: abcdefghijklmnopqr > notes.txt
> I do not know: abcdefghijklmnopqrs I do not know: notes.txt
> dir list what is on the disk > nosuchprogram
I do not know: nosuchprogram
> abcdefghijklmnopqr
I do not know: abcdefghijklmnopqr
> abcdefghijklmnopqrs
I do not know: abcdefghijklmnopqrs
> help
dir list what is on the disk
load <file> read a program off the disk load <file> read a program off the disk
run [words] start what was loaded, and tell it those words run [words] start what was loaded, and tell it those words
<name> [words] look where you are and then in /Apps, and start that <name> [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 cd [path] go to a directory, or to the root with nothing after it
mkdir <path> make a directory mkdir <path> make a directory
rmdir <path> remove an empty one rmdir <path> remove an empty one
do <file> run the lines in a file, which must start with #!
echo [words] say them
clear empty the screen
delete <file> take it off the disk delete <file> take it off the disk
rename <file> <to> call it something else rename <file> <to> call it something else
monitor look at memory, change it, and jump into it monitor look at memory, change it, and jump into it
help this help this
exit stop, or leave the monitor if you are in it exit stop, or leave the monitor if you are in it
> halted > exit
halted
Execution halted. Execution halted.
[exit 0] [exit 0]
+17 -7
View File
@@ -1,19 +1,29 @@
CosmOS CosmOS
> loaded, starting at 4000 > load Keys.sbx
> keys, by interrupt. q stops. loaded, starting at 4000
> run
keys, by interrupt. q stops.
ab ab
the console has been handed back the console has been handed back
finished finished
> > keys, by interrupt. q stops. >
> run
keys, by interrupt. q stops.
cd cd
the console has been handed back the console has been handed back
finished finished
> > x examine, d disassemble, a assemble, s set, b bank, g go, exit leaves >
* bank 00 > monitor
* FE00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................ x examine, d disassemble, a assemble, s set, b bank, g go, exit leaves
* b program
bank 00
* x fe00
FE00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
FE10 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................ FE10 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
FE20 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................ FE20 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
FE30 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................ FE30 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
* > halted * exit
> exit
halted
Execution halted. Execution halted.
[exit 0] [exit 0]
+57 -55
View File
@@ -1,6 +1,8 @@
CosmOS CosmOS
> loaded, starting at 4000 > load Life.sbx
>  # loaded, starting at 4000
> run
 #
# #
### ###
@@ -16,7 +18,7 @@ CosmOS
 
# # # #
## ##
# #
@@ -32,7 +34,7 @@ CosmOS
 
# #
# # # #
## ##
@@ -48,7 +50,7 @@ CosmOS
 
# #
## ##
## ##
@@ -64,7 +66,7 @@ CosmOS
 
# #
# #
### ###
@@ -80,7 +82,7 @@ CosmOS
 
# # # #
## ##
@@ -96,7 +98,7 @@ CosmOS
 
# #
# # # #
@@ -112,7 +114,7 @@ CosmOS
 
# #
## ##
@@ -128,7 +130,7 @@ CosmOS
 
# #
# #
@@ -144,7 +146,7 @@ CosmOS
 
# # # #
@@ -160,7 +162,7 @@ CosmOS
 
# #
@@ -176,7 +178,7 @@ CosmOS
 
# #
@@ -192,7 +194,7 @@ CosmOS
 
# #
@@ -208,7 +210,7 @@ CosmOS
 
@@ -224,7 +226,7 @@ CosmOS
 
@@ -240,7 +242,7 @@ CosmOS
 
@@ -256,7 +258,7 @@ CosmOS
 
@@ -272,7 +274,7 @@ CosmOS
 
@@ -288,7 +290,7 @@ CosmOS
 
@@ -304,7 +306,7 @@ CosmOS
 
@@ -320,7 +322,7 @@ CosmOS
 
@@ -336,7 +338,7 @@ CosmOS
 
@@ -352,7 +354,7 @@ CosmOS
 
@@ -368,7 +370,7 @@ CosmOS
 
@@ -384,7 +386,7 @@ CosmOS
 
@@ -400,7 +402,7 @@ CosmOS
 
@@ -416,7 +418,7 @@ CosmOS
 
@@ -432,7 +434,7 @@ CosmOS
 
@@ -448,7 +450,7 @@ CosmOS
 
@@ -464,7 +466,7 @@ CosmOS
 
@@ -480,7 +482,7 @@ CosmOS
 
@@ -496,7 +498,7 @@ CosmOS
 
@@ -512,7 +514,7 @@ CosmOS
 
@@ -528,7 +530,7 @@ CosmOS
 
@@ -544,7 +546,7 @@ CosmOS
 
@@ -560,7 +562,7 @@ CosmOS
 
@@ -576,7 +578,7 @@ CosmOS
 
@@ -592,7 +594,7 @@ CosmOS
 
@@ -608,7 +610,7 @@ CosmOS
 
@@ -624,7 +626,7 @@ CosmOS
 
@@ -640,7 +642,7 @@ CosmOS
 
@@ -656,7 +658,7 @@ CosmOS
 
@@ -672,7 +674,7 @@ CosmOS
# #
 
@@ -688,7 +690,7 @@ CosmOS
## ##
 
@@ -704,7 +706,7 @@ CosmOS
## ##
 
@@ -720,7 +722,7 @@ CosmOS
### ###
 
@@ -736,7 +738,7 @@ CosmOS
## ##
# #
 
@@ -752,7 +754,7 @@ CosmOS
# # # #
## ##
 
@@ -768,7 +770,7 @@ CosmOS
## ##
## ##
 
@@ -784,7 +786,7 @@ CosmOS
# #
### ###
 
@@ -800,7 +802,7 @@ CosmOS
# # # #
## ##
# #
 
@@ -816,7 +818,7 @@ CosmOS
# #
# # # #
## ##
 
@@ -832,7 +834,7 @@ CosmOS
# #
# #
## ##
 
@@ -848,7 +850,7 @@ CosmOS
# #
## ##
 
+6 -3
View File
@@ -1,6 +1,8 @@
CosmOS CosmOS
> loaded, starting at 4000 > load Life.sbx
>  # loaded, starting at 4000
> run
 #
# #
### ###
@@ -19,7 +21,8 @@ CosmOS
stopped stopped
finished finished
> > >
>
halted halted
Execution halted. Execution halted.
[exit 0] [exit 0]
+31 -15
View File
@@ -1,10 +1,16 @@
CosmOS CosmOS
> x examine, d disassemble, a assemble, s set, b bank, g go, exit leaves > monitor
* b <program|data|number> x examine, d disassemble, a assemble, s set, b bank, g go, exit leaves
* there is no such bank * b nonsense
* loaded, starting at 4000 b <program|data|number>
* bank 00 * b 9
* 4000 47 00 20 00 SETD.0 2000 there is no such bank
* load greet.sbx
loaded, starting at 4000
* b program
bank 00
* d 4000
4000 47 00 20 00 SETD.0 2000
4004 72 10 SWI 10 4004 72 10 SWI 10
4006 47 00 20 44 SETD.0 2044 4006 47 00 20 44 SETD.0 2044
400A 72 10 SWI 10 400A 72 10 SWI 10
@@ -12,23 +18,32 @@ CosmOS
4010 27 1F INIB 1F 4010 27 1F INIB 1F
4012 72 11 SWI 11 4012 72 11 SWI 11
4014 47 00 20 5D SETD.0 205D 4014 47 00 20 5D SETD.0 205D
* 4000 47 00 20 00 72 10 47 00 20 44 72 10 47 00 20 7A G. .r.G. Dr.G. z * x 4000
4000 47 00 20 00 72 10 47 00 20 44 72 10 47 00 20 7A G. .r.G. Dr.G. z
4010 27 1F 72 11 47 00 20 5D 72 10 47 00 20 7A 72 10 '.r.G. ]r.G. zr. 4010 27 1F 72 11 47 00 20 5D 72 10 47 00 20 7A 72 10 '.r.G. ]r.G. zr.
4020 47 00 20 65 72 10 20 72 12 00 00 00 00 00 00 00 G. er. r........ 4020 47 00 20 65 72 10 20 72 12 00 00 00 00 00 00 00 G. er. r........
4030 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................ 4030 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
* bank 01 * b data
* 2000 61 20 70 72 6F 67 72 61 6D 2C 20 6C 6F 61 64 65 a program, loade bank 01
* x 2000
2000 61 20 70 72 6F 67 72 61 6D 2C 20 6C 6F 61 64 65 a program, loade
2010 64 20 6F 66 66 20 61 20 64 69 73 6B 2C 20 72 75 d off a disk, ru 2010 64 20 6F 66 66 20 61 20 64 69 73 6B 2C 20 72 75 d off a disk, ru
2020 6E 6E 69 6E 67 20 6F 6E 20 74 68 65 20 73 79 73 nning on the sys 2020 6E 6E 69 6E 67 20 6F 6E 20 74 68 65 20 73 79 73 nning on the sys
2030 74 65 6D 20 74 68 61 74 20 6C 6F 61 64 65 64 20 tem that loaded 2030 74 65 6D 20 74 68 61 74 20 6C 6F 61 64 65 64 20 tem that loaded
* bank 02 * b 2
* 0000 01 FF 00 00 00 00 00 00 01 FF 00 00 00 00 00 00 ................ bank 02
* x 0
0000 01 FF 00 00 00 00 00 00 01 FF 00 00 00 00 00 00 ................
0010 03 FF 08 00 00 00 00 00 01 20 01 00 00 00 00 00 ......... ...... 0010 03 FF 08 00 00 00 00 00 01 20 01 00 00 00 00 00 ......... ......
0020 00 FF 00 00 00 00 00 00 00 FF 00 00 00 00 00 00 ................ 0020 00 FF 00 00 00 00 00 00 00 FF 00 00 00 00 00 00 ................
0030 00 FF 00 00 00 00 00 00 00 FF 00 00 00 00 00 00 ................ 0030 00 FF 00 00 00 00 00 00 00 FF 00 00 00 00 00 00 ................
* that bank will not be written * s 0 FF
* bank 00 that bank will not be written
* * 8000 26 48 INIA 48 * b program
bank 00
* s 8000 26 48 D1 00 26 0A D1 00 18 12
* d 8000
8000 26 48 INIA 48
8002 D1 00 OUTA 00 8002 D1 00 OUTA 00
8004 26 0A INIA 0A 8004 26 0A INIA 0A
8006 D1 00 OUTA 00 8006 D1 00 OUTA 00
@@ -36,7 +51,8 @@ CosmOS
8009 12 AND 8009 12 AND
800A 00 ? 800A 00 ?
800B 00 ? 800B 00 ?
* Fault: 0x00 at Program Address 0x800A is not an instruction. * g 8000Fault: 0x00 at Program Address 0x800A is not an instruction.
H H
Execution halted. Execution halted.
[exit 1] [exit 1]
+10 -5
View File
@@ -1,6 +1,8 @@
CosmOS CosmOS
> loaded, starting at 4000 > load More.sbx
> first line loaded, starting at 4000
> run readable.txt
first line
second line second line
line 00: ABCDEFGHIJKLMNOPQRSTUVWXYZ line 00: ABCDEFGHIJKLMNOPQRSTUVWXYZ
line 01: ABCDEFGHIJKLMNOPQRSTUVWXYZ line 01: ABCDEFGHIJKLMNOPQRSTUVWXYZ
@@ -24,7 +26,8 @@ line 18: ABCDEFGHIJKLMNOPQRSTUVWXYZ
line 19: ABCDEFGHIJKLMNOPQRSTUVWXYZ line 19: ABCDEFGHIJKLMNOPQRSTUVWXYZ
-- more -- -- more --
finished finished
> first line > run readable.txt
first line
second line second line
line 00: ABCDEFGHIJKLMNOPQRSTUVWXYZ line 00: ABCDEFGHIJKLMNOPQRSTUVWXYZ
line 01: ABCDEFGHIJKLMNOPQRSTUVWXYZ line 01: ABCDEFGHIJKLMNOPQRSTUVWXYZ
@@ -58,7 +61,8 @@ line 27: ABCDEFGHIJKLMNOPQRSTUVWXYZ
line 28: ABCDEFGHIJKLMNOPQRSTUVWXYZ line 28: ABCDEFGHIJKLMNOPQRSTUVWXYZ
line 29: ABCDEFGHIJKLMNOPQRSTUVWXYZ line 29: ABCDEFGHIJKLMNOPQRSTUVWXYZ
finished finished
> first line > run readable.txt
first line
second line second line
line 00: ABCDEFGHIJKLMNOPQRSTUVWXYZ line 00: ABCDEFGHIJKLMNOPQRSTUVWXYZ
line 01: ABCDEFGHIJKLMNOPQRSTUVWXYZ line 01: ABCDEFGHIJKLMNOPQRSTUVWXYZ
@@ -84,6 +88,7 @@ line 19: ABCDEFGHIJKLMNOPQRSTUVWXYZ
line 20: ABCDEFGHIJKLMNOPQRSTUVWXYZ line 20: ABCDEFGHIJKLMNOPQRSTUVWXYZ
-- more -- -- more --
finished finished
> halted > exit
halted
Execution halted. Execution halted.
[exit 0] [exit 0]
+2 -1
View File
@@ -1,6 +1,7 @@
CosmOS CosmOS
no filesystem on the disk no filesystem on the disk
> no filesystem on the disk > dir
no filesystem on the disk
> >
halted halted
Execution halted. Execution halted.
+16
View File
@@ -0,0 +1,16 @@
CosmOS
> Press
a line, then keys. q stops.
61 62 63 64
keys:
80 up
82 left
86 delete
5A Z
done
finished
>
> exit
halted
Execution halted.
[exit 0]
+16
View File
@@ -0,0 +1,16 @@
CosmOS
> Press
a line, then keys. q stops.
61 62 63 64
keys:
80 up
82 left
86 delete
5A Z
done
finished
>
> exit
halted
Execution halted.
[exit 0]
+16
View File
@@ -0,0 +1,16 @@
CosmOS
> drive 1
> dir
0 files
> Copy 0:/Say.sbx 1:/Say.sbx
copied
finished
> dir
Say.sbx 156
1 file
> drive
1
> exit
halted
Execution halted.
[exit 0]
+32 -12
View File
@@ -1,24 +1,44 @@
CosmOS CosmOS
> nothing is loaded > run
> greet.sbx 211 nothing is loaded
> dir
greet.sbx 211
hello.sbx 53 hello.sbx 53
Life.sbx 1410 Life.sbx 1396
Snake.sbx 2168 Snake.sbx 2164
Keys.sbx 664 Keys.sbx 664
Say.sbx 156 Say.sbx 156
Break.sbx 149 Break.sbx 149
Grid.sbx 559
Press.sbx 872
notes.txt 21 notes.txt 21
8 files Apps <dir>
> load what? hi.script 121
> no such file bad.script 45
> not a program plain.script 24
> loaded, starting at 4000 cross.script 280
> a program, loaded off a disk, running on the system that loaded it nonl.script 38
outer.script 376
inner.script 44
loop.script 35
18 files, 1 directory
> load
load what?
> load nosuch.sbx
no such file
> load notes.txt
not a program
> load greet.sbx
loaded, starting at 4000
> run
a program, loaded off a disk, running on the system that loaded it
what should I call you? hello, Anachronaut. that is all I do. what should I call you? hello, Anachronaut. that is all I do.
finished finished
> a program, loaded off a disk, running on the system that loaded it > run
a program, loaded off a disk, running on the system that loaded it
what should I call you? hello, Claude. that is all I do. what should I call you? hello, Claude. that is all I do.
finished finished
> halted > exit
halted
Execution halted. Execution halted.
[exit 0] [exit 0]
+10 -5
View File
@@ -1,11 +1,16 @@
CosmOS CosmOS
> loaded, starting at 4000 > load Say.sbx
> nothing was said loaded, starting at 4000
> run
nothing was said
finished finished
> it says: notes.txt > run notes.txt
it says: notes.txt
finished finished
> it says: a longer thing with spaces > run a longer thing with spaces
it says: a longer thing with spaces
finished finished
> halted > exit
halted
Execution halted. Execution halted.
[exit 0] [exit 0]
+16
View File
@@ -0,0 +1,16 @@
CosmOS
> do hi.script
> echo saying what it is doing
saying what it is doing
> Say from a script
it says: from a script
finished
> echo
> Say and again
it says: and again
finished
> exit
halted
Execution halted.
[exit 0]

Some files were not shown because too many files have changed in this diff Show More