Compare commits

..
75 Commits
Author SHA1 Message Date
Anachronaut 5e85356245 Every thruster that catches pops, not only the first
A pilot already burning upwards who then adds a sideways thruster has lit
an engine, and that is what an engine lighting sounds like. So what is
watched is now the SET of thrusters rather than whether any of them is
lit: the bits on now that were not on before, which is an exclusive or
and an and and no comparison at all.

The rumble still asks the old question, because it is the right question
for it - struck when the first lights and not again until every one has
gone out. Restriking it when a second joins would start its attack over,
which is a stutter rather than an engine, so it keeps a flag of its own.

Pitches are as tuned by ear: 36 for the pop, 60 for the rumble.

Checked by two flights of the same length, one thruster held throughout
against one that gains a second in the middle. The rumble is identical in
both, so the whole of the difference is the extra pop: peak 11,452 against
15,540, and the recordings diverge at 3.19 seconds, which is the frame the
second thruster lights.
2026-09-04 18:30:17 -04:00
Anachronaut 388faafd04 A thruster you can hear, and a latch on arriving and leaving
Three patches, and the last of the sounds that were asked for.

The thruster bangs when it lights and rumbles while it burns. The rumble
is this game's first HELD note: its gate goes down when a thruster lights
and does not come up until every one is out. Only the edges matter - a
rumble restruck every frame would never get past its own attack, and a
bang struck every frame is a buzz - and the condition is the flames':
a held button with a dry tank is a pilot doing nothing.

Arriving latches two notes quickly. Middle C then the C above for taking
hold of the station, the same pair reversed for letting go, and two
octaves lower for the ground - the same shape in a different register,
because setting down and taking hold are the same kind of event. The
second note is PENDING rather than played: at an undocking the pilot is
mid-burn, and stopping the world for an eighth of a second to fit a note
in would be felt as the controls sticking. Where the game is stopping
anyway, runPend simply pumps it out.

Four channels for five sounds. The bang and the latch share one, because
a lander arriving either arrives or does not, and a crash ends the run.
That leaves the thruster its two, which it needs: a held note struck on
the same channel as the ignition bang would cut the bang off at the
moment it was meant to be heard.

---- A held note outlives the loop that was holding it ----

Landing while the thruster was still down ended the flying and then
waited to be told the message had been read, so the frame that would have
noticed the button coming up never ran. The engine roared under the
verdict and went on roaring until the machine stopped. Anything that
stops to wait hushes it now, and forgets last frame with it, so a thruster
still held when the waiting ends counts as lighting again.

---- Three checks that had to be rebuilt around the new noise ----

A landing is not silent any more, so the crash is measured against the
second BEFORE it rather than against a quiet landing. The dust samples
moved eight frames later, because the latch plays first. And the warning
check lost its measure twice: counting bursts could not tell a beep from
a nag, and measuring total length stopped working the day the thruster
got a rumble. It burns, stops, and listens AFTER - warned once there is
nothing left, nagging the last beep is still fading. Nought against 5,679.
2026-09-04 17:34:16 -04:00
Anachronaut 6fe898b5fc A quarter-tank warning that says it once, and a gauge that keeps saying it
Two halves of the same number. The warning is the moment it happened and
the colour is how things stand: it fires on the way down through a
quarter of a tank and then holds its peace, and the gauge stays red until
a base fills the lander up, which also allows the warning again.

Once, because a lander is at its most careful in the last few seconds
before it touches, and something repeating in its ear through that is not
a warning, it is a distraction.

Checked where the fuel actually moves rather than once a frame - the tank
only changes in takeFuel and payFuel, so there is nowhere else it can
cross a threshold.

Channel two, with the crash on three. Separate channels rather than one
reused, because a crash while the warning is still sounding should not cut
it off, and on a machine with four voices there is no reason to be clever.

Half and a tenth are the obvious next thresholds and are deliberately not
here: one is enough to find out whether being told at all is welcome.

---- And the crash sound is the one that was designed for it ----

Crash.json, which carries voice_gate itself, so the program's own trigger
write is gone - it would have masked a deliberate choice rather than
backing one up. The note moved from 24 to 60 by ear, and the comment
saying it was low went with it.

---- A check that could not tell a beep from a nag ----

Counting bursts of sound cannot: without the latch the warning fires
every tick the tank drops, far faster than the sound decays, so the beeps
run into each other and a burst counter sees one long burst either way.
It measures the LENGTH now - 26,944 samples against 490,348 - which is
the difference between six tenths of a second and ten seconds of it.
2026-09-04 16:45:26 -04:00
Anachronaut de765267c5 Lunar Porter's crash is a designed sound now, not a guessed one
Crash1.json, made in soundThing and converted by SoundPatch. Two noise
oscillators fifteen cents apart, a filter opened 11.6 kHz by the
modulation envelope, and the LEVEL shaped by that same envelope rather
than by the amplitude one - which is exactly the freedom the routed level
was for, and not a thing the engine could express a week ago.

It peaks at 10,501 and is gone in about six tenths of a second, inside
the eight tenths the pieces are in the air. The guessed one wandered
between 1600 and 8200 and sounded like a drain.

The patch predates the gate field, so the program's own trigger write is
what makes it a one-shot - which is the argument for having put it there
rather than leaving it to the patch. LowFuel.json is here too, saved from
the newer soundThing with the gate and LFO mode in it, and is not wired
to anything yet.

Both JSONs live beside the tables they produce. The build has never
needed soundThing and still does not, but a sound can now be regenerated,
or simply read, without going to find the editor first.
2026-09-04 16:13:44 -04:00
Anachronaut b2ff8d64e5 Fold soundThing's changes back down, and expose the two new switches
The three changes that went up came back as part of soundThing, along
with two more that they made possible. The engine here is now b73e5c0
character for character, except that em-dashes and arrows in comments are
written as ASCII because this tree is ASCII only - a local rule, not an
improvement, and not sent up.

So synth.h's "what was changed" list is gone. There is nothing to list:
what has to be kept current is only that if either copy changes, the
other one has to be told.

---- What came back ----

A VOICE CAN END ITSELF. Naming the level's source said what shapes a
voice; nothing said what ends one, so the only thing that could ever
finish one was a key coming up. A game is nearly all one-shots and not
one of them wants its length decided by how long a note was held. Exposed
as parameter 0x51: 0 gated, 1 triggered.

AND A ONE-SHOT IS THE SAME ONE-SHOT TWICE. A triggered voice re-arms its
oscillators, and an LFO can be told to start over with each voice -
parameter 3 of either LFO. Both halves are needed and the check proves
it: with the LFO left free, two triggered hits still differ.

Their note warned that whatever applies a patch to a channel has to set
these or they hold synthInit's defaults. Checked: Voyager never calls
synthSyncVoices, so their 0001 is a no-op here as they predicted, and
nothing reaches into an LFO's phase, so the struct split is safe.

---- What it is for ----

Lander's crash is a triggered voice now, so boomOff is gone. Nothing has
to remember to end a bang.

SoundPatch learnt voice_levelSource, voice_gate and lfo<N>_mode, which
the new soundThing writes - without that it would have refused every
patch saved from it, since an unknown field stops the tool on purpose. A
patch from before those fields still converts, and says in its own
comments that it predates the level routing.

Three checks, each seen to fail on its own break: a gated voice still
sounding with nothing holding it, a triggered one down to nothing with no
gate ever dropped, and two hits identical sample for sample.

One test bug worth keeping: the first version of the repeatability check
struck the second note while the first was still ringing, so what it
found and compared as "the second hit" was a point in the middle of the
first one's tail. It now looks for sound after SILENCE rather than sound
after an offset.
2026-09-04 16:02:40 -04:00
Anachronaut a8b6b09a59 SoundPatch: design a sound where it can be heard, then convert it
The bang was guessed at directly in bytes and came out as a low gurgle,
which is what guessing at bytes gets you: a cutoff of 40 looks small and
is 57 Hz, so the filter sweep ended almost shut. Voyager's sound device
is soundThing's voice engine with the editor taken off, so a patch
designed in soundThing - where there is a screen, a keyboard and a pair
of ears - makes the same sound here. What differs is only how it arrives.

SoundPatch converts one into the other. Every parameter the device takes
is a documented function of a natural value and all of them invert: times
are squared into four seconds, cutoff and rate are exponential, depths
and detune are centred on 128. It writes a table of a count and that many
parameter and value pairs, and playPatch hands it to the device - so
every sound after this costs a table and a call rather than forty lines
of its own.

Two things the conversion has to say out loud. soundThing has no field
for the level routing, because there it is always the amplitude envelope,
so the table says so explicitly - a channel keeps its patch between notes
and a leftover from a previous one would otherwise be carried in. And a
field the tool does not recognise STOPS it: a patch format that has moved
on would otherwise produce a table that quietly means something else.

THE BUILD DOES NOT DEPEND ON IT. soundThing lives in its own repository
and is not needed to build anything here; the tables are checked in and
the tool is for when a sound is being changed.

Lander's crash now plays Kick808 as a stand-in until a bang is designed
for it, and the difference is the point: the hand-guessed patch wandered
between 1600 and 8200 for eight tenths of a second, and this decays
3492, 2743, 2037, 1515, 1040, 614, 87, nothing.

The docs check caught the tool count in two manuals, which is what it is
for.
2026-09-04 13:00:18 -04:00
Anachronaut 9ae59bfccb A bang for the crash, which is this game's first sound
Noise through a low pass that the modulation envelope shuts as the level
falls, so the bright part is only at the front of it: a boom rather than
a hiss. Noise because every other waveform here has a pitch, and a
pitched bang is a note.

The instrument is built at startup the way the tiles are, and a crash
only says "this channel, this note". That is what the selector and value
registers are for - a patch is twenty odd writes and a note is two - and
it is the shape every sound after this one should take.

CHANNEL THREE. There are four, and effects count down from the top so
that music, if it ever arrives, can take nought and count up and the two
never have to negotiate. This is also the first program to drive the
sound device while also doing something else; the only other customer is
the patch editor, whose whole job is the device.

---- A byte of envelope is not seconds ----

It is squared and scaled to four of them, so the decay first written here
was 200 - which is two and a half seconds. Over the eight tenths of a
second the pieces are in the air that is not a bang fading, it is the
FRONT THIRD of one, and it both sounded and measured as a flat wash of
noise. Ninety is about half a second and it fades to silence with time to
spare.

Two checks, each seen to fail on its own break. The first is against
SILENCE - a landing in the same conditions makes no sound at all, so it
is measuring the crash and not the machine humming - and the second is
that it is louder in its first half than its second, which is the
difference the decay was getting wrong.
2026-09-04 12:35:29 -04:00
Anachronaut 0264b19a3d Dust on landing, gas on letting go, and a lander that stays let go
One particle system, three uses now: a lander coming apart, dust kicked
up by a landing, and gas out of a docking port on release. Where they
start, how fast they go, what colour they are and how long they last are
arguments; everything else is shared.

Dust goes sideways and UP off the lander's feet, because that is where
kicked dust goes and there is ground in the way of the rest of it. Gas
goes evenly in every direction, because nothing is in the way of a
docking port. Neither happens on docking - a dock is a catch and not a
touchdown, and there is nothing under it to kick.

---- Ticked from the frame loop, not run in place ----

The explosion can afford to stop the world; there is nothing left to fly.
The undocking puff cannot, because it goes off on the frame a thruster is
pressed, and freezing a quarter of a second exactly then is felt as the
controls sticking. So a burst advances one frame at a time from the main
loop, and the two that can afford to wait just pump that same tick until
the air is clear.

---- And letting go did not let go ----

Which the puff is what found. A docked lander sits EXACTLY one tile under
the station, so releasing upwards moved it towards the station and it
docked again on the very next frame: took the fuel again, said so again,
and waited to be told the message had been read - which reads as the
controls locking up the instant they are used.

It has to get clear now before it can take hold again, and the two
distances have to differ: docking wants one tile, re-arming wants two.
A single distance re-armed on the frame it let go, because a tile is
exactly where it was sitting.

Three checks, each seen to fail on its own break. The last of them
measures HOW FAR the lander has got and not merely that it moved: a
sixteen frame pause on release still leaves it climbing, four rows short
of a free run, so "it moved" would pass for a stall that has been slept
through.
2026-09-04 12:23:31 -04:00
Anachronaut 115efa1fa1 A crash takes the lander apart, instead of just saying so
The verdict used to be the whole of it: a line of text and a lander still
sitting there in one piece, so somebody watching a recording had to read
the words to know what had happened. That is the same problem the flames
were for.

The lander goes, and both flames with it, and six pieces of it leave in a
rough hexagon at its own colour for about a second. Then they go too,
rather than hanging over the words. The world is not running while it
plays: it is a loop of its own, so nothing else in the program has to
know how to be half destroyed.

Yellow, which is the lander's own colour, because it IS the lander - and
it is only free to use because the lander itself is hidden by then. The
check that counts exactly forty pixels of yellow looks at a flying frame.

---- Two mistakes worth keeping ----

The block went in between touchdownCrash and touchdownStop, so a crash
fell into the explosion and returned from there - no verdict, no end of
run, and the lander sitting there being crashed into the ground again
every frame. The linter caught it as a subroutine nothing called walking
into, which is exactly what it was.

And copyWord goes DP0 to DP1, so setting the pieces off from the lander's
position had the pointers the wrong way round: it copied the empty pieces
OVER ShipX. Since that is in the view block, it took the lander's own
column with it, and the one piece that could be seen drifted out of the
top left corner of the screen.

Three checks, each seen to fail on its own break. They measure the SPREAD
and not the count: two of the six leave the top of the screen on the way,
so the count drops from twenty four to sixteen, which is correct and
would make an exact count a check that breaks the day a lander crashes
somewhere else.
2026-09-04 12:06:00 -04:00
Anachronaut c408fc6cf6 Thruster flames, so a watcher can see what the pilot is doing
Every reading on this screen is a number drawn as a bar - how fast
sideways, how fast down, how much sky, how much tank - and all of it says
what is happening TO the lander. None of it says what the pilot is doing
about it, so somebody watching over a shoulder has to read gauges to work
out that a thruster is even lit.

A plume hangs off whichever side the engine is pushing from: under the
lander to lift, over it to retro, and on the far side from the way it is
being pushed sideways, since that is the side the gas leaves. One tile
does up and down, because a vertical flip turns one into the other, and a
second does the sides, because a flip cannot rotate a tile a quarter
turn. Twenty four pixels each, on purpose, so a count of them means
something.

HELD, NOT FIRED. The engine fires one frame in ten, because that is the
tick gravity is applied on, and a flame that honest would be one frame of
light six times a second - a fault lamp, not a rocket. What is drawn is
the button being down, which is the truthful answer to "is the pilot
burning": the tick is how the sum gets done, not what is happening.

An empty tank draws nothing, and nor does the retro thruster on the
ground, because in both cases the button really is doing nothing. The
second of those was a lie the first version told.

Red, and that is by elimination again: white is the ceiling warning, cyan
the landing pads, magenta the instruments, blue the station, yellow the
lander itself - and the lander is counted as exactly forty pixels of
yellow, so a yellow flame would have broken it.

Three checks, each seen to fail on its own break. Two things the fixtures
taught: a lander placed at the world's origin sits BEHIND the two row
window, which reads exactly like a flame that is not drawn; and red has
to be looked for in a box round the lander rather than a column, because
the speed bars go red and one of the four pads is red too.
2026-09-04 11:49:38 -04:00
Anachronaut 418631a221 The orbit check is back, and osFileRead says it reads in blocks
Placing a state retired the reason the orbit check was deleted. Reaching
a given orbit through the controls takes a sustained burn while holding
height, and the phase of that burn against the gravity tick - one frame
in ten - decides whether the thruster is seen at all, so two pad files a
frame apart fly differently. The old check passed against one disk and
failed against another, which is a check measuring the boot time rather
than the physics.

Placed at eighty sideways it climbs to row 51, falls to row 190, and
climbs again to row 20 - and the turning points are BROAD, tens of pixels
across, so the samples have nothing like the margin problem the old one
had. Three claims: it climbs, it turns over on its own, and it comes
round again no lower than the first time.

Both halves of the mechanic are separately caught. Without the outward
push it sinks and lands and never climbs; without the exchange it climbs
away and never comes back, which is the one way trip the whole thing
exists to prevent.

---- And osFileRead writes whole blocks, which nothing said ----

A disk is read a block at a time, so a sixteen byte file still puts 256
bytes where it is told to. Reserving exactly the file's length writes
over whatever follows - a quiet corruption rather than a refusal, and it
looks like a bug somewhere else entirely. It cost an afternoon here: the
state buffer sat in front of the view tables, so the program read its
state, wiped the numbers every gauge draws from, and left immediately.

Said now in services.asm beside the vector and in the CosmOS manual,
along with the pattern that works: reserve the length rounded up to the
next 256, read into that, and copy the parts wanted where they are wanted.

The orbit fixture also needs its own keyboard file. The shared one holds
a key down every forty eight bytes for the held-thruster check, which
would fly this orbit as well as measure it.
2026-09-04 11:31:33 -04:00
Anachronaut 7e82b64d47 A dock that slides into line, and settles squarely on the port
Two things, and the second is the one that was actually wrong.

The lander is slid into place at half a pixel a frame rather than put
there. A dock is allowed eight pixels out in either direction, so
snapping moved it a whole tile in a single frame - a jump, at the very
moment the player was being told they had been careful. The worst gap
closes in about half a second, which reads as the two of them settling
together.

And it settles SQUARELY now. It used to come to rest four pixels out
however carefully it was flown, because the lander is drawn from half a
screen less half a tile - which is what centres an eight pixel lander on
the middle - while the station was drawn from half a screen exactly, so
its left edge sat where the lander's centre was. Both come off the same
origin now, and a gap of nothing puts one exactly above the other.

stationGap is factored out along the way. Three callers wanted it: the
one that draws the station, the one that decides whether it can be docked
with, and now the one that slides the lander in under it. The sideways
ease goes through that wrapped gap rather than the raw positions, because
a dock made either side of the moon's seam has a raw difference of most
of a moon.

---- And the fixtures moved to frame 400 ----

A dock waits to be told its message has been read, and reading a state
file costs a disk read, so a placed run starts a good deal later than a
plain one. The acknowledgement was at frame 100 and stopped working the
day the state file arrived: the program had not reached the dock yet and
the press went by unheard, which shows up as every sprite missing and
reads like a drawing bug. Everything after it is sampled well clear of
both ends.

Two checks, each seen to fail on its own break - the alignment settles at
4,-8 without the shared origin, and the slide reads 0,-8 the whole way
without the easing. Which of the two axes each one actually watches is
written down beside them, because it is not the one you would guess.
2026-09-04 11:23:54 -04:00
Anachronaut 1928275f87 A state can be placed, which four things were queued behind
Lander reads sixteen bytes from /lander.state if the disk has one and
starts from those: position, velocity, where the station is, fuel, and
which screen to be in. A disk without the file is the game as it always
was, which is every other flight in the suite.

It exists because some states cannot be flown to. A successful dock needs
the lander alongside the station and matched, and NINETY SIX pad files
failed to get there - not for want of trying, but because climbing spends
sideways speed, so a lander cannot rise while matched and arrives slower
than orbital every time. Reaching it wants two burns and a phase. The
orbit check had already been deleted for the same reason, and the strike
check was flown on a nineteen frame window, which is the sort of fixture
that ends up measuring the boot time rather than the physics.

Binary, because that is what a file is on this machine. A tool to build
one from readable text is a small job for another day; until then the
tests write the bytes with the fields named, which reads plainly enough.

---- The buffer is a whole block, and that is not caution ----

osFileRead lands a file in Data Memory and a file is stored in blocks of
256, so reading sixteen bytes into sixteen bytes of room writes over
whatever follows. It did: the first version put the buffer in front of
the view tables, and the program read its state, wiped the numbers every
gauge draws from, and left immediately - "finished" and back to the
prompt, with nothing on the screen to say why.

Four checks, each seen to fail on its own break: a state file places the
lander (row 192 and twelve cells of gauge, against thirty one with no
file), a matched approach docks and is paid once and not once a frame, it
then rides the station a tile under it, and the same approach unmatched
is a wreck. The flown strike fixture and its narrow window are gone.
2026-09-04 10:51:01 -04:00
Anachronaut 804dd3040e Docking, and a ceiling that is no longer the old screen's edge
The station can be docked with: close enough, and slow enough RELATIVE TO
IT, or it is a wreck. Its speed is orbital speed, which is what the marks
on the drift bar point at, so the instrument for this was on the screen
before there was anything to dock with. A dock pays eighty units of fuel,
once and not once a frame, and holds the lander a tile under the station
until a thruster lets go - checked before the speeds are put back, or a
burn would be wiped on the frame it was made and the lander could never
leave.

---- And the ceiling went up, which is what made it work ----

Sixty four pixels was the whole of the sky a forty column screen had over
the world's origin. It was never a fact about the world, it was a fact
about the view, and the wide one starts twenty four rows higher: a lander
stopped at the old line was stopped a long way short of the top of its own
picture for no reason it could see. It is 192 pixels now, the top of the
wide view.

The station went from four rows up to twelve - about two thirds of the way
from the ground to the ceiling. At four it sat exactly where anything
climbing away from the surface had to pass, and being run down there is
not a hazard, it is a toll: ELEVEN of this suite's flights were being run
down as collateral, including the ceiling check, which has to climb past
it to reach the ceiling at all. Moving both fixed all eleven at once.

The height bar divides by sixty four rather than thirty two, because the
band it describes is 368 pixels now and half a pixel of bar to a pixel of
sky would stand 184 tall and run off the top of the forty column screen it
is drawn beside.

---- What is checked, and what is written down instead ----

The wreck is flown. The fixture is narrow - the window measured 496 to 514
frames of climb and it sits at 505 - and that is said in place, along with
the instruction to re-measure before believing the code is broken. It was
not always narrow: at the old altitude any climb from 135 to 300 frames
struck. Narrow is the right way round, because it means the station is
hard to blunder into.

The successful dock is NOT flown, and ninety six pad files failed to find
it. That is not the search's fault: climbing spends sideways speed, so a
lander cannot rise while matched. It is measured working instead - tank
100 to 180, message up, and the pair holding together one tile apart for
two hundred and forty frames.
2026-09-04 10:37:52 -04:00
Anachronaut 922511c8a7 A retro thruster, at half the strength of the one that lifts
Arresting a rise meant a sideways burn and a wait for the orbit to come
back round. That is how a rendezvous really is flown and it is a lot to
ask of somebody who has not flown one before, so down now makes the
correction directly.

HALF THE STRENGTH, deliberately: one sixteenth a tick against two, which
is exactly gravity's own step. So it can stop a climb and it can hurry a
descent, and it can never turn a landing approach into a crash faster
than simply letting go would - the cheap way out of a mistake stays the
expensive one. Four against eight on a keyboard, which is the same ratio.

It does NOTHING to a lander on the ground, and that guard is load
bearing rather than tidy. touchdown has already had its say and returns
early once a lander is down, so there is nothing underneath to stop it
and no crash to say it happened: measured without the guard, holding it
drives the lander clean off the picture and spends a fifth of the tank
doing it.

---- And the fixture that hid all of that ----

The first version of the landed check passed just as happily with the
guard deleted, and the reason is worth writing down. A lander that has
landed WAITS to be told its message has been read, and the keyboard
fixture pads with NULs, which are not keys. So the program sat in that
loop for ever and the picture was frozen at the moment of touchdown -
every thruster held afterwards did nothing, which reads exactly like a
working guard. The pad now presses A to get past the wait before the
check tests anything, and both the row AND the fuel are read, because an
engine that fired and moved nothing would keep the row and one that moved
the lander for free would keep the fuel.

The altitude bar check upstream leans on that same freeze for its stable
end, and its comment said "stays landed" when what it means is "is not
running any more". Corrected, because that is the sort of comment that
sends the next person looking in the wrong place.
2026-09-03 23:02:19 -04:00
Anachronaut 800f555ffb A station in orbit, going round and not yet dockable
S1 of the station: it exists, it orbits, it wraps, it is drawn. Docking
is deliberately not here - the point of stopping at this rung is to fly
up and find out whether matching a four pixel a frame target feels good
before any rules are written about what happens when you reach it.

IT NEEDS NO PHYSICS OF ITS OWN. A body at 64 sixteenths is exactly what
a circular orbit is under the rules already here: the pull and the swing
cancel at that speed at ANY height, because gravity never falls off and
the moon is a cylinder that does not rotate. So the station is a
position, a constant, and the same fourteen bit wrap the lander uses.
That also means there is no prograde or retrograde to choose - nothing
privileges a direction, which is what makes a second station going the
other way a thing that can exist later.

Four rows above the world's origin: off the top of a forty column screen
and comfortably inside a wide one, so it is somewhere to go that the zoom
is needed to see. It starts half a moon away and a lap is 256 frames, a
little over four seconds, so it has to be found but will not stay lost.

Its column is the middle of the screen plus how far round it is from the
lander, wrapped to fourteen bits - which measures the long way round
whenever it is behind, so anything past the half way point becomes a
negative offset instead. Off the edge needs no test at all: a sprite's X
is signed and sixteen bits, so a station three hundred pixels to the left
is asked for at minus a hundred and forty and the device declines.

Blue, and that is not taste. White is counted to find the ceiling
warning, cyan to find the landing pads, magenta is the instruments and
yellow is the lander. Blue is the one ink no check measures, and picking
a measured one has broken a test twice already.

toPixelsSigned is factored out of showLander, since the station wants the
same sign-extended conversion.

Four checks, each seen to fail on its own break. Measured going round at
244 pixels in sixty frames, off the far side of the moon, and back on the
other edge.
2026-09-03 22:41:28 -04:00
Anachronaut 32559ce872 The room a zoom buys goes to the sky, not to the moon
Zooming out drew fifty rows DOWN from the world's origin, which put
exactly the same sky on the screen as before with twice as much moon
under it. Measured: 47 per cent rock zoomed in and 71 per cent zoomed
out. A zoom that shows you more of the thing you cannot fly through is
not worth a button.

The eighty column screen is fifty rows and the flyable band is thirty -
the ceiling is eight rows above the origin and the deepest valley is
twenty two below it - so the twenty rows a zoom buys have to go
somewhere. They go above. The wide view starts twenty four rows over the
origin, the ground sits near the bottom, and the whole band is on the
screen: 23 per cent rock instead of 71.

Which needed three things. The moon is drawn from row minus twenty four
rather than from nought, because rows above the origin are sky by
definition and because whatever the shell left in them is otherwise still
there. The row origin comes out of the view block like every other screen
number. And the lander's own Y moves with the view, which meant making
toPixels' answer SIGNED at last: it masks to twelve bits, so a lander
above the origin came back as a large positive number rather than a small
negative one - harmless while such a lander was off the picture either
way, and wrong the moment the view moved up to include it.

That is the point of the button. A lander at the ceiling is off the top
of a forty column screen, which is where the orbit lives and why the
altitude bar had to exist; zoomed out it is at row 149 and you can watch
the whole orbit.

Four checks, and the proportion is deliberately not on its own: measured
alone it passes for a moon floating over a void, because pointing the
view back at the origin leaves the rows under the terrain simply never
drawn, and black counts as sky. Both breaks that matter went straight
through it. What catches them is that the ground has to reach the bottom
of the screen and the sky has to be empty - the latter only on a shell
scrolled a hundred and eighteen lines deep, since that is what it takes
to get anything into the rows the wide view moves into.

The tap fixture also moved to frame 100. Fifty rows of moon take longer
to draw than twenty five, and a six frame tap at frame thirty now lands
before the program is reading a controller at all, which reads exactly
like a button that has stopped working.
2026-09-03 22:14:02 -04:00
Anachronaut 437ddf8ebe z zooms too, so a keyboard is not shut out of it
B was the only way to swap the view, and the game is meant to be flyable
without a controller - the arrows fly it when there is no pad. Somebody
without one had no way to zoom at all.

Tested ABOVE the pad test rather than beside the arrows, and that is the
distinction: which way the lander is flown is a question a controller
answers better, so the arrows stand aside for one. How much of the moon
is on the screen is not that kind of question, and a player with a pad
may still have a keyboard in front of them.

No edge to remember here either. The console delivers a key ONCE, which
is the whole difference between a key and a held button.

The check puts the z forty bytes into the keyboard file, because the
console hands over one key a frame: a z two hundred bytes in is a z two
hundred frames away, which is past the end of the capture and reads
exactly like a key that does nothing. It cost a wrong answer first time.
2026-09-03 21:54:12 -04:00
Anachronaut a02d701efe Two zoom levels on a button, which the device already had
Forty columns and eighty are the same map, the same 8x8 cells and the
same engine; only how many of them fit differs. The map is 128 by 128
either way and the moon is exactly 128 columns of it. And the front end
scales whatever it is handed by the largest whole number that fits, so
320 by 200 at four times and 640 by 400 at twice fill the same glass.

So the two modes ARE two zoom levels and nothing in the video device had
to change to get them: forty columns shows under a third of the moon at
twice the size, eighty shows nearly two thirds. Out for the orbit, in for
the landing, and B says which.

What did have to change is every screen coordinate in Lander, because the
middle of the screen is 160 on one and 320 on the other. They now live in
one block that setView copies over from whichever of two tables matches
the mode, so a gauge reads a variable and never has to know which screen
it is on. Several coordinates became sixteen bit on the way, since 620
will not go in a byte. follow already read HalfScreen and showLander
already writes the world position straight through, so the lander itself
needed nothing but its resting column.

The moon is redrawn on a swap because it is filled as many rows deep as
the mode shows, and a moon drawn 25 deep on a screen showing 50 floats
over nothing.

The swap is edge triggered. A pad is LEVEL and not an event, so a view
that swapped while B was down would swap sixty times a second - which is
not a zoom, it is a strobe, and it redraws the whole moon each time. The
check for that holds the button for three hundred frames and requires the
lander to land where a six frame tap left it; with the edge dropped it
ends seventeen pixels adrift, which is the strobe costing it frames.

Four checks, each seen to fail on its own break. The README's Lander
entry also still described the relief orbit that the previous commit
replaced, and now describes the one that is there.
2026-09-03 21:48:32 -04:00
Anachronaut 85029d3b85 An altitude bar, and orbital speed marked on the drift bar
The orbit takes the lander off the top of the screen, and the panel only
worked while the ground was in sight. Both other bars are rates: they say
how fast, and neither says where.

The altitude bar is height above the surface underneath, up the left edge,
half a pixel of bar to a pixel of sky - the flyable band is about 256
pixels and the screen is 200 tall, so pixel for pixel would run off the
top of the very screen it describes. Above the surface rather than above
some fixed line, so it reads NOUGHT the moment the lander is down.

The marks say where 64 sixteenths is. Without one that number is folklore:
a pilot can feel that somewhere around here the falling stops and has no
way to see where. The bar is a pixel a sixteenth from the middle at 160,
so the marks sit at 224 and at the two pixels before 96, adjacent rather
than overlapping - a bar at orbital speed would otherwise hide the thing
it is being measured against.

Both in magenta. Red and green are taken and they MEAN something here,
how fast and whether it can be landed with, and an altitude is neither.
White was the first choice and the ceiling check counts white to find its
warning, so it read a warning that was never up - the suite caught that.
Cyan was the second and it is the colour of a landing pad, which is
checked as whole cells of it.

groundLevel is factored out of restOnSurface, which had the same sum.

Three checks, each seen to fail on its own break. Two flights, because no
one flight holds both ends of the bar well: the climb reads 219 pixels and
the descent lands and then stays landed. They fly on their own keyboard
file - the shared one holds a key down every forty eight bytes for the
held-thruster check, so borrowing it flew the lander from the keyboard and
the controller at once and turned the gentle descent into a crash.
2026-09-03 20:57:16 -04:00
Anachronaut c514d5328e An orbit that comes back round, out of gravity minus the swing
The relief version could never make one. It only ever SUBTRACTED from
gravity, so a lander a little too slow sank for ever and one a little
too fast rose for ever - nothing in it could turn a fall around, because
nothing in it ever pushed up. There was no periapse to have.

Gravity minus the swing outwards has a sign change in it, and that is
the whole mechanic. Below orbital speed the pull wins and the lander
falls; above it the swing wins and the lander climbs; at 64 sixteenths
they cancel and it circles. Falling buys sideways speed and climbing
spends it, so a fall carries the lander past orbital and turns into a
climb, and the climb pays it back and turns into a fall.

The trade is the quarter square multiply, because the rate has to be
the PRODUCT of the two speeds. Set by the vertical speed alone it drained
a climb to nothing, and any minimum to stop that became a trap the climb
spent its way into - measured freezing at 23 with a gate of 24 and at 3
with a gate of 4. With the product in it there is no gate: as the
sideways speed goes to nothing the trade stops by itself.

Half the product rather than a quarter or the high half. The high half
alone is nought below a product of 256, which is a dead patch exactly
where the turn begins, and a quarter still ran the lander into the roof
before it came round - the whole sky is about 145 pixels.

Measured, placed at 80 sideways and left alone: apoapse at -1024 with 59
sideways, periapse at -124 with 71, and round again at 165, 241, 299 and
369 ticks with no sign of decay. A period of about 22 seconds.

The ceiling also spends one sideways when it wipes a climb. Without that
it was a trap with no way out: the pin wipes the climb, so the trade sees
neither fall nor climb and never touches the speed that is pushing the
lander up. Measured pinned at the top with 117 sideways, unmoving, for
the whole of a four minute flight.

Tests/makedisks.sh needed the library path, since Lander now includes
math.asm, and the app went from 2,924 bytes to 4,651 - mostly the 1,022
byte table - which is what the listing expectations move for.
2026-09-03 19:15:50 -04:00
AnachronautandClaude Opus 5 f5642f52b5 A ceiling that pins rather than ends
Climbing made a sixteen bit height count down past nought and round to
65535, so a lander that kept going came back through the bottom and hit
the ground FROM ABOVE. Two thousand pixels of climb, which a full tank
reaches easily.

Pinned instead, and told so in the window. Leaving upward is RECOVERABLE -
gravity is always there and a lander with fuel can always come back - so
ending the run would punish a state the player can fly out of. What kills
you out here is running dry a long way from the ground, which is a death
somebody flew into rather than one a boundary handed them.

TWO DIFFERENT LINES, and both were got wrong before they were got right.
The warning covers being AT the ceiling or above it: compared against the
ceiling itself, a pinned lander read as back inside the world the next
frame and the warning was written and wiped sixty times a second, so it
never appeared at all. The pin covers being STRICTLY above it: including
the ceiling dragged the height back every frame and the lander could never
descend, which is a lid nobody can leave and worse than the wrap.

And only the climb is spent, never the fall. Zeroing the speed outright
pinned it there for ever - gravity adds once a tick and a clamp running
every frame wiped the pull nine times out of ten.

The orbit check is gone, and the reason is in video.sh. Every window where
the difference showed turned out to be a few frames wide: hold the
thruster and both landers are pinned with their climbs spent, ease off and
both land and freeze. A version of it passed against one disk and failed
against another, which is a check measuring the boot time rather than the
physics. Orbit is verified by measurement and said to be so, rather than
left looking tested.

cosmosLanderDry wants seventy million cycles now instead of forty: it
spends the whole tank at the ceiling before it falls the length of the
world.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-03 18:42:34 -04:00
AnachronautandClaude Opus 5 d896c9d433 Multiplying, on a machine with no multiplier
a * b = qs[a + b] - qs[|a - b|]     where qs[n] is n squared over four

because (a+b)^2/4 minus (a-b)^2/4 is exactly a*b, and the halves the
flooring throws away cancel between the two terms. A multiply is two
lookups and a subtract.

AND THE TABLE IS BUILT BY ADDING, which is the part that makes it fit a
machine with no multiplier at all. A table of squares would need squaring
to fill; this one does not, because qs[n] = qs[n-1] + n/2, and n/2 goes 0,
1, 1, 2, 2, 3 - a number that steps up on every even n. So the whole thing
is a running total and a toggle, and nothing harder than an add appears
anywhere in building the thing that does the multiplying.

511 entries of two bytes, because a byte plus a byte reaches 510. That is
1,022 bytes of Data Memory, and it is the price: a kilobyte traded for an
operation the hardware has not got.

The operands go in memory rather than in registers. B cannot be stored and
a product does not fit in one byte anyway, so two in and two out would
spend more instructions shuffling than the multiply costs.

Checked against nought, the commutation both ways round, a square, and 255
times 255 - which is 0xFE01 and the largest product two bytes hold. The
square is the case the identity leans on hardest: the difference term is
nought and the whole answer comes out of one entry.

Wanted for Lunar Porter's orbit, where the trade between height and speed
has to be proportional to vx times vy and could not be. Useful well beyond
it: this is the routine every fixed point sum on this machine has been
doing without.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-03 18:07:07 -04:00
AnachronautandClaude Opus 5 a069ee7a00 Orbit, and two bars that answer instead of reporting
Going sideways lifts the moon off you. Not because gravity weakened -
because at speed the surface falls away underneath as fast as the lander
falls towards it, which is what an orbit is.

A GRADIENT OUT OF INTEGER ARITHMETIC. Gravity is one sixteenth of a pixel
a tick and there is nothing between that and nothing, so it cannot be
scaled down. Instead four times the sideways speed goes into a byte every
tick and the tick's gravity is skipped whenever that byte carries: the
fraction cancelled is the speed over 64, smoothly, with no multiply and no
divide. At four pixels a frame it carries every time. That is the linear
approximation; the honest one is the square, and wants a table.

It did nothing at all for its first two versions. Once because the relief
was a 256th a tick, so orbit wanted a speed no lander would reach; and
once because A IS THE HIGH HALF of the shift register, so multiplying by
four left the answer in A while the code read B, which is nought. The same
trap as the scroll register and the pixel conversion before it.

And a bar for the vertical speed beside the one for drift, both GREEN
WHILE A LANDING WOULD SURVIVE AND RED WHILE IT WOULD NOT. That turns two
numbers into one question - can I put down - and answers it at a glance.

WHAT THIS COST: the flown delivery check. A recording is a list of buttons
and not a flight, so replaying it under different gravity flies somewhere
else; the delivery became a crash two columns short. The fixture is still
there and is still a faithful record of what somebody did, and is no
longer a record of what happens.

That is the standing cost of a flown fixture, and it is worse than the
transcript tests dropped earlier: those broke when an output moved, and
this breaks whenever a NUMBER moves. Making the delivery reachable without
flying - a way to start already carrying, or at a chosen base - is what
would fix it properly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-03 16:25:56 -04:00
AnachronautandClaude Opus 5 4b1c3d8e3f A missing file is an error, not a core dump
Naming a pad file that is not there printed the error and then said the
machine had STARTED. MACHINE_OK is nought and the code returned nought, so
the front end ran a machine whose clock had never been set up and divided
by it: a typo in a path came out as a floating point exception and a core
dump.

The trap is two functions in one file with opposite conventions -
machineStart returns MACHINE_OK for worked, machineRestart thirty lines up
returns 1 for worked - and this copied the nearer one. Both of the returns
I added last week had it.

Checked now for all three files the replay suite is about, because the
same mistake fits all of them, and re-broken to be sure: the check comes
back exit 136, which is a signal 8, which is the crash.

Found by somebody typing a path that was not there, which is the fourth
thing this week that no test would have reached.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-03 15:50:40 -04:00
AnachronautandClaude Opus 5 60e0196fe2 A delivery, flown by hand and kept
Cyan base to red base: twenty five seconds of steering, recorded with
--record-pad and replayed as a test. THE FIRST FIXTURE HERE THAT WAS
PLAYED RATHER THAN WRITTEN.

It is the only check that a cargo ever reaches anywhere. Several attempts
at authoring a flight like it by hand got within two columns and no
closer, which is a piloting exercise rather than a test - and the whole
reason the recorder exists.

What is checked is the FIRST LETTER of what the base answers. Delivered,
Loaded, Nowhere and Not are 30, 22, 37 and 37 pixels of white in that
cell, so a D is a delivery and nothing else is. Counting the whole message
would pass on any message of the same length, and comparing the picture
would fail the next time anything about a font changed.

It took three flights to get here and two of them were lost to bugs in the
recorder: one that recorded the wrong pad, and one that recorded a pad
sampled on a different clock from the one the machine read. Both were
found by somebody watching a replay and saying it was not what they flew,
which nothing in this suite could have said.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-03 14:54:00 -04:00
AnachronautandClaude Opus 5 ee77d79780 A pad is sampled once a frame, and the recorder writes what was sampled
The live state is written by whatever watches real hardware, on ITS clock:
a window polls its keyboard once a HOST frame, which is not a machine
frame. Read straight through, that made a pad whose value could change in
the middle of a machine frame - breaking the one promise the device makes,
that asking twice in a frame gives the same answer both times. The manual
said it could not happen and the code allowed it.

It also made recordings that were not of the flight. The recorder sampled
on a frame boundary and the program read whenever it read, so the two saw
different bytes. A replay of that is a DIFFERENT FLIGHT, faithfully
reproduced: it flew a lander off the top of the screen that had never gone
there, and every check said the replay was deterministic and re-recorded
as itself, because it was. Both were true and neither was the point.

So the live state is latched once a frame. What the machine reads and what
the recorder writes are now the same thing by construction rather than by
two clocks happening to agree. Real hardware latches a controller once a
frame for the same reason.

WHAT IS STILL NOT COVERED: the latch itself. Every recorded pad already
changes only on a frame, so the tests cannot tell a latched live pad from
an unlatched one - the case that went wrong is the one with a real hand on
a real controller, which is the case a headless suite has none of. Said
here rather than left to look tested.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-03 14:43:58 -04:00
AnachronautandClaude Opus 5 de1857f5f7 Record every pad, not the one that happened to be first
The first recording ever made with this came back 1,766 frames of nothing.
It recorded pad NOUGHT and the controller was somewhere else - which pad
one lands on is an accident of the host, the same accident that made Lunar
Porter read all four in the first place - and a flight flown for the
purpose was lost to it.

So every pad is or-ed into the byte. A demo is a record of what somebody
DID, and on a machine one person is playing the number it arrived on is
not part of that. It plays back on pad nought, where --pad puts the first
file given, and any program that reads more than one pad reads them or-ed
anyway for exactly the same reason.

--record-pad takes one file now rather than filling pads in turn, because
there is nothing left for the second one to mean.

The check for it plays a recording on pad ONE with nought holding nothing
and requires the bytes back. That is the case that was missing: the round
trip was tested and passed, on pad nought, which is the only pad it could
not have gone wrong on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-03 14:27:58 -04:00
AnachronautandClaude Opus 5 eb695a3f3b --keyboard was a flag that did nothing behind a window
Voyager installed its own input hook after machineStart, which had already
installed the keyboard file's - so naming a keyboard file and running the
window silently got the window, and the flag said nothing about being
ignored.

Both together is the combination a demo wants. Recording a flight needs
the typing that STARTS it to be the same every time, because a human
reaching the shell a moment later shifts every frame of the recording
after it - while the flying itself has to come from whatever is actually
in somebody's hands.

So the window only takes the keyboard when no file was named.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-03 12:52:17 -04:00
AnachronautandClaude Opus 5 a163c670d0 A demo recorder: --record-pad writes what --pad reads
One byte a frame, in exactly the format the player takes, so a recording
needs no conversion and there is no second format to keep in step. That
symmetry is the feature, and it makes the strongest form of the claim
testable: a recording is made OF a playback, and the bytes coming out have
to be the bytes that went in.

It exists because some inputs cannot sensibly be written by hand. Flying a
lander from one base to another is a few hundred frames of steering that
has to arrive somewhere eight cells wide, and several attempts at
authoring one got within two columns and no closer. That is a piloting
exercise rather than a test. Playing it once and keeping what happened is
the answer.

A BYTE FOR EVERY FRAME, written inside the loop that advances the
recordings rather than after it, so a machine that jumped several frames
at once still writes one for each. A recording is a timeline: one that
skipped the frames nobody looked at would play back faster than it was
flown.

What is recorded is what the DEVICE WOULD REPORT, not the live state - a
recording of a playback that wrote the live state would be a file of
noughts. And it is flushed as it goes, because a recording is usually
stopped by whoever is playing rather than by the program ending, and a
demo lost to a buffer is a demo flown twice.

Tests/replay.sh is where this and whatever follows it are checked. Twelve
scripts now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-03 12:38:43 -04:00
AnachronautandClaude Opus 5 caf5e1f99d A base speaks in the window, not into the world
Two bugs with one cause. The console draws into the map, so a message
printed while flying was a message the lander then flew over - and
printing scrolls, so every one of them moved the whole world up a row. The
window is at a screen position and forty cells wide, and neither is true
of it.

So the window is two rows now: the gauge, and whatever there is to say.
The letters are ordinary tiles, because the character generator starts at
the space and glyph n is character n less thirty two. The rest of the row
is blanked after every message, or a short one would leave the tail of a
long one behind it.

Opening the throttle wipes the line, because a message that outlived the
moment would be read as describing this one.

The crash still goes to the console, deliberately: it is the last thing
the program says and it should survive the program.

A or Start continues from a message as readily as a key does. Somebody
flying on a controller should not have to reach for the keyboard to say
they have read something.

AND TWO TESTS WENT WITH IT, which is the interesting part. cosmosLanderSoft
and cosmosLanderPadOne asserted on lines in a transcript, and the lines
moved off the console - so both went on passing while checking nothing at
all. A test that asserts a side effect rather than the thing itself is
always one refactor from being decorative. What they were for is now
checked in the picture, where the message actually is.

The lander check moved earlier too. The window grew to two rows, so by 1.5
million cycles the lander had climbed behind the status bar - the window
doing exactly what it should, and leaving the check counting six pixels of
a forty pixel lander.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-03 12:23:12 -04:00
AnachronautandClaude Opus 5 bfb515e23b Cargo: bases with names, and a landing that is not an ending
Four bases, told apart by the scheme their pad is drawn in, so "the cyan
one" is a thing a person can say and a thing the machine already knows.
Yellow is missing on purpose: it is the lander, and a base the same colour
as the thing landing on it would be a poor joke.

Land empty at a base and it loads cargo for the base ACROSS THE MOON, two
along - so the pairs are cyan with red and green with blue, and the
wrapping surface is a route rather than scenery. Land carrying at the
right one and it takes the cargo and pays eighty units of fuel. Land at
the wrong one and nothing happens, which is why the destination will want
to be on the screen.

A LANDING NO LONGER ENDS THE RUN. The lander rests where it is, exactly on
the surface with both speeds zeroed, until the throttle opens again -
which is the only way to stop being landed. Gravity does not pull on
something already sitting down, and a base does not hand out cargo sixty
times a second to a lander parked on it.

The pad array holds the base's number plus one rather than a flag. Nought
still means no pad, so it is still one lookup, and a flag would have to be
followed by "and which of the four" - the same walk done twice for an
answer already in hand.

Pads are eight columns rather than four. Four was 32 pixels in a moon 1024
round, which is a target somebody flying by feel misses over and over.

WHAT IS NOT COVERED, and why: the delivery and wrong-base paths need a
lander flown from one base to another, and hand-authoring a recorded pad
input that hits an eight column pad across a 128 column moon is a piloting
exercise rather than a correctness one. Several attempts got within two
columns. Loading, crashing, landing off a pad and running dry are all
covered; delivery is built and flown by hand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-03 12:04:27 -04:00
AnachronautandClaude Opus 5 7257ad369c Landing pads, carved rather than looked for
A random walk does not leave flat ground and a lander wants some. Four
pads are cut into the moon after it is generated, each four columns
levelled to whatever height its first column happened to have - so they
sit in the landscape rather than on a shelf above it. The moon decides
where they are; this only decides that they are flat.

Searching for flat spots was the alternative and it can fail, which means
a fallback that carves anyway - the carving, plus a search nobody needed.

They are marked by an ATTRIBUTE and not a tile of their own, which costs
no art at all: a nibble is added to every index in a tile, so one solid
block is grey moon or a cyan pad depending on the byte beside it.

Which columns are pads is an array, because asking has to be one lookup.
Four comparisons per column per row is 12,800 of them for one screen, and
the landing verdict asks the same question again.

THE LANDER STARTS ABOVE ONE, because that is where a porter's day begins.
Starting in the middle of nowhere meant a straight descent landed in the
middle of nowhere, which is a fine thing to be able to do and a poor thing
to have to.

That change cost the crash test its teeth, and the way it did is worth
keeping. It held nothing at all and let the lander fall - and a short drop
onto the high ground of the base you started above is survivable, which is
correct, and left the test saying nothing. It holds Right now: lateral
speed has no limit and nothing slows it, so a slide always ends badly
however the rest is tuned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-03 11:45:30 -04:00
AnachronautandClaude Opus 5 6073086584 The system takes a leftover window down
Lunar Porter put a fuel gauge up and never took it away, so the shell came
back with FUEL across the top and the cursor underneath. Clearing did not
help: a window is a layer at a SCREEN position that does not scroll, which
is exactly what makes one left behind unpleasant - it sits over whatever
comes next and cannot be scrolled off, cleared away or typed past.

Taken away rather than given back, like the sprite table and for the same
reason: nothing the shell draws is a window, so there is nothing to
restore. And a program that FAULTED while one was up could not have taken
it down itself, which is why this belongs to the system rather than to
whichever programs remember.

The check for it needed writing twice, and the first version was the
familiar kind of wrong. It counted the gauge BAR's colour - and the bar
disappears on its own whatever happens, because it is drawn with a tile
the screen save puts back, so the check passed with the teardown deleted.
What actually survives is the LABEL, in font tiles the shell needs anyway.

So the screen is cleared afterwards and read cell by cell. With the window
down a cleared row is "> " and a cursor; with it up the same row is F, U,
E, L. Cells one and three being empty is the whole difference, and it is
29 and 22 pixels of it rather than a threshold somebody has to believe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-03 00:50:42 -04:00
AnachronautandClaude Opus 5 2274be4b68 Lunar Porter, rung three and a half: fuel
Every thruster costs a unit every tick it fires, so holding two at once
costs two - the honest price, and it makes a drift you corrected expensive
in a way a drift you avoided is not.

AN EMPTY TANK IS NOT AN ENDING. There is no message and nothing stops: a
lander with no fuel is still flying, it just cannot do anything about
where. What happens next is gravity, and gravity is patient. The test for
it holds the thruster from the first frame to the last and crashes anyway,
which is what says the fuel is real - a lander that could hold Up for ever
would land every time, and the economy this is the first half of would
have nothing to buy.

The gauge is in the window, which is what the window was built for two
commits ago: a bar at a SCREEN position, so the moon turning underneath
does not carry it off. Thirty five cells after a label, redrawn whole
every frame because seventy bytes out of one port is cheaper than working
out which of them changed.

A byte of fuel, and a byte is enough. Over eight it is a bar of up to
thirty one cells - a shift, because there is no divide - and at a unit a
thruster a tick it is about forty seconds of holding the engine open.
Sixteen bits would be more arithmetic for a number nobody reads to the
unit.

Cargo and the bases are the other half.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-03 00:35:34 -04:00
AnachronautandClaude Opus 5 17c8da111f A window: a layer that does not scroll
The map moves and this does not, which the map alone cannot express. The
scroll registers move ALL of it, so a score printed into the map slides
away, and one printed into whichever rows the view happens to be showing
jumps a pixel at a time as the fine offset changes.

Port 0x3D is how many rows tall and 0x3E is which row it starts at. Nought
tall is no window, so a cleared screen has none and every program written
before this means what it meant. A start row is a register because a
status bar along the bottom is as common as one along the top.

IT HAS ITS OWN MEMORY, and that is the argument for it. The cheaper design
draws the top rows of the MAP without the scroll applied - no new memory,
one register - and makes those rows part of the playfield's ring, so a
game that scrolls vertically has to route its world around its own
scoreboard for ever. The point of a status bar is that it is not somewhere
in the level. Lunar Porter does not scroll vertically today and will the
moment an orbit is a thing you can reach.

0xC000 in the screen bank, which the map does not reach: it ends at
0xBFFF. Same cells, same tiles, same pages, same schemes. Being in the
screen bank makes it per screen, so flipping the buffer flips the status
bar with it - what a double buffered game wants, and surprising the other
way round.

Drawn over everything, sprites included. A sprite that could cover the
fuel gauge would be a bug in every game that had both.

Tile modes only. In bitmap mode the picture is using that memory, so a
bitmap program pins things to the screen with sprites, which are in screen
coordinates for the same reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-03 00:19:09 -04:00
AnachronautandClaude Opus 5 e3eccd17a8 Settle the view before saying how the landing went
The console draws into the map and the map is what is being scrolled, so a
message printed while the view was three pixels into a cell came out three
pixels off the top, with as much of its first row missing as the cell above
it had lost.

The flying is over by then, so the fractional part of the view has no more
work to do. Putting it back is what makes the whole message visible.

This is not the general problem. A status bar that has to stay readable
WHILE the map moves is a different thing entirely, and nothing here solves
it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-03 00:02:59 -04:00
AnachronautandClaude Opus 5 a074a831f0 The shell scrolls, and the moon was drawn where nobody was looking
Lunar Porter never put the row origin back. The map is a ring 128 rows
tall that the screen shows 25 of, and the shell leaves that origin
wherever its last command finished - so a moon drawn into rows nought to
24 while the screen is reading from row forty is a moon nobody can see. It
came out as terrain missing, or half there, depending on how far down the
prompt had got. Running Pad first was enough; so was holding Return.

Nothing here is tidiness. It is the difference between the rows a program
WRITES and the rows the screen READS, and only one of those is under the
program's control. Grid has always known this; Lander did not.

The check for it needed writing twice. Forty returns caught nothing,
because the shell runs an eighty column screen which is FIFTY rows tall -
forty returns fill it and never scroll it, so the origin was still nought
and the test passed against a build with the fix taken out. The screenful
that matters is the one the shell is using, not the one the program is
about to ask for. At eighty it is 28,608 pixels of moon with the fix and
none at all without it.

break.sh is what said so. The first version of this check looked exactly
like a passing test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-02 23:51:48 -04:00
AnachronautandClaude Opus 5 f7be843ed9 Lunar Porter takes any controller, not the first one
A controller does not always arrive on pad nought. The front end hands out
the numbers the host gave it, so a game that reads only the first one
works on the machine it was written on and silently does nothing on the
next - which is the shape of "the pad is detected, Pad shows it, and the
game ignores it".

Four reads and three ORs. One person flies this and which socket they
plugged into is not a thing they should have to know. Presence is any of
the four bits rather than the low one, for the same reason.

The manifest's pad column takes several fixtures now, comma separated, and
they fill the pads in turn. So cosmosLanderPadOne holds nothing on pad
nought and flies the whole landing on pad one - a test that fails on the
version of this program that shipped an hour ago.

Also confirmed while looking: raylib 6 does refresh which gamepads are
ready every frame in PollInputEvents, so a hot-plugged pad should be seen.
Whatever is stopping that is above us and worth a separate look.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-02 23:36:05 -04:00
AnachronautandClaude Opus 5 6def343e97 Find Raylib's link flags instead of guessing one set
Raylib 6 calls X11 directly from GetClipboardImage, so a static libraylib
needs -lX11 on a Linux desktop where 5.6 did not - and because it is an
archive, one function nobody calls drags in the whole object and every X
symbol with it.

THE OLD SHAPE FAILED IN THE WORST AVAILABLE WAY. One hardcoded guess was
test-linked, and when the guess went short the probe reported that Raylib
was not installed - so make quietly stopped building Voyager and said so
in the words it keeps for a machine with no graphics library at all. The
answer looked like an absence and was a missing flag.

So the candidates are tried in order and the first that links is the
answer. The probe and the flags are the same thing now rather than two
facts that can disagree, which is the property that was actually missing:
a probe that tests something other than what gets built can only ever be
a coincidence.

Verified against 6.1-dev: Voyager builds and all 210 tests pass under it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-02 23:24:06 -04:00
AnachronautandClaude Opus 5 df50c2f0f8 The pad was working; the game was told there was not one
0x64 counted only the RECORDED pads. So a controller plugged into Voyager
reported its buttons perfectly, and every game asking whether there was a
controller was told no - which is exactly what Lunar Porter asked, once,
at startup, before falling back to the console for the rest of the run.

The cause is worth naming: a front end calls padSet every frame for every
pad, so "held nothing" is the commonest thing it says and cannot also mean
"there is no pad here". Connected is said separately now. Pad nought is
always there behind a window, because the keyboard is behind it - which is
the useful answer rather than the literal one.

And Pad.asm, which is what should have existed before any of that guessing
began. It prints a line whenever a pad changes, and tells apart the three
states that look identical from inside a game that will not respond: one
nobody noticed, one mapped to nothing, and a mapping that is wrong.

WHY A PROGRAM AND NOT A PRINT IN THE FRONT END: because the question is
what the MACHINE can see. A front end reporting what it thinks it is
sending answers a different question, and the gap between those two is the
whole of this bug.

It also found that osPrintNumber takes A as the HIGH half - the same way
round as the shift register and every other pair here, and not what a byte
in A wants. Every value came out 256 times too big.

Gravity is one frame in ten rather than six. The ratio between thrust and
gravity is the feel; how often the tick comes round is how fast that feel
arrives, and one in six was still touchy. Same lander, more time to think.

And the verdict waits for a key. It printed and left immediately, taking
the screen with it - so the one thing worth seeing, the lander sitting on
the ground it had just reached, was gone before it could be looked at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-02 22:48:24 -04:00
AnachronautandClaude Opus 5 8eb4e4d67e Lunar Porter, rung two: it lands, or it does not
The terrain is an array in Data Memory rather than something read back out
of the map, and that is the whole reason this is cheap: the ground under
the lander is one index into 128 bytes, where asking the screen would be a
transfer through the controller every frame.

The column is the world position over eight, masked to the moon's 128. The
surface is that column's row times eight - three turns left of the shift
register, since a row is at most 24 and 192 fits in the low half. The feet
are the lander's top plus its eight pixels.

WHAT DECIDES IS THE SPEED AT THE MOMENT IT ARRIVES. Both of them, and both
have to be gentle: three quarters of a pixel a frame downwards and half of
one sideways. Sideways is the tighter on purpose, because a landing that
was soft downwards and sliding is a lander on its side - which is the
interesting half of the difficulty, and the half the drift bar was blind
about until it existed.

Two fixtures say it works, and they differ only in what was held: one
holds nothing and falls the whole way, the other pulses the thruster six
frames in sixteen and survives. Same terrain, same seed, same keys.

Also: the gamepad did nothing, and the reason is that the four direction
buttons are the D-PAD. A lot of controllers made this century have one
nobody uses - the thumb goes on the stick, which reports as an axis rather
than a button - so a pad that was plugged in and working correctly did
nothing at all. The stick counts as held past halfway now. Untested here,
because there is no controller in this environment and the suite runs
headless; Voyager also says at startup which controllers it can see, so a
pad that still does nothing can be told apart from one nothing noticed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-02 22:33:32 -04:00
AnachronautandClaude Opus 5 db0c26e13f A bar for the drift, and a lighter touch sideways
A moon has no air, so a sideways drift never stops by itself and stopping
one means cancelling the velocity EXACTLY. That is not hard to do; it is
hard to do blind, which is what it was - a number nothing on the screen
said anything about.

So sprite one is a bar whose width is the drift. It runs right from the
middle of the screen for a rightward one and left for a leftward one, so
which way is as plain as how fast, and stopped is the one state with
nothing drawn at all. The whole of it is a target width written once a
frame; the device stretches one tile into it and the program draws
nothing.

Sideways thrust is one a tick rather than two. At two, the smallest
correction available was twice the size it needed to be and overshooting
was the normal outcome.

WHICH ZERO MEANS NOTHING TURNED OUT TO MATTER. A target width of nought is
the NATURAL width, not an empty sprite - so a bar with no drift in it came
out eight pixels wide, sitting at the middle of the screen, saying
"stopped" in the same shape it says "drifting slightly". What draws
nothing is a SIZE of nought, which is the other zero in the other byte.
Both meanings are deliberate and documented and it still caught me out
inside a week of writing them down.

The check for it earned its place by failing on that before it was found,
which is the best evidence a check can offer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-02 22:14:36 -04:00
AnachronautandClaude Opus 5 b1dde7908c Lunar Porter flies on a controller
A held thruster burns every tick it is held for, which is the whole reason
the pad exists: the console can only say a key went down, so a thruster
driven by it could be pumped and never leaned on.

The burn happens on the same tick gravity does, and for the same reason -
a sixteenth of a pixel is the smallest step this arithmetic takes, and
applied sixty times a second it is an enormous acceleration. On the tick,
thrust and gravity are two numbers whose RATIO is the whole feel of the
thing. Position still moves every frame; only the acceleration is stepped,
and nothing can see that.

Two against gravity's one, so climbing and falling are the same speed.
Three was the first try and it left the moon after about a second of
holding.

If there is a pad the console's arrows are ignored, because under a window
the same keypress reaches both - the pad as a level, the console as a byte
- and a thruster that fired twice for one press would be a mystery to
anybody tuning it. q still quits, since a pad has no letter for it. With
no pad the arrows still burn once a press, which is the most that can be
done down a wire.

And break.sh now rebuilds the disk images as well as the binaries. Half
the things worth breaking here are SplitBit assembly rather than C, and
those live on the fixture disks - so an edit to a .asm file changed
nothing the suite could see, and the tool reported that nothing caught the
break. That is the exact lie it was written to prevent, turning up in a
new place. With the disks rebuilt it catches this one: the lander falls
between the two captures instead of climbing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-02 21:58:31 -04:00
AnachronautandClaude Opus 5 fed1453e6e Controllers: four pads that say what is held
The console says WHICH KEY WENT DOWN, which is the right shape for typing
and the wrong one for playing. A game wants to know what is being held,
this frame, possibly several things at once, and a stream of presses
cannot say that: a key that is down and staying down sends nothing at all.
Lunar Porter's thrust is a burn per press for exactly that reason.

So a pad is its own device on ports 0x60 to 0x6F, reporting a LEVEL. One
read gives every button at once, holding is the natural thing to express,
two directions together cost nothing, and reading does not consume it - a
game may ask twice in a frame and be told the same thing both times.

Four of them, because a party is four. They cost a port each and nothing
at all when unused. The directions are the low nibble so "which way" is an
AND with 0x0F; the buttons are the high nibble for the same reason. 0x64
says which are really there, so a game can ask for a controller rather
than sitting silent while somebody presses things at it. They never
interrupt: a game polls once a frame because that is when it draws.

KEY-UP ON THE CONSOLE WAS THE OTHER WAY TO DO THIS AND WAS REJECTED. A
terminal hands over characters and can never report a key coming up
however it is asked, so it would have been a thing that worked behind a
window and silently did not down a wire. A separate device can honestly
say it is not there.

Voyager drives pad nought from the keyboard as well as from any real
controller, OR-ed rather than chosen between, so a game written for a pad
is playable on a machine with none and unplugging one mid-game does not
leave somebody holding nothing.

And a recorded path, which is what makes any of it testable: --pad names a
file of one byte a frame, and the manifest has an eighth column for it.
A BYTE A FRAME AND NOT A BYTE A READ - a level asked twice in one frame
has to answer the same both times, and a file that advanced per read would
depend on how the program happened to be written. Voyager's own tests run
headless with nobody holding anything, so without this the device would be
exercised only by somebody playing: the state the console's line editing
was in when it broke twice in two days.

0x50 is the timer, not free. The block this went in was chosen after
looking rather than before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-02 21:44:55 -04:00
AnachronautandClaude Opus 5 88ecb208f4 The coarse scroll register was being sent the wrong register
CALL eighth
  OUTA 0x36

RET puts A back the way it found it, so the column origin was written the
high byte of the position that had been passed in, and the answer the
subroutine had worked out went nowhere. The fine register was computed
inline with OUTQ and was correct, which is exactly what it looked like
from the outside: smooth scrolling within a cell that never advanced one.

Q is the only register that crosses a RET. Every other answer in this
program already came back in it; this one had been written as if A would
do, and A very nearly does, which is what makes it worth a comment rather
than a fix.

Gravity was Jupiter's. A sixteenth of a pixel per frame per frame is the
smallest step this arithmetic can take and it crossed the screen in a
second, so it is applied one frame in six instead - which divides the pull
by six and costs a byte and a compare. The alternative was a finer unit
for velocity than for position, and that means a shift every time one is
added to the other, twice a frame, for ever.

And the check that catches all this now looks 1.5 million cycles in rather
than twelve. The first number came from assuming a program that saves a
whole screen takes a long time to start; it does not, and by twelve
million the lander had flown seven hundred frames and left the picture.
A capture near the beginning is worth more than a tuned one - there is
less between it and the start that can move.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-02 21:13:09 -04:00
AnachronautandClaude Opus 5 d6c81fa32c Lunar Porter, rung one: it flies
A lander over a moon that wraps. Landing, crashing, fuel, cargo and bases
are not here - this rung exists to answer whether it FEELS right, because
everything after it is bookkeeping and none of it is worth building on a
lander that is no fun to fly.

The moon comes for free. The map's column origin is a ring in hardware, so
128 cells is 1024 pixels of surface with no edge and no seam to cross.

Position and velocity are sixteen bit in SIXTEENTHS OF A PIXEL, and the
unit is the design: gravity is a small number added to a velocity and a
velocity is a number added to a position, with no multiply or divide
anywhere. 1024 pixels is 16,384 sixteenths, which is 2^14 - so going all
the way round is an AND with 0x3FFF rather than a comparison, and it is
never wrong at the seam.

The lander never moves sideways. The world scrolls under it and it sits at
the middle of the screen, which is a byte a frame instead of two and is
also what makes the wrap invisible: there is no moment where it jumps.

One key is one burn. The console says which key went down and there is no
such thing as a key coming up, so a thruster cannot be held - a press adds
to the velocity once. That is a property of the machine rather than a
choice this program made, and it reads as pumping the engine.

Four bugs found by running it, all worth keeping written down:

  B CANNOT BE A LOOP COUNTER here. Every comparison is an INIB, so the
  count was overwritten by whichever bound was last tested and the loop
  reset itself for ever. Counters that outlive arithmetic live in memory.

  A subroutine answers in Q, and the AND after it read A. The moon came
  out flat because it was testing the height against 1 instead of the
  random number.

  Row minus height, not height minus row: they are equal at the surface,
  equal does not borrow, and the surface row has to be ground.

  And the shift register has A as its HIGH half. Written the other way,
  the view scrolled by 256 cells for every one it should have.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-02 16:52:01 -04:00
AnachronautandClaude Opus 5 5222c85100 More fills the screen it is on, not the screen it was written for
Twenty two lines was right when there was one screen size. It still is on
the forty column screen and wastes three fifths of the eighty column one,
so More asks the rows register instead - which is readable for exactly
this sort of reason.

Rows minus three is twenty two on a twenty five row screen, so nothing
changed underneath anyone already reading files this way. It fills a
bigger screen and leaves a smaller one alone.

A bitmap screen has no rows and says so with a nought, which through an
eight bit subtraction would be 253 lines. Anything under five falls back.

The existing test stopped testing paging the moment this worked: 32 lines
fits in a 47 line page, so the file never paged and the recording lost the
prompt entirely. The fixture is 60 lines now - the INPUT needed moving,
not just the output, which is the failure this project keeps meeting.

And cosmosMoreNarrow, which runs Mode first and pages the same file on the
forty column screen. Two recordings of one file at 47 lines and at 22: a
More that went back to a constant would make them the same length.

Both were verified with break.sh, and the first attempt was a bad break
rather than a bad test - it replaced one of two reads of the rows port and
the other still fetched the real value. Which is a fair argument against
reading a port twice, so it is read once and kept now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-02 16:18:37 -04:00
AnachronautandClaude Opus 5 fba1b553d2 Depth: a ball behind the near pillars and in front of the far ones
The demo for what V5 added. Four pillars at four distances, each ONE 8 by
8 tile stretched to its own width and height, and a ball walking past all
of them at a distance between two.

What it shows is the thing an ordering cannot. The ball is sprite NOUGHT
and every pillar is numbered after it, so table order puts the ball in
front of all four - and it is still hidden behind two of them, because the
depth buffer is asked per column. Caught mid-straddle in the checks: the
ball is 48 wide and the pillar 32, so it shows on both sides and nowhere
across the middle.

Writing it found the conceptual trap in the feature, which is now written
down where somebody will hit it. The pillars first carried their own
distance in their entries AND wrote that same distance into their columns,
so each was asked whether it was in front of itself - and 20 is not nearer
than 20, so all four vanished. THE BUFFER IS WHAT HAS BEEN DRAWN AND A
SPRITE'S DEPTH IS A QUESTION ASKED OF IT. Scenery writes it; it does not
ask.

Also found that a scheme only gives a colour to index one. The default
palette sets each scheme's paper and ink and nothing between them, so
art drawn in index two comes out black until a program writes a palette.

And the fixture disk's root directory was full: four blocks, 32 entries,
all taken, so adding an app failed the whole disk build. Loudly, which is
the right way round - but it is a wall that moves for free, so it is eight
blocks and 64 entries now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-02 15:58:34 -04:00
AnachronautandClaude Opus 5 cb898450b5 Sprites that scale, and a depth buffer to hide them behind
A target size in PIXELS rather than a multiplier, which is the whole of
why this is usable here. A billboard at distance d wants to be k/d pixels
tall, and that is a number a program has anyway - out of a lookup table,
most likely. A multiplier would have to be a fixed point fraction arrived
at by dividing, and this CPU cannot divide.

Zero on an axis means the natural size, so every sprite written before
scaling existed still means what it meant.

The two axes are independent, and that shape - one tile wide at its own
size, stretched to whatever height a distance says - is a wall column in a
pseudo-3D game. Measured: a DDA step costs 85 cycles, so 80 columns of ray
casting is about 85,000 cycles, or 12fps. Drawing those walls from the CPU
instead would be 256,000 writes, fifteen frames of cycles for one frame of
screen. The device doing the pixels is what makes such a game possible at
all here, not merely faster.

And a depth buffer, one byte a screen column at 0xD000, written by the
program. A sprite with a depth draws only in the columns it is in front
of. PER COLUMN, and that is the point: a billboard is nearer than the wall
at one end of itself and further at the other, and no ordering of the
table can say that. Table order settles sprites against each other; the
buffer settles them against the scenery. Zero means no test at both ends,
so a program that never writes it behaves as it did before it existed.

The entry grew from 8 bytes to 16 - now, while two programs use the table,
rather than once a game is written on it. Bytes 0 to 7 kept their
meanings, so Sprite.asm needed no change.

The pass is rewritten to walk where a sprite is GOING rather than where it
came from, which is what makes a stretch and a squash one operation. It
also made flipping fall out: turning the source coordinate round mirrors
the tile order and the pixels inside each tile in one step, where drawing
tile by tile had to be told to do both. All 111 checks passed unchanged at
natural size, which is what says the rewrite changed nothing it should not.

Clipping moved out of the inner loop and had to: a target size is sixteen
bits, so a sprite asked to be 60,000 pixels tall would have been sixty
thousand turns of a loop that drew eight rows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-02 14:07:38 -04:00
AnachronautandClaude Opus 5 f8c3db5d56 A tool for breaking things, since doing it by hand went wrong twice
A check that passes proves nothing until it has been seen to fail. Doing
that by hand failed twice in two days, and BOTH TIMES IT LOOKED LIKE A
RESULT - the suite ran, went green, and read exactly like "this check does
not catch that".

Once the edit produced code that would not compile, make failed, the exit
status was not looked at, and the previous binary ran the suite. Once the
anchor was right and the filename was wrong, so nothing was edited at all.

Neither had anything to do with header dependencies, which have always
worked: DEPFLAGS is -MMD -MP and every .d is included. What was missing
was a harness that refuses to report a result it did not earn.

So Tests/break.sh checks every step of its own work and treats anything
unexpected as a hard error rather than a green run. Not finding the break
is the answer it exists to give, and it is worthless if it can also be the
answer when the break never happened. It restores the file on the way out,
including on an interrupt.

It is not in the suite and docs.sh does not count it, for the reason
makedisks.sh is not counted turned round - but being left out of the count
is not being left out of the manual, and that gap is where a script goes
undocumented for months. So docs.sh now requires both of them to be
described, and caught this one being missing.

Also: video.sh reads the fixture disks and does not build them, so after
make sanitize clears the build directory it reported SEVEN product-looking
failures for a missing file. It builds them now and says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-02 13:19:22 -04:00
AnachronautandClaude Opus 5 9eed23120f Four pages of tiles, in bits that were already there
A tile number is a byte and a byte reaches 256, which is not many once a
font has taken 135 of them and a game wants a character, a background and
a wall. Bits 4 and 5 of the attribute now say which page of 256 the
number is in - bits already written on every cell and every sprite, and
reserved for this since the attribute was defined.

Four pages of 16K is 64K, which is the whole atlas, so THE FOURTH PAGE IS
THE MEMORY THE SPRITE TABLE AND THE PALETTE ARE IN. That is not a hole in
the design; it is the answer shared video memory has always given, and it
is checked rather than forbidden. The atlas is 1024 tiles, and what a
program spends on sprites and colours comes out of them: no sprites means
page 3 is art, and sprites means 768 tiles and a reason.

The page is a property of the CELL and not a mode, so one screen shows
tiles from all four at once and nothing has to decide which page it is in.

Both places a tile is drawn from now ask one function where the art is.
They would otherwise drift: the sprite pass was written days after the map
pass and neither is where the other is looked at.

Nothing in CosmOS changes. The shell draws from page 0, which the screen
save covers; a tile left in another page is invisible unless a map cell
names that page, and the map is given back or cleared.

Both breaks were tried and both failed the checks - and the second had to
be tried twice, because the constant it needed lives in video.h and the
harness was only editing video.c. That is the same silent no-op as
yesterday's uncompiled break, in a different disguise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-02 12:59:02 -04:00
AnachronautandClaude Opus 5 a916103a7f Sprites: things that move without the screen moving
Everything drawn on this machine was in a cell. Something between two
cells meant rewriting both; something moving a pixel at a time meant
rewriting them sixty times a second, which is affordable for one thing
and not for twenty. A sprite is put at a pixel and the device draws it
over whatever is behind, so moving it costs two bytes.

MADE OF TILES, which is the decision the rest follows from: m by n taken
in reading order from one index, so there is no second pixel format, no
second kind of memory, and nothing a sprite can show that the map cannot.
A 16 by 16 character is four tiles and the background can name the same
four.

256 entries of 8 bytes at 0xC000 in the atlas - eight so the entry
address is a shift, the same no-multiply argument as the palette's four.
Position is signed and sixteen bits, because 640 by 400 does not fit in a
byte and a sprite has to be able to sit half off the left rather than
appearing whole at the edge.

A PIXEL OF ZERO IS NOT DRAWN, or every sprite is a rectangle. Tested
before the attribute is added, so a hole belongs to the art and not to
the colour scheme. The same rule the other way round is what "behind"
means: drawn only where the background pixel was zero, so a thing walks
behind a pillar and in front of the floor in one frame.

All of them draw, every frame, so they cannot flicker. Real machines
dropped them per scanline because they had a fixed number of shift
registers; this has a loop. The limit is the size of the table, which is
a constant rather than a property of what is on screen.

And the system takes them down at exit. The sprite table sits in the gap
the screen save walks around - to the end of the map, then the palette -
and that is right, because nothing the shell draws is a sprite: there is
nothing to give back, only something to take away. Otherwise a program
that put a ball up and left would leave it over the prompt, in front of
everything, with nothing able to type it away. Sprite.asm deliberately
leaves its own, because a program that faulted could not have cleared it.

Every check here was re-broken and failed: transparency, reading order,
draw order, priority, and size. Size needed breaking twice - the first
attempt did not compile, and a silent build failure had left the old
binary passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-02 11:42:11 -04:00
AnachronautandClaude Opus 5 eee95ef0ce Flip says the true thing, and the checks that let it lie
Two bugs, both in what the demo claimed rather than in the device.

The assembler has no string escapes, so the "\n" written in a literal
printed as a backslash and an n. A newline is a byte; Say.asm has always
written one as 0x0A 0x00 and this now writes it out of the console port.

And the line whose whole job was to still be there afterwards was wiped
out on the way back, because the program called osTakeScreen - which
restores the screen AS IT WAS BEFORE, so the tidy-up erased the one thing
the demo was pointing at. It did not need saving: nothing it touches is
the shell's. A program that damages nothing should not ask, and asking
anyway costs it the screen it was standing on.

Which turned out to be untrue as written, and that is the third thing.
Flip drew with a tile of its own, and the system copies the font back
over every tile at exit - so the filled screen went blank the moment the
program left, and the check that the system put the display back could
not tell a restored screen from an abandoned one. It passed with the
restore deleted. So did the check that a program can show the other
screen at all: a blank screen counts as one colour just as well as a
filled one does.

Now it fills with 0x0A, which is an asterisk in one of the reversed
colour schemes: paper is the colour and ink is black, so a whole screen
is drawn with NO TILE REDEFINED and it survives leaving. Both checks ask
for the commonest colour in the picture rather than counting colours or
naming a pixel - the font's only blank glyph is the space, whose
attribute nibble is nought, so a filled screen is always a pattern and
which pixel lands on paper depends on the character.

Both were re-broken afterwards and both failed this time.

Also cosmosFlip, a transcript test, which is what would have caught the
printed backslash in the first place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-02 11:08:09 -04:00
AnachronautandClaude Opus 5 023362b05a A second screen, and one port to say which is shown
A screen drawn where it can be seen is seen half drawn. A program that
moves forty things and rewrites the map underneath them is wrong for as
long as it takes to put them all right, and at a megahertz that is long
enough to look at.

So the device brings a second screen bank, on port 0x3B, and port 0x3C
says which of the two is displayed. Everything a program draws into the
other one is invisible until one byte shows the whole of it at once.

ONE REGISTER IS ENOUGH, where the hardware this imitates needed two. The
other said which screen the CPU's window pointed at; there is no window
here, because a program reaches a bank through the memory controller by
its number. Writing to the screen that is not shown is a matter of naming
its bank, and the device never has to be told.

And a flip cannot tear: a frame is drawn from one bank in one go, so a
flip either happened before that frame or happens before the next. There
is nothing to race, where the real machines had to catch the few lines
between frames to swap in.

The console draws into whichever screen is displayed rather than one of
its own, so a fault message lands where somebody can read it even if a
game had flipped. And CosmOS puts the displayed screen back at exit, the
way it already puts back the cursor and the ink: a program that faulted
while flipped could not have, and a shell that only came out right for
programs which remembered would come out wrong the day one crashed.

Flip.asm is the worked example. It deliberately does NOT restore the
display itself - that is the point of the paragraph above, and it is what
makes the system's guarantee the thing under test rather than the
program's good manners. Written the other way round first, where it
passed with the guarantee deleted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-02 10:31:29 -04:00
AnachronautandClaude Opus 5 66e7b84272 The screen is two banks: an atlas and a screen
Tiles and colours are written when a program loads; the map is written
whenever anything moves. Sharing one 64K bank made them compete for room
neither needed all of, and had a worse consequence than being cramped: a
bitmap covers the whole bank, so entering bitmap mode destroyed the font.
A program could not draw a picture and then say anything about it.

Split, each gets a whole bank. The atlas holds the tiles and the palette,
the screen holds the map or a bitmap, and a picture now costs the map and
nothing else. It also leaves 48K free in the atlas, which is where the
sprite table and a second page of tiles are going.

No new mechanism was needed. A bank is registered by naming the port that
owns it, so a device with two banks needs two ports that own memory: the
base port keeps the atlas, since tiles have been at 0x0000 since there was
a screen at all, and 0x3A owns the screen. The registry now answers
honestly about which ports in the block bring memory, where it used to say
all sixteen did.

CosmOS never addresses video memory except in one place - the screen save,
which walks 196 pages of it. The page number already says which bank a page
is in, so screenBankFor works it out rather than keeping a second list
beside screenPageFor. Grid and picture.asm register both banks; colours.asm
only touches the palette and needed none of it.

Tests/video.sh names the memory every write is for, because an address
cannot: tile 5 and bitmap pixel 5 are both 0x0005, and a helper that
guessed would be right for the tiles and silently wrong for a picture.

And picture.asm gained a check, because this change broke it and nothing
noticed - registering the second bank leaves DestBank pointing at it, so
the palette went into the wrong one and the picture came out black. It was
the only thing here found by looking rather than by a test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-02 10:09:53 -04:00
AnachronautandClaude Opus 5 2abc8281df Loops, and the scripting language is a language
while and for. Both only mean anything in a script, because a loop goes back to
the line that opened it and a prompt has no line to go back to - and both say so
rather than doing something surprising.

THE SCRIPT READER KEEPS THE POSITION OF EVERY LINE before reading it, which is
what makes any of this possible: by the time a line has been read the reader is
past it, and a line is not a fixed size to subtract. Three words per line, and
the block is read again on the way back so the pointer into it means what it
meant - the same thing nesting one script inside another already did, for a
different reason.

THE TWO LOOPS END DIFFERENTLY, and that is the design rather than an accident. A
while is taken away at its end and its own line asks the question again, so
nothing has to be remembered. A for is not: how many words it has used is kept
in the block, and its line reads itself again and counts one more off the front.
That is a byte in a block instead of a copy of the word list in every one of
them.

Blocks grew from a byte to a record of sixteen - state, kind, words used, and
where the line that opened it was - and sixteen because A and B are a shift
register, so four rotations turn a block number into its offset. The history and
the variables are addressed the same way for the same reason.

Nested loops, an if inside a loop, a loop inside a branch nobody takes, and a for
with no words: the last two run no times rather than once, which is the case
worth having a test for.

Three things found by running it:

textSame asks whether two WHOLE strings are the same, so "in red green blue" is
not "in". The word has to be split off before it is compared.

A for typed at a prompt complained about while, because both arrive at the same
place. One message that names neither is better than one that names the wrong
one.

And docs.sh caught a naming convention nobody had written down: it recognises a
packed name by its label ending in "Name", so ForName2 was silently not counted.
It failed the right way round - saying the run was shorter than the count claims
rather than passing - but the convention now lives where the names are and not
only in the checker.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-01 20:50:05 -04:00
AnachronautandClaude Opus 5 16f8232a35 Lines that are only run sometimes
if, else, end, and same.

IF TAKES A COMMAND, which is one rule rather than two and is why comparing
values needs no syntax of its own: "same" is an ordinary command that fails when
its two words differ, so "if same $a $b" falls out of the rule instead of being
an exception to it. Anything else that can fail is a question too - "if load
Snake.sbx" is a perfectly good one.

The shell already had the other half. LineFailed exists because a script stops at
the first line that did not work, so every command was already saying whether it
had, for a different reason entirely.

A BLOCK HAS TWO KINDS OF NOT-RUNNING. One where an else would turn it on, and
one where it would not - which is what an if pushes when something above it is
already being skipped. That is what makes nesting need no looking down the
stack: the top of it says everything.

A branch nobody is taking is not even looked at. The skipping happens BEFORE the
names are filled in, so a variable mentioned in a branch that is not running is
not an error - a line nobody runs must not be able to fail.

AND LINES MAY BE INDENTED, which they could not be before there was anything to
indent inside. Nobody writes an if inside an if without indenting what is in
them, and a leading space used to make the first word empty and match nothing.
Found by writing the test script the way anybody would write one.

CALL commandFailed became BRI commandFailed in nine places. It never returns - it
marks the line and branches to the prompt - so calling it was a lie that cost a
Stack frame each time, and fourteen other sites already branched. THE LINT RULE
FOUND THIS, three days after I wrote the rule and on my own code: two false
positives that were really the linter being right about a CALL that is not one.
It does not fix the leak on its own, since a failure inside any called routine
still abandons that frame, but it removes the cause of the commonest case and
makes the code true.

The mechanical edit then left a BRI prompt stranded behind one of them, and the
linter caught that too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-01 20:33:33 -04:00
AnachronautandClaude Opus 5 4b109f704c The shell starts each line with the Stack where it left it
Every failure in this shell abandons a frame. commandFailed is reached with CALL
and never returns: it marks the line and branches to the prompt, which is the
idiom every command uses and is why a failure needs no unwinding anywhere. What
it costs is the frame of that call and of everything between the prompt and it -
twenty bytes for a name that was never set, more from somewhere deeper - and
nothing ever gave them back.

MEASURED BEFORE IT WAS FIXED. Twenty failed lines moved the Stack Pointer from
FFFD to FE6D, and it only ever went one way.

Nothing had noticed because it takes thousands of failures to reach anything and
nobody types thousands of anything. A loop in a script would, which is why this
is worth doing before there are loops rather than after.

So the loop starts each turn from a known place. SystemStack is NOT that place:
it is taken when a program starts, so that the shell's Stack can be given back
when the program stops - which means it holds wherever the shell had got to at
that moment, the value that needs correcting rather than the one to correct
from. ShellStack is taken once, at boot, when nothing is happening.

Second use of MVDS in the system, and it earns it for the same reason as the
first: a Stack that is right by construction beats one that is right because
everybody remembered.

Break prints the registers, so the test is two dumps with eight failures between
them and a requirement that they agree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-01 19:29:20 -04:00
AnachronautandClaude Opus 5 a8707f29f0 Names for things
"set apps /Apps", and then "$apps" anywhere on a later line stands for it. A
name stops where a name stops - letters and digits - so it composes into a path
without anything having to be quoted, which is the whole reason a script would
want one.

THE SUBSTITUTION HAPPENS ON EVERY LINE THE SHELL IS ABOUT TO RUN, typed or read
out of a file, so the two behave the same and no command below has to know that
variables exist. Same shape as the line editing: one place the whole system
already flows through, rather than a decision made twenty times.

A NAME NOTHING WAS SET TO DOES NOT RUN THE LINE. Every other shell expands it to
nothing, and that is the wrong answer here: a mistyped name would quietly become
an empty path, which is the class of silent wrong answer the rest of this system
spends its effort refusing. It says so and the line counts as failed, which
stops a script - and the test proves that by running one, where the line after
it must not appear. Somebody who wants an empty value writes "set name" and gets
one, so the escape hatch exists and has to be asked for.

A NAME TOO LONG IS AN ERROR RATHER THAN A SHORTER NAME. Cutting it off at
fifteen characters was the first version, and it is the same fault wearing a
different coat: two names differing only after the fifteenth would be one
variable, and the complaint about a missing one printed a word nobody typed.

Eight slots of sixty four bytes - sixteen of name, forty eight of value - and
sixty four rather than eighty because A and B are a sixteen bit shift register,
so two rotations turn a slot number into its offset. The same trick the history
uses, and the reason neither needs a multiply this machine has not got.

TWO THINGS I GOT WRONG AND ONE I FOUND:

doSetVar ended in RET. It is BRANCHED to from the dispatch, not called, so that
RET went wherever the Stack happened to point - the same fault that formatted a
disk last week, in a command written three days after the rule was named. The
new lint rule does not catch this shape: it fires on falling INTO a subroutine,
not on a branch target that ends like one.

And a test of the expansion's answer, which is dead code: commandFailed does not
return. It marks the line and branches to the prompt, the way every failure in
this shell is reported, so the only way out of the expansion is the one where it
worked.

Which turned up a real leak, measured and not yet fixed: every failure that goes
through commandFailed abandons the frames between the prompt and the call. SP
goes from FFFD to FE6D over twenty of them, twenty bytes each. Its own commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-01 19:23:55 -04:00
AnachronautandClaude Opus 5 3c76934a9a Tab reaches the disk
Paths and programs, which is the half that makes it worth having. The first word
of a line is a command or a PROGRAM, offered under the name somebody would type
- the extension taken off - and anything after it is a file, offered as it
really is. A separator anywhere in the word says which directory to look in.

A directory answers with a separator on the end instead of a space, which says
what it is and lets the next part be typed straight away. The answer ending in
one is also what stops a space being added, so that is one test rather than a
flag.

PROGRAMS ARE LOOKED FOR WHERE THE SHELL WOULD LOOK to run one: where you are,
/Apps on the disk you are on, and /Apps on drive 0. Offering something the shell
would not find would be finishing a word into a thing that then does not work.
Drive 0's is skipped when that is already the drive, or every program in it
would be offered twice and nothing would ever be the only match.

Walking somebody else's directory means standing in it, which is the only way to
walk one here, so where the person was and which drive they were on are put down
first and restored whatever happens.

Three bugs, all found by running it:

THE DIRECTORY TEST WAS INVERTED. dir asks the same question the same way round
four hundred lines further up, which is what made it obvious once looked at.

THE /Apps WALK OVERWROTE THE TYPED PATH. The whole search runs a second time to
list the matches, and by then TabDir said "/Apps" - so a word that had named
nowhere went looking in the wrong place and listed nothing at all. Two ways into
the walk now, and the typed path is never written over.

AND LISTING ONLY KNEW ABOUT COMMANDS, because it was a second copy of the walk.
It is the same walk with a flag now: finding the answer and showing the matches
are the same question asked twice.

Also cosmosMonitor, which had been RE-BLESSED INTO MEANINGLESSNESS by the wall
move. It disassembles a loaded program, at an address the input names - and that
address moved a page while the recording was simply re-recorded to whatever came
out, which was a page of zeroes. It is pointed at 5000 again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-01 18:10:40 -04:00
AnachronautandClaude Opus 5 bb065fe221 Tab finishes a word somebody started
The first word of a line, against the shell's fifteen commands. One match goes in
with a space after it, because a word that can only be one thing is finished.
Several are folded into their longest common prefix and that goes in, which is
the most that can be said without guessing which was meant - and if that adds
nothing, the matches are listed and the line put back underneath.

THE LINE COMING BACK IS THE HALF I EXPECTED TO BE HARD and it was already
solved. The prompt has been reprinted somewhere else entirely, so the editor's
idea of where the line begins is wrong - but editAnchor works that out backwards
from where printing ended, precisely so it survives the screen moving. Listing is
a redraw it already knew how to do.

editInsert became editPut, a routine, because completing a word puts in several
characters and every one of them is that. Which cost a bug immediately: the old
inline code left the insertion point in A, and a RET puts A back to what the
caller had.

Two more bugs worth naming, both mine and both the same shape - a pointer that
had moved:

THE CANDIDATE'S START HAS TO BE KEPT. The comparison walks DP3 through the name
as it matches, so by the time a match is declared, DP3 points at the part AFTER
what was typed - and that is what got copied. "he" completed to "he" because the
answer taken was "lp".

AND THE INSERTION STOPS AT OR PAST, not exactly equal. With the wrong answer the
two counters passed each other and the loop ran off the end of the buffer,
filling the line with whatever was next in memory. They cannot pass each other
now, and the branch stays, because the cheaper failure is worth nothing.

MY OWN TEST HAD A HOLE and breaking the code found it. The later-word case
pressed Tab after a space, where there is nothing to finish anyway, so it passed
whether or not the shell checked which word it was on. It types "echo he" now,
which would become "echo help" if it did not.

The assembler's label table went past 1024 and is doubled. A ceiling reached
once will be reached again, and it is pointers into source already in memory.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-01 17:43:48 -04:00
AnachronautandClaude Opus 5 749fef8ce2 The shell's own words, as a table and not just a chain
The dispatch is a run of "is the line this name" comparisons. That is fine to
execute and impossible to WALK, and completing a half typed command needs to
walk them - so the names have to be data as well as code.

They nearly were already: DirName through ExitName were fourteen zero
terminated strings sitting back to back, which is a table by accident of layout.
This makes it deliberate. MonitorName joins them, the run is labelled, and a
count goes underneath because a run of strings does not say where it stops.

WHAT MAKES IT A TABLE IS THE ZEROES. Each name ends in one, so the next begins
after it: no pointers, no lengths, and adding a command costs a line.

Tests/docs.sh reads both the dispatch and the run and compares them, because the
two can disagree and every way they do is quiet. A command added to the dispatch
and not to the run simply never completes, which nobody would think to check by
hand. Something put BETWEEN the strings is worse: the walk ends there and takes
every command after it, and the machine goes on working perfectly except that
Tab knows about six things instead of fifteen.

All three break that way and say something useful. Putting one byte in the
middle of the run reports that it holds ten names against the fifteen claimed,
which points at roughly where.

Groundwork for Tab completion. Nothing uses it yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-01 17:21:30 -04:00
AnachronautandClaude Opus 5 b04e4b7d1c Finish making the tune a program
The user's conversion, which I reverted while I was working out whether it was
half done or broken. It was half done: an sbx application wants its #Include
above #Program, because services.asm ends in a #Vectors block and a #Base
written after that has no segment to be the base of.

So the include moves up, the bases move a page with everything else, and the
test starts it from the shell instead of booting it. It plays for 9,469,987
cycles, 9,423,527 of them waiting, which is the same 567 frames of music it
played as a boot image.

BEING A PROGRAM MEANS ITS VECTOR IS THE SYSTEM'S TO INSTALL. It brings the
screen's, so that it has a beat to play to, and CosmOS puts it in when the tune
starts and takes it back out when it stops - a thing a boot image never had to
have right, and the second program here to exercise the version two format at
all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-01 16:56:17 -04:00
AnachronautandClaude Opus 5 3449405b18 Give the system another page of each memory
CosmOS had 1,161 bytes of Program Memory left before the address applications
load at, and Tab completion is not going to fit in that with anything to spare.
So the wall moves up one page: the system keeps below 0x4FFF and 0x2FFF, and an
application is based at 0x5000 and 0x3000.

A PAGE IS A CHEAP THING TO GIVE IT AND AN EXPENSIVE THING TO RUN OUT OF. An
application still has 44K of Program Memory before the vector table and the
largest one here uses 7.5K, so what was taken from applications is space nothing
has ever asked for - while what the system gained is the difference between
building the next thing and counting bytes while building it.

Not doubling, which was the version that would have cost application space worth
minding. One page, and the same again when it is needed.

Nothing in the machine knows where the wall is, so this is 34 #Base lines, one
threshold in the fault handler, and the table in the CosmOS README that
Tests/docs.sh reads its limits out of.

The native assembler's scratch map had to move with it, and docs.sh said so
before anything ran: its data reached 0x40D6 and its buffers began at 0x4000, so
they were sitting on its variables. That file already carries a paragraph about
the floor coming up and the map staying where it was. It has happened twice now,
and been caught by a check the first time wrote.

Twenty three recordings are the same runs a page higher.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-01 16:49:12 -04:00
AnachronautandClaude Opus 5 f97d15de08 The font comes from a chip, not from RAM that remembers
videoReset zeroed video memory and then wrote the font and the sixteen colour
schemes into it, and the comment above that said out loud what was wrong with
it: "everything here is ordinary video memory". RAM does not wake up with
anything in it. That was the last piece of magic in this device, and it looked
harmless until something wanted the font BACK - a program that redefines a glyph
had destroyed the only copy there was.

So the device has a character generator, the way the machines this one is
pretending to be really did, and the copy into RAM is a thing it DOES rather
than a state it mysteriously starts in. Command port 0x39: bit 0 for the font,
bit 1 for the schemes.

THE RAM IS STILL RAM. A program may overwrite every glyph and every colour and
should be able to, which is what makes this a tile engine rather than a text
display. What changed is that it is no longer a one way door.

NEITHER COMMAND CLEARS WHAT IT DOES NOT OWN. The font used to clear the whole of
tile memory before writing itself, which was harmless while it happened only at
reset and is wrong the moment a program can ask: a program that defined a tile
of its own and then wanted its text back would have paid for it with the tile.

The reason it is a chip rather than a file on the disk, which was the other
candidate: the boot chain prints before CosmOS exists. Stage one prints "?" when
there is nothing to boot, and if the font came off the disk then the message
about the disk having failed would be the one thing that could not be drawn. A
system that wants its own font still loads one over the top - the ROM is the
floor, not the policy.

Two things that had been worked around now simply work. The shell asks for both
whenever a program exits, so a program that redefined a letter no longer leaves
it unable to spell, and Grid no longer needs to have saved the screen to avoid
handing back green text on blue. And the fault screen asks for the glyphs first,
because a message spelled in somebody's tile graphics is no message at all.

Five video checks. Two runs each for the font and the schemes, since the map
holds a tile NUMBER and the glyph is looked up when the frame is drawn - so
restoring changes every cell using it, including ones drawn before, and what the
two runs differ by is the command. The third guards the decision not to clear:
tile 200 has to survive the font coming back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-01 16:21:24 -04:00
AnachronautandClaude Opus 5 925388c2f2 Keep the personal disk three starts back
Copied before every start of the machine, and kept several deep rather than
copied over one file.

ONE BACKUP TAKEN AT EVERY START IS WORSE THAN NONE. The way a disk is lost is
that something goes wrong - and the very next thing anybody does is start the
machine again to see how bad it is, which is exactly when a single backup gets
overwritten by the wreckage. Three deep means the damage has to happen and then
be started past three times before the copy that would have helped is gone.

AND IT IS NOT GUARDED BY A CHECK THAT THE DISK STILL LOOKS RIGHT, because no
such check can be written. The disk that went missing this week was a perfectly
valid and perfectly empty filesystem: the format had succeeded, and there is
nothing to look at that says a disk has lost anything. That is the whole reason
to keep the old ones rather than to judge the new one.

The backups sit outside clean's reach like the disk itself. A backup a rebuild
deletes is not one.

The message the disk prints when it is first made said "nothing in this makefile
will touch it again", which stopped being true the moment this was added. It now
says written to, and says where the copies go.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-01 15:54:55 -04:00
AnachronautandClaude Opus 5 c1b3c4c156 A rule for the bug that formatted a disk
falls-into-subroutine. The code above a label ends without going anywhere and
the label is one something CALLs, so execution walks into the subroutine,
reaches its RET, and returns to whatever the Stack happens to hold - because
nobody called, there is no caller, and it goes somewhere nobody named.

It is worth a rule because the symptom is nowhere near the cause and changes
with the Stack. In CosmOS's monitor it was usually a byte that does not decode,
in the middle of newLine; once it was inside sbfsFormat, and the machine
formatted the disk it had booted from.

Two exemptions, and both had to exist or the rule would have reported well
written code:

A TAIL CALL IS THE SAME SHAPE AND IS FINE. Falling out of one subroutine into
another means the RET returns to the outer caller, which is real. So it only
fires when nothing since the last branch or return was a call target either -
which is the linter's usual trade of precision for being worth reading.

AND osExit NEVER RETURNS. It is how a loaded program gives the machine back, and
every program here ends with it and then writes its helpers underneath. Without
that, twelve well written programs were reported. It is the one name from the
system this tool knows, and the comment says why it is there.

Also SRET, which stopsFallthrough did not list. It returns from a handler
exactly as RET returns from a call, and leaving it out is a gap in every rule
that asks what reaches an instruction. Load bearing rather than tidy: without it
cosmos.asm reports a handler ending in SRET as falling into the routine written
under it.

A first pass over the file collects call targets, because a subroutine is very
often called from further down than it is written.

The corpus reports none of it, which is the point rather than a disappointment,
and the Test Manual now says so - a baseline entry that is absent is otherwise
indistinguishable from a rule that never runs. Checked against the version of
cosmos.asm from before the fix, where it names the line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-01 15:43:25 -04:00
AnachronautandClaude Opus 5 66be42d7bb A word the monitor does not know goes to the disk, not into the weeds
There was nothing at the end of the monitor's command list. An unrecognised word
fell off it and straight into sayPrompt - which is a ROUTINE, so its RET had
nothing of its own to return to and went wherever the Stack happened to be
pointing.

The user found it by typing a program's name at the monitor prompt, which is an
entirely reasonable thing to do: the monitor is a mode of the shell, so
everything the shell does is meant to work in it. What they got was a fault, and
before that a second prompt printed on top of the first - which is sayPrompt
doing exactly what it is for on its way past, and the tell that it had been
entered rather than called.

WHERE THAT RET WENT DECIDED HOW BAD IT WAS. Usually 0x0003, in the middle of
newLine, and the machine stopped on a byte that is not an instruction. Once it
was inside sbfsFormat, and the machine formatted the disk it had booted from -
the user's would not start again, and neither would mine, which is how I came to
have a reproduction before I had a diagnosis.

Pre-existing, and not recent: it is there at 2a29ceb and every revision I
checked back through.

The fix is one branch. cosmosMonitorRun covers all three cases the monitor now
has to handle - a program started by name, a program that faults, and a word
that is nothing at all - because the first of those is what the user did and the
last is what used to be fatal.

Worth naming as a shape: a run of tests falling through into a subroutine. The
symptom is not at the site, the failure depends on the Stack, and the damage is
whatever the return address happens to land on. SplitLint has no rule for it and
could have one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-01 15:30:04 -04:00
AnachronautandClaude Opus 5 fd9c4c75f8 Tell the person where it hurts
A fault stopped the machine and printed a line to standard error. On a terminal
that is a diagnosis. Behind a window it is a frozen picture and no reason at
all, because the message went somewhere nobody was looking - the machine looked
hung and was not. It had stopped, and said so invisibly.

CosmOS catches all five faults now and says what happened on the screen, with
the address, in red.

A FAULT ENDS THE PROGRAM, NOT THE MACHINE. That is the answer to "carry on or
start again", and it is not a compromise: a bare RETI from most of these meets
the instruction that failed and fails again, so carrying on was never on offer.
But the machine is almost never what is broken. Everything the shell puts back
when a program exits - the Stack, its vectors, the drive, the working directory,
the console, the screen - is exactly what wants putting back after one dies, so
the handler sets a status and joins handleExit. You are back at the prompt, and
the program is recorded as having STOPPED rather than finished, because saying
"finished" under a red fault message would be the shell contradicting itself.

A fault below where programs load is the system's own, and there is nothing to
go back to. That one says so and stops.

THE SCREEN GOES BACK TO A MODE TEXT CAN BE SEEN IN, and that is the part that
matters rather than the part that is prettiest. A program that faulted in bitmap
mode left the console with no text rows, so it draws nothing at all: the message
would be perfectly correct and completely invisible, which is the one thing it
must never be. Two palette entries go back for the same reason, since a program
that wrote its own colours can leave every ink the same as every paper. Only the
two the message needs, so the rest of what the program chose is left alone.

Both halves are checked by looking at the PICTURE, because the serial line was
never where the problem was. Crash blind ruins the palette and drops into bitmap
mode before it faults; without the mode the screen comes back 320 by 200 with
nothing on it, and without the palette it is the right size with the message
present and unreadable. Each break loses the red on its own.

Crash is also a program worth having: it breaks in whichever of the five ways
you name, so a fault screen can be looked at without having written a bug first.

Two things found on the way:

The native assembler keeps its OWN copy of the reserved vector names, so it did
not know NoHandler or NoDevice and built a cosmos.bin that differed from the
host assembler's. Caught by native.sh, which is exactly the drift that test
exists for.

And cosmosMonitor had dead input. It assembles code into 0x8000 and runs it, and
that code faults - which used to kill the machine, so everything after it in the
file had never run. It runs now, and the recording grew by sixty lines of
monitor session that had been unreachable since the day the fault was put there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-01 15:08:20 -04:00
AnachronautandClaude Opus 5 000a6d39cb Somewhere to send the fault about there being nowhere to send it
Dispatching through a vector with nothing in it was the one fault this machine
could not hand over, because the thing that would hand it over is the thing that
has just found nothing to hand it to. It stopped the machine and no program
could do anything about it - so calling a service the system does not implement
was fatal, and that is an ordinary mistake to make.

Two new fault vectors: 5 when a software vector was empty, 6 when a device
interrupted and its hardware entry was. Separate, because they are separate
mistakes with separate fixes - one is a program calling something that is not
there, the other a program that asked to be interrupted and forgot the handler.

WHICH ENTRY WAS EMPTY ARRIVES IN Q, and it is the only thing on this machine a
handler is given in a register. Not a fault cause register by another route: the
vector still says what happened and Q says which of the 256 entries it happened
about, which is a parameter and not a cause. It costs no new state at all,
because the frame already saved the Q the interrupted program had and RETI puts
it back.

The escalation happens once. If vector 5 or 6 is itself empty the machine stops
the way it always did, having genuinely run out of places to go.

swiFaultTest is what guards that, and it was written long before any of this: it
installs nothing, so it must still get the old halt. Breaking the escalation
fails the two new tests and not that one; making the escalation unbounded fails
that one and not the two new ones. Each break fails exactly the half it belongs
to.

noDeviceTest is fed no input on purpose. The console raises its line once when
input ENDS as well as when a byte arrives - which exists so a program driven by
interrupts is told when nothing more is coming - so with no input at all, that
end is what turns up.

Groundwork for CosmOS's fault screen, which wanted to catch these two and could
not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-01 14:40:53 -04:00
AnachronautandClaude Opus 5 4d976fc22a A program reading a line gets the editing too
osReadLine goes through the shell's editor now, so anything that asks the system
for a line gets arrows, Home, End and Delete. The editor is a program, and a word
typed with two letters the wrong way round can be put right without starting the
line again.

IT DOES NOT GET THE HISTORY, and that is the interesting half. Edit would
otherwise fill the history with the text of somebody's document, and pressing Up
in the middle of writing one would put "dir" into it. The history belongs to the
thing whose lines are commands. Two entry points rather than a flag the caller
sets first, so a caller cannot forget which it wanted.

And the console is put back the way it was FOUND rather than the way the shell
likes it. A program that had asked for key mode and then read a line through the
system used to be handed back a console in line mode having asked for nothing of
the sort. The status port reports all three things the control port can ask for,
in the same order two bits along, so one shift turns what the console IS into
what to write to make it that again.

Which uncovered a real fault in the console. READING THE STATUS PORT WAS EATING A
KEY: in line mode the poll consumed an arrow key and dropped it, so a program
that looked and then asked for key mode - exactly what reading a line now does -
found the first key it was reaching for already gone. A look must not consume
what it cannot report, because the mode can change. It is held now and delivered
as soon as something will take it. A blocking read still discards it, and must:
that read IS the delivery, and a byte held there would be met again forever.

Four recordings gained a program's echo, and cosmosEdit's went from
"> : : : : > : : > 1: alpha" to a session you can read. VERIFIED THE SAME WAY AS
BEFORE: with only the program side of the echo silenced, all 192 tests pass
against the recordings as they were before this commit, so the echo is the whole
of what changed.

cosmosEditService is the new test and it checks both halves at once. Inside Edit,
Left/Delete/Left puts "alpah" right. Up and Down do nothing there - were a
program's line walking the shell's history, the next line would come out as the
echo command from the top of the file instead of the word. And one press of Up
back at the prompt finds the command typed before Edit was started, which is the
proof that nothing the editor read went into the history at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-01 14:07:09 -04:00
AnachronautandClaude Opus 5 71f6e215f9 The shell remembers what was typed before it
Up walks back through the last eight lines and Down forward again. It exists
only because the keys reach the system now: until A1 and A2 there was nothing to
press Up at, and the line was assembled somewhere the shell could not see.

A RING RATHER THAN A LIST. A ninth line pushes the oldest out by moving where
the ring starts, not by moving any of the lines - so keeping a line costs a copy
of that line and nothing else, however full the history is. Eight is a power of
two, so which slot an entry lives in is an AND. The ISA had the awkward part
already: A and B are a sixteen bit shift register, so one SHR with B empty turns
a slot number into the offset of a 128 byte slot, high byte and low, ready for
DPUW.

A NINTH SLOT HOLDS WHAT WAS BEING TYPED when Up left it, and Down brings it
back. Losing a half written line to a keypress is the sort of small rudeness
that makes a thing unpleasant to use, and it costs one slot to avoid.

An empty line is not kept, and neither is one the same as the line already at the
top. The test proves the second by looking one further back: if a repeated
command were kept twice, the line behind the newest would be the same line
again.

The redraw had to learn to rub out. One space was enough while the only thing
that shortened a line was taking one character out of it; a recalled line
replaces the whole of it, and a short line over a long one left the tail of the
long one on screen looking like part of what you were typing. It now covers
exactly what was lost - which turned out to be one space fewer than before in
the cases that GREW, so two lines of cosmosEditKeys lost a trailing space that
was never doing anything.

Costs 1157 bytes of Data Memory, taking CosmOS to 6220 of the 8192 it has before
a loaded program's data begins. Worth writing down: that is the budget, and this
is the largest single thing in it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-01 12:24:23 -04:00
AnachronautandClaude Opus 5 81e544eb3d Load a program that has no data
A five instruction program that writes one port and exits has no Data Segment at
all, and the loader stopped the machine dead on it. It asked the memory
controller to move a segment of no bytes, and a length of zero asks for the
whole 64K - which is the machine's rule, and a reasonable one, since two bytes
cannot say 65536 and a transfer of nothing is not usually what anybody meant. It
is exactly what was meant here. 64K did not fit, the controller refused, and the
load stopped half done.

ON A TERMINAL THAT PRINTS A FAULT WITH AN ADDRESS. Behind a window it is a
frozen picture and no reason at all, which is how it was found and is a separate
problem from this one.

The header says how long each segment is, so the loader knows before it asks.
Both bytes are already in hand, so the test costs one OR. Nothing is lost by
skipping the transfer: a blit leaves the controller's addresses past whatever it
touched, and a blit of nothing would have left them where they already are,
which is where the vectors are read from next.

Guarded for the code segment too. A program with no code is equally assemblable
and would have stopped in exactly the same place.

Mode.sbx is the fix's test and a program worth having on its own: forty columns
or eighty, whichever the screen is not in, which is what a person wanting Snake
drawn twice the size actually needs. Ten instructions and no data, deliberately
- it prints its two digits a register at a time rather than from a string, so it
stays the smallest shape a loadable program can take. Nothing else on that disk
had ever been that shape, which is why nothing had ever tried it.

Reported by the user, who wrote the program.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-01 11:47:20 -04:00
AnachronautandClaude Opus 5 373454ec00 A fresh disk for every test, fixtures included
A fixture built by makedisks.sh was handed to each test where it lay. Twenty
four tests name disks/cosmos.img and several of them write to one, so a test
could hand the next one a disk with its leavings on.

romBoot is what found it. Its recorded output described a directory that
selfBoot had made earlier in the same run, so it passed in a full run and failed
on its own - which is the worst way round for a test to be wrong, because the
form nobody runs is the one telling the truth. Its recording now says "made"
like selfBoot's, which is what running the same input on the same disk should
always have said.

Fixed as a class rather than as an instance: run.sh copies a fixture before
attaching it, the same way it already removed a scratch image. Then every one of
the 138 run and rom tests was run on its own to see whether anything else was
leaning on what ran before it. Nothing was, before or after.

Also, cosmosEditKeys.in was written by Python's write_text, which encodes as
UTF-8, so every key byte was 0xC2 and then the key. The test passed anyway,
because the shell ignores a byte it has no use for - a fixture working for a
reason it was not built on, which is exactly the thing that stops working
without anybody touching it. Written as bytes now; the recording is unchanged,
which is the proof the stray bytes were being ignored.

docs.sh is what caught that, and it turns out to draw the line in the right
place by construction: a deliberately binary fixture does not decode as UTF-8
and is skipped, while one that is accidentally UTF-8 decodes and is reported.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-01 11:22:43 -04:00
174 changed files with 16664 additions and 483 deletions
+2 -2
View File
@@ -39,7 +39,7 @@
#Program
#Base 0x4000
#Base 0x5000
start:
SETD.0 Banner
@@ -72,7 +72,7 @@ deeper:
#Data
#Base 0x2000
#Base 0x3000
Banner:
"two stops, and what the registers were at each
+2 -2
View File
@@ -19,7 +19,7 @@
#Include services.asm
#Program
#Base 0x4000
#Base 0x5000
start:
; One block, and ten bytes after it.
@@ -81,7 +81,7 @@ noWrite:
SWI osExit
#Data
#Base 0x2000
#Base 0x3000
Name:
"claim.dat"
+2 -2
View File
@@ -9,7 +9,7 @@
#Include services.asm
#Program
#Base 0x4000
#Base 0x5000
start:
SETD.0 Arguments
@@ -236,7 +236,7 @@ secondReadFailed:
SWI osExit
#Data
#Base 0x2000
#Base 0x3000
Arguments:
#Reserve 0d256
+2 -2
View File
@@ -14,7 +14,7 @@
#Include services.asm
#Program
#Base 0x4000
#Base 0x5000
start:
SETD.0 Arguments
@@ -232,7 +232,7 @@ doneFailed:
SWI osExit
#Data
#Base 0x2000
#Base 0x3000
Arguments:
#Reserve 0d256
+145
View File
@@ -0,0 +1,145 @@
; Breaks on purpose, in whichever of the four ways it is asked for.
;
; Every one of these used to stop the machine and print a line to a standard error that
; nobody behind a window is looking at, so the machine appeared to hang. The system catches
; all of them now and says what happened and where, and this is what says so - and what a
; person can run when they want to see the fault screen without having written a bug first.
;
; Crash opcode a byte in the middle of the code that does not decode
; Crash service a SWI naming a service the system does not implement
; Crash bank a transfer out of a bank that has nothing registered in it
; Crash device asking the console to interrupt, with no handler installed
; Crash blind the same bad byte, but from a screen with nowhere to print at all
;
; The last one is the odd one: it is not something this program does wrong, it is something
; it fails to have done. The interrupt arrives from outside once the console has anything to
; say, and with no input it is the END of input that arrives.
;
; Written by Anachronaut
#Include services.asm
#Program
#Base 0x5000
start:
SETD.0 Argument
INIB 0d15
SWI osArgument
SETD.0 Argument
SETD.1 WordOpcode
CALL textSame
BRQ crashOpcode
SETD.0 Argument
SETD.1 WordService
CALL textSame
BRQ crashService
SETD.0 Argument
SETD.1 WordBank
CALL textSame
BRQ crashBank
SETD.0 Argument
SETD.1 WordDevice
CALL textSame
BRQ crashDevice
SETD.0 Argument
SETD.1 WordBlind
CALL textSame
BRQ crashBlind
SETD.0 Usage
SWI osPrintString
INIA 0x0A
OUTA 0x00
INIA 0x01
SWI osExit
; ---- The case the fault screen exists for ----
;
; Bitmap mode has no text rows, so the console draws NOTHING there: a program that faults
; here leaves the system with a message to print and nowhere to print it. Putting the screen
; back into a mode that has characters in it is the difference between a diagnosis and a
; machine that appears to have hung.
crashBlind:
; ---- And with the colours ruined as well ----
;
; A known mode is only half a known screen. This makes the ink of attribute one the same as
; its paper, which is what a program that wrote its own palette can easily leave behind -
; and a message printed into that is perfectly present and completely invisible.
INIA 0d4
OUTA 0xE3
INIA 0x30
OUTA 0xE2
INIA 0x03
OUTA 0xE8 ; Video memory as bank four.
INIA 0d4
OUTA 0xE3
INIA 0xFC
OUTA 0xE4
INIA 0x40
OUTA 0xE5 ; 0xFC40, the two entries attribute one draws from.
RSTA
INIB 0d8
crashBlindWipe:
OUTA 0xE9
DECB
BNB crashBlindWipe ; Both of them black, ink and paper alike.
INIA 0x02
OUTA 0x31
crashOpcode:
0x00 ; Not an instruction, and never will be.
crashService:
SWI 0d40 ; Forty is nobody's.
crashBank:
INIA 0d9 ; Nothing is registered there.
OUTA 0xE0
RSTA
OUTA 0xE1
OUTA 0xE2
INIA 0d1
OUTA 0xE3 ; Into Data Memory.
RSTA
OUTA 0xE4
OUTA 0xE5
OUTA 0xE6
INIA 0d16
OUTA 0xE7
INIA 0x01
OUTA 0xE8 ; Blit, from a bank that is not there.
crashDevice:
INIA 0x02 ; Interrupt me when the console has something to say.
OUTA 0x02
SIF
crashWait:
; Never touches the console, so whatever happens next came from outside.
BRI crashWait
#Data
#Base 0x3000
WordOpcode:
"opcode"
WordService:
"service"
WordBank:
"bank"
WordDevice:
"device"
WordBlind:
"blind"
Usage:
"Crash opcode | service | bank | device | blind"
Argument:
#Reserve 0d16
#Include text.asm
+377
View File
@@ -0,0 +1,377 @@
; A ball that goes behind some pillars and in front of others.
;
; Four pillars, each ONE 8 by 8 TILE stretched to its own width and height, at four different
; distances. One ball, moving across all of them. Which pillars it passes behind is decided
; per screen column by the depth buffer, and that is the thing worth watching: the ball is in
; front of the far pillars and behind the near ones IN THE SAME FRAME, without the program
; sorting anything or drawing anything twice.
;
; ---- Why that needs a buffer rather than an ordering ----
;
; Sprites are drawn in table order, so a program can put one in front of another by numbering
; them. That is enough when things are flat. It stops being enough the moment a thing is
; nearer than one part of the scenery and further than another - a ball halfway past a pillar
; is in front of the floor beside it and behind the pillar itself, and there is no order of
; two sprites that means "behind, on those columns only".
;
; So the scenery says how far away it is, a column at a time, and the ball says how far away
; IT is. The device compares them per pixel.
;
; Written by Anachronaut
#Include services.asm
#Program
#Base 0x5000
start:
; The atlas: the tiles, the sprite table and the depth buffer are all in it.
INIA 0d4
OUTA 0xE3
INIA 0x30
OUTA 0xE2
INIA 0x03
OUTA 0xE8
SETD.0 Message
SWI osPrintString
INIA 0x0A
OUTA 0x00
CALL putArt
CALL clearDepth
CALL putPillars
CALL putBall
INIA 0x01
OUTA 0x02 ; Key mode.
everyFrame:
CALL waitFrame
CALL stepBall
CALL moveBall
INA 0x01
INIB 0x01 ; READY
AND
BRQ everyFrame
INA 0x00
RSTA
OUTA 0x02 ; Line mode. The sprites and the buffer are the system's to clear.
SWI osExit
; ---- Two tiles ----
;
; Tile 200 is the ball, which has a shape and so comes out of the Data Segment. Tile 201 is
; the pillar, which is a solid block and so is a Fill: 64 bytes of index one, no art needed.
putArt:
INIA 0x01
OUTA 0xE0 ; SourceBank: Data Memory.
SETD.1 BallArtAt
SETD.0 BallArt
STD.0.1
LDA.1
OUTA 0xE1
INCD.1
LDA.1
OUTA 0xE2
INIA 0d4
OUTA 0xE3
INIA 0x32
OUTA 0xE4 ; Tile 200 is at 200 times 64, which is 0x3200.
RSTA
OUTA 0xE5
OUTA 0xE6
INIA 0x40
OUTA 0xE7
INIA 0x01
OUTA 0xE8 ; Blit.
INIA 0x32
OUTA 0xE4
INIA 0x40
OUTA 0xE5 ; Tile 201 is 64 bytes further on, at 0x3240.
INIA 0x01
OUTA 0xE2 ; Index ONE, which is the only index a scheme gives a colour to:
; the default palette sets each scheme's paper and ink and
; nothing between them.
RSTA
OUTA 0xE6
INIA 0x40
OUTA 0xE7
INIA 0x02
OUTA 0xE8 ; Fill.
RET
; ---- The buffer, emptied ----
;
; Nought in a column means nothing is there. It is the program's buffer and nobody clears it
; between programs, so a program that means "nothing yet" has to say so.
clearDepth:
INIA 0d4
OUTA 0xE3
INIA 0xD0
OUTA 0xE4
RSTA
OUTA 0xE5
OUTA 0xE2 ; Fill takes the byte it writes from SourceLow.
INIA 0x02
OUTA 0xE6
INIA 0x80
OUTA 0xE7 ; 640 bytes, one a column.
INIA 0x02
OUTA 0xE8
RET
; ---- The pillars ----
;
; Four records of seven bytes: x, y, height, depth. Each becomes a sprite entry and a run of
; the depth buffer saying how far away that part of the scenery is.
putPillars:
INIA 0d4
SETD.0 PillarLeft
STA.0 ; Sprite one is at 0xC010; sprite nought is the ball.
INIA 0x10
SETD.0 EntryLow
STA.0
SETD.3 Pillars
putOnePillar:
; ---- The entry, written straight through ----
;
; Sixteen bytes out of one port with the address named once, because the controller's Data
; port steps on after every byte. DP3 walks the record and survives the calls below.
INIA 0d4
OUTA 0xE3
INIA 0xC0
OUTA 0xE4
SETD.0 EntryLow
LDA.0
OUTA 0xE5
INIA 0xC9
OUTA 0xE9 ; Tile 201, the pillar block.
INIA 0x01
OUTA 0xE9 ; Attribute one.
; Kept as they go past, because putPillarDepth needs the column and a Data Pointer cannot
; be walked backwards to find it again.
LDA.3
OUTA 0xE9 ; X low.
SETD.0 PillarXLow
STA.0
INCD.3
LDA.3
OUTA 0xE9 ; X high.
SETD.0 PillarXHigh
STA.0
INCD.3
LDA.3
OUTA 0xE9 ; Y low.
INCD.3
LDA.3
OUTA 0xE9 ; Y high.
INCD.3
INIA 0x11
OUTA 0xE9 ; One tile by one, which is the art. The size below is the look.
RSTA
OUTA 0xE9 ; No flags.
INIA 0d32
OUTA 0xE9
RSTA
OUTA 0xE9 ; Thirty two pixels wide, whatever the tile is.
LDA.3
OUTA 0xE9 ; Height low.
INCD.3
LDA.3
OUTA 0xE9 ; Height high.
INCD.3
; ---- And a depth of NOUGHT, which is not the pillar's distance ----
;
; It is tempting to put the pillar's own distance here, and it is wrong: the depth buffer
; is what has already been DRAWN, and a sprite's depth is a QUESTION ASKED OF IT. A pillar
; that both wrote 20 into its own columns and carried 20 would be asked whether it was in
; front of itself, and 20 is not nearer than 20, so it would vanish. Which it did.
;
; Scenery writes the buffer. It does not ask.
RSTA
OUTA 0xE9
CALL putPillarDepth
; On to the next record and the next entry.
INCD.3
SETD.0 EntryLow
LDA.0
INIB 0d16
CCF
ADD
STQ.0
SETD.0 PillarLeft
LDA.0
DECA
STA.0
BNA putOnePillar
RET
; ---- How far away those thirty two columns are ----
;
; A Fill, because the whole run is one number: the pillar is flat on, so every column of it
; is the same distance. DP3 is on the depth byte of the record and stays there.
;
; The buffer begins at 0xD000, whose low byte is nought - so the column's low byte IS the
; address's low byte, and its high byte only has to be added to 0xD0. No sixteen bit sum.
putPillarDepth:
LDA.3
OUTA 0xE2 ; The byte to fill with, which is the depth.
INIA 0d4
OUTA 0xE3
SETD.0 PillarXHigh
LDA.0
INIB 0xD0
CCF
ADD
OUTQ 0xE4
SETD.0 PillarXLow
LDA.0
OUTA 0xE5
RSTA
OUTA 0xE6
INIA 0d32
OUTA 0xE7 ; Thirty two columns of it.
INIA 0x02
OUTA 0xE8
RET
; ---- The ball ----
;
; Sprite nought, so it is in front of every pillar as far as the TABLE is concerned. What
; puts it behind some of them is its depth and nothing else, which is the whole point.
putBall:
INIA 0d4
OUTA 0xE3
INIA 0xC0
OUTA 0xE4
RSTA
OUTA 0xE5
INIA 0xC8
OUTA 0xE9 ; Tile 200.
INIA 0x02
OUTA 0xE9 ; Attribute two.
RSTA
OUTA 0xE9
OUTA 0xE9 ; X, low then high.
INIA 0d230
OUTA 0xE9
RSTA
OUTA 0xE9 ; Y.
INIA 0x11
OUTA 0xE9 ; One tile by one.
RSTA
OUTA 0xE9 ; No flags.
INIA 0d48
OUTA 0xE9
RSTA
OUTA 0xE9
INIA 0d48
OUTA 0xE9
RSTA
OUTA 0xE9 ; Forty eight by forty eight, from eight by eight of art.
INIA 0d45
OUTA 0xE9 ; And forty five away, which is between the pillars.
RET
waitFrame:
INA 0x30
INIB 0x01
AND
BRQ waitFrame
RET
; Two pixels to the right, wrapping past the far side. Sixteen bits in two bytes, so the high
; one steps only when the low one came round to nought.
stepBall:
SETD.0 BallX
LDA.0
INCA
INCA
STA.0
BNA stepCheck
SETD.0 BallXHigh
LDA.0
INCA
STA.0
stepCheck:
SETD.0 BallXHigh
LDA.0
INIB 0x02
CCF
SUB
BNQ stepDone ; Not past 0x0200, so nowhere near the end.
RSTA
SETD.0 BallX
STA.0
SETD.0 BallXHigh
STA.0
stepDone:
RET
moveBall:
INIA 0d4
OUTA 0xE3
INIA 0xC0
OUTA 0xE4
INIA 0x02
OUTA 0xE5 ; X is bytes two and three of the entry.
SETD.0 BallX
LDA.0
OUTA 0xE9
SETD.0 BallXHigh
LDA.0
OUTA 0xE9
RET
#Data
#Base 0x3000
Message:
"A ball, behind the near pillars and in front of the far ones. Press a key."
; x, y, height, depth. Nearer pillars are taller, which is the only thing making this look
; like distance rather than like four bars.
Pillars:
0x50 0x00 0x64 0x00 0xF0 0x00 0d20 ; x 80, y 100, 240 tall, near
0xC8 0x00 0xDC 0x00 0x78 0x00 0d60 ; x 200, y 220, 120 tall, far
0x68 0x01 0xA0 0x00 0xB4 0x00 0d30 ; x 360, y 160, 180 tall, nearish
0xF4 0x01 0x04 0x01 0x50 0x00 0d80 ; x 500, y 260, 80 tall, furthest
; Where putPillarDepth reads the column from. DP3 is on the record's depth byte by then, and
; a Data Pointer cannot be read backwards, so the two halves are kept here as they go past.
PillarXLow:
0x00
PillarXHigh:
0x00
PillarLeft:
0x00
EntryLow:
0x00
BallArt:
0x00 0x00 0x01 0x01 0x01 0x01 0x00 0x00
0x00 0x01 0x01 0x01 0x01 0x01 0x01 0x00
0x01 0x01 0x01 0x01 0x01 0x01 0x01 0x01
0x01 0x01 0x01 0x01 0x01 0x01 0x01 0x01
0x01 0x01 0x01 0x01 0x01 0x01 0x01 0x01
0x01 0x01 0x01 0x01 0x01 0x01 0x01 0x01
0x00 0x01 0x01 0x01 0x01 0x01 0x01 0x00
0x00 0x00 0x01 0x01 0x01 0x01 0x00 0x00
BallArtAt:
#Reserve 0d2
BallX:
0x00
BallXHigh:
0x00
+2 -2
View File
@@ -51,7 +51,7 @@
#Program
#Base 0x4000
#Base 0x5000
start:
; ---- Room for a PATH, not a name ----
@@ -869,7 +869,7 @@ addByteToWord:
#Data
#Base 0x2000
#Base 0x3000
Break:
0x0A 0x00
+2 -2
View File
@@ -4,7 +4,7 @@
#Program
#Base 0x4000
#Base 0x5000
start:
; Swap ValueB and ValueA.
@@ -72,7 +72,7 @@ start:
#Data
#Base 0x2000
#Base 0x3000
ValueA:
; Low byte, high byte.
+2 -2
View File
@@ -4,7 +4,7 @@
#Program
#Base 0x4000
#Base 0x5000
start:
; Swap ValueB and ValueA.
@@ -128,7 +128,7 @@ start:
#Data
#Base 0x2000
#Base 0x3000
ValueA:
; Lowest byte ... Highest byte.
+2 -2
View File
@@ -4,7 +4,7 @@
#Program
#Base 0x4000
#Base 0x5000
start:
; Load our initial values into A and B.
@@ -38,6 +38,6 @@ start:
#Data
#Base 0x2000
#Base 0x3000
#Include print.asm
+2 -2
View File
@@ -24,7 +24,7 @@
#Program
#Base 0x4000
#Base 0x5000
start:
; ---- Write it ----
@@ -133,7 +133,7 @@ noDelete:
#Data
#Base 0x2000
#Base 0x3000
Name:
"kept.txt"
+148
View File
@@ -0,0 +1,148 @@
; The screen nobody is looking at.
;
; The screen brings two map banks and shows one of them. Everything this program draws goes
; into the other, so the picture does not change at all while it is being built - and then
; one byte out of one port shows the whole of it at once.
;
; ---- Why that is worth a port ----
;
; A screen drawn where it can be seen is seen half drawn. A game that moves forty things and
; rewrites the map underneath them is WRONG for as long as it takes to put them all right,
; and at a megahertz that is long enough to look at. The machines this one is pretending to
; be had the same problem and solved it the same way, except that they had to catch the few
; lines between one frame and the next to do the swap in. Here a frame is drawn from one bank
; in one go, so a flip cannot land halfway through one and there is nothing to race.
;
; ---- What it costs ----
;
; A whole bank, which is 64K of somebody's memory - and nothing, which is the point. The
; second screen is memory the device brought, the same as the first, so a program that wants
; it registers it and a program that does not never pays for it.
;
; Written by Anachronaut
#Include services.asm
#Program
#Base 0x5000
start:
; ---- The one bank this touches ----
;
; Six: three is the disk's and four and five are the ones the system registers to save a
; screen with, so six is the first number free. Nothing hands these out - see the table in
; the CosmOS README.
INIA 0d6
OUTA 0xE3
INIA 0x3B
OUTA 0xE2
INIA 0x03
OUTA 0xE8
; ---- And it does NOT take the screen ----
;
; osTakeScreen saves what is on the screen and puts it back at exit, and this program
; called it at first. That was wrong in a way worth keeping written down: it saves the
; screen AS IT WAS BEFORE, so the line printed below - the line whose whole job is to still
; be there afterwards - was wiped out by the restore that was meant to be tidy.
;
; It was not needed either. NOTHING THIS TOUCHES IS THE SHELL'S: the only memory it writes
; is the map of the screen nobody is looking at, and the only register it leaves changed is
; which screen that is - which the system puts back itself.
;
; A program that wants a screen saved should ask. A program that damages nothing should
; not, and asking anyway is not free - it costs the screen it was standing on.
SETD.0 Message
SWI osPrintString
INIA 0x0A
OUTA 0x00 ; The assembler has no escapes; a newline is a byte.
; Key mode, so a key arrives when it is pressed rather than when Return is.
INIA 0x01
OUTA 0x02
; ---- Every cell of the other screen, in one command ----
;
; TEN, and the reason is worth the paragraph. Fill writes one byte, and a cell is two, so
; whatever is filled with is BOTH the tile and the attribute - there is no filling a map
; with a tile and a colour that are different numbers.
;
; Ten as a tile is an asterisk, because the font begins at the space and glyph n is
; character n plus thirty two. Ten as an attribute is one of the eight REVERSED schemes,
; whose paper is a colour and whose ink is black. So the screen comes out green, covered in
; black asterisks, without a single tile being redefined.
;
; The high nibble stays at nought, which the manual asks for: it is reserved. That is the
; real constraint on which byte this can be - it has to be under sixteen to leave the
; nibble alone, and eight or over to land on a reversed scheme whose PAPER is the colour.
;
; Which means THIS PROGRAM WRITES NOTHING BUT THE SCREEN NOBODY IS LOOKING AT. It was
; drawn with a tile of its own first, and that tile was one the system copies the font back
; over at every exit - so the screen it had filled turned blank the moment it left, and a
; check that it had been put back could not tell a restored screen from an abandoned one.
; A picture that depends on the atlas does not survive leaving.
INIA 0d6
OUTA 0xE3
INIA 0x40
OUTA 0xE4
RSTA
OUTA 0xE5
INIA 0x0A
OUTA 0xE2 ; Fill takes the byte it writes from SourceLow.
INIA 0x80
OUTA 0xE6
RSTA
OUTA 0xE7 ; The whole map, which is 0x8000 bytes.
INIA 0x02
OUTA 0xE8
; NOTHING HAS CHANGED ON THE SCREEN. Every byte of that went where nobody can see it, and
; the line printed above is still sitting there to prove it - which is the reason this
; waits here rather than flipping straight away. What a back buffer is for is not visible
; in the flip; it is visible in the time before one.
CALL waitKey
; And this is the whole of showing it.
INIA 0x01
OUTA 0x3C
CALL waitKey
; ---- Which screen is showing is NOT put back here ----
;
; On purpose, and it is the one thing in this program worth arguing about. The system
; restores it at exit, the same way it restores the cursor and the ink, and for the same
; reason: a program that FAULTED while flipped could not have put it back, and a shell that
; only came out right for programs which remembered would be a shell that came out wrong
; the day one crashed. What the person is looking at belongs to the system.
;
; The console mode below IS put back, because that is this program's own borrowing rather
; than something the system hands out.
RSTA
OUTA 0x02 ; Line mode again.
SWI osExit
; ---- A key, asked for rather than waited on ----
;
; The console holds one until somebody wants it, so nothing pressed while the map was being
; filled is lost - it is sitting there and this returns immediately, which is right. A key
; pressed is a key meant for this program.
waitKey:
INA 0x01
INIB 0x01 ; READY
AND
BRQ waitKey
INA 0x00 ; Taken, so the shell is not handed a key meant for this.
RET
#Data
#Base 0x3000
; One line and no newline in it. A string literal is terminated where it ends, so a second
; one after a 0x0A byte would never be reached - printing stops at the first terminator. The
; newline is written to the console directly instead.
Message:
"A screen is drawn where you cannot see it. A key shows it, another comes back."
+16 -5
View File
@@ -23,7 +23,7 @@
#Program
#Base 0x4000
#Base 0x5000
start:
; ---- Reaching video memory ----
@@ -37,12 +37,23 @@ start:
; 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.
; TWO BANKS, because the screen has two. The atlas holds the tiles and the palette and is
; written when a program starts; the screen holds the map and is written as things move.
; They are separate memories, so a bank number reaches one or the other and never both -
; which is the whole of what this program had to learn when they were split apart.
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.
OUTA 0xE8 ; RegisterBank. Four is the atlas.
INIA 0d5
OUTA 0xE3
INIA 0x3A
OUTA 0xE2 ; And the port that owns the screen.
INIA 0x03
OUTA 0xE8 ; RegisterBank. Five is the map.
; ---- Asking for the screen back afterwards ----
;
@@ -153,7 +164,7 @@ finished:
OUTA 0x34
OUTA 0x36 ; The origins, or the shell looks at a corner of the map.
INIA 0d4
INIA 0d5
OUTA 0xE3
INIA 0x40
OUTA 0xE4
@@ -350,7 +361,7 @@ putMap:
STA.0
everyRow:
INIA 0d4
INIA 0d5
OUTA 0xE3
SETD.0 MapRow
LDA.0
@@ -411,7 +422,7 @@ everyCell:
#Data
#Base 0x2000
#Base 0x3000
ScreenKept:
0x00
+2 -2
View File
@@ -17,7 +17,7 @@
#Program
#Base 0x4000
#Base 0x5000
start:
CIF ; Nothing arrives until there is something to catch it.
@@ -85,7 +85,7 @@ keyStop:
#Data
#Base 0x2000
#Base 0x3000
Banner:
"keys, by interrupt. q stops."
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -32,7 +32,7 @@
#Program
#Base 0x4000
#Base 0x5000
start:
CALL seedGlider
@@ -350,7 +350,7 @@ delayDone:
#Data
#Base 0x2000
#Base 0x3000
RowCount:
0x00
+50
View File
@@ -0,0 +1,50 @@
; Forty columns or eighty, whichever the screen is not in.
;
; A machine wakes up in the forty column mode and CosmOS asks for eighty, because that is
; what its own output was written for. A game is the other way round: Snake on a forty column
; screen is the same board drawn twice the size, which is what a person sitting in front of
; it actually wants.
;
; ---- The smallest program this system can load ----
;
; Ten instructions and NO DATA AT ALL, which is not a curiosity: it is the shape that found
; a bug in the loader. Every program written for CosmOS until this one had something in its
; Data Segment, so the loader had never been asked to move a segment of no bytes - and a
; length of zero asks the memory controller for the whole 64K, which does not fit, which it
; refused, which stopped the machine in the middle of loading. On a terminal that printed a
; fault. Behind a window it looked exactly like a hang.
;
; So this is kept dataless on purpose. The two digits it prints are put in a register one at
; a time rather than being a string, which is the only reason it can say anything at all.
;
; Written by Anachronaut
#Include services.asm
#Program
#Base 0x5000
start:
INA 0x31 ; Which mode the screen is in now.
BRA modeWide ; Nought is the forty column one, so go the other way.
; Anything else becomes forty, and that deliberately includes bitmap mode. A program that
; left the screen with no text on it left nowhere to print, so coming back to a mode that
; has characters in it is more use than refusing.
RSTA
OUTA 0x31
INIA 0x34 ; '4'
BRI modeSay
modeWide:
INIA 0x01
OUTA 0x31
INIA 0x38 ; '8'
modeSay:
OUTA 0x00
INIA 0x30 ; '0'
OUTA 0x00
INIA 0x0A
OUTA 0x00
RSTA
SWI osExit
+33 -5
View File
@@ -1,14 +1,15 @@
; Read a text file one screen at a time.
;
; Twenty two lines are shown before a prompt. Space advances another screen, Return one
; line, and q gives the machine back to CosmOS. This is forward-only on purpose: the file
; A screenful of lines is shown before a prompt - as many as the screen has, asked for rather
; than assumed, so that the eighty column mode is not read three fifths empty. Space advances
; another screen, Return one line, and q gives the machine back to CosmOS. This is forward-only on purpose: the file
; stream holds one block and never asks the whole document to fit in memory.
;
; Written by ChatGPT for Anachronaut's SplitBit
#Include services.asm
#Program
#Base 0x4000
#Base 0x5000
start:
SETD.0 Name
@@ -100,11 +101,38 @@ pauseQuit:
INIB 0x01
OR
RET
; ---- As many lines as the screen has, less the prompt ----
;
; Twenty two was written when there was one screen size. It is still right on the forty
; column screen and wastes half of the eighty column one, so this ASKS: the rows register
; says how tall the screen is, and it is readable for exactly this sort of reason.
;
; Rows minus three is twenty two on a twenty five row screen, so nothing changed underneath
; anyone who was already reading files this way - it fills a bigger screen and leaves a
; smaller one alone.
;
; A BITMAP SCREEN HAS NO ROWS AT ALL and says so with a nought, which would come out as 253
; lines through an eight bit subtraction. Anything under five falls back, because a page of
; two lines is not a page and a program should not be the thing that discovers this.
fullPage:
INA 0x33
SETD.3 LinesLeft
INIA 0d22
STA.3 ; Kept, because the sums below want A for themselves.
INIB 0d5
CCF
SUB
BRC fullPageFallback ; Borrowed, so there are fewer than five rows.
LDA.3
INIB 0d3
CCF
SUB
MVQA
STA.3
RET
fullPageFallback:
INIA 0d22
STA.3 ; DP3 is still LinesLeft, from above.
RET
noName:
SETD.0 Usage
@@ -139,7 +167,7 @@ finished:
SWI osExit
#Data
#Base 0x2000
#Base 0x3000
Usage:
"more: give me a file name
"
+2 -2
View File
@@ -25,7 +25,7 @@
#Program
#Base 0x4000
#Base 0x5000
start:
SETD.0 Wanted
@@ -114,7 +114,7 @@ measureDone:
#Data
#Base 0x2000
#Base 0x3000
Prefix:
"system "
+162
View File
@@ -0,0 +1,162 @@
; What the controllers are doing, as they do it.
;
; Prints a line whenever any pad changes: which pad, and the byte it is now reporting. That is
; the whole of the diagnostic, and it is enough to tell apart the three things that look
; identical from inside a game which is not responding.
;
; Nothing printed at all, and nought pads present: the front end never saw a controller.
; A pad present but no lines: it is seen, and the buttons are mapped to nothing.
; Lines that do not match the buttons pressed: the mapping is wrong rather than missing.
;
; ---- Why a program rather than a print in the front end ----
;
; Because the question is what the MACHINE can see. A front end that printed what it thought
; it was sending would answer a different question, and the gap between those two is exactly
; where a controller that was detected, mapped and reported still did nothing: the port that
; says which pads exist counted only recorded ones, so a game asked whether there was a
; controller and was told no while the buttons underneath worked perfectly.
;
; Written by Anachronaut
#Include services.asm
#Program
#Base 0x5000
start:
SETD.0 Intro
SWI osPrintString
; How many are there, before anybody presses anything. One bit a pad.
SETD.0 PresentText
SWI osPrintString
INA 0x64
CALL sayByte
INIA 0x0A
OUTA 0x00
INIA 0x01
OUTA 0x02 ; Key mode, so q arrives without a Return.
everyFrame:
INA 0x30
INIB 0x01
AND
BRQ everyFrame ; A frame, which is when a pad's recording steps.
RSTA
SETD.2 Which
STA.2
eachPad:
CALL onePad
SETD.2 Which
LDA.2
INCA
STA.2
INIB 0d4
CCF
SUB
BNQ eachPad
; q gives the machine back. Anything else typed is ignored, because a pad test that
; stopped on a stray keypress would be a poor thing to lean on.
INA 0x01
INIB 0x01
AND
BRQ everyFrame
INA 0x00
INIB 0x71 ; q
CCF
SUB
BNQ everyFrame
RSTA
OUTA 0x02
SWI osExit
; ---- One pad, printed only when it changes ----
;
; A line a frame for four pads would be two hundred and forty lines a second and unreadable.
; What is worth seeing is the moment something goes down or comes up.
onePad:
; The port is 0x60 plus the pad number, and a port is an immediate byte inside the
; instruction that names it - so it cannot be computed, and the four are written out.
SETD.2 Which
LDA.2
BRA padZero
DECA
BRA padOne
DECA
BRA padTwo
INA 0x63
BRI padGot
padZero:
INA 0x60
BRI padGot
padOne:
INA 0x61
BRI padGot
padTwo:
INA 0x62
padGot:
; Against what it was last time. Last is four bytes, one a pad, so DP1 walks to this one.
SETD.1 Last
PSHA
SETD.2 Which
LDA.2
DPUA.1
POPA
LDB.1
CCF
SUB
BRQ padSame ; The same as last frame, so there is nothing to say.
STA.1 ; Remembered, so the next frame has something to compare with.
PSHA
LDA.2 ; DP2 is still Which, from working out where in Last to look.
INIB 0x30 ; '0'
CCF
ADD
OUTQ 0x00
INIA 0x3A ; ':'
OUTA 0x00
INIA 0x20
OUTA 0x00
POPA
CALL sayByte
INIA 0x0A
OUTA 0x00
padSame:
RET
; ---- One byte, as a number ----
;
; osPrintNumber takes A and B TOGETHER, A being the high half - which is the same way round as
; the shift register and every other pair on this machine, and is not what a byte in A wants.
; Passed as it stood, every value came out two hundred and fifty six times too big.
sayByte:
RSTB
CCF
ADD
MVQB ; The byte, in the low half where it belongs.
RSTA ; And nothing in the high one.
SWI osPrintNumber
RET
#Data
#Base 0x3000
Intro:
"Press buttons on a controller. q quits.
"
PresentText:
"Pads present, as a bit each: "
; One byte a pad, holding what it said last frame.
Last:
0x00 0x00 0x00 0x00
Which:
0x00
+2 -2
View File
@@ -12,7 +12,7 @@
#Include services.asm
#Program
#Base 0x4000
#Base 0x5000
start:
SETD.0 Argument
@@ -213,7 +213,7 @@ doneFailed:
SWI osExit
#Data
#Base 0x2000
#Base 0x3000
Name:
"poured.dat"
+2 -2
View File
@@ -19,7 +19,7 @@
#Program
#Base 0x4000
#Base 0x5000
start:
SETD.0 LineText
@@ -141,7 +141,7 @@ keySay:
#Data
#Base 0x2000
#Base 0x3000
LineText:
"a line, then keys. q stops."
+2 -2
View File
@@ -27,7 +27,7 @@
#Program
#Base 0x4000
#Base 0x5000
start:
SETD.0 Saying
@@ -45,7 +45,7 @@ start:
#Data
#Base 0x2000
#Base 0x3000
Saying:
"starting again
+2 -2
View File
@@ -16,7 +16,7 @@
#Program
#Base 0x4000
#Base 0x5000
start:
SETD.0 Given
@@ -45,7 +45,7 @@ sayEnd:
#Data
#Base 0x2000
#Base 0x3000
SaidText:
"it says: "
+2 -2
View File
@@ -23,7 +23,7 @@
#Program
#Base 0x4000
#Base 0x5000
start:
SWI osBootState
@@ -71,7 +71,7 @@ alreadySettled:
#Data
#Base 0x2000
#Base 0x3000
WasTrying:
"the disk says a start is still in progress
+2 -2
View File
@@ -9,7 +9,7 @@
#Program
#Base 0x4000
#Base 0x5000
start:
RSTA
@@ -140,7 +140,7 @@ printCandidateHex:
#Data
#Base 0x2000
#Base 0x3000
; Segment has to begin on a page boundary, and now says so itself rather than relying on
; whatever happens to have been assembled before it. The marking loop adds the prime to
+2 -2
View File
@@ -4,7 +4,7 @@
#Program
#Base 0x4000
#Base 0x5000
start:
; Search the list until we find a prime.
@@ -46,7 +46,7 @@ start:
#Data
#Base 0x2000
#Base 0x3000
; The table of our prime candidates. It has to begin on a page boundary: marking walks
; the pointer's low byte and treats the carry out as running off the end of the table,
+2 -2
View File
@@ -38,7 +38,7 @@
#Program
#Base 0x4000
#Base 0x5000
start:
; The two pointers whose low byte is a square number. Both regions are page aligned, so
@@ -615,7 +615,7 @@ pauseInner:
#Data
#Base 0x2000
#Base 0x3000
HeadCell:
0x00
+247
View File
@@ -0,0 +1,247 @@
; A thing that moves without the screen moving.
;
; Everything drawn on this machine before sprites was in a CELL. Something between two cells
; meant rewriting both of them, and something moving a pixel at a time meant rewriting them
; sixty times a second - which is affordable for one thing and not for twenty.
;
; A sprite is put at a PIXEL, and the device draws it over whatever is already there. Moving
; it costs two bytes: the low and high halves of where it now is. That is the whole of the
; loop below, and it is why this program can run over the shell's own text without disturbing
; a single character of it - nothing underneath is written to at all.
;
; ---- What it leaves behind ----
;
; The sprite, still in the table. On purpose, and for the same reason Flip leaves the screen
; it flipped to: a program that FAULTED would have left it too, and a system that only tidied
; up after programs which remembered would be one that left a ball sitting over the prompt
; the first time somebody's game crashed. The table is the system's to clear.
;
; Written by Anachronaut
#Include services.asm
#Program
#Base 0x5000
start:
; The atlas, which is where both the tiles and the sprite table live. Four is what CosmOS
; uses for it; see the table in the CosmOS README for who owns which number.
INIA 0d4
OUTA 0xE3
INIA 0x30
OUTA 0xE2
INIA 0x03
OUTA 0xE8
SETD.0 Message
SWI osPrintString
INIA 0x0A
OUTA 0x00
CALL putBall
CALL screenWidth
CALL putSprite
; Key mode, so a key arrives when it is pressed rather than when Return is.
INIA 0x01
OUTA 0x02
everyFrame:
CALL waitFrame
CALL stepBall
CALL moveSprite
; Anything typed ends it. Asked for and never waited on, so a key pressed between frames
; is still there when this looks.
INA 0x01
INIB 0x01 ; READY
AND
BRQ everyFrame
INA 0x00 ; Taken, so the shell is not handed a key meant for this.
RSTA
OUTA 0x02 ; Line mode again. The sprite is left where it is.
SWI osExit
; ---- The art, into a tile above the font ----
;
; Two hundred, which is well clear of the 135 glyphs the character generator copies back, so
; nothing here costs the shell a letter. Blitted rather than poked: it is already sixty four
; bytes of Data Segment and the controller moves it in one command.
putBall:
INIA 0x01
OUTA 0xE0 ; SourceBank: Data Memory.
; The pointer written down before it is read out of memory a byte at a time, because a Data
; Pointer's two halves cannot be got at any other way.
SETD.1 BallArtAt
SETD.0 BallArt
STD.0.1
LDA.1
OUTA 0xE1
INCD.1
LDA.1
OUTA 0xE2
INIA 0d4
OUTA 0xE3
INIA 0x32
OUTA 0xE4 ; Tile 200 begins at 200 times 64, which is 0x3200.
RSTA
OUTA 0xE5
OUTA 0xE6
INIA 0x40
OUTA 0xE7 ; Sixty four bytes.
INIA 0x01
OUTA 0xE8 ; Blit.
RET
; ---- How wide the screen is, in pixels ----
;
; Columns times eight, and this machine cannot multiply. A and B are one sixteen bit shift
; register though: with the column count in A and nothing in B, A:B holds columns times 256,
; and five shifts right divide that by thirty two - which is columns times eight, high byte
; left in A and low byte in B.
;
; Asked rather than assumed, because the shell runs eighty columns and a game may well have
; asked for forty before starting this.
screenWidth:
INA 0x32
RSTB
SHR
SHR
SHR
SHR
SHR
SETD.0 WidthHigh
STA.0
; B cannot be stored, and there is no move from it. Adding nothing to it puts it in Q,
; which can be copied to A, which can.
RSTA
CCF
ADD
MVQA
SETD.0 WidthLow
STA.0
RET
; ---- The entry, written straight through ----
;
; The controller's Data port steps its address on after every byte, so all eight go out of
; one port with the address named once.
putSprite:
INIA 0d4
OUTA 0xE3
INIA 0xC0
OUTA 0xE4
RSTA
OUTA 0xE5 ; Sprite nought is at 0xC000.
INIA 0xC8
OUTA 0xE9 ; Tile 200.
INIA 0x01
OUTA 0xE9 ; Attribute one, so the ball comes out in scheme one's ink.
RSTA
OUTA 0xE9
OUTA 0xE9 ; X, low then high.
INIA 0d96
OUTA 0xE9
RSTA
OUTA 0xE9 ; Y, ninety six pixels down.
INIA 0x11
OUTA 0xE9 ; One tile across by one down.
RSTA
OUTA 0xE9 ; Not mirrored, not turned over, not behind.
RET
; A frame, which is the only regular beat this machine has.
waitFrame:
INA 0x30
INIB 0x01
AND
BRQ waitFrame
RET
; ---- One pixel to the right ----
;
; Sixteen bits in two bytes, so the high one is stepped only when the low one came back round
; to nought - which is what a carry is, done by hand.
stepBall:
SETD.0 BallX
LDA.0
INCA
STA.0
BNA stepCheck
SETD.0 BallXHigh
LDA.0
INCA
STA.0
; Round to the left edge at the far side. Both halves have to match, and the high one is
; tested first because it is the one that is usually wrong.
stepCheck:
SETD.0 BallXHigh
LDA.0
SETD.1 WidthHigh
LDB.1
CCF
SUB
BNQ stepDone
SETD.0 BallX
LDA.0
SETD.1 WidthLow
LDB.1
CCF
SUB
BNQ stepDone
RSTA
STA.0 ; DP0 is still BallX, from the comparison just above.
SETD.0 BallXHigh
STA.0
stepDone:
RET
; Two bytes out of one port, which is the whole cost of moving a sprite.
moveSprite:
INIA 0d4
OUTA 0xE3
INIA 0xC0
OUTA 0xE4
INIA 0x02
OUTA 0xE5 ; X is bytes two and three of the entry.
SETD.0 BallX
LDA.0
OUTA 0xE9
SETD.0 BallXHigh
LDA.0
OUTA 0xE9
RET
#Data
#Base 0x3000
Message:
"A ball, over the shell's own words. Nothing underneath is written to. Press a key."
; Index nought is not a colour, it is a hole - so the corners of the tile are what the ball
; is not, and whatever is behind shows through them.
BallArt:
0x00 0x00 0x01 0x01 0x01 0x01 0x00 0x00
0x00 0x01 0x01 0x01 0x01 0x01 0x01 0x00
0x01 0x01 0x01 0x01 0x01 0x01 0x01 0x01
0x01 0x01 0x01 0x01 0x01 0x01 0x01 0x01
0x01 0x01 0x01 0x01 0x01 0x01 0x01 0x01
0x01 0x01 0x01 0x01 0x01 0x01 0x01 0x01
0x00 0x01 0x01 0x01 0x01 0x01 0x01 0x00
0x00 0x00 0x01 0x01 0x01 0x01 0x00 0x00
BallArtAt:
#Reserve 0d2
BallX:
0x00
BallXHigh:
0x00
WidthLow:
0x00
WidthHigh:
0x00
+2 -2
View File
@@ -18,7 +18,7 @@
#Program
#Base 0x4000
#Base 0x5000
start:
SWI osLastStatus
@@ -70,7 +70,7 @@ done:
#Data
#Base 0x2000
#Base 0x3000
Prefix:
"the last program left "
+2 -2
View File
@@ -36,7 +36,7 @@
#Program
#Base 0x4000
#Base 0x5000
start:
; ---- 1. How big is something that will not fit ----
@@ -408,7 +408,7 @@ printWhy:
#Data
#Base 0x2000
#Base 0x3000
BigName:
"big.txt"
+2 -2
View File
@@ -6,7 +6,7 @@
#Include services.asm
#Program
#Base 0x4000
#Base 0x5000
start:
SETD.0 Name
@@ -70,7 +70,7 @@ finished:
SWI osExit
#Data
#Base 0x2000
#Base 0x3000
Usage:
"type: give me a file name
"
+2 -2
View File
@@ -10,7 +10,7 @@
#Include services.asm
#Program
#Base 0x4000
#Base 0x5000
start:
; Where to go is the argument. Nothing else about this program says a directory name, so
@@ -85,7 +85,7 @@ noFile:
SWI osExit
#Data
#Base 0x2000
#Base 0x3000
Where:
#Reserve 0d64
+2 -2
View File
@@ -17,7 +17,7 @@
#Program
#Base 0x4000 ; Above the system, which keeps below here.
#Base 0x5000 ; Above the system, which keeps below here.
greet:
SETD.0 Opening
@@ -44,7 +44,7 @@ greet:
#Data
#Base 0x2000 ; And its data above the system's.
#Base 0x3000 ; And its data above the system's.
Opening:
"a program, loaded off a disk, running on the system that loaded it
+2 -2
View File
@@ -6,7 +6,7 @@
#Program
#Base 0x4000 ; Change two:
#Base 0x5000 ; Change two:
SETD hello ; Change three
Start:
@@ -26,6 +26,6 @@ End:
#Data
#Base 0x2000 ; Five, adjust the base of the data segment.
#Base 0x3000 ; Five, adjust the base of the data segment.
hello: ; Throw a label here so we can explicitly point at this data. Six, actually.
"Hello, World!"
+9 -3
View File
@@ -38,7 +38,7 @@
#Program
#Base 0x4000
#Base 0x5000
start:
SETD.0 Argument
@@ -2180,7 +2180,7 @@ report:
#Data
#Base 0x2000
#Base 0x3000
Argument:
#Reserve 0d23
@@ -2258,7 +2258,7 @@ ReservedLeft:
ReservedWalk:
0x00 0x00
ReservedCount:
0d5
0d7
VecHandlerName:
#Reserve 0d23
@@ -2285,6 +2285,12 @@ ReservedNames:
"BankFault"
#Reserve 0d5
0d4
"NoHandler"
#Reserve 0d5
0d5
"NoDevice"
#Reserve 0d6
0d6
ProgPut:
0x00 0x00
+2 -2
View File
@@ -14,7 +14,7 @@
#Program
#Base 0x4000
#Base 0x5000
start:
SETD.0 Wanted
@@ -60,7 +60,7 @@ noFile:
#Data
#Base 0x2000
#Base 0x3000
Wanted:
#Reserve 0d23
+17 -11
View File
@@ -12,7 +12,7 @@
;
; None of this is initialised data. It is scratch, wanted only while the assembler is
; running, and while it is running everything above its own data is free: the system keeps
; below 0x1FFF, the staging area is only in use during a load, and the Stack comes down
; below 0x2FFF, the staging area is only in use during a load, and the Stack comes down
; from the top. So the addresses are written down here and the file carries none of it.
;
; That sentence said 0x1000 for a while after the system's half of Data Memory was
@@ -20,10 +20,10 @@
; bad enough; a stale number sitting next to the correction is worse, because whichever
; one a reader takes is a coin toss.
;
; 0x4000 6144 the label index, 1536 entries of four
; 0x5800 16384 the label names, packed end to end
; 0x9800 256 one block of the output file, on its way to the disk
; 0x9900 18176 free
; 0x5000 6144 the label index, 1536 entries of four
; 0x6800 16384 the label names, packed end to end
; 0xA800 256 one block of the output file, on its way to the disk
; 0xA900 17152 free
; 0xE000 1792 the vector names, 64 entries of twenty eight
; 0xE700 2048 the reader's stack, six levels of 301
; 0xEF00 368 which files have been included, sixteen names of 23
@@ -35,9 +35,15 @@
; and the map stayed where it was, leaving sixteen kilobytes between the two that nothing
; touched.
;
; Starting at 0x4000 takes that back. The assembler's data is a little over four kilobytes
; from 0x2000, so there is still nearly four kilobytes of slack in front of this - and room
; for its data to double before the two would meet. `make test` measures that gap now
; Starting above the assembler's own data takes that back. It began at 0x4000 with the data
; from 0x2000, and moved to 0x5000 when the system was given another page and every
; application's data moved to 0x3000 with it - THE FLOOR CAME UP A SECOND TIME, exactly as
; the paragraph above says it did the first, and this time the check below said so before
; anything ran: the assembler's data reached 0x40D6 and the index began at 0x4000, so the
; buffers were sitting on the variables.
;
; There is still nearly four kilobytes of slack in front of this, and room for the data to
; double before the two would meet. `make test` measures that gap now
; rather than trusting this paragraph, and measures the floor above as well, because both
; of those numbers describe the machine AROUND this file and neither is enforced by a line
; of code anywhere.
@@ -77,11 +83,11 @@
; second is bigger than the first, which is worth knowing: the hardest thing this assembles
; is not the operating system, it is itself.
ScratchLabIndex:
0x40 0x00
0x50 0x00
ScratchLabArena:
0x58 0x00
0x68 0x00
ScratchWindow:
0x98 0x00
0xA8 0x00
ScratchVecNames:
0xE0 0x00
ScratchSrcStack:
+2 -2
View File
@@ -15,7 +15,7 @@
#Program
#Base 0x4000
#Base 0x5000
start:
SETD.0 Wanted
@@ -128,7 +128,7 @@ noFile:
#Data
#Base 0x2000
#Base 0x3000
Wanted:
#Reserve 0d23
+279 -11
View File
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+28
View File
@@ -243,6 +243,21 @@ scriptLine:
STD.0.1
scriptLineAgain:
; ---- Where this line begins ----
;
; Kept before it is read, because a loop has to be able to go back to the line that opened
; it and by the time that line has been read the reader is past it. Three words, and the
; block itself is read again on the way back, which is what scriptReread is for.
SETD.0 ScriptIndex
SETD.1 ScriptLineIndex
CALL sbfsCopyWord
SETD.0 ScriptAt
SETD.1 ScriptLineAt
CALL sbfsCopyWord
SETD.0 ScriptBlocks
SETD.1 ScriptLineBlocks
CALL sbfsCopyWord
SETD.1 ScriptLength
RSTA
STA.1
@@ -539,6 +554,19 @@ ScriptIndex:
0x00 0x00
ScriptAt:
0x00 0x00
; ---- And where the line being read began ----
;
; A loop goes back to the line that opened it, and by the time that line has been read the
; reader is past it. So the position is kept before every line rather than worked out
; afterwards, which cannot be done: a line is not a fixed size and there is nothing to
; subtract.
ScriptLineIndex:
0x00 0x00
ScriptLineAt:
0x00 0x00
ScriptLineBlocks:
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
+7
View File
@@ -41,6 +41,13 @@
; going out it is A and B together, and neither direction needs a record in memory that
; both sides have to agree on the shape of.
osFileRead 0d20 ; DP0 names it, DP1 says where. Q is zero if it read, DP3 is how many bytes.
; ---- AND IT WRITES WHOLE BLOCKS ----
;
; A disk is read a block at a time, so a sixteen byte file still puts
; 256 bytes where it is told to. The room given has to be the file's
; length ROUNDED UP to the next 256, and a caller that gives exactly
; the length writes over whatever follows it. DP3 still says how many
; bytes are the file's; the rest is whatever was on the block.
osFileSave 0d21 ; DP0 names it, DP1 is the bytes, A and B are how many. Q is zero if it saved.
osFileDelete 0d22 ; DP0 names it. Q is zero if it went.
osFileRename 0d23 ; DP0 is the name it has, DP1 the name it should have. Q is zero if it moved.
+5 -3
View File
@@ -80,8 +80,10 @@ nextBank:
; 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.
; Give the screen's memory a bank number. The screen brings TWO banks and this only wants
; one of them: port 0x30 owns the atlas, where the tiles and the palette are, and the
; palette is all this touches. 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
@@ -89,7 +91,7 @@ nextBank:
INIA 0x03
OUTA 0xE8 ; Command: RegisterBank
; The palette sits at the top of video memory, at 0xFC00, and entry n is at n times
; The palette sits at the top of the atlas, 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
+23 -7
View File
@@ -13,13 +13,13 @@
; 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.
; It lives over the top of the map, in the SCREEN bank, because that is the bank whose
; contents change: a picture and a map are the same memory called two different things in two
; different modes. Going to bitmap mode does not clear the text screen, it stops calling it
; one - and coming back finds the map holding whatever the picture put there.
;
; 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.
; WHAT IT DOES NOT COST IS THE FONT. The tiles and the palette are in the other bank and a
; picture cannot reach them, which is what lets this program draw and then say something.
#Program
@@ -27,17 +27,31 @@ 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.
; TWO OF THEM, because the screen brings two banks and this program writes to both: the
; palette is in the atlas and the picture is in the screen.
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
OUTA 0xE8 ; Command: RegisterBank. Three is the atlas.
INIA 0d4
OUTA 0xE3
INIA 0x3A
OUTA 0xE2
INIA 0x03
OUTA 0xE8 ; And four is the screen.
; ---- 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.
; A already holds 3, because RegisterBank is command 3 and the bank wanted is bank 3.
; Leaving the line out would turn a program that says which bank it means into one that
; works by coincidence, and the coincidence breaks the day either number changes.
INIA 0d3 ; splitlint[redundant-assignment]: said rather than coincided
OUTA 0xE3 ; The ATLAS, which registering the second bank moved off
INIA 0xFC
OUTA 0xE4
RSTA
@@ -67,6 +81,8 @@ palette:
; 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.
INIA 0d4
OUTA 0xE3 ; Into the SCREEN bank now, which is where a picture is
RSTA
OUTA 0xE4
OUTA 0xE5 ; Dest 0x0000, the top left corner
+7 -1
View File
@@ -35,8 +35,12 @@
; 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.
#Include services.asm
#Program
#Base 0x5000
start:
; ---- The instrument ----
@@ -182,7 +186,7 @@ lastRing:
CIF
RSTA
OUTA 0x35
HALT
SWI osExit
; 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
@@ -192,6 +196,8 @@ frame:
#Data
#Base 0x3000
; ---- 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
+235
View File
@@ -54,8 +54,243 @@ int16sub:
STQ ; Store the result.
RET ; Return to the caller.
; ---- Multiplying, which this machine cannot do ----
;
; There is no MUL. What there is instead is an identity:
;
; a * b = qs[a + b] - qs[|a - b|] where qs[n] is n squared over four
;
; because (a+b)^2/4 - (a-b)^2/4 is exactly a*b, and the halves that the flooring throws away
; cancel between the two terms. So a multiply is TWO LOOKUPS AND A SUBTRACT.
;
; ---- And the table is built by adding ----
;
; Which is the part that makes this fit a machine with no multiplier at all. A table of
; squares would need squaring to fill, and this one does not:
;
; qs[n] = qs[n-1] + n/2
;
; and n/2 goes 0, 1, 1, 2, 2, 3, 3 - a number that steps up on every even n. So the whole
; table is one running total and a counter, and nothing harder than an add appears anywhere in
; building the thing that does the multiplying.
;
; 511 entries of two bytes, because a and b are bytes and a+b reaches 510. That is 1,022 bytes
; of Data Memory, which is the price: this is a routine that trades a kilobyte for an
; operation the hardware has not got.
; Fills the table. Call once, before the first multiply, and never again.
mulReady:
SETD.0 MulTable
RSTA
STA.0
INCD.0
STA.0 ; qs[0] is nought.
INCD.0
SETD.1 MulTotalLow
STA.1
INCD.1
STA.1 ; And so is the running total.
SETD.1 MulStep
STA.1
SETD.1 MulToggle
STA.1
; Five hundred and ten more entries to write.
INIA 0xFE
SETD.1 MulLeftLow
STA.1
INIA 0x01
SETD.1 MulLeftHigh
STA.1
mulEntry:
; ---- The step goes up on every even index ----
;
; n/2 for n = 1, 2, 3, 4 is 0, 1, 1, 2: it rises at the even ones. A toggle says which this
; is, which is cheaper than halving a sixteen bit counter every time round.
SETD.1 MulToggle
LDA.1
INIB 0x01
XOR
STQ.1
BNQ mulNoStep ; The toggle is one, so this index is odd and the step stands.
SETD.1 MulStep
LDA.1
INCA
STA.1
mulNoStep:
; The running total, plus the step, sixteen bits.
CCF
SETD.1 MulTotalLow
LDA.1
SETD.2 MulStep
LDB.2
ADD
STQ.1
SETD.1 MulTotalHigh
LDA.1
RSTB
ADD ; Nothing but the carry out of the half below.
STQ.1
; And into the table, where DP0 has been walking all along.
SETD.1 MulTotalLow
LDA.1
STA.0
INCD.0
SETD.1 MulTotalHigh
LDA.1
STA.0
INCD.0
; One fewer to do, counted down sixteen bits.
SETD.1 MulLeftLow
LDA.1
BNA mulCountLow
SETD.1 MulLeftHigh
LDA.1
DECA
STA.1
INIA 0xFF
SETD.1 MulLeftLow
STA.1
BRI mulCounted
mulCountLow:
DECA
STA.1
mulCounted:
SETD.1 MulLeftLow
LDA.1
SETD.2 MulLeftHigh
LDB.2
OR
BNQ mulEntry
RET
; ---- One multiply ----
;
; The operands go in MulA and MulB and the sixteen bit product comes back in MulLow and
; MulHigh. IN MEMORY RATHER THAN IN REGISTERS, because B cannot be stored and a product does
; not fit in one byte anyway - two registers in and two out would spend more instructions
; shuffling than the multiply costs.
mul8:
; The sum, which is nine bits: two bytes can reach 510 between them.
SETD.0 MulA
LDA.0
SETD.1 MulB
LDB.1
CCF
ADD
BRC mulSumOver
RSTA
BRI mulSumHigh
mulSumOver:
INIA 0x01
mulSumHigh:
SETD.0 MulSumHigh
STA.0
MVQA ; Q is still the sum's low half; nothing above touched it.
SETD.0 MulSumLow
STA.0
; The difference, without its sign, which is what the identity wants.
SETD.0 MulA
LDA.0
SETD.1 MulB
LDB.1
CCF
SUB
BRC mulOtherWay ; Borrowed, so B is the larger and the sum goes the other way.
MVQA
BRI mulDiffGot
mulOtherWay:
SETD.0 MulB
LDA.0
SETD.1 MulA
LDB.1
CCF
SUB
MVQA
mulDiffGot:
SETD.0 MulDiff
STA.0
; qs[sum]. An entry is two bytes, so the index is doubled - one turn of the shift register,
; where A is the high half and B the low, which is the way DPUW wants them too.
SETD.0 MulSumHigh
LDA.0
SETD.0 MulSumLow
LDB.0
SHL
SETD.0 MulTable
DPUW.0
LDA.0
SETD.1 MulLow
STA.1
INCD.0
LDA.0
SETD.1 MulHigh
STA.1
; Less qs[difference], sixteen bits, the borrow running from one half to the other.
RSTA
SETD.0 MulDiff
LDB.0
SHL
SETD.0 MulTable
DPUW.0
CCF
SETD.1 MulLow
LDA.1
LDB.0
SUB
STQ.1
INCD.0
SETD.1 MulHigh
LDA.1
LDB.0
SUB
STQ.1
RET
#Data
; ---- The multiply's working ----
;
; MulLow and MulHigh are next to each other on purpose: the product is read as a pair.
MulA:
0x00
MulB:
0x00
MulLow:
0x00
MulHigh:
0x00
MulSumLow:
0x00
MulSumHigh:
0x00
MulDiff:
0x00
MulStep:
0x00
MulToggle:
0x00
MulTotalLow:
0x00
MulTotalHigh:
0x00
MulLeftLow:
0x00
MulLeftHigh:
0x00
; 511 entries of two bytes: n from nought to 510, which is as far as a byte plus a byte goes.
MulTable:
#Reserve 0d1022
Int16operands:
0x00 ; Operand A high byte.
0x00 ; Operand A low byte.
+54
View File
@@ -0,0 +1,54 @@
{
"osc0_waveform": 5,
"osc0_dutyCycle": 0.500000,
"osc0_detune": 0.000000,
"osc0_gain": 4.000000,
"osc0_active": 1,
"osc0_octave": 2,
"osc0_modRouting0": 0,
"osc0_modRouting1": 2,
"osc0_modRouting2": 0,
"osc0_modDepth0": 0.000000,
"osc0_modDepth1": 180.000000,
"osc0_modDepth2": 4.000000,
"osc1_waveform": 5,
"osc1_dutyCycle": 0.545000,
"osc1_detune": -15.000000,
"osc1_gain": 4.000000,
"osc1_active": 1,
"osc1_octave": 1,
"osc1_modRouting0": 0,
"osc1_modRouting1": 2,
"osc1_modRouting2": 0,
"osc1_modDepth0": -0.750000,
"osc1_modDepth1": 1104.000000,
"osc1_modDepth2": 4.000000,
"ampEnv_attack": 2.000000,
"ampEnv_decay": 0.100000,
"ampEnv_sustain": 0.000000,
"ampEnv_release": 0.001000,
"modEnv_attack": 0.001000,
"modEnv_decay": 0.603263,
"modEnv_sustain": 0.000000,
"modEnv_release": 2.000000,
"lfo0_rate": 8.796101,
"lfo0_waveform": 0,
"lfo0_active": 0,
"lfo0_mode": 0,
"lfo1_rate": 1.000000,
"lfo1_waveform": 0,
"lfo1_active": 0,
"lfo1_mode": 0,
"filter_cutoff": 13294.700195,
"filter_resonance": 0.574200,
"filter_type": 0,
"filter_active": 1,
"filter_modRouting": 2,
"filter_modDepth": 11600.000000,
"filter_resModRouting": 0,
"filter_resModDepth": 0.000000,
"voice_levelSource": 2,
"voice_gate": 1,
"master_volume": 1.000000,
"master_pitchBendRange": 2.000000
}
+54
View File
@@ -0,0 +1,54 @@
{
"osc0_waveform": 5,
"osc0_dutyCycle": 0.500000,
"osc0_detune": 0.000000,
"osc0_gain": 4.000000,
"osc0_active": 1,
"osc0_octave": 2,
"osc0_modRouting0": 0,
"osc0_modRouting1": 2,
"osc0_modRouting2": 0,
"osc0_modDepth0": 0.000000,
"osc0_modDepth1": 180.000000,
"osc0_modDepth2": 4.000000,
"osc1_waveform": 5,
"osc1_dutyCycle": 0.545000,
"osc1_detune": -15.000000,
"osc1_gain": 4.000000,
"osc1_active": 1,
"osc1_octave": 1,
"osc1_modRouting0": 0,
"osc1_modRouting1": 2,
"osc1_modRouting2": 0,
"osc1_modDepth0": -0.750000,
"osc1_modDepth1": 1104.000000,
"osc1_modDepth2": 4.000000,
"ampEnv_attack": 0.001000,
"ampEnv_decay": 0.100000,
"ampEnv_sustain": 0.000000,
"ampEnv_release": 0.001000,
"modEnv_attack": 0.001000,
"modEnv_decay": 0.288601,
"modEnv_sustain": 0.000000,
"modEnv_release": 2.000000,
"lfo0_rate": 8.796101,
"lfo0_waveform": 0,
"lfo0_active": 0,
"lfo0_mode": 0,
"lfo1_rate": 1.000000,
"lfo1_waveform": 0,
"lfo1_active": 0,
"lfo1_mode": 0,
"filter_cutoff": 389.089539,
"filter_resonance": 0.164624,
"filter_type": 0,
"filter_active": 1,
"filter_modRouting": 0,
"filter_modDepth": 11600.000000,
"filter_resModRouting": 0,
"filter_resModDepth": 0.000000,
"voice_levelSource": 2,
"voice_gate": 1,
"master_volume": 1.000000,
"master_pitchBendRange": 2.000000
}
+54
View File
@@ -0,0 +1,54 @@
{
"osc0_waveform": 1,
"osc0_dutyCycle": 0.500000,
"osc0_detune": 0.000000,
"osc0_gain": 4.000000,
"osc0_active": 1,
"osc0_octave": 2,
"osc0_modRouting0": 0,
"osc0_modRouting1": 3,
"osc0_modRouting2": 1,
"osc0_modDepth0": 0.000000,
"osc0_modDepth1": -420.000000,
"osc0_modDepth2": 4.000000,
"osc1_waveform": 1,
"osc1_dutyCycle": 0.500000,
"osc1_detune": 0.000000,
"osc1_gain": 4.000000,
"osc1_active": 1,
"osc1_octave": 0,
"osc1_modRouting0": 0,
"osc1_modRouting1": 3,
"osc1_modRouting2": 1,
"osc1_modDepth0": 0.000000,
"osc1_modDepth1": -420.000000,
"osc1_modDepth2": 4.000000,
"ampEnv_attack": 0.001000,
"ampEnv_decay": 0.577635,
"ampEnv_sustain": 1.000000,
"ampEnv_release": 0.065071,
"modEnv_attack": 0.005000,
"modEnv_decay": 0.500000,
"modEnv_sustain": 0.000000,
"modEnv_release": 0.100000,
"lfo0_rate": 3.398800,
"lfo0_waveform": 2,
"lfo0_active": 1,
"lfo0_mode": 1,
"lfo1_rate": 1.000000,
"lfo1_waveform": 0,
"lfo1_active": 0,
"lfo1_mode": 0,
"filter_cutoff": 8000.000000,
"filter_resonance": 0.000000,
"filter_type": 0,
"filter_active": 0,
"filter_modRouting": 0,
"filter_modDepth": 0.000000,
"filter_resModRouting": 0,
"filter_resModDepth": 0.000000,
"voice_levelSource": 1,
"voice_gate": 1,
"master_volume": 1.000000,
"master_pitchBendRange": 2.000000
}
+54
View File
@@ -0,0 +1,54 @@
{
"osc0_waveform": 5,
"osc0_dutyCycle": 0.500000,
"osc0_detune": 0.000000,
"osc0_gain": 4.000000,
"osc0_active": 1,
"osc0_octave": 0,
"osc0_modRouting0": 0,
"osc0_modRouting1": 0,
"osc0_modRouting2": 0,
"osc0_modDepth0": 0.000000,
"osc0_modDepth1": 0.000000,
"osc0_modDepth2": 0.000000,
"osc1_waveform": 0,
"osc1_dutyCycle": 0.500000,
"osc1_detune": -29.360291,
"osc1_gain": 2.868294,
"osc1_active": 1,
"osc1_octave": -1,
"osc1_modRouting0": 0,
"osc1_modRouting1": 3,
"osc1_modRouting2": 0,
"osc1_modDepth0": 0.000000,
"osc1_modDepth1": 307.482422,
"osc1_modDepth2": 0.000000,
"ampEnv_attack": 0.076245,
"ampEnv_decay": 0.182086,
"ampEnv_sustain": 1.000000,
"ampEnv_release": 0.001000,
"modEnv_attack": 0.001000,
"modEnv_decay": 0.213054,
"modEnv_sustain": 0.000000,
"modEnv_release": 0.001000,
"lfo0_rate": 19.253021,
"lfo0_waveform": 0,
"lfo0_active": 1,
"lfo0_mode": 1,
"lfo1_rate": 1.000000,
"lfo1_waveform": 0,
"lfo1_active": 0,
"lfo1_mode": 0,
"filter_cutoff": 2081.465332,
"filter_resonance": 0.000000,
"filter_type": 0,
"filter_active": 1,
"filter_modRouting": 2,
"filter_modDepth": -12846.765625,
"filter_resModRouting": 0,
"filter_resModDepth": 0.000000,
"voice_levelSource": 1,
"voice_gate": 0,
"master_volume": 1.000000,
"master_pitchBendRange": 2.000000
}
+54
View File
@@ -0,0 +1,54 @@
{
"osc0_waveform": 5,
"osc0_dutyCycle": 0.500000,
"osc0_detune": 0.000000,
"osc0_gain": 4.000000,
"osc0_active": 1,
"osc0_octave": 2,
"osc0_modRouting0": 0,
"osc0_modRouting1": 2,
"osc0_modRouting2": 0,
"osc0_modDepth0": 0.000000,
"osc0_modDepth1": 180.000000,
"osc0_modDepth2": 4.000000,
"osc1_waveform": 5,
"osc1_dutyCycle": 0.545000,
"osc1_detune": -15.000000,
"osc1_gain": 4.000000,
"osc1_active": 1,
"osc1_octave": 1,
"osc1_modRouting0": 0,
"osc1_modRouting1": 2,
"osc1_modRouting2": 0,
"osc1_modDepth0": -0.750000,
"osc1_modDepth1": 1104.000000,
"osc1_modDepth2": 4.000000,
"ampEnv_attack": 2.000000,
"ampEnv_decay": 0.100000,
"ampEnv_sustain": 0.000000,
"ampEnv_release": 0.001000,
"modEnv_attack": 0.001000,
"modEnv_decay": 0.247558,
"modEnv_sustain": 0.000000,
"modEnv_release": 2.000000,
"lfo0_rate": 8.796101,
"lfo0_waveform": 0,
"lfo0_active": 0,
"lfo0_mode": 0,
"lfo1_rate": 1.000000,
"lfo1_waveform": 0,
"lfo1_active": 0,
"lfo1_mode": 0,
"filter_cutoff": 1242.353027,
"filter_resonance": 0.164624,
"filter_type": 0,
"filter_active": 1,
"filter_modRouting": 0,
"filter_modDepth": 11600.000000,
"filter_resModRouting": 0,
"filter_resModDepth": 0.000000,
"voice_levelSource": 2,
"voice_gate": 1,
"master_volume": 1.000000,
"master_pitchBendRange": 2.000000
}
+58
View File
@@ -0,0 +1,58 @@
; AlarmPatch, converted from Programs/Sounds/LowFuel.json by SoundPatch.
; Designed in soundThing, where it can be heard. Do not edit the numbers
; here: change the patch and convert it again.
#Data
AlarmPatch:
0d50 ; how many pairs follow
0x00 0d1 ; oscillator 0, waveform
0x01 0d255 ; gain
0x02 0d128 ; duty
0x03 0d128 ; detune, in cents
0x04 0d130 ; octave
0x05 0d1 ; on
0x06 0d0 ; what moves its width
0x07 0d128 ; and how far
0x08 0d3 ; what moves its pitch
0x09 0d83 ; and how far
0x0A 0d1 ; what moves its gain
0x0B 0d255 ; and how far
0x10 0d1 ; oscillator 1, waveform
0x11 0d255 ; gain
0x12 0d128 ; duty
0x13 0d128 ; detune, in cents
0x14 0d128 ; octave
0x15 0d1 ; on
0x16 0d0 ; what moves its width
0x17 0d128 ; and how far
0x18 0d3 ; what moves its pitch
0x19 0d83 ; and how far
0x1A 0d1 ; what moves its gain
0x1B 0d255 ; and how far
0x20 0d4 ; amplitude envelope, attack
0x21 0d97 ; decay
0x22 0d255 ; sustain
0x23 0d33 ; release
0x30 0d9 ; modulation envelope, attack
0x31 0d90 ; decay
0x32 0d0 ; sustain
0x33 0d40 ; release
0x40 0d0 ; filter, on
0x41 0d0 ; type: 0 low, 1 high, 2 band
0x42 0d221 ; cutoff, in hertz
0x43 0d0 ; resonance
0x44 0d0 ; what moves the cutoff
0x45 0d128 ; and how far, in hertz
0x46 0d0 ; what moves the resonance
0x47 0d128 ; and how far
0x60 0d1 ; LFO 0, on
0x61 0d2 ; waveform
0x62 0d180 ; rate, in hertz
0x63 0d1 ; 0 free, 1 starts with a voice
0x70 0d0 ; LFO 1, on
0x71 0d0 ; waveform
0x72 0d127 ; rate, in hertz
0x73 0d0 ; 0 free, 1 starts with a voice
0x50 0d1 ; what shapes the level
0x51 0d1 ; 0 gated, 1 triggered
+58
View File
@@ -0,0 +1,58 @@
; BoomPatch, converted from Programs/Sounds/Crash.json by SoundPatch.
; Designed in soundThing, where it can be heard. Do not edit the numbers
; here: change the patch and convert it again.
#Data
BoomPatch:
0d50 ; how many pairs follow
0x00 0d5 ; oscillator 0, waveform
0x01 0d255 ; gain
0x02 0d128 ; duty
0x03 0d128 ; detune, in cents
0x04 0d130 ; octave
0x05 0d1 ; on
0x06 0d0 ; what moves its width
0x07 0d128 ; and how far
0x08 0d2 ; what moves its pitch
0x09 0d147 ; and how far
0x0A 0d0 ; what moves its gain
0x0B 0d255 ; and how far
0x10 0d5 ; oscillator 1, waveform
0x11 0d255 ; gain
0x12 0d140 ; duty
0x13 0d126 ; detune, in cents
0x14 0d129 ; octave
0x15 0d1 ; on
0x16 0d0 ; what moves its width
0x17 0d0 ; and how far
0x18 0d2 ; what moves its pitch
0x19 0d246 ; and how far
0x1A 0d0 ; what moves its gain
0x1B 0d255 ; and how far
0x20 0d180 ; amplitude envelope, attack
0x21 0d40 ; decay
0x22 0d0 ; sustain
0x23 0d4 ; release
0x30 0d4 ; modulation envelope, attack
0x31 0d99 ; decay
0x32 0d0 ; sustain
0x33 0d180 ; release
0x40 0d1 ; filter, on
0x41 0d0 ; type: 0 low, 1 high, 2 band
0x42 0d240 ; cutoff, in hertz
0x43 0d148 ; resonance
0x44 0d2 ; what moves the cutoff
0x45 0d255 ; and how far, in hertz
0x46 0d0 ; what moves the resonance
0x47 0d128 ; and how far
0x60 0d0 ; LFO 0, on
0x61 0d0 ; waveform
0x62 0d220 ; rate, in hertz
0x63 0d0 ; 0 free, 1 starts with a voice
0x70 0d0 ; LFO 1, on
0x71 0d0 ; waveform
0x72 0d127 ; rate, in hertz
0x73 0d0 ; 0 free, 1 starts with a voice
0x50 0d2 ; what shapes the level
0x51 0d1 ; 0 gated, 1 triggered
+58
View File
@@ -0,0 +1,58 @@
; LatchPatch, converted from Programs/Sounds/DockLatch.json by SoundPatch.
; Designed in soundThing, where it can be heard. Do not edit the numbers
; here: change the patch and convert it again.
#Data
LatchPatch:
0d50 ; how many pairs follow
0x00 0d5 ; oscillator 0, waveform
0x01 0d255 ; gain
0x02 0d128 ; duty
0x03 0d128 ; detune, in cents
0x04 0d130 ; octave
0x05 0d1 ; on
0x06 0d0 ; what moves its width
0x07 0d128 ; and how far
0x08 0d2 ; what moves its pitch
0x09 0d147 ; and how far
0x0A 0d0 ; what moves its gain
0x0B 0d255 ; and how far
0x10 0d5 ; oscillator 1, waveform
0x11 0d255 ; gain
0x12 0d140 ; duty
0x13 0d126 ; detune, in cents
0x14 0d129 ; octave
0x15 0d1 ; on
0x16 0d0 ; what moves its width
0x17 0d0 ; and how far
0x18 0d2 ; what moves its pitch
0x19 0d246 ; and how far
0x1A 0d0 ; what moves its gain
0x1B 0d255 ; and how far
0x20 0d4 ; amplitude envelope, attack
0x21 0d40 ; decay
0x22 0d0 ; sustain
0x23 0d4 ; release
0x30 0d4 ; modulation envelope, attack
0x31 0d68 ; decay
0x32 0d0 ; sustain
0x33 0d180 ; release
0x40 0d1 ; filter, on
0x41 0d0 ; type: 0 low, 1 high, 2 band
0x42 0d110 ; cutoff, in hertz
0x43 0d42 ; resonance
0x44 0d0 ; what moves the cutoff
0x45 0d255 ; and how far, in hertz
0x46 0d0 ; what moves the resonance
0x47 0d128 ; and how far
0x60 0d0 ; LFO 0, on
0x61 0d0 ; waveform
0x62 0d220 ; rate, in hertz
0x63 0d0 ; 0 free, 1 starts with a voice
0x70 0d0 ; LFO 1, on
0x71 0d0 ; waveform
0x72 0d127 ; rate, in hertz
0x73 0d0 ; 0 free, 1 starts with a voice
0x50 0d2 ; what shapes the level
0x51 0d1 ; 0 gated, 1 triggered
+58
View File
@@ -0,0 +1,58 @@
; ThrustHoldPatch, converted from Programs/Sounds/ThrustHold.json by SoundPatch.
; Designed in soundThing, where it can be heard. Do not edit the numbers
; here: change the patch and convert it again.
#Data
ThrustHoldPatch:
0d50 ; how many pairs follow
0x00 0d5 ; oscillator 0, waveform
0x01 0d255 ; gain
0x02 0d128 ; duty
0x03 0d128 ; detune, in cents
0x04 0d128 ; octave
0x05 0d1 ; on
0x06 0d0 ; what moves its width
0x07 0d128 ; and how far
0x08 0d0 ; what moves its pitch
0x09 0d128 ; and how far
0x0A 0d0 ; what moves its gain
0x0B 0d128 ; and how far
0x10 0d0 ; oscillator 1, waveform
0x11 0d183 ; gain
0x12 0d128 ; duty
0x13 0d125 ; detune, in cents
0x14 0d127 ; octave
0x15 0d1 ; on
0x16 0d0 ; what moves its width
0x17 0d128 ; and how far
0x18 0d3 ; what moves its pitch
0x19 0d161 ; and how far
0x1A 0d0 ; what moves its gain
0x1B 0d128 ; and how far
0x20 0d35 ; amplitude envelope, attack
0x21 0d54 ; decay
0x22 0d255 ; sustain
0x23 0d4 ; release
0x30 0d4 ; modulation envelope, attack
0x31 0d59 ; decay
0x32 0d0 ; sustain
0x33 0d4 ; release
0x40 0d1 ; filter, on
0x41 0d0 ; type: 0 low, 1 high, 2 band
0x42 0d171 ; cutoff, in hertz
0x43 0d0 ; resonance
0x44 0d2 ; what moves the cutoff
0x45 0d0 ; and how far, in hertz
0x46 0d0 ; what moves the resonance
0x47 0d128 ; and how far
0x60 0d1 ; LFO 0, on
0x61 0d0 ; waveform
0x62 0d253 ; rate, in hertz
0x63 0d1 ; 0 free, 1 starts with a voice
0x70 0d0 ; LFO 1, on
0x71 0d0 ; waveform
0x72 0d127 ; rate, in hertz
0x73 0d0 ; 0 free, 1 starts with a voice
0x50 0d1 ; what shapes the level
0x51 0d0 ; 0 gated, 1 triggered
+58
View File
@@ -0,0 +1,58 @@
; ThrustOnPatch, converted from Programs/Sounds/ThrustOn.json by SoundPatch.
; Designed in soundThing, where it can be heard. Do not edit the numbers
; here: change the patch and convert it again.
#Data
ThrustOnPatch:
0d50 ; how many pairs follow
0x00 0d5 ; oscillator 0, waveform
0x01 0d255 ; gain
0x02 0d128 ; duty
0x03 0d128 ; detune, in cents
0x04 0d130 ; octave
0x05 0d1 ; on
0x06 0d0 ; what moves its width
0x07 0d128 ; and how far
0x08 0d2 ; what moves its pitch
0x09 0d147 ; and how far
0x0A 0d0 ; what moves its gain
0x0B 0d255 ; and how far
0x10 0d5 ; oscillator 1, waveform
0x11 0d255 ; gain
0x12 0d140 ; duty
0x13 0d126 ; detune, in cents
0x14 0d129 ; octave
0x15 0d1 ; on
0x16 0d0 ; what moves its width
0x17 0d0 ; and how far
0x18 0d2 ; what moves its pitch
0x19 0d246 ; and how far
0x1A 0d0 ; what moves its gain
0x1B 0d255 ; and how far
0x20 0d180 ; amplitude envelope, attack
0x21 0d40 ; decay
0x22 0d0 ; sustain
0x23 0d4 ; release
0x30 0d4 ; modulation envelope, attack
0x31 0d63 ; decay
0x32 0d0 ; sustain
0x33 0d180 ; release
0x40 0d1 ; filter, on
0x41 0d0 ; type: 0 low, 1 high, 2 band
0x42 0d152 ; cutoff, in hertz
0x43 0d42 ; resonance
0x44 0d0 ; what moves the cutoff
0x45 0d255 ; and how far, in hertz
0x46 0d0 ; what moves the resonance
0x47 0d128 ; and how far
0x60 0d0 ; LFO 0, on
0x61 0d0 ; waveform
0x62 0d220 ; rate, in hertz
0x63 0d0 ; 0 free, 1 starts with a voice
0x70 0d0 ; LFO 1, on
0x71 0d0 ; waveform
0x72 0d127 ; rate, in hertz
0x73 0d0 ; 0 free, 1 starts with a voice
0x50 0d2 ; what shapes the level
0x51 0d1 ; 0 gated, 1 triggered
+83
View File
@@ -0,0 +1,83 @@
; The quarter square multiply, against numbers whose products are known.
;
; a * b = qs[a+b] - qs[|a-b|], and the table of quarter squares is built by adding, so nothing
; in the whole arrangement needs a multiply to exist. What this checks is that the arrangement
; actually multiplies: the cases below cover nought, one, the commutation, a square, and the
; largest product two bytes can hold.
;
; Written by Anachronaut
#Include print.asm
#Include math.asm
#Program
start:
CALL mulReady ; The table, once, before anything asks for a product.
RSTA
CALL times ; 0 x 0
INIA 0d7
SETD.0 MulA
STA.0
RSTA
SETD.0 MulB
STA.0
CALL show ; 7 x 0, which is the other way round from the last one.
INIA 0d1
CALL both
CALL show ; 1 x 1
INIA 0d12
CALL both
CALL show ; 12 x 12, a square, which is the case the identity leans on:
; the difference term is nought and the whole answer is one entry.
INIA 0d3
SETD.0 MulA
STA.0
INIA 0d200
SETD.0 MulB
STA.0
CALL show ; 3 x 200
INIA 0d200
SETD.0 MulA
STA.0
INIA 0d3
SETD.0 MulB
STA.0
CALL show ; and 200 x 3, which had better agree.
INIA 0xFF
CALL both
CALL show ; 255 x 255, the largest a byte times a byte can be.
HALT
; A in both operands, for the square cases.
both:
SETD.0 MulA
STA.0
SETD.0 MulB
STA.0
RET
; A in both, then show it. The nought case wants this and nothing else does.
times:
CALL both
CALL show
RET
; The product, high byte then low, which is how a sixteen bit number reads.
show:
CALL mul8
SETD.0 MulHigh
LDA.0
CALL printByteHex
SETD.0 MulLow
LDA.0
CALL printByteHex
CALL lineFeed
RET
+51
View File
@@ -0,0 +1,51 @@
; A device raising its line with nothing installed to answer it, CAUGHT.
;
; The other half of the fault that could not be handed over. A program that asks a device to
; interrupt it and then forgets the handler used to stop the machine dead; now it goes to a
; vector of its own with the port in Q, so the program can say which device it was that
; nobody was listening to.
;
; NOTHING IS TYPED AT THIS. The console raises its line once at the end of input as well as
; for an arriving byte - which is exactly so that a program driven by interrupts is told when
; nothing more is coming - and the test is run with no input at all, so that end is what
; arrives.
;
; Correct output is:
; nobody listening on port 00
; and a clean halt.
#Include console.asm
#Program
start:
INIA 0x02 ; Interrupt when the console has something to say.
OUTA 0x02
SIF
spin:
; Never touches the console. Whatever happens below is something that interrupted this.
BRI spin
; Q holds the port whose entry was empty. The console is port zero.
nobody:
SETD.0 NobodyText
CALL printString
MVQA
CALL printByteHex
CALL newLine
; The console back to how it was found, so nothing else is asked for, and stop. There is
; nothing to resume to: the loop above only exists to be interrupted.
RSTA
OUTA 0x02
HALT
#Data
NobodyText:
"nobody listening on port "
#Vectors
Boot start
NoDevice nobody
+56
View File
@@ -0,0 +1,56 @@
; A software interrupt that names a vector with nothing in it, CAUGHT.
;
; That fault used to be the one the machine had no way of handing over, because the thing
; that would hand it over is the thing that has just found nothing to hand it to. It stopped
; the machine, and no program could do anything about it - which made calling a service the
; system does not implement fatal, and that is an ordinary mistake to make.
;
; It goes to a vector of its own now, with the number of the empty entry in Q. This catches
; it, says which one it was, steps over the SWI and its operand, and carries on - so the
; output says both that the fault arrived and that a program can survive it.
;
; Correct output is:
; empty vector 28
; carried on
; and a clean halt.
#Include console.asm
#Program
start:
SWI 0d40 ; Forty is 0x28, and nothing is installed there.
SETD.0 Carried
CALL printString
CALL newLine
HALT
; Entered because there was nowhere to go. Q holds which entry was empty, and it is the only
; thing on this machine a handler is handed in a register.
missing:
SETD.0 EmptyText
CALL printString
MVQA
CALL printByteHex
CALL newLine
; ---- Carrying on past it ----
;
; The frame holds the address after the SWI and the byte naming its vector, so a bare RETI
; already lands past the instruction that faulted. That is not true of every fault here -
; a refused port or a byte that does not decode both resume ON the thing that failed - so
; it is worth saying out loud which kind this one is.
RETI
#Data
EmptyText:
"empty vector "
Carried:
"carried on"
#Vectors
Boot start
NoHandler missing
+51
View File
@@ -0,0 +1,51 @@
; Asks the machine what its controllers are doing.
;
; A pad reports a LEVEL and not an event: one read gives every button at once, holding is the
; natural thing to say, and reading does not consume anything - so asking twice in a frame
; gives the same answer twice, which this checks by doing exactly that.
;
; The recording behind it is one byte a frame. It presses up, holds it a second frame, adds
; right, lets go of up, lets go of everything, then presses A and B together - which is the
; case a console's key-at-a-time stream cannot express at all.
;
; Written by Anachronaut
#Program
start:
INA 0x64
OUTA 0x00 ; Which pads are there: one bit each, so pad nought alone is 1.
RSTA
SETD.0 Count
STA.0
everyFrame:
INA 0x30
INIB 0x01
AND
BRQ everyFrame ; The frame the recording steps on.
INA 0x60
OUTA 0x00
; And again, without a frame in between. A level does not go away when it is looked at.
INA 0x60
OUTA 0x00
SETD.0 Count
LDA.0
INCA
STA.0
INIB 0d6
CCF
SUB
BNQ everyFrame
; A pad that is not there reads as nothing held, which is honest rather than an error.
INA 0x63
OUTA 0x00
HALT
#Data
Count:
0x00
+37 -3
View File
@@ -24,8 +24,10 @@ wrote Asm.sbx: program 7533, data 4099, labels 555
| [`Source/Assembler`](Source/Assembler) | The assembler that runs on a host |
| [`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/Patch`](Source/Patch) | SoundPatch, which turns a soundThing patch into a table the sound device takes |
| [`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/Sounds`](Programs/Sounds) | Patches as soundThing saved them, and the tables SoundPatch made from them, both checked in so the build never needs soundThing |
| [`Programs/Loader`](Programs/Loader) | The standalone loader CosmOS grew out of |
| [`Programs/CosmOS`](Programs/CosmOS) | The operating system, its applications, and the native assembler |
| [`Programs/testPrograms`](Programs/testPrograms) | What the test suite drives |
@@ -114,7 +116,7 @@ Manual.
## Getting Started:
Clone it and build the four tools. You need gcc and make, or similar:
Clone it and build the five tools. You need gcc and make, or similar:
```
git clone https://github.com/RealBusinessAccount/SplitBit-Emulator.git
@@ -146,6 +148,11 @@ Both put a second disk in drive 1, at `Disks/personal.img`. It is made once and
rebuilt, cleaned or committed: everything else here can be thrown away and made again from
source, and that one is where anything made ON the machine lives.
Starting the machine copies it first, three starts back, as `personal.img.1` and so on. Not
one copy: the way a disk is lost is that something goes wrong and the very next thing anybody
does is start the machine again to see how bad it is, which is exactly when a single backup
would be overwritten by the wreckage.
Then `dir` to see what is there, `load Snake.sbx` and `run` to play something, or `load Asm.sbx` and `run cosmos.asm` to watch the machine build itself.
Every source in `Programs/` is on that disk, under `/Source`, so anything not shipped as a
@@ -299,6 +306,33 @@ this is what turns such a count into a list of routine names.
Without `-o` the output takes the source file's name, in the directory you called the assembler from, with the extension the format asks for: `.bin` for a boot image and `.sbx` for a loadable program. Included files are looked for beside the file that includes them, and then along the directories given with `-I`.
## Making A Patch: SoundPatch
```
./SoundPatch <patch.json> <label> [output.asm]
```
The sound device is soundThing's voice engine with the editor taken off, so a patch designed
in soundThing makes the same sound here. What differs is how it arrives: soundThing writes
seconds and hertz, and the device takes bytes through a selector. SoundPatch converts one into
the other and writes a table a program can hand to the device.
A byte is not a number of seconds, and that is the whole reason this exists. Times are squared
into four seconds, because the difference between five and fifty milliseconds is the character
of a percussive sound and the difference between three and four seconds is nothing anybody can
hear. Cutoff and LFO rate are exponential, because hearing is logarithmic. Depths and detune
are centred on 128, so half of nothing is no change. The first sound written for a game here
was guessed at directly in bytes: a cutoff of 40 looks small and is 57 Hz, and the bang came
out as a low gurgle.
A field the tool does not recognise stops it rather than being skipped, because a patch format
that has moved on would otherwise produce a table that quietly means something else.
**The build does not depend on it.** soundThing lives in its own repository and is not
required to build anything here. `Programs/Sounds` holds both halves - the patch as soundThing
saved it and the table made from it - so a sound can be regenerated, or read, without going
and finding the editor first. The tool is for when a sound is being changed.
## Checking Assembly: SplitLint
```
@@ -606,7 +640,7 @@ $(BUILD)/%.bin: %.asm
make test
```
Builds the four tools, checks they build clean under strict ISO C, and runs nine scripts.
Builds the five 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
@@ -625,7 +659,7 @@ correct.
make sanitize
```
Rebuilds all four tools with the address and undefined behaviour sanitizers and runs the
Rebuilds all five 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.
Executable
BIN
View File
Binary file not shown.
+25 -1
View File
@@ -66,7 +66,31 @@
#define VECTOR_GUARD_VIOLATION 3
// A bank was named that has nothing registered in it, or an access ran past its end.
#define VECTOR_BANK_FAULT 4
// Vectors 5 to 15 are held back for faults that do not exist yet, so that each cause
// ---- The two faults that used to be uncatchable ----
//
// A vector was dispatched through and had nothing in it. That is discovered by the very
// thing that would dispatch, so for a long time it could only stop the machine: there was
// no way to hand a program a fault about there being nowhere to hand it.
//
// These two are where it goes instead, and they are separate for the same reason every
// other cause is - a missing software vector and a device nobody is listening to are
// different mistakes with different fixes, and a handler should not have to work out which
// it was.
//
// WHICH ENTRY WAS EMPTY ARRIVES IN Q, and that is the only thing on this machine a handler
// is given in a register. It is not a cause register by the back door: the vector still
// says what happened, and Q says which of the 256 entries it happened about, which is a
// parameter rather than a cause. It costs no new state, because the frame already saved the
// Q the interrupted program had and RETI puts it back.
//
// A vector THESE dispatch through and find empty stops the machine, and has to: a machine
// that cannot report a fault about a missing handler by any route has run out of places to
// go, and looping there would be worse than stopping.
#define VECTOR_NO_HANDLER 5
#define VECTOR_NO_DEVICE 6
// Vectors 7 to 15 are held back for faults that do not exist yet, so that each cause
// can have an entry of its own rather than sharing one and needing a cause register to
// tell them apart. Everything from 16 up belongs to programs, in two halves.
//
+2
View File
@@ -226,6 +226,8 @@ static const struct {
{ "BadOpcode", VECTOR_INVALID_OPCODE },
{ "GuardViolation", VECTOR_GUARD_VIOLATION },
{ "BankFault", VECTOR_BANK_FAULT },
{ "NoHandler", VECTOR_NO_HANDLER },
{ "NoDevice", VECTOR_NO_DEVICE },
};
static const int reservedVectorCount = (int)(sizeof(reservedVectors) / sizeof(reservedVectors[0]));
+6 -1
View File
@@ -15,7 +15,12 @@
// share one namespace: a name may only be defined once across the whole assembly. So this
// is not the size of one file but the size of a program and its libraries together, and
// CosmOS with its four libraries went past 256 while still being a small system.
#define MAX_LABELS 1024
//
// AND PAST 1024 the day the shell learned to finish a word somebody had started. Doubled
// rather than nudged: a ceiling reached once is a ceiling that will be reached again, and
// the table is pointers into source that is already in memory - 2048 of them is sixteen
// kilobytes on a host with gigabytes of it.
#define MAX_LABELS 2048
#define MAX_VECTORS 256
typedef struct {
+30 -1
View File
@@ -69,9 +69,31 @@ static uint16_t readVector(const uint8_t *programMemory, uint16_t base, uint8_t
// Note that a zero entry means "no handler" to everything that dispatches, including the
// two entries the CPU treats as start addresses when it reads them at reset. The
// exemption belongs to that one read, not to the entries themselves.
static uint8_t enterInterrupt(CPURegisters *cpu, uint16_t base, uint8_t index, uint16_t resumeAddress) {
static uint8_t dispatch(CPURegisters *cpu, uint16_t base, uint8_t index,
uint16_t resumeAddress, int mayEscalate) {
uint16_t handler = readVector(cpu->Program, base, index);
if (handler == 0x0000) {
// ---- Nowhere to go is itself something to report ----
//
// This is the one fault the machine used to have no way of handing over, because
// the thing that would hand it over is the thing that has just found nothing to
// hand it to. So it goes to a vector of its own instead, with the number of the
// empty entry in Q - and a missing software vector and a device nobody is
// listening to are separate entries, because they are separate mistakes.
//
// NOT WHEN ALREADY ESCALATING. If the fault vector for this is itself empty then
// the machine really has run out of places to go, and stopping is the only honest
// answer left.
if (mayEscalate) {
const uint8_t escalation = (base == HARDWARE_VECTOR_BASE)
? VECTOR_NO_DEVICE : VECTOR_NO_HANDLER;
if (!dispatch(cpu, SOFTWARE_VECTOR_BASE, escalation, resumeAddress, 0)) {
// After the frame, so the Q the interrupted program had is safely in it and
// RETI will put it back. What the handler sees is which entry was empty.
cpu->Q = index;
return 0;
}
}
cpu->Fault = FAULT_NO_HANDLER;
cpu->FaultVector = index;
cpu->Status |= STATUS_FAULT;
@@ -109,6 +131,13 @@ static uint8_t enterInterrupt(CPURegisters *cpu, uint16_t base, uint8_t index, u
return 0;
}
// Dispatching the ordinary way: through the vector asked for, and through the fault vector
// for a missing one if that is what it turns out to be.
static uint8_t enterInterrupt(CPURegisters *cpu, uint16_t base, uint8_t index,
uint16_t resumeAddress) {
return dispatch(cpu, base, index, resumeAddress, 1);
}
// A device that refused what it was asked stops the machine where it stands, rather than
// raising a line and letting execution carry on past the mistake. The frame carries the
// address of the instruction that asked, so a handler can see which one it was, and so a
+53 -8
View File
@@ -7,6 +7,7 @@
#include "../Assembler/assembly.h" // For the fault vector numbers.
#include "controller.h"
#include "video.h"
#include "pad.h"
#include "sound.h"
#include "font.h"
#include <stdio.h>
@@ -602,8 +603,25 @@ static int consoleKeyFromInput(int mayWait) {
// Nothing to hand over: either that sequence meant nothing here, or it meant a key
// and line mode does not deliver keys. Both are the same answer to a caller - there
// is still no byte - so a blocking read asks again and a poll says so and leaves.
if (got < 0 || (!consoleKeyMode && got >= CONSOLE_KEY_FIRST && got <= CONSOLE_KEY_LAST)) {
const int undeliverable =
!consoleKeyMode && got >= CONSOLE_KEY_FIRST && got <= CONSOLE_KEY_LAST;
if (got < 0 || undeliverable) {
if (!mayWait) {
// ---- A LOOK MUST NOT CONSUME WHAT IT CANNOT REPORT ----
//
// This is the status port asking, and either way it has no byte to report.
// But a key that line mode will not deliver is not the same as a key that is
// gone: THE MODE CAN CHANGE. A program that polls and then asks for key mode
// - which is exactly what the shell does before it reads a line - would find
// that the first key it was reaching for had been swallowed by the looking.
//
// So it is held rather than dropped, and delivered as soon as something is
// willing to take it. The blocking read below drops it instead, and must:
// that read IS the delivery, line mode genuinely has no use for the key, and
// a byte held there would be met again forever.
if (undeliverable) {
consoleHeldByte = got;
}
return CONSOLE_NOTHING_YET;
}
continue;
@@ -1151,6 +1169,9 @@ void deviceTick(unsigned long now) {
soundTick(now);
// And the timer, which is the only beat a program can choose for itself.
timerTick(now);
// And the pads, whose recordings step on a frame so that a level read twice in one is
// the same level both times.
padTick(now);
if (diskPending && now >= diskReadyAt) {
diskSettle();
}
@@ -1287,11 +1308,12 @@ uint8_t *deviceMemory(uint8_t port, uint32_t *capacity) {
*capacity = DISK_BLOCK_BYTES;
return diskBuffer;
}
if (port == PORT_VIDEO) {
// Tiles, the map and the palette, in one bank. A program blits the part that
// changed and the rest stays as it was, which is the whole reason the screen is a
// bank rather than a window onto a port.
return videoMemory(capacity);
if (port == PORT_VIDEO || port == VIDEO_SCREEN0 || port == VIDEO_SCREEN1) {
// Two banks: the atlas of tiles and colours on the base port, and the map or the
// bitmap on its own. A program blits the part that changed and the rest stays as it
// was, which is the whole reason the screen is memory rather than a window onto a
// port - and having two means a picture costs the map and not the font.
return videoMemory(port, capacity);
}
return NULL;
}
@@ -1323,6 +1345,7 @@ static const DeviceRecord deviceTable[] = {
{ PORT_VIDEO, DEVICE_VIDEO, DEVICE_FLAG_HAS_MEMORY },
{ PORT_SOUND, DEVICE_SOUND, 0 },
{ PORT_TIMER, DEVICE_TIMER, 0 },
{ PORT_PAD, DEVICE_PAD, 0 },
{ PORT_REGISTRY, DEVICE_REGISTRY, 0 },
};
static const int deviceCount = (int)(sizeof(deviceTable) / sizeof(deviceTable[0]));
@@ -1352,8 +1375,22 @@ static const DeviceRecord *deviceOnPort(uint8_t port) {
return deviceOnPort(PORT_DISK);
}
if (port > PORT_VIDEO && port <= PORT_VIDEO_TOP) {
// Sixteen ports, one device, and the same rule again.
return deviceOnPort(PORT_VIDEO);
// Sixteen ports, one device, and the same rule again - with one difference, because
// this device owns TWO banks. Forwarding the whole block to the base record used to
// say that all sixteen ports brought memory, which was harmless only while nobody
// believed it: a program that enumerated the block and registered everything
// claiming memory would have faulted on the fourteen that have none.
//
// So the block answers honestly. The screen port says it brings memory because it
// does, and the rest of the block says it does not.
static const DeviceRecord videoScreen0Record =
{ VIDEO_SCREEN0, DEVICE_VIDEO, DEVICE_FLAG_HAS_MEMORY };
static const DeviceRecord videoScreen1Record =
{ VIDEO_SCREEN1, DEVICE_VIDEO, DEVICE_FLAG_HAS_MEMORY };
static const DeviceRecord videoPlainRecord = { PORT_VIDEO, DEVICE_VIDEO, 0 };
if (port == VIDEO_SCREEN0) { return &videoScreen0Record; }
if (port == VIDEO_SCREEN1) { return &videoScreen1Record; }
return &videoPlainRecord;
}
if (port > PORT_SOUND && port <= PORT_SOUND_TOP) {
return deviceOnPort(PORT_SOUND);
@@ -1361,6 +1398,11 @@ static const DeviceRecord *deviceOnPort(uint8_t port) {
if (port > PORT_TIMER && port <= PORT_TIMER_TOP) {
return deviceOnPort(PORT_TIMER);
}
// Sixteen ports, one device: four pads, a port saying which are there, and eleven kept
// for the analogue axes that are not built.
if (port > PORT_PAD && port <= PORT_PAD_TOP) {
return deviceOnPort(PORT_PAD);
}
for (int i = 0; i < deviceCount; i++) {
if (deviceTable[i].port == port) {
return &deviceTable[i];
@@ -1538,6 +1580,9 @@ uint8_t InputHandler(uint8_t Address) {
if (Address >= PORT_SOUND && Address <= PORT_SOUND_TOP) {
return soundRead(Address);
}
if (Address >= PORT_PAD && Address <= PORT_PAD_TOP) {
return padRead(Address);
}
if (Address >= PORT_TIMER && Address <= PORT_TIMER_TOP) {
return timerRead(Address);
}
+3
View File
@@ -314,6 +314,9 @@ void consoleSetInputHook(int (*hook)(int mayWait));
#define DEVICE_VIDEO 0x14
#define DEVICE_SOUND 0x15
#define DEVICE_TIMER 0x16
// Game controllers. Polled, never interrupting: a game asks once a frame because that is when
// it draws, and an interrupt for every button would be the event model a pad exists to avoid.
#define DEVICE_PAD 0x17
// 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.
+36
View File
@@ -8,6 +8,7 @@
#include "cpu.h"
#include "controller.h"
#include "io.h"
#include "pad.h"
#include "video.h"
#include "sound.h"
#include "utility.h"
@@ -134,6 +135,7 @@ static int machineRestart(Machine *m) {
return 0;
}
videoReset();
padReset();
soundReset();
timerReset();
consoleHome();
@@ -207,6 +209,7 @@ uint8_t machineStart(Machine *m, const EmulatorOptions *options, const char *pro
// the device's, and a reset that left last program's screen up would be a reset that
// did not happen.
videoReset();
padReset();
soundReset();
timerReset();
if (options->sound != NULL) {
@@ -220,6 +223,39 @@ uint8_t machineStart(Machine *m, const EmulatorOptions *options, const char *pro
if (m->options.debug) {
printRegisters(&m->cpu, Program, Data);
}
// ---- The pads, from files ----
//
// MACHINE_ERROR and not nought. Nought is MACHINE_OK here, so returning it after printing
// an error said the machine had started - and the front end then ran a machine whose
// clock had never been set up and divided by it. A missing file came out as a floating
// point exception and a core dump.
//
// The trap is that machineRestart, thirty lines up, returns 1 for worked and 0 for did
// not. Two functions in one file with opposite conventions, and this copied the nearer
// one.
//
// Opened here beside the keyboard because they are the same kind of thing: a recording
// standing in for a person, so that what a person would exercise is reachable from a
// suite. They are never closed, for the same reason the keyboard is not - the machine
// outlives the call and the host reclaims them when it stops.
for (int n = 0; n < options->padCount; n++) {
FILE *pad = fopen(options->pads[n], "rb");
if (pad == NULL) {
fprintf(stderr, "Error: Couldn't open pad file: %s\n", options->pads[n]);
return MACHINE_ERROR;
}
padFromFile(n, pad);
}
if (options->padRecord != NULL) {
FILE *pad = fopen(options->padRecord, "wb");
if (pad == NULL) {
fprintf(stderr, "Error: Couldn't write pad file: %s\n", options->padRecord);
return MACHINE_ERROR;
}
padRecordTo(pad);
}
if (options->keyboard != NULL) {
keyboardFile = fopen(options->keyboard, "rb");
if (keyboardFile == NULL) {
+151
View File
@@ -0,0 +1,151 @@
// pad.c
// Game controllers for the Voyager.
// Written by Anachronaut
#include "pad.h"
#include "video.h"
// What each pad is holding, and where each one gets it from.
static uint8_t held[PAD_COUNT];
static FILE *recorded[PAD_COUNT];
static uint8_t live[PAD_COUNT];
static int connected[PAD_COUNT];
// ---- What the machine is told, which changes only on a frame ----
//
// The live state is written by whatever is watching real hardware, on ITS clock - a window
// polls its keyboard once a host frame, and a host frame is not a machine frame. Read
// straight through, that made a pad whose value could change in the middle of a machine
// frame, which breaks the one promise the device makes: that asking twice in a frame gives
// the same answer both times.
//
// It also made recordings that were not of the flight. The recorder samples on a frame
// boundary and the program reads whenever it reads, so the two saw different bytes - and a
// replay of that is a DIFFERENT FLIGHT, faithfully reproduced. It flew a lander off the top
// of the screen that had never gone there.
//
// So the live state is latched here once a frame, and what the machine reads and what the
// recorder writes are the same thing by construction. Real hardware does this too, and for
// the same reason: a controller is sampled once a frame, not continuously.
static uint8_t reported[PAD_COUNT];
static FILE *recording;
// ---- The frame the recordings advance on ----
//
// The screen's frame, and it is the same one on purpose: a game reads its pad once a frame
// because that is when it draws, so a byte a frame is a byte a poll for anything written the
// ordinary way - without making it a byte a READ, which would answer a game that asked twice
// differently from one that asked once.
//
// On the machine's clock, so a recording plays back the same over the same cycles however
// fast the host ran.
static unsigned long lastFrame;
static int started;
void padReset(void) {
for (int n = 0; n < PAD_COUNT; n++) {
held[n] = 0;
live[n] = 0;
reported[n] = 0;
connected[n] = 0;
// The files are NOT closed or forgotten. They were named on the command line and
// outlive a reset, the same as a disk image does: a machine that restarted itself
// and lost its controllers would be a strange thing to debug.
}
lastFrame = 0;
started = 0;
}
// What the device would report for this pad, which is what a recording has to hold: a
// recording of a playback that wrote the LIVE state would be a file of noughts.
static uint8_t effective(int which) {
return (recorded[which] != NULL) ? held[which] : reported[which];
}
void padRecordTo(FILE *file) {
recording = file;
}
void padFromFile(int which, FILE *file) {
if (which < 0 || which >= PAD_COUNT) {
return;
}
recorded[which] = file;
}
void padSet(int which, int isConnected, uint8_t heldNow) {
if (which < 0 || which >= PAD_COUNT) {
return;
}
connected[which] = isConnected;
live[which] = heldNow;
}
void padTick(unsigned long now) {
// The first tick sets the clock rather than counting a frame from nought, or a machine
// that started late would take a run of bytes all at once.
if (!started) {
lastFrame = now;
started = 1;
}
while (now - lastFrame >= VIDEO_FRAME_CYCLES) {
lastFrame += VIDEO_FRAME_CYCLES;
for (int n = 0; n < PAD_COUNT; n++) {
// The live state is taken as it stands at the frame boundary and held there
// until the next one, so nothing the machine reads was never recorded.
reported[n] = live[n];
if (recorded[n] == NULL) {
continue;
}
const int byte = fgetc(recorded[n]);
// ---- The end of a recording is nothing held ----
//
// Not a pad that vanishes and not the last frame repeating for ever. A recording
// that ran out and left a direction pressed would send whatever it was driving
// off the edge of the world long after the test meant to stop.
held[n] = (byte == EOF) ? 0 : (uint8_t)byte;
}
// ---- And a byte written for every frame that went by ----
//
// Inside the loop rather than after it, so a machine that jumped several frames at
// once still writes one byte for each of them. A recording is a TIMELINE, and one
// that skipped the frames nobody was looking at would play back faster than it was
// flown.
//
// Flushed as it goes, because a recording is usually stopped by whoever is playing
// rather than by the program ending, and a demo lost to a buffer would be a demo
// flown twice.
if (recording != NULL) {
uint8_t all = 0;
for (int n = 0; n < PAD_COUNT; n++) {
all |= effective(n);
}
fputc(all, recording);
fflush(recording);
}
}
}
uint8_t padRead(uint8_t port) {
if (port == PAD_PRESENT) {
uint8_t there = 0;
for (int n = 0; n < PAD_COUNT; n++) {
// A recording is a pad, and so is anything the front end says is plugged in.
// Counting only the recordings meant this said nought on the one machine that
// has real controllers, which is the only machine where the answer matters.
if (recorded[n] != NULL || connected[n]) {
there |= (uint8_t)(1u << n);
}
}
return there;
}
const int which = port - PORT_PAD;
if (which < 0 || which >= PAD_COUNT) {
// Everything else in the block is reserved and reads as nothing, which is what a
// port block being kept for later should do.
return 0;
}
// A recording wins over a live pad, so a test is not at the mercy of whatever somebody
// is leaning on while it runs.
return effective(which);
}
+107
View File
@@ -0,0 +1,107 @@
// pad.h
// Game controllers for the Voyager.
// Written by Anachronaut
#ifndef PAD_H
#define PAD_H
#include <stdint.h>
#include <stdio.h>
// ---- What a pad is, and why it is not the console ----
//
// The console says WHICH KEY WENT DOWN. That is the right shape for typing and the wrong one
// for playing: a game wants to know what is being held, this frame, possibly several things
// at once, and a stream of presses cannot say that. A key that is down and staying down sends
// nothing at all.
//
// So a pad reports a LEVEL rather than an event. One read gives the state of every button at
// once, holding is the natural thing to express, and two directions at the same time costs
// nothing. Reading it does not consume it: a game may ask twice in a frame and get the same
// answer both times, which an event queue cannot promise.
//
// IT IS NOT AN EXTENSION OF THE CONSOLE, and that is deliberate. A terminal hands over
// characters and cannot report a key coming up however it is asked, so key-up on the console
// would have been a thing that worked behind a window and silently did not down a wire - and
// "the same program behaves the same everywhere" is worth more than the convenience. A
// separate device can honestly say it is not there.
#define PORT_PAD 0x60
#define PORT_PAD_TOP 0x6F
// Four, because a party is four. They cost a port each and nothing at all when unused.
#define PAD_COUNT 4
// ---- The buttons, in one byte ----
//
// The four directions in the low nibble, so "which way" is an AND with 0x0F and needs no
// shifting. The four buttons in the high nibble for the same reason.
#define PAD_RIGHT 0x01
#define PAD_LEFT 0x02
#define PAD_DOWN 0x04
#define PAD_UP 0x08
#define PAD_A 0x10
#define PAD_B 0x20
#define PAD_START 0x40
#define PAD_SELECT 0x80
// ---- Which of them are there ----
//
// One bit a pad, so a game can say "this wants a controller" rather than sitting silent while
// somebody presses things at it. A pad that is not there reads as nothing held, which is the
// same as a pad nobody is touching - the difference matters only to whoever wants to explain
// it, and that is exactly who this port is for.
#define PAD_PRESENT 0x64
// Ports 0x65 to 0x6F are reserved. Analogue axes are the thing they are being kept for: a
// paddle is two more bytes a pad and an argument about deadzones, and nothing wants one yet.
void padReset(void);
// ---- Recorded input, which is what makes this testable ----
//
// Voyager reads a real pad, and its tests run headless with no window and no hands. Without a
// recorded path this whole device would be exercised only by somebody playing, which is the
// state the console's line editing was in when it broke twice in two days.
//
// A file is one byte a FRAME, not one byte a read. A pad is a level: a game that asks twice
// in one frame has to be told the same thing both times, and a file that advanced per read
// would answer differently depending on how the game was written.
void padFromFile(int which, FILE *file);
// A live pad, set by whatever is watching real hardware. Ignored for a pad that has a file,
// so a recording always wins over whatever somebody happens to be holding.
//
// CONNECTED IS SAID SEPARATELY FROM HELD, and it has to be: a front end calls this every
// frame for every pad, so "held nothing" is the commonest thing it says and cannot also mean
// "there is no pad here". Getting that wrong made 0x64 answer nought with a controller
// plugged in, and a game that asked whether there was one was told no while the device
// underneath was reporting its buttons perfectly.
void padSet(int which, int connected, uint8_t held);
// ---- Writing one down ----
//
// A demo recorder. What it writes is EXACTLY WHAT --pad READS, one byte a frame, so playing a
// recording back needs no conversion and no second format - and the round trip is a property
// worth testing: play a file while recording it and the same bytes come out.
//
// It exists because some inputs cannot sensibly be written by hand. Flying a lander from one
// base to another is a few hundred frames of steering that has to arrive somewhere eight cells
// wide, and hand-authoring one is a piloting exercise rather than a test. Playing it once and
// keeping what happened is the whole answer.
//
// EVERY PAD AT ONCE, or-ed into one byte, and not one pad chosen by number. Which pad a
// controller lands on is an accident of the host - the first recording made with this came
// back 1,766 frames of nothing, because it recorded pad nought and the controller was
// somewhere else. A demo is a record of what somebody DID, and on a machine one person is
// playing the number it arrived on is not part of that.
//
// A recording therefore plays back on pad nought, which is where --pad puts the first file
// given. Any program that reads more than one pad reads them or-ed anyway, for exactly the
// same reason.
void padRecordTo(FILE *file);
void padTick(unsigned long now);
uint8_t padRead(uint8_t port);
#endif // PAD_H
+5
View File
@@ -213,6 +213,7 @@ static void setLfo(LFO *l, uint8_t which, uint8_t value) {
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;
case SP_LFO_MODE: l->mode = value ? LFO_RETRIGGER : LFO_FREE; break;
default: break;
}
}
@@ -228,8 +229,12 @@ static void soundParameter(uint8_t value) {
case SP_MODENV: setEnvelope(&v->modEnv, which, value); break;
case SP_FILTER: setFilter(&v->filter, parameter, value); break;
case SP_LEVEL_SOURCE:
// Two settings share a group, because both are about the voice as a whole rather
// than about one of its parts.
if (parameter == SP_LEVEL_SOURCE) {
v->levelSource = sourceFor(value);
} else if (parameter == SP_VOICE_GATE) {
v->gate = value ? VOICE_TRIGGER : VOICE_GATE;
}
break;
// The LFOs belong to the device rather than to a channel, so whichever channel is
+21
View File
@@ -82,6 +82,17 @@
// LFOs. Nought is the one that could not be said before - see synth.h.
#define SP_LEVEL_SOURCE 0x50
// ---- Whether a note waits to be let go of ----
//
// 0 is gated and 1 is triggered. Gated is what a keyboard wants: the sound lasts as long as
// the finger does, and dropping the gate starts the release. Triggered is what a GAME wants -
// a bang, a pickup, a door - where the note is struck and then plays its own length, and
// nothing has to remember to end it. Sustain and release have no meaning in a triggered
// voice, because both are answers to a question about a key that is not being asked.
//
// A program driving one-shots does not need port 0x45 at all once this is set.
#define SP_VOICE_GATE 0x51
// 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
@@ -89,6 +100,16 @@
#define SP_LFO_ACTIVE 0x00
#define SP_LFO_WAVE 0x01
#define SP_LFO_RATE 0x02
// ---- Whether it starts over when a voice does ----
//
// 0 free and 1 retriggered. Free is one cycle running under everything, which is what vibrato
// across a held chord wants. Retriggered starts at the beginning of its shape every time a
// voice begins, which is the only way a one-shot sounds the SAME twice: a free LFO is wherever
// the wall clock left it, so the same drum caught at a different moment is a different drum.
//
// The mode belongs to the LFO and the cycle belongs to the voice, so retriggering costs
// nothing to a channel that is not using it.
#define SP_LFO_MODE 0x03
// ---- Samples come from the machine's clock ----
//
+150 -56
View File
@@ -10,25 +10,22 @@
//
// 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.
// carried across on purpose - in BOTH directions, which has now happened once each way.
//
// ---- 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.
// Nothing. This is soundThing's engine at b73e5c0, character for character, except that
// em-dashes and arrows in its comments are written as ASCII here because this tree is ASCII
// only. That rule is local and is not an improvement, so it was not sent upstream.
//
// 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.
// It did not start that way. Three changes were made here first - a routed voice level, a
// seeded noise generator, and channels asked for by number - and all three went up. What came
// back was those three plus what they made possible: a voice that can end itself rather than
// waiting for a key, and a triggered voice that re-arms its oscillators so a one-shot is the
// same one-shot twice. A game is nearly all one-shots, which is why the traffic went that 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.
// So the thing to keep current is no longer a list. It is this: if either copy changes, the
// other one has to be told.
//
// Written by Anachronaut
@@ -42,6 +39,30 @@
#define M_PI 3.14159265358979323846
#endif
// ---- The seeds a retrigger goes back to ----
//
// Deliberately NOT keyed on the voice, unlike the seeds synthInit hands out. A retriggered
// one-shot has to sound the same whichever voice happens to be free for it, and a seed that
// varied per voice would make the same drum a different drum eight ways. Keyed on the
// oscillator, though, because osc 0 and osc 1 drawing one stream are one noise heard twice.
static uint32_t oscTriggerSeed(int o)
{
return (o == 0) ? 0x9E3779B9u : 0x7F4A7C15u;
}
static uint32_t lfoTriggerSeed(int l)
{
return 0x2545F491u + (uint32_t)l * 3266489917u;
}
static void lfoStateInit(LfoState *st, uint32_t seed)
{
st->phase = 0.0f;
st->noiseHeld = 0.0f;
st->noisePhase = 0.0f;
st->noiseState = seed;
}
void synthInit(Synth *s, float sampleRate)
{
s->sampleRate = sampleRate;
@@ -58,14 +79,16 @@ void synthInit(Synth *s, float sampleRate)
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.
// than one heard twice. The constants are arbitrary and non-zero.
// Envelope 0 shapes the level, which is what it always did - the difference is that
// it is now said rather than assumed, and can be said differently.
s->voices[i].levelSource = MOD_SOURCE_AMP_ENV;
s->voices[i].gate = VOICE_GATE;
for (int l = 0; l < LFO_COUNT; l++)
lfoStateInit(&s->voices[i].lfoRun[l], lfoTriggerSeed(l));
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
@@ -92,13 +115,13 @@ void synthInit(Synth *s, float sampleRate)
s->voices[0].oscillators[0].active = 1;
for (int l = 0; l < LFO_COUNT; l++) {
s->lfos[l].phase = 0.0f;
lfoStateInit(&s->lfos[l].run, lfoTriggerSeed(l));
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;
// Free unless a patch says otherwise, which is what every patch that exists was
// made against.
s->lfos[l].mode = LFO_FREE;
}
}
@@ -107,9 +130,14 @@ 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.
// always was - now said out loud, so that a patch which routed it elsewhere does not
// leave the next one silent.
v->levelSource = MOD_SOURCE_AMP_ENV;
// Held up by the key unless a patch says otherwise, which is what every patch that
// exists was made against.
v->gate = VOICE_GATE;
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++) {
@@ -139,13 +167,13 @@ void synthResetPatch(Synth *s)
v->filter.resModDepth = 0.0f;
for (int l = 0; l < LFO_COUNT; l++) {
s->lfos[l].phase = 0.0f;
lfoStateInit(&s->lfos[l].run, lfoTriggerSeed(l));
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;
// Free unless a patch says otherwise, which is what every patch that exists was
// made against.
s->lfos[l].mode = LFO_FREE;
}
s->volume = 0.8f;
@@ -191,19 +219,25 @@ static float getModValue(float ampEnv, float modEnv, float lfo0, float lfo1, Mod
}
}
float lfoTick(LFO *l, float sampleRate)
float lfoTickState(const LFO *l, LfoState *st, float sampleRate)
{
if (!l->active) return 0.0f;
l->phase += l->rate / sampleRate;
if (l->phase >= 1.0f) l->phase -= 1.0f;
st->phase += l->rate / sampleRate;
if (st->phase >= 1.0f) st->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);
st->noisePhase += l->rate / sampleRate;
if (st->noisePhase >= 1.0f) {
st->noisePhase -= 1.0f;
st->noiseHeld = nextNoise(&st->noiseState);
}
}
return waveformSample(l->waveform, l->phase, 0.5f, l->noiseHeld);
return waveformSample(l->waveform, st->phase, 0.5f, st->noiseHeld);
}
// The LFO advancing its own cycle: the free-running one, the same for every voice.
float lfoTick(LFO *l, float sampleRate)
{
return lfoTickState(l, &l->run, sampleRate);
}
float filterTick(Filter *f, float input, float cutoff, float resonance, float sampleRate)
@@ -330,6 +364,43 @@ const char *waveformName(Waveform w)
}
}
// Everything about a voice that a note begins rather than inherits.
//
// The envelopes are told, once, whether this note is waiting on a key - asked here rather
// than read live in the mixer so a note already sounding keeps the shape it began with.
//
// A TRIGGERED voice also starts its oscillators over. They are the larger half of why the
// same one-shot came out different every time: the envelopes restarted and the filter was
// cleared, but the oscillator phase carried on from wherever the last note left it, so a
// kick began a third of the way into its own cycle depending on what played before it. A
// gated voice is left alone, because a key being held is not a claim about phase and every
// patch that exists was made against the old behaviour.
//
// Retriggered LFOs restart for THIS voice only, whatever the gate - an LFO starting fresh
// per note is wanted under held notes too, and it is the shared cycle that must not move.
static void voiceArm(Synth *s, Voice *v)
{
int oneShot = (v->gate == VOICE_TRIGGER);
v->ampEnv.oneShot = oneShot;
v->modEnv.oneShot = oneShot;
if (v->gate == VOICE_TRIGGER) {
for (int o = 0; o < OSC_COUNT; o++) {
v->oscillators[o].phase = 0.0f;
v->oscillators[o].noiseHeld = 0.0f;
v->oscillators[o].noisePhase = 0.0f;
v->oscillators[o].noiseState = oscTriggerSeed(o);
}
}
for (int l = 0; l < LFO_COUNT; l++)
if (s->lfos[l].mode == LFO_RETRIGGER)
lfoStateInit(&v->lfoRun[l], lfoTriggerSeed(l));
envelopeNoteOn(&v->ampEnv);
envelopeNoteOn(&v->modEnv);
}
void synthNoteOn(Synth *s, int midiNote)
{
float hz = 440.0f * powf(2.0f, (midiNote - 69) / 12.0f);
@@ -341,8 +412,7 @@ void synthNoteOn(Synth *s, int 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);
voiceArm(s, &s->voices[i]);
return;
}
}
@@ -355,29 +425,28 @@ void synthNoteOn(Synth *s, int 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);
voiceArm(s, &s->voices[i]);
}
void synthNoteOff(Synth *s, int midiNote) {
for (int i = 0; i < VOICE_COUNT; i++) {
if (s->voices[i].active && s->voices[i].midiNote == midiNote) {
// A triggered voice plays its own length; the key coming up is not its business.
// Asked of the envelope rather than the voice, because that is what the note was
// started with and the switch may have moved since.
if (s->voices[i].ampEnv.oneShot) continue;
envelopeNoteOff(&s->voices[i].ampEnv);
envelopeNoteOff(&s->voices[i].modEnv);
}
}
}
// ---- A channel is the channel you asked for ----
// ---- Asked for by number, rather than allocated ----
//
// 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.
// Channel two is channel two, it holds its patch between notes, and a program driving this as
// hardware can rely on both. synthNoteOn above steals a voice round-robin, which is right for
// a keyboard and wrong for anything addressing a fixed set of parts.
void synthChannelOn(Synth *s, int channel, int midiNote)
{
if (channel < 0 || channel >= VOICE_COUNT) {
@@ -391,8 +460,7 @@ void synthChannelOn(Synth *s, int channel, int midiNote)
// 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);
voiceArm(s, v);
}
void synthChannelOff(Synth *s, int channel)
@@ -413,8 +481,10 @@ void synthFillBuffer(Synth *s, int16_t *out, int frames) {
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);
// The shared cycle advances once per sample whatever is listening, so a free LFO is
// one sweep under everything and does not stall when nothing is sounding.
float freeLfo0 = lfoTick(&s->lfos[0], sr);
float freeLfo1 = lfoTick(&s->lfos[1], sr);
for (int v = 0; v < VOICE_COUNT; v++) {
Voice *vv = &s->voices[v];
@@ -423,11 +493,24 @@ void synthFillBuffer(Synth *s, int16_t *out, int frames) {
float amp = envelopeTick(&vv->ampEnv, sr);
float mod = envelopeTick(&vv->modEnv, sr);
if (vv->ampEnv.stage == ENV_IDLE) {
// A gated voice is over when envelope 0 is, which is after the key came up.
// A triggered one has no key to wait for, so it is over only when BOTH envelopes
// are - envelope 0 alone would cut a level that envelope 1 is still shaping.
int finished = (vv->ampEnv.stage == ENV_IDLE);
if (vv->ampEnv.oneShot)
finished = finished && (vv->modEnv.stage == ENV_IDLE);
if (finished) {
vv->active = 0;
continue;
}
// A retriggered LFO reads this voice's own cycle, started when it was struck.
// A free one reads the shared cycle above, exactly as it always did.
float lfo0 = (s->lfos[0].mode == LFO_RETRIGGER)
? lfoTickState(&s->lfos[0], &vv->lfoRun[0], sr) : freeLfo0;
float lfo1 = (s->lfos[1].mode == LFO_RETRIGGER)
? lfoTickState(&s->lfos[1], &vv->lfoRun[1], sr) : freeLfo1;
float oscMix = 0.0f;
int activeOscs = 0;
for (int o = 0; o < OSC_COUNT; o++) {
@@ -491,6 +574,7 @@ void envelopeInit(Envelope *e, float attackSec, float decaySec, float sustainLev
e->decaySec = decaySec;
e->sustainLevel = sustainLevel;
e->releaseSec = releaseSec;
e->oneShot = 0;
}
void envelopeNoteOn(Envelope *e)
@@ -521,9 +605,13 @@ float envelopeTick(Envelope *e, float sampleRate)
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;
// Sustain is where the decay stops and waits for the key. A one-shot has no key
// to wait for, so it decays the whole way and is finished - and MUST, or a patch
// with a sustain above nothing would hold a triggered voice open forever.
float floorLevel = e->oneShot ? 0.0f : e->sustainLevel;
if (e->value <= floorLevel) {
e->value = floorLevel;
e->stage = e->oneShot ? ENV_IDLE : ENV_SUSTAIN;
}
break;
}
@@ -549,6 +637,12 @@ float envelopeTick(Envelope *e, float sampleRate)
void synthSyncVoices(Synth *s)
{
for (int v = 1; v < VOICE_COUNT; v++) {
// Sync what shapes the level. Without this the voices below hold whatever synthInit
// gave them, so a patch that routes its level elsewhere is honoured by voice 0 and by
// nothing else - which sounds like it works until a second note is playing.
s->voices[v].levelSource = s->voices[0].levelSource;
s->voices[v].gate = s->voices[0].gate;
// Sync oscillator settings
for (int o = 0; o < OSC_COUNT; o++) {
s->voices[v].oscillators[o].waveform = s->voices[0].oscillators[o].waveform;
+70 -24
View File
@@ -10,25 +10,22 @@
//
// 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.
// carried across on purpose - in BOTH directions, which has now happened once each way.
//
// ---- 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.
// Nothing. This is soundThing's engine at b73e5c0, character for character, except that
// em-dashes and arrows in its comments are written as ASCII here because this tree is ASCII
// only. That rule is local and is not an improvement, so it was not sent upstream.
//
// 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.
// It did not start that way. Three changes were made here first - a routed voice level, a
// seeded noise generator, and channels asked for by number - and all three went up. What came
// back was those three plus what they made possible: a voice that can end itself rather than
// waiting for a key, and a triggered voice that re-arms its oscillators so a one-shot is the
// same one-shot twice. A game is nearly all one-shots, which is why the traffic went that 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.
// So the thing to keep current is no longer a list. It is this: if either copy changes, the
// other one has to be told.
//
// Written by Anachronaut
@@ -69,6 +66,17 @@ typedef enum {
MOD_SOURCE_LFO2 = 4
} ModSource;
// ---- Whether the key holds the note up ----
//
// Gated is what a keyboard wants: the sound lasts as long as the finger does, and lifting it
// starts the release. Triggered is what a drum wants - the note is struck and then plays its
// own length, and the key coming up is not its business. Sustain and release have no meaning
// in a triggered voice, because both of them are answers to a question about the key.
typedef enum {
VOICE_GATE = 0,
VOICE_TRIGGER = 1
} VoiceGate;
typedef struct {
EnvStage stage;
float value; // current output value, 0.0 to 1.0
@@ -76,20 +84,46 @@ typedef struct {
float decaySec;
float sustainLevel;
float releaseSec;
// Set from the voice's gate at note-on, not read from it live, so that flipping the
// switch under a sounding note cannot strand it half-way through a shape it was not
// started in. One-shot runs the decay to nothing and finishes there.
int oneShot;
} Envelope;
// ---- Where an LFO is in its cycle ----
//
// Split out of the LFO itself because there is now more than one answer at a time. A free
// LFO has one cycle that every voice reads, which is what makes it a single sweep across a
// chord. A retriggered one has a cycle PER VOICE, restarted when that voice is struck - and
// a voice resetting the shared one would drag every note already sounding along with it.
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
// varies between C libraries, so the same patch 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;
} LfoState;
// ---- Whether an LFO keeps its own time or starts when struck ----
//
// Free is one cycle running under everything, which is what vibrato across a held chord
// wants. Retriggered starts at the beginning of its shape every time a voice begins, which
// is the only way a one-shot sounds the same twice - a free LFO is wherever the wall clock
// left it, so the same drum caught at a different moment is a different drum.
typedef enum {
LFO_FREE = 0,
LFO_RETRIGGER = 1
} LfoMode;
typedef struct {
LfoState run; // the free-running cycle, read by every voice in LFO_FREE
float rate; // Hz
Waveform waveform;
int active;
LfoMode mode;
} LFO;
typedef enum {
@@ -119,11 +153,10 @@ typedef struct {
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
// varies between C libraries, so the same patch 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
@@ -155,6 +188,16 @@ typedef struct {
// It also makes two things possible that were not: envelope 1 shaping the volume, and an
// LFO doing it, which is tremolo.
ModSource levelSource;
// ---- Whether the key holds this voice up ----
//
// The other half of what envelope 0 used to decide on its own. Naming the level's source
// said what shapes the sound; this says who ends it. Without it a voice can only ever
// finish because a key came up, which is no use to a drum.
VoiceGate gate;
// One cycle per LFO per voice, used only by the LFOs set to LFO_RETRIGGER. Runtime state
// like the envelope stages and the filter's integrators, so synthSyncVoices leaves it
// alone - it is where this voice is, not what the patch says.
LfoState lfoRun[LFO_COUNT];
} Voice;
typedef struct {
@@ -184,11 +227,12 @@ void synthResetPatch(Synth *s);
void synthNoteOn(Synth *s, int midiNote);
void synthNoteOff(Synth *s, int midiNote);
// ---- 3. A channel is asked for by number ----
// ---- A channel 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.
// The two above hunt for a free voice and steal round-robin, which is what a keyboard wants:
// a player presses keys and does not care which voice sounds them. Something driving this as
// HARDWARE does care - channel two is channel two, it keeps its patch between notes, and
// nothing may take it away. Both ways of asking are here and neither changes the other.
void synthChannelOn(Synth *s, int channel, int midiNote);
void synthChannelOff(Synth *s, int channel);
void synthFillBuffer(Synth *s, int16_t *out, int frames);
@@ -196,6 +240,8 @@ void synthSyncVoices(Synth *s);
// LFO functions:
float lfoTick(LFO *l, float sampleRate);
// The same advance against a cycle that is not the LFO's own, so a voice can run its own.
float lfoTickState(const LFO *l, LfoState *st, float sampleRate);
// Filter functions:
float filterTick(Filter *f, float input, float cutoff, float resonance, float sampleRate);
+22 -1
View File
@@ -34,6 +34,15 @@ void printHelp(const char *programName) {
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(" -P, --pad FILE Hold a controller from a file, one byte a frame. Given\n");
printf(" again for the next pad. A byte is the buttons held:\n");
printf(" 1 right, 2 left, 4 down, 8 up, 16 A, 32 B, 64 start,\n");
printf(" 128 select.\n");
printf(" -Y, --record-pad FILE\n");
printf(" Write what the controllers held, one byte a frame, in the\n");
printf(" format --pad reads. Every pad at once, because which one a\n");
printf(" controller lands on is the host's business. Play a thing\n");
printf(" once and keep what happened.\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");
@@ -52,6 +61,8 @@ uint8_t parseOptions(int argc, char *argv[], EmulatorOptions *options) {
{"screen", required_argument, 0, 'S'},
{"keyboard", required_argument, 0, 'K'},
{"sound", required_argument, 0, 'N'},
{"pad", required_argument, 0, 'P'},
{"record-pad", required_argument, 0, 'Y'},
{"help", no_argument, 0, 'h'},
{0, 0, 0, 0 }
};
@@ -71,7 +82,7 @@ uint8_t parseOptions(int argc, char *argv[], EmulatorOptions *options) {
*options = (EmulatorOptions){0};
// Parse options
while ((opt = getopt_long(argc, argv, "dc:fhD:WL:S:K:N:R:", long_options, &option_index)) != -1) {
while ((opt = getopt_long(argc, argv, "dc:fhD:WL:S:K:N:R:P:Y:", long_options, &option_index)) != -1) {
switch (opt) {
case 'd':
options->debug = 1;
@@ -131,6 +142,16 @@ uint8_t parseOptions(int argc, char *argv[], EmulatorOptions *options) {
case 'N':
options->sound = optarg;
break;
case 'Y':
options->padRecord = optarg;
break;
case 'P':
// Fills the pads in turn, the same way --disk fills the drives, so the first
// one named is pad nought and the machine has as many as were asked for.
if (options->padCount < PAD_DRIVE_COUNT) {
options->pads[options->padCount++] = optarg;
}
break;
case 'h':
printHelp(argv[0]);
return OPTIONS_HELP;
+15
View File
@@ -16,6 +16,9 @@
#define OPTIONS_HELP 1 // The user asked for help, so stop, but not because of an error.
#define OPTIONS_ERROR 2 // The command line was no good, stop and complain.
// As many as the machine has pads.
#define PAD_DRIVE_COUNT 4
typedef struct {
uint8_t debug; // Step one instruction at a time, printing the registers.
uint8_t fast; // Ignore the cycle rate and run as fast as the host allows.
@@ -36,6 +39,18 @@ typedef struct {
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.
// ---- A controller made of a file ----
//
// One byte a frame, each byte the buttons held during it. Voyager reads a real pad, and
// its own tests run headless with nobody holding anything - so without this the device
// would be exercised only by somebody playing, which is exactly the state the console's
// line editing was in when it broke twice in two days.
const char *pads[PAD_DRIVE_COUNT];
int padCount; // --pad given more than once fills them in turn, like --disk.
// Where to write what the controllers held, one byte a frame, in the format --pad reads.
// Every pad or-ed together, because which one a controller lands on is the host's business
// and a demo is a record of what somebody did.
const char *padRecord;
} EmulatorOptions;
uint8_t parseOptions(int argc, char *argv[], EmulatorOptions *options);
+297 -22
View File
@@ -11,7 +11,44 @@
// 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];
// The two banks. Which one an address is in is a property of the address and never of the
// mode: tiles and the palette are always in the atlas, the map and a bitmap always in the
// screen. That is what makes the split cost nothing to think about at a call site.
static uint8_t videoAtlas[VIDEO_MEMORY_BYTES];
static uint8_t videoScreen[VIDEO_SCREEN_COUNT][VIDEO_MEMORY_BYTES];
// ---- Where the background was empty ----
//
// One byte a pixel, set while the map or the bitmap is drawn and read while the sprites are.
// A sprite marked "behind" needs to know whether the thing already at a pixel was a picture
// or a gap, and by the time it is drawn the pixel holds a colour rather than the index it
// came from - the palette is not one to one, so two different indices can be the same
// colour and asking the picture would get it wrong.
//
// Host memory, and it costs the machine nothing: it is scratch the device uses inside one
// frame, exactly like the pixel buffer beside it.
static uint8_t backgroundEmpty[VIDEO_MAX_WIDTH * VIDEO_MAX_HEIGHT];
// ---- Where a tile's art is ----
//
// The scheme nibble and the page bits live in the same byte and are asked of it in the same
// breath, in the two places a tile is drawn from: a map cell and a sprite. One function, so
// that the two cannot drift apart - which they would, because the sprite pass was written
// three days after the map pass and neither is where the other is looked at.
static const uint8_t *tileArt(uint8_t attribute, uint8_t tile) {
const int page = (attribute & VIDEO_ATTRIBUTE_PAGE) >> VIDEO_ATTRIBUTE_SHIFT;
return videoAtlas + VIDEO_TILE_BASE + page * VIDEO_TILE_PAGE_BYTES
+ tile * VIDEO_TILE_BYTES;
}
// Which screen is being shown. The console draws into THIS one rather than into a screen of
// its own, so text goes where whoever is looking is looking - which matters most when the
// text is a fault message printed over a game that had flipped.
static uint8_t displayed = 0;
// How tall the window is and where it starts. Nought tall is no window.
static uint8_t windowHeight = 0;
static uint8_t windowAt = 0;
static uint8_t mode;
// Which map row is drawn at the top. THE MAP IS A RING: rendering row r reads map row
@@ -119,13 +156,17 @@ void videoTick(unsigned long now) {
}
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);
// 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 of a scheme mean ink and
// paper.
//
// ONLY THE GLYPHS THE ROM HAS. Tile memory used to be cleared first, on the grounds that
// a glyph the font does not have should be blank rather than whatever was there - which
// was fine while this happened at reset and nothing else, and is wrong now that a program
// can ask for it. A program that defined a tile of its own above the font and then wanted
// its text back would have lost the tile to get it.
for (int glyph = 0; glyph < CONSOLE_FONT_GLYPHS && glyph < VIDEO_TILE_COUNT; glyph++) {
uint8_t *tile = videoRAM + VIDEO_TILE_BASE + glyph * VIDEO_TILE_BYTES;
uint8_t *tile = videoAtlas + 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++) {
@@ -133,7 +174,10 @@ void videoLoadFont(void) {
}
}
}
uint8_t *palette = videoRAM + VIDEO_PALETTE_BASE;
}
void videoLoadPalette(void) {
uint8_t *palette = videoAtlas + 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.
@@ -158,7 +202,7 @@ void videoPutCell(int screenRow, int column, uint8_t tile, uint8_t attribute) {
// 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
uint8_t *cell = videoScreen[displayed] + VIDEO_MAP_BASE + mapRow * VIDEO_MAP_STRIDE
+ mapColumn * VIDEO_CELL_BYTES;
cell[0] = tile;
cell[1] = attribute;
@@ -171,11 +215,16 @@ void videoScrollUp(void) {
// 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);
memset(videoScreen[displayed] + VIDEO_MAP_BASE + mapRow * VIDEO_MAP_STRIDE, 0,
VIDEO_MAP_STRIDE);
}
void videoReset(void) {
memset(videoRAM, 0, sizeof(videoRAM));
memset(videoAtlas, 0, sizeof(videoAtlas));
memset(videoScreen, 0, sizeof(videoScreen));
displayed = 0;
windowHeight = 0;
windowAt = 0;
mode = VIDEO_MODE_40x25;
scroll = 0;
scrollColumn = 0;
@@ -187,14 +236,27 @@ void videoReset(void) {
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.
// A machine wakes up able to show text, and it does so by COPYING from the character
// generator into ordinary video memory - which a program may overwrite the moment it
// wants the screen for something else, and can ask back afterwards.
videoLoadFont();
videoLoadPalette();
}
uint8_t *videoMemory(uint32_t *capacity) {
uint8_t *videoMemory(uint8_t port, uint32_t *capacity) {
*capacity = VIDEO_MEMORY_BYTES;
return videoRAM;
if (port == VIDEO_STATUS) {
return videoAtlas;
}
if (port == VIDEO_SCREEN0) {
return videoScreen[0];
}
if (port == VIDEO_SCREEN1) {
return videoScreen[1];
}
// Every other port in the block owns no memory. Saying so is what stops a bank being
// registered onto one of them and pointing at nothing.
return NULL;
}
uint8_t videoWrite(uint8_t value, uint8_t port) {
@@ -207,6 +269,20 @@ uint8_t videoWrite(uint8_t value, uint8_t port) {
mode = value;
}
break;
case VIDEO_WINDOW_HEIGHT:
windowHeight = value;
break;
case VIDEO_WINDOW_AT:
windowAt = value;
break;
case VIDEO_DISPLAY:
// A screen that does not exist is not taken, for the same reason a mode that
// does not exist is not: whoever asked still has the screen they had, and
// stopping the machine over it would be a poor trade.
if (value < VIDEO_SCREEN_COUNT) {
displayed = value;
}
break;
case VIDEO_CONTROL:
frameInterrupts = (value & VIDEO_CONTROL_FRAME) != 0;
if (!frameInterrupts) {
@@ -235,6 +311,25 @@ uint8_t videoWrite(uint8_t value, uint8_t port) {
case VIDEO_FINE_Y:
fineY = (uint8_t)(value & VIDEO_FINE_MASK);
break;
case VIDEO_COMMAND:
// ---- Asking the character generator for its contents ----
//
// Written, and it happens at once - the same shape as the console's Command port
// and the controller's, rather than a bit in a register that otherwise holds
// state. There is nothing to read back: what a copy did is visible in the memory
// it copied into.
//
// IN BITMAP MODE THE TILES ARE THE PICTURE, so asking for the font there draws
// glyphs across the top of it. That is not a special case being ignored; it is
// what the memory means in that mode, and a caller that wants text has to be in
// a mode that has some.
if (value & VIDEO_COMMAND_FONT) {
videoLoadFont();
}
if (value & VIDEO_COMMAND_PALETTE) {
videoLoadPalette();
}
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.
@@ -245,6 +340,10 @@ uint8_t videoWrite(uint8_t value, uint8_t port) {
uint8_t videoRead(uint8_t port) {
switch (port) {
case VIDEO_DISPLAY:
return displayed;
case VIDEO_WINDOW_HEIGHT: return windowHeight;
case VIDEO_WINDOW_AT: return windowAt;
case VIDEO_STATUS: {
uint8_t status = 0;
if (frameWaiting) {
@@ -272,6 +371,10 @@ uint8_t videoRead(uint8_t port) {
case VIDEO_SCROLL_COLUMN: return scrollColumn;
case VIDEO_FINE_X: return fineX;
case VIDEO_FINE_Y: return fineY;
case VIDEO_COMMAND:
// Write only, like the console's. A device that does something when told does
// not take instructions and hand out state through the same hole.
return 0;
// 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);
@@ -281,6 +384,159 @@ uint8_t videoRead(uint8_t port) {
}
}
// ---- The sprites, over whatever is already there ----
//
// BACKWARDS THROUGH THE TABLE, so that where two overlap the lower number comes out on top:
// it is drawn last and writes over. Every entry is looked at, because the ones that draw
// nothing say so in a byte and skipping them costs one test.
static void drawSprites(uint8_t *pixels, int width, int height) {
const uint8_t *palette = videoAtlas + VIDEO_PALETTE_BASE;
const uint8_t *depths = videoAtlas + VIDEO_DEPTH_BASE;
for (int n = VIDEO_SPRITE_COUNT - 1; n >= 0; n--) {
const uint8_t *entry = videoAtlas + VIDEO_SPRITE_BASE + n * VIDEO_SPRITE_BYTES;
const int wide = (entry[VIDEO_SPRITE_SIZE] >> 4) & 0x0F;
const int tall = entry[VIDEO_SPRITE_SIZE] & 0x0F;
if (wide == 0 || tall == 0) {
continue;
}
// How big the art is, and how big it is being asked to look. Nought means the one
// is the other, which is what every sprite written before scaling existed says.
const int naturalWide = wide * VIDEO_CELL_PIXELS;
const int naturalTall = tall * VIDEO_CELL_PIXELS;
int drawWide = entry[VIDEO_SPRITE_WIDTH] | (entry[VIDEO_SPRITE_WIDTH + 1] << 8);
int drawTall = entry[VIDEO_SPRITE_HEIGHT] | (entry[VIDEO_SPRITE_HEIGHT + 1] << 8);
if (drawWide == 0) { drawWide = naturalWide; }
if (drawTall == 0) { drawTall = naturalTall; }
// Signed, and low byte first like everything else this machine writes to a device.
const int left = (int16_t)(uint16_t)(entry[VIDEO_SPRITE_X]
| (entry[VIDEO_SPRITE_X + 1] << 8));
const int top = (int16_t)(uint16_t)(entry[VIDEO_SPRITE_Y]
| (entry[VIDEO_SPRITE_Y + 1] << 8));
const uint8_t bank =
(uint8_t)((entry[VIDEO_SPRITE_ATTRIBUTE] & VIDEO_ATTRIBUTE_SCHEME) << 4);
const uint8_t flags = entry[VIDEO_SPRITE_FLAGS];
const int mirrored = (flags & VIDEO_SPRITE_HFLIP) != 0;
const int inverted = (flags & VIDEO_SPRITE_VFLIP) != 0;
const int behind = (flags & VIDEO_SPRITE_BEHIND) != 0;
const uint8_t depth = entry[VIDEO_SPRITE_DEPTH];
// ---- Only the part that lands on the screen is walked ----
//
// Clipped BEFORE the loop rather than inside it, which used to be enough and is not
// any more: a target size is sixteen bits, so a sprite asked to be 60,000 pixels
// tall would otherwise be sixty thousand turns of a loop that drew eight rows.
const int fromDown = (top < 0) ? -top : 0;
const int toDown = (drawTall < height - top) ? drawTall : height - top;
const int fromAcross = (left < 0) ? -left : 0;
const int toAcross = (drawWide < width - left) ? drawWide : width - left;
// ---- Walked over where it is GOING, not over where it came from ----
//
// Every destination pixel asks which source pixel it is showing, which is what makes
// a stretch and a squash the same operation and needs no accumulator carried between
// rows. It also makes flipping fall out: turning the source coordinate round mirrors
// the tile ORDER and the pixels inside each tile in one step, where drawing tile by
// tile had to be told to do both.
for (int down = fromDown; down < toDown; down++) {
const int atY = top + down;
int sourceDown = (int)((long)down * naturalTall / drawTall);
if (inverted) {
sourceDown = naturalTall - 1 - sourceDown;
}
for (int across = fromAcross; across < toAcross; across++) {
const int atX = left + across;
int sourceAcross = (int)((long)across * naturalWide / drawWide);
if (mirrored) {
sourceAcross = naturalWide - 1 - sourceAcross;
}
// Which tile of the group, in reading order, and where inside it. Wrapping,
// because a byte plus a byte is a byte and the tile number is one.
const uint8_t tile = (uint8_t)(entry[VIDEO_SPRITE_TILE]
+ (sourceDown / VIDEO_CELL_PIXELS) * wide
+ (sourceAcross / VIDEO_CELL_PIXELS));
const uint8_t *art = tileArt(entry[VIDEO_SPRITE_ATTRIBUTE], tile);
const uint8_t pixel = art[(sourceDown % VIDEO_CELL_PIXELS) * VIDEO_CELL_PIXELS
+ (sourceAcross % VIDEO_CELL_PIXELS)];
// Nought is not a colour here, it is the absence of one, and it is tested
// before the attribute is added so that it stays the same hole in all
// sixteen schemes.
if (pixel == 0) {
continue;
}
if (behind && !backgroundEmpty[atY * width + atX]) {
continue;
}
// ---- And whether anything nearer is already in this column ----
//
// Per column, which is the whole reason this is a buffer and not a number:
// a billboard is in front of the wall at one end of itself and behind it at
// the other, and no ordering of the table can say that.
if (depth != 0) {
const uint8_t there = depths[atX];
if (there != 0 && depth >= there) {
continue;
}
}
const uint8_t index = (uint8_t)(pixel + bank);
const uint8_t *colour = palette + index * VIDEO_PALETTE_BYTES;
uint8_t *out = pixels + (atY * width + atX) * 3;
out[0] = colour[0];
out[1] = colour[1];
out[2] = colour[2];
}
}
}
}
// ---- The window, over everything ----
//
// Drawn last, after the sprites, because a status bar is the thing nothing gets in front of.
// A sprite that could cover the fuel gauge would be a bug in every game that had both.
//
// NO SCROLL AND NO FINE OFFSET. That is the entire feature: a window cell is at a screen
// position, full stop, where a map cell is at a position in a world the screen is looking at
// part of. The two coordinate systems are what the register block keeps apart.
static void drawWindow(uint8_t *pixels, int width, int height, int columns) {
if (windowHeight == 0) {
return;
}
const uint8_t *palette = videoAtlas + VIDEO_PALETTE_BASE;
for (int row = 0; row < windowHeight && row < VIDEO_WINDOW_ROWS; row++) {
const int atRow = windowAt + row;
if (atRow < 0 || atRow * VIDEO_CELL_PIXELS >= height) {
continue;
}
const uint8_t *cells = videoScreen[displayed] + VIDEO_WINDOW_BASE
+ row * VIDEO_MAP_STRIDE;
for (int column = 0; column < columns; column++) {
const uint8_t tile = cells[column * VIDEO_CELL_BYTES];
const uint8_t attribute = cells[column * VIDEO_CELL_BYTES + 1];
const uint8_t bank = (uint8_t)((attribute & VIDEO_ATTRIBUTE_SCHEME) << 4);
const uint8_t *art = tileArt(attribute, tile);
for (int y = 0; y < VIDEO_CELL_PIXELS; y++) {
const int atY = atRow * VIDEO_CELL_PIXELS + y;
if (atY >= height) {
break;
}
for (int x = 0; x < VIDEO_CELL_PIXELS; x++) {
const int atX = column * VIDEO_CELL_PIXELS + x;
if (atX >= width) {
break;
}
const uint8_t index =
(uint8_t)(art[y * VIDEO_CELL_PIXELS + x] + bank);
const uint8_t *entry = palette + index * VIDEO_PALETTE_BYTES;
uint8_t *out = pixels + (atY * width + atX) * 3;
out[0] = entry[0];
out[1] = entry[1];
out[2] = entry[2];
}
}
}
}
}
void videoRender(void) {
if (mode == VIDEO_MODE_BITMAP) {
// ---- A byte a pixel, and nothing in the way ----
@@ -288,17 +544,22 @@ void videoRender(void) {
// 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;
const uint8_t *palette = videoAtlas + VIDEO_PALETTE_BASE;
const uint8_t *from = videoScreen[displayed] + 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];
backgroundEmpty[at] = (from[at] == 0);
}
renderedWidth = VIDEO_BITMAP_WIDTH;
renderedHeight = VIDEO_BITMAP_HEIGHT;
// Over a picture as much as over a map. A bitmap is what a program draws once and
// leaves; sprites are what moves on top of it, and there is no reason the mode that
// cannot afford to redraw itself should be the one that cannot have them.
drawSprites(pixels, VIDEO_BITMAP_WIDTH, VIDEO_BITMAP_HEIGHT);
return;
}
@@ -319,7 +580,8 @@ void videoRender(void) {
// 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;
const uint8_t *cells = videoScreen[displayed] + 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];
@@ -344,8 +606,8 @@ void videoRender(void) {
// 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;
const uint8_t bank = (uint8_t)((attribute & VIDEO_ATTRIBUTE_SCHEME) << 4);
const uint8_t *art = tileArt(attribute, tile);
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
@@ -362,17 +624,30 @@ void videoRender(void) {
// 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
const uint8_t was = art[y * VIDEO_CELL_PIXELS + x];
const uint8_t index = (uint8_t)(was + bank);
const uint8_t *entry = videoAtlas + 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];
// Before the nibble, so that a cell drawn in scheme five is empty in the
// same places as the same cell drawn in scheme nought.
backgroundEmpty[atY * width + atX] = (was == 0);
}
}
}
}
// ---- And then the things that move ----
//
// After the map and not woven into it, because a sprite is not tied to a cell: one can
// sit across four of them, and a pass that drew each cell and then whatever overlapped it
// would have to draw parts of the same sprite four times and get the order right between
// them. Over the finished picture there is no order to get wrong.
drawSprites(pixels, width, height);
drawWindow(pixels, width, height, columns);
renderedWidth = width;
renderedHeight = rows * VIDEO_CELL_PIXELS;
}
+293 -10
View File
@@ -19,20 +19,53 @@
// 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 ----
// ---- The device brings memory, in two banks ----
//
// One bank, registered the way the disk's buffer is, so it costs a program nothing in Data
// Registered the way the disk's buffer is, so the screen 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
// and the rest stays as it was, which is the whole reason this is memory rather than a
// window onto a port.
//
// TWO BANKS AND NOT ONE, because the two halves of a screen are written at completely
// different rates. Tiles and colours are an ATLAS: put there when a program loads and then
// left alone. The map is a SCREEN: rewritten as often as anything moves. Sharing one bank
// made them compete for 64K they did not both need, and it had a worse consequence than
// being cramped - a bitmap took the whole bank, so ENTERING BITMAP MODE DESTROYED THE FONT.
// A program could not draw a picture and then say anything about it.
//
// Split, each gets a whole 64K and neither can tread on the other. A bitmap now overwrites
// the map, which is the same memory meaning a different thing in a different mode and is
// exactly what it should overwrite. The tiles behind the text survive it.
//
// Each bank is the same size, and every address below says which of the two it is in.
#define VIDEO_MEMORY_BYTES 0x10000
// ---- In the ATLAS bank ----
//
// 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
// ---- Four pages of them ----
//
// A tile number is a byte and a byte reaches 256, which is not many once a font has taken
// 135 of them and a game wants a character, a background and a wall. So two bits of the
// ATTRIBUTE say which page of 256 the number is in - bits that were already there, already
// written on every cell and every sprite, and reserved from the day the attribute was
// defined for exactly this.
//
// Four pages of 16K is 64K, which is the whole atlas, so THE FOURTH PAGE IS THE MEMORY THE
// SPRITE TABLE AND THE PALETTE ARE IN. That is not a hole in the design, it is the same
// answer shared video memory has always given: the atlas is 1024 tiles, and what a program
// spends on sprites and colours comes out of them. A program that wants no sprites may have
// page three for art, and one that wants sprites has 768 tiles and knows why.
//
// The page is per CELL and per SPRITE rather than a mode, so a screen can show tiles from
// all four at once and a program never has to decide which page it is "in".
#define VIDEO_TILE_PAGE_BYTES (VIDEO_TILE_COUNT * VIDEO_TILE_BYTES)
#define VIDEO_TILE_PAGES 4
// ---- 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
@@ -43,6 +76,11 @@
//
// It also frees the geometry from having to be a power of two, which is what lets the
// pixel resolution be whatever looks right.
//
// IN THE SCREEN BANK, and still at 0x4000 rather than at the bottom of a bank it now has to
// itself. Moving it would have been tidier and would have meant changing which bank a
// program registers AND which address it writes to in the same breath - so a screen that
// came out wrong would have had two possible causes. The address costs nothing where it is.
#define VIDEO_MAP_BASE 0x4000
#define VIDEO_MAP_STRIDE 256
#define VIDEO_MAP_ROWS 128
@@ -51,6 +89,16 @@
// Two bytes to a cell: which tile, and how to colour it.
#define VIDEO_CELL_BYTES 2
// ---- What the attribute byte means, in both places it appears ----
//
// A map cell's second byte and a sprite's byte 1 are the same thing and are read the same
// way, which is what lets the same art be a background in one place and a moving thing in
// another with nothing rewritten.
#define VIDEO_ATTRIBUTE_SCHEME 0x0F
#define VIDEO_ATTRIBUTE_PAGE 0x30
#define VIDEO_ATTRIBUTE_SHIFT 4
// Bits 6 and 7 are still reserved and should be left at nought.
// ---- 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
@@ -59,25 +107,190 @@
// 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.
// screen; it stops calling it a text screen. Coming back finds the map holding whatever the
// picture put there, which is what taking the screen means.
//
// IN THE SCREEN BANK, so what a picture costs is the map and nothing else. THE TILES AND THE
// PALETTE ARE IN THE OTHER BANK AND SURVIVE IT, which is what lets a program draw a picture
// and then put text back on the screen without reloading the character generator first.
#define VIDEO_BITMAP_BASE 0x0000
#define VIDEO_BITMAP_WIDTH 320
#define VIDEO_BITMAP_HEIGHT 200
// ---- Sprites ----
//
// Things that move without the map moving. A map cell is where it is, and a program that
// wanted something between two cells had to redraw both of them; a sprite is put at a PIXEL
// and the device draws it over whatever is behind.
//
// MADE OF TILES, which is the decision the rest follows from. A sprite is m by n tiles taken
// in reading order from one index, so it needs no pixel format of its own, no second kind of
// memory, and no way for its art to be anything the map could not also show. A 16 by 16
// character is four tiles and a program that wants it in the background too just names the
// same four.
//
// The table is 256 entries of 16 bytes. Sixteen so that entry n begins at n times sixteen,
// which is a shift - the same no-multiply argument that makes a palette entry four bytes and
// a map row a page.
//
// It was eight, and grew when scaling arrived: four bytes for a target size and one for a
// depth would not fit beside what was already there. Grown NOW rather than later, because
// the cost of moving it is a rebuild of the two programs that use it, and the cost of moving
// it once somebody has written a game on top of it is not.
#define VIDEO_SPRITE_BASE 0xC000
#define VIDEO_SPRITE_COUNT 256
#define VIDEO_SPRITE_BYTES 16
// Byte 0 is the top left tile, byte 1 the attribute, which means what a map cell's attribute
// means: its low nibble times sixteen is added to every index in the art.
#define VIDEO_SPRITE_TILE 0
#define VIDEO_SPRITE_ATTRIBUTE 1
// Bytes 2 to 5, low byte first, and SIGNED - a screen is 640 by 400 in the larger mode, so
// neither axis fits in a byte, and a sprite has to be able to sit half off the left or the
// top rather than appearing whole at the edge.
#define VIDEO_SPRITE_X 2
#define VIDEO_SPRITE_Y 4
// Byte 6: how many tiles across in the high nibble, how many down in the low. Fifteen each
// way, so 120 by 120 pixels.
//
// A SPRITE OF NO WIDTH OR NO HEIGHT DRAWS NOTHING, and that is the off switch. It saves a
// flag, it is per sprite rather than a global the whole table shares, and it means the table
// is already off when the machine starts, since the atlas wakes up cleared.
//
// Deliberately the OPPOSITE of what a length of zero means to the memory controller, where
// it means the whole 64K. The reason is the same both times: moving no bytes is a useless
// thing to ask for, so zero was free to mean something else there - and drawing no sprite is
// the commonest state in this table, so zero has to mean nothing here.
#define VIDEO_SPRITE_SIZE 6
// Byte 7.
#define VIDEO_SPRITE_FLAGS 7
#define VIDEO_SPRITE_HFLIP 0x01
#define VIDEO_SPRITE_VFLIP 0x02
// Drawn only where the background had nothing, so a thing can walk behind a pillar. See
// below for what "nothing" means.
#define VIDEO_SPRITE_BEHIND 0x04
// ---- How big to draw it, which is not the same as how big it is ----
//
// Bytes 8 to 11: a target width and a target height in PIXELS, low byte first. The device
// stretches the m by n tiles to fill that, so a program says how big the thing should look
// and never works out a ratio.
//
// A TARGET IN PIXELS RATHER THAN A MULTIPLIER, which is the whole of why this is usable on
// a machine with no divide. A billboard at distance d wants to be k/d pixels tall, and that
// is a number the program has anyway - out of a lookup table, most likely. A multiplier
// would have to be a fixed point fraction, computed by dividing, which is the one thing
// this CPU cannot do.
//
// NOUGHT MEANS NATURAL SIZE, eight times the tile count on that axis. So every sprite
// written before scaling existed still means what it meant, and the common case - a thing
// drawn at the size it was drawn at - costs nothing to say.
#define VIDEO_SPRITE_WIDTH 8
#define VIDEO_SPRITE_HEIGHT 10
// ---- And how far away it is ----
//
// Byte 12. Nought means no depth test at all, which is what every ordinary sprite wants and
// what a cleared table already says.
//
// Otherwise it is compared against the DEPTH BUFFER below, one column at a time, and the
// sprite draws only in the columns it is in front of. That is the thing table order cannot
// do: a billboard can be nearer than the wall in one column and further in the next, and no
// amount of sorting the table expresses that.
#define VIDEO_SPRITE_DEPTH 12
// Bytes 13 to 15 are reserved and should be left at nought.
// ---- The depth buffer ----
//
// One byte a screen column, written by the program and read by the device. A wall pass says
// how far away the thing it drew in each column was; a sprite with a depth says how far away
// it is; and a sprite pixel is drawn only where it is nearer.
//
// NOUGHT IN A COLUMN MEANS NOTHING IS THERE, so a program that never writes this has a
// buffer of noughts and every sprite draws - which is the behaviour there was before it
// existed. It is not cleared between frames: it belongs to the program, and a program that
// draws walls rewrites all of it every frame anyway.
//
// It is in the atlas because that is where the things a program sets up live, and because
// the alternative is a port and this is 640 bytes.
#define VIDEO_DEPTH_BASE 0xD000
// ---- What a sprite does not cover ----
//
// A PIXEL OF ZERO IS NOT DRAWN. Without that every sprite is a rectangle, and there is no
// other candidate: the font already uses index 0 for paper, so it is the value art in this
// machine has always left empty.
//
// Tested BEFORE the attribute is added, so it is a property of the art and not of the colour
// scheme it is being shown in. A sprite drawn in indices 1 to 15 is transparent in the same
// places in all sixteen schemes, which is the whole point of the additive nibble.
//
// The same rule read the other way is what "behind" means: a sprite marked behind draws only
// where the BACKGROUND pixel was zero. One rule, applied to whichever layer is in front.
//
// ---- How many at once ----
//
// All of them. Every entry in the table is drawn every frame, so sprites cannot flicker.
// Real machines dropped them per scanline because they had a fixed number of shift registers
// and a fixed time to fill them; this has a loop. The limit is how many entries there are,
// which is a constant a program can count on rather than a property of what it happens to be
// drawing this frame.
//
// Where they overlap, THE LOWER NUMBER IS IN FRONT.
// ---- 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.
// At the TOP of the ATLAS bank, 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 it
// is written when a program loads rather than per frame, which is what the atlas is for.
//
// Being out of the screen bank is what leaves a bitmap the WHOLE of one: 64,000 bytes of
// picture in 65,536, with nothing it has to dodge.
#define VIDEO_PALETTE_BASE 0xFC00
#define VIDEO_PALETTE_BYTES 4
#define VIDEO_PALETTE_SIZE 256
// ---- The window: a layer that does not scroll ----
//
// A status bar over a playfield. The map moves and this does not, which the map alone cannot
// express: the scroll registers move ALL of it, so a score printed into the map is a score
// that slides away, and one printed into the rows the view happens to be showing is one that
// jumps a pixel at a time as the fine offset changes.
//
// ITS OWN MEMORY, and that is the whole argument. The first design drew the top rows of the
// MAP without the scroll applied, which needs no new memory and costs one register - and
// makes those rows part of the playfield's ring, so a game that scrolls vertically has to
// route its world around its own scoreboard for ever. The whole point of a status bar is that
// it is not somewhere in the level.
//
// 0xC000 to 0xFFFF in the screen bank, which the map does not reach: it ends at 0xBFFF. Sixty
// four rows of the same 256 byte cells, so a window row is a page like a map row is, and a
// cell in it means exactly what a cell in the map means - same tiles, same pages, same
// colour schemes.
//
// PER SCREEN, because it is in the screen bank: flipping to the other buffer flips the status
// bar with it, which is what a double buffered game wants and would be surprising the other
// way round.
#define VIDEO_WINDOW_BASE 0xC000
#define VIDEO_WINDOW_ROWS 64
// How many screen rows tall it is, and which screen row it starts at. NOUGHT IS OFF, so a
// machine that wakes up with a cleared screen has no window and every program written before
// this one means what it meant.
//
// A start row is worth a register because a status bar at the bottom is as common as one at
// the top, and working it out from the screen height is a thing every program would have to
// do again.
#define VIDEO_WINDOW_HEIGHT 0x3D
#define VIDEO_WINDOW_AT 0x3E
// ---- Modes ----
//
// Both are 8x8 cells over the same engine; only how many of them differ. The pixel count
@@ -124,6 +337,63 @@ int videoTextRows(void);
#define VIDEO_FINE_X 0x37
#define VIDEO_FINE_Y 0x38
// ---- The character generator, which is a chip and not a memory that remembers ----
//
// The font and the sixteen colour schemes used to be WRITTEN INTO VIDEO RAM at reset, and
// that was the one piece of magic left in this device: RAM does not wake up with anything
// in it. It looked harmless until something needed the font BACK - a program that redefines
// a tile has overwritten a glyph, and there was nowhere to get it from, because the only
// copy was the one that had just been drawn over.
//
// So the device has a ROM, the way the machines this one is pretending to be really did,
// and the copy into RAM is a thing it DOES rather than a state it mysteriously starts in.
// Reset performs it, and a program can ask for it again.
//
// THE RAM IS STILL RAM. A program may overwrite every glyph and every colour, and should be
// able to: that is what makes a tile engine a tile engine rather than a text display. What
// has changed is that doing so is no longer a one way door.
//
// A SYSTEM THAT WANTS ITS OWN FONT still loads one over the top. This is the floor, not the
// policy - it is what makes a machine with no disk able to say so, and what lets a program
// with no system behind it put readable text on a screen.
#define VIDEO_COMMAND 0x39
// Copy the 135 glyphs back into the tiles they live in, leaving every other tile alone.
#define VIDEO_COMMAND_FONT 0x01
// Copy the sixteen ink and paper pairs back, leaving the rest of the palette alone.
#define VIDEO_COMMAND_PALETTE 0x02
// ---- The ports that own banks ----
//
// A bank is registered by naming THE PORT THAT OWNS IT, which the controller settled long
// before the screen had more than one. So a device with several banks needs several ports
// that own memory, and needs no new mechanism at all.
//
// The base port keeps the atlas rather than a screen because tiles have been at 0x0000 since
// there was a screen at all, and whichever way round that went, one of the two meanings had
// to move. Nothing is READ OR WRITTEN at any of these - they are names for banks, and the
// registry is where a program finds out which of them bring one.
#define VIDEO_SCREEN0 0x3A
#define VIDEO_SCREEN1 0x3B
// ---- Two screens, and only one register to say which ----
//
// A back buffer is a whole screen's worth of map written where nobody can see it, and then
// shown all at once. It is what stops a picture being seen half finished - a game that moves
// forty sprites and rewrites the map underneath them is not finished being wrong until the
// last of them has been put right.
//
// THE DEVICE ONLY NEEDS TO KNOW WHICH IS DISPLAYED. Real machines needed a second register
// saying which one the CPU's window pointed at; there is no window here, because a program
// reaches a bank through the memory controller BY ITS NUMBER. Writing to the one that is not
// being shown is a matter of naming its bank, and the screen never has to be told.
//
// A FLIP CANNOT TEAR. A frame is drawn from one bank in one go, so a flip either happened
// before that frame or it happens before the next one; there is no state of having flipped
// halfway. That is worth saying because it is the thing the hardware this imitates had to
// work for, with an interrupt and a register written in the few lines between frames.
#define VIDEO_DISPLAY 0x3C
#define VIDEO_SCREEN_COUNT 2
// Eight pixels to a cell, so three bits say where inside one the view begins.
#define VIDEO_FINE_MASK 0x07
@@ -163,7 +433,16 @@ void videoReset(void);
//
// 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.
// Copies the character generator ROM into the tiles the console draws from, and the default
// schemes into the palette entries they live in. Reset does both; the Command port is how a
// program asks for either afterwards.
//
// NEITHER CLEARS WHAT IT DOES NOT OWN. The font writes glyphs 0 to 134 and stops, so a tile
// a program defined above them survives; the palette writes the two entries of each of the
// sixteen schemes and stops, so a program's own colours in between survive. Asking for the
// font back should not cost a program the tile it was drawing with.
void videoLoadFont(void);
void videoLoadPalette(void);
// ---- The cursor ----
//
@@ -191,7 +470,11 @@ void videoPutCell(int screenRow, int column, uint8_t tile, uint8_t attribute);
// 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);
// The memory behind one of the device's memory-owning ports: VIDEO_STATUS for the atlas,
// VIDEO_SCREEN0 and VIDEO_SCREEN1 for the two screens. NULL for any other port, because the
// rest of the block owns no memory and registering a bank onto one would put a number in the
// table that leads nowhere.
uint8_t *videoMemory(uint8_t port, uint32_t *capacity);
uint8_t videoWrite(uint8_t value, uint8_t port);
uint8_t videoRead(uint8_t port);
+94 -2
View File
@@ -22,6 +22,7 @@
#include "machine.h"
#include "video.h"
#include "pad.h"
#include "sound.h"
#include "io.h"
#include "utility.h"
@@ -106,6 +107,73 @@ static int keyTake(void) {
}
// Everything Raylib has, taken before it can throw any of it away.
// ---- The pads, read as levels ----
//
// Once a frame, from whatever is actually there. THIS IS THE ONE THING THE CONSOLE CANNOT DO:
// a window knows which keys are down, a terminal only ever learns which one was pressed, and
// asking the console to report a key coming up would have been a promise it could keep behind
// a window and nowhere else.
//
// The keyboard drives pad nought as well as any real controller, so a game written for a pad
// is playable on a machine with none - and so is a game written for four, badly. What a game
// reads is the pad; it never learns which one of them somebody used.
static void readPads(void) {
for (int n = 0; n < PAD_COUNT; n++) {
uint8_t held = 0;
// ---- Pad nought is always there, because the keyboard is behind it ----
//
// Which is the useful answer rather than the literal one: a game that asks for a
// controller and finds none falls back to whatever the console can tell it, and
// behind a window the console is the worse of the two ways to read the same keys.
int there = (n == 0);
if (IsGamepadAvailable(n)) {
there = 1;
if (IsGamepadButtonDown(n, GAMEPAD_BUTTON_LEFT_FACE_RIGHT)) { held |= PAD_RIGHT; }
if (IsGamepadButtonDown(n, GAMEPAD_BUTTON_LEFT_FACE_LEFT)) { held |= PAD_LEFT; }
if (IsGamepadButtonDown(n, GAMEPAD_BUTTON_LEFT_FACE_DOWN)) { held |= PAD_DOWN; }
if (IsGamepadButtonDown(n, GAMEPAD_BUTTON_LEFT_FACE_UP)) { held |= PAD_UP; }
if (IsGamepadButtonDown(n, GAMEPAD_BUTTON_RIGHT_FACE_DOWN)) { held |= PAD_A; }
if (IsGamepadButtonDown(n, GAMEPAD_BUTTON_RIGHT_FACE_RIGHT)){ held |= PAD_B; }
if (IsGamepadButtonDown(n, GAMEPAD_BUTTON_MIDDLE_RIGHT)) { held |= PAD_START; }
if (IsGamepadButtonDown(n, GAMEPAD_BUTTON_MIDDLE_LEFT)) { held |= PAD_SELECT; }
// ---- And the stick, which is what most people actually push ----
//
// The four buttons above are the D-PAD, and a lot of controllers made this
// century have one that nobody uses: the thumb goes on the stick, which reports
// as an axis and not as a button, so a pad that was plugged in and working did
// nothing at all.
//
// Past halfway counts as held. That is a blunt line and the right kind of blunt
// for a device that reports what is DOWN - a machine with eight bits a pad has
// nothing to say about three-fifths of a push, and picking the threshold here
// rather than in every program is the point of the pad being a device.
const float across = GetGamepadAxisMovement(n, GAMEPAD_AXIS_LEFT_X);
const float down = GetGamepadAxisMovement(n, GAMEPAD_AXIS_LEFT_Y);
if (across > 0.5f) { held |= PAD_RIGHT; }
if (across < -0.5f) { held |= PAD_LEFT; }
if (down > 0.5f) { held |= PAD_DOWN; }
if (down < -0.5f) { held |= PAD_UP; }
}
if (n == 0) {
// ---- And the keyboard, on top of it ----
//
// Arrows or WASD for the direction, Z and X for the buttons. OR-ed with whatever
// a real pad is doing rather than chosen between, so unplugging one mid-game does
// not leave somebody holding nothing.
if (IsKeyDown(KEY_RIGHT) || IsKeyDown(KEY_D)) { held |= PAD_RIGHT; }
if (IsKeyDown(KEY_LEFT) || IsKeyDown(KEY_A)) { held |= PAD_LEFT; }
if (IsKeyDown(KEY_DOWN) || IsKeyDown(KEY_S)) { held |= PAD_DOWN; }
if (IsKeyDown(KEY_UP) || IsKeyDown(KEY_W)) { held |= PAD_UP; }
if (IsKeyDown(KEY_Z)) { held |= PAD_A; }
if (IsKeyDown(KEY_X)) { held |= PAD_B; }
if (IsKeyDown(KEY_ENTER)) { held |= PAD_START; }
if (IsKeyDown(KEY_TAB)) { held |= PAD_SELECT; }
}
padSet(n, there, held);
}
}
static void drainKeyboard(void) {
int character;
while ((character = GetCharPressed()) > 0) {
@@ -276,6 +344,7 @@ static void presentFrame(void) {
EndDrawing();
// EndDrawing has just polled, which is the one moment Raylib's queues hold anything.
drainKeyboard();
readPads();
checkResetButton();
}
@@ -387,6 +456,18 @@ int main(int argc, char *argv[]) {
// to find out that a default was left as it was found.
SetExitKey(KEY_NULL);
// ---- What controllers the host can see, said out loud ----
//
// A pad that is plugged in and does nothing is indistinguishable from a pad the front
// end never noticed, and the difference is the whole of what to do about it: one is a
// mapping to fix and the other is a driver. Saying which at startup costs a line and
// answers it without anybody having to guess.
for (int n = 0; n < PAD_COUNT; n++) {
if (IsGamepadAvailable(n)) {
printf("Controller %d: %s\n", n, GetGamepadName(n));
}
}
// ---- And a speaker, if the host has one ----
//
// Asked for rather than assumed: a machine with no audio device is a perfectly good
@@ -406,9 +487,20 @@ int main(int argc, char *argv[]) {
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.
// ---- The keyboard becomes the console's input ----
//
// In place of a standard input the window does not have. UNLESS A KEYBOARD FILE WAS
// NAMED, which used to be overridden here without a word: machineStart installs the
// file's hook and this replaced it, so --keyboard was a flag that did nothing behind
// a window and said nothing about it.
//
// Both together is the combination a demo wants. Recording a flight needs the typing
// that STARTS it to be the same every time - a human reaching the shell at a slightly
// different moment shifts every frame of the recording after it - while the flying
// itself comes from whatever is actually in somebody's hands.
if (options.keyboard == NULL) {
consoleSetInputHook(voyagerKey);
}
// ---- A slice a frame ----
//
// The machine gets its turn, then the window gets its turn. Closing the window stops
+112 -2
View File
@@ -23,7 +23,7 @@
static const char *const ruleNames[] = {
"redundant-setd", "redundant-assignment", "pointer-offset", "redundant-ccf",
"known-branch", "zero-load", "dead-assignment", "q-through-stack", "self-push-pop",
"branch-to-next", "unreachable", "dead-suppression",
"branch-to-next", "unreachable", "dead-suppression", "falls-into-subroutine",
};
#define RULE_COUNT ((int)(sizeof(ruleNames) / sizeof(ruleNames[0])))
@@ -329,7 +329,76 @@ static int stopsFallthrough(const SourceInstruction *instruction) {
|| strcmp(instruction->mnemonic, "RET") == 0
|| strcmp(instruction->mnemonic, "RRET") == 0
|| strcmp(instruction->mnemonic, "RETI") == 0
|| strcmp(instruction->mnemonic, "HALT") == 0;
// SRET was missing here, which is a gap in every rule that asks what reaches an
// instruction: it returns from a handler exactly as RET returns from a call.
|| strcmp(instruction->mnemonic, "SRET") == 0
|| strcmp(instruction->mnemonic, "HALT") == 0
// ---- The one trap that does not come back ----
//
// A SWI is a call and almost every one of them returns, so a SWI does not end a run
// of code. osExit is the exception and the exception matters: it is how a loaded
// program gives the machine back, so nothing after it runs, and every program on
// this machine ends with it and then writes its helper routines underneath.
//
// NAMED RATHER THAN NUMBERED, because that is what the source says. This is the only
// name from the system that this tool knows, and it is here because without it the
// rule below would report every well written program in the repository.
|| (strcmp(instruction->mnemonic, "SWI") == 0
&& strcmp(instruction->operand, "osExit") == 0);
}
// ---- Which labels are entered by a CALL ----
//
// Collected in a pass of its own before anything is judged, because the rule below asks a
// question about a label that cannot be answered until the whole file has been read: a
// subroutine is very often called from further down than it is written.
#define CALL_TARGET_CAPACITY 2048
static char callTargets[CALL_TARGET_CAPACITY][TOKEN_CAPACITY];
static int callTargetCount = 0;
static int isCallTarget(const char *name) {
for (int i = 0; i < callTargetCount; i++) {
if (strcmp(callTargets[i], name) == 0) {
return 1;
}
}
return 0;
}
// Reads the file once for nothing but CALL and RCAL operands. Answers 0 if there were more
// distinct ones than there is room for, which is reported rather than quietly truncated: a
// rule that silently forgot half its input would go quiet instead of wrong, which is worse.
static int collectCallTargets(const char *path) {
callTargetCount = 0;
FILE *file = fopen(path, "r");
if (!file) {
return 1; // The caller opens it again and reports the failure properly.
}
char line[LINE_CAPACITY];
int lineNumber = 0;
while (fgets(line, sizeof(line), file)) {
lineNumber++;
SourceInstruction current;
if (parseInstruction(line, lineNumber, &current) != SOURCE_INSTRUCTION) {
continue;
}
if (strcmp(current.mnemonic, "CALL") != 0 && strcmp(current.mnemonic, "RCAL") != 0) {
continue;
}
if (!current.operand[0] || isCallTarget(current.operand)) {
continue;
}
if (callTargetCount >= CALL_TARGET_CAPACITY) {
fclose(file);
fprintf(stderr, "%s: error: more than %d distinct call targets\n",
path, CALL_TARGET_CAPACITY);
return 0;
}
snprintf(callTargets[callTargetCount], TOKEN_CAPACITY, "%s", current.operand);
callTargetCount++;
}
fclose(file);
return 1;
}
static int isDirectBranch(const SourceInstruction *instruction) {
@@ -699,12 +768,21 @@ static int lintFile(const char *path) {
return -1;
}
if (!collectCallTargets(path)) {
fclose(file);
return -1;
}
char line[LINE_CAPACITY];
int lineNumber = 0;
int warnings = 0;
int havePrevious = 0;
int fallthroughStopped = 0;
int havePendingBranch = 0;
// Whether anything at all has been read since the last #Program or #Data, and whether
// the run of code we are in began at a label something calls. See the rule below.
int sawInstruction = 0;
int runHasCallTarget = 0;
SourceInstruction pendingBranch;
KnownRegisters known = {0};
KnownPointer pointers[DATA_POINTERS] = {0};
@@ -751,6 +829,33 @@ static int lintFile(const char *path) {
"remove the branch");
havePendingBranch = 0;
}
// ---- Walking into a subroutine instead of calling it ----
//
// The code above ends without going anywhere, and the label below is one
// something CALLs. So execution walks into the subroutine, reaches its RET,
// and returns to whatever the Stack happens to hold - which is not a caller,
// because nobody called. It goes somewhere nobody named.
//
// THAT IS WHAT FORMATTED A DISK. CosmOS's monitor had no branch at the end
// of its command list, so an unrecognised word walked into sayPrompt; the
// RET at the bottom of it went to whatever was on the Stack, and one of the
// places that turned out to be was inside the filesystem's format routine.
// The symptom is nowhere near the cause and changes with the Stack, which is
// exactly the kind of fault worth spending a rule on.
//
// UNLESS THE RUN WE ARE IN WAS ITSELF CALLED. Falling out of one subroutine
// into another is an ordinary tail call: the RET returns to the outer
// caller, which is real and is what the author meant. So this only fires
// when nothing since the last RET or branch was a call target either.
if (!fallthroughStopped && sawInstruction && !runHasCallTarget
&& current.spelling[0] && isCallTarget(current.spelling)) {
warnings += warning(path, "falls-into-subroutine", current.line,
"execution walks into a subroutine nothing here called",
"branch past it, or end the code above with a branch or a return");
}
if (current.spelling[0] && isCallTarget(current.spelling)) {
runHasCallTarget = 1;
}
fallthroughStopped = 0;
forgetRegisters(&known);
forgetPointers(pointers);
@@ -758,6 +863,8 @@ static int lintFile(const char *path) {
} else if (sourceLine == SOURCE_BOUNDARY) {
fallthroughStopped = 0;
havePendingBranch = 0;
sawInstruction = 0;
runHasCallTarget = 0;
forgetRegisters(&known);
forgetPointers(pointers);
carry = CARRY_UNKNOWN;
@@ -778,8 +885,11 @@ static int lintFile(const char *path) {
warnings += lintKnownPointers(path, &current, pointers);
warnings += lintKnownCarry(path, &current, carry);
warnings += lintInstruction(path, havePrevious ? &previous : &empty, &current);
sawInstruction = 1;
if (stopsFallthrough(&current)) {
fallthroughStopped = 1;
// A new run of code starts after this, and it has not been called yet.
runHasCallTarget = 0;
}
if (isDirectBranch(&current) && current.operand[0]) {
pendingBranch = current;
+247
View File
@@ -0,0 +1,247 @@
// SoundPatch.c
// Turns a soundThing patch into a table the sound device can be handed.
//
// ---- Why this exists ----
//
// soundThing is where a patch gets DESIGNED, because it has a screen, a keyboard and a pair
// of ears attached to it. Voyager's sound device is the same voice engine with the editor
// taken off, so the sound a patch makes is the same sound - but its settings arrive as bytes
// through a selector, and a byte is not seconds or hertz.
//
// The first sound written for a game here was guessed at in bytes: a cutoff of 40 looked
// small and is 57 Hz, so the bang came out as a low gurgle. That is what this is for. Design
// it where it can be heard, convert it, and the numbers stop being a matter of opinion.
//
// ---- What a byte means ----
//
// Every parameter the device takes is a documented function of a natural value, and all of
// them invert. Times are SQUARED into four seconds, 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 and LFO rate are EXPONENTIAL,
// because pitch is logarithmic and so is where a filter sounds like it is. Depths and detune
// are CENTRED on 128, so half of nothing is no change and either side of it is a direction.
//
// Written by Anachronaut
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <math.h>
// How a natural value becomes a byte. The inverse of soundParameter in sound.c, and the
// reason that file and this one have to be read together if either changes.
typedef enum {
AS_DIRECT, // already a byte: a waveform, a routing, a filter type
AS_BOOL, // nought or one
AS_RANGE, // linear from low to high
AS_SQUARED, // times, so that the short end has the resolution
AS_EXPONENTIAL, // cutoff and rate, because hearing is logarithmic
AS_SIGNED, // centred on 128, reaching `high` either way
AS_OCTAVE // a small signed number, offset by 128
} Shape;
typedef struct {
const char *field; // what soundThing calls it
unsigned char parameter; // what the device calls it
Shape shape;
double low, high;
const char *note;
} Mapping;
// ---- The whole of the correspondence ----
//
// Oscillator one is oscillator nought's parameters plus 0x10, and the modulation envelope is
// the amplitude one plus 0x10, which is why the parameter numbers are laid out the way they
// are. Written out in full anyway: a table that has to be understood before it can be read is
// worse than a long one.
static const Mapping mappings[] = {
{ "osc0_waveform", 0x00, AS_DIRECT, 0, 0, "oscillator 0, waveform" },
{ "osc0_gain", 0x01, AS_RANGE, 0, 4.0, "gain" },
{ "osc0_dutyCycle", 0x02, AS_RANGE, 0.05, 0.95, "duty" },
{ "osc0_detune", 0x03, AS_SIGNED, 0, 1200, "detune, in cents" },
{ "osc0_octave", 0x04, AS_OCTAVE, 0, 0, "octave" },
{ "osc0_active", 0x05, AS_BOOL, 0, 0, "on" },
{ "osc0_modRouting0", 0x06, AS_DIRECT, 0, 0, "what moves its width" },
{ "osc0_modDepth0", 0x07, AS_SIGNED, 0, 0.5, "and how far" },
{ "osc0_modRouting1", 0x08, AS_DIRECT, 0, 0, "what moves its pitch" },
{ "osc0_modDepth1", 0x09, AS_SIGNED, 0, 1200, "and how far" },
{ "osc0_modRouting2", 0x0A, AS_DIRECT, 0, 0, "what moves its gain" },
{ "osc0_modDepth2", 0x0B, AS_SIGNED, 0, 4.0, "and how far" },
{ "osc1_waveform", 0x10, AS_DIRECT, 0, 0, "oscillator 1, waveform" },
{ "osc1_gain", 0x11, AS_RANGE, 0, 4.0, "gain" },
{ "osc1_dutyCycle", 0x12, AS_RANGE, 0.05, 0.95, "duty" },
{ "osc1_detune", 0x13, AS_SIGNED, 0, 1200, "detune, in cents" },
{ "osc1_octave", 0x14, AS_OCTAVE, 0, 0, "octave" },
{ "osc1_active", 0x15, AS_BOOL, 0, 0, "on" },
{ "osc1_modRouting0", 0x16, AS_DIRECT, 0, 0, "what moves its width" },
{ "osc1_modDepth0", 0x17, AS_SIGNED, 0, 0.5, "and how far" },
{ "osc1_modRouting1", 0x18, AS_DIRECT, 0, 0, "what moves its pitch" },
{ "osc1_modDepth1", 0x19, AS_SIGNED, 0, 1200, "and how far" },
{ "osc1_modRouting2", 0x1A, AS_DIRECT, 0, 0, "what moves its gain" },
{ "osc1_modDepth2", 0x1B, AS_SIGNED, 0, 4.0, "and how far" },
{ "ampEnv_attack", 0x20, AS_SQUARED, 0, 4.0, "amplitude envelope, attack" },
{ "ampEnv_decay", 0x21, AS_SQUARED, 0, 4.0, "decay" },
{ "ampEnv_sustain", 0x22, AS_RANGE, 0, 1.0, "sustain" },
{ "ampEnv_release", 0x23, AS_SQUARED, 0, 4.0, "release" },
{ "modEnv_attack", 0x30, AS_SQUARED, 0, 4.0, "modulation envelope, attack" },
{ "modEnv_decay", 0x31, AS_SQUARED, 0, 4.0, "decay" },
{ "modEnv_sustain", 0x32, AS_RANGE, 0, 1.0, "sustain" },
{ "modEnv_release", 0x33, AS_SQUARED, 0, 4.0, "release" },
{ "filter_active", 0x40, AS_BOOL, 0, 0, "filter, on" },
{ "filter_type", 0x41, AS_DIRECT, 0, 0, "type: 0 low, 1 high, 2 band" },
{ "filter_cutoff", 0x42, AS_EXPONENTIAL, 20.0, 20000.0, "cutoff, in hertz" },
{ "filter_resonance", 0x43, AS_RANGE, 0, 0.99, "resonance" },
{ "filter_modRouting", 0x44, AS_DIRECT, 0, 0, "what moves the cutoff" },
{ "filter_modDepth", 0x45, AS_SIGNED, 0, 8000.0, "and how far, in hertz" },
{ "filter_resModRouting", 0x46, AS_DIRECT, 0, 0, "what moves the resonance" },
{ "filter_resModDepth", 0x47, AS_SIGNED, 0, 0.99, "and how far" },
{ "lfo0_active", 0x60, AS_BOOL, 0, 0, "LFO 0, on" },
{ "lfo0_waveform", 0x61, AS_DIRECT, 0, 0, "waveform" },
{ "lfo0_rate", 0x62, AS_EXPONENTIAL, 0.05, 20.0, "rate, in hertz" },
{ "lfo0_mode", 0x63, AS_DIRECT, 0, 0, "0 free, 1 starts with a voice" },
{ "lfo1_active", 0x70, AS_BOOL, 0, 0, "LFO 1, on" },
{ "lfo1_waveform", 0x71, AS_DIRECT, 0, 0, "waveform" },
{ "lfo1_rate", 0x72, AS_EXPONENTIAL, 0.05, 20.0, "rate, in hertz" },
{ "lfo1_mode", 0x73, AS_DIRECT, 0, 0, "0 free, 1 starts with a voice" },
// ---- The two that are about the voice as a whole ----
//
// soundThing gained a field for each of these at the same time the engine did. A patch
// written before that has neither, which is why the level source has a default below: it
// was always envelope 0 when there was nothing to say otherwise.
{ "voice_levelSource", 0x50, AS_DIRECT, 0, 0, "what shapes the level" },
{ "voice_gate", 0x51, AS_DIRECT, 0, 0, "0 gated, 1 triggered" },
};
#define MAPPING_COUNT ((int)(sizeof(mappings) / sizeof(mappings[0])))
// ---- What soundThing has and the device does not ----
//
// Named rather than ignored, so a field that is simply new shows up as unknown and a field
// that is deliberately dropped does not. Master volume is the editor's own output level and
// the device has one of its own; pitch bend has no wheel to come from.
static const char *ignored[] = { "master_volume", "master_pitchBendRange" };
#define IGNORED_COUNT ((int)(sizeof(ignored) / sizeof(ignored[0])))
static int clampByte(double raw) {
long v = lround(raw);
if (v < 0) return 0;
if (v > 255) return 255;
return (int)v;
}
static int toByte(const Mapping *m, double value) {
switch (m->shape) {
case AS_DIRECT: return clampByte(value);
case AS_BOOL: return value != 0.0 ? 1 : 0;
case AS_RANGE: return clampByte(255.0 * (value - m->low) / (m->high - m->low));
// The inverse of part*part*high, so the root comes back out.
case AS_SQUARED: return clampByte(255.0 * sqrt(value / m->high));
// And of low * (high/low)^part.
case AS_EXPONENTIAL:
if (value < m->low) value = m->low;
return clampByte(255.0 * log(value / m->low) / log(m->high / m->low));
case AS_SIGNED: return clampByte(128.0 + 128.0 * value / m->high);
case AS_OCTAVE: return clampByte(128.0 + value);
}
return 0;
}
int main(int argc, char **argv) {
if (argc < 3) {
fprintf(stderr,
"Usage: %s <patch.json> <label> [output.asm]\n\n"
"Turns a soundThing patch into a table for the sound device: a count, then that\n"
"many parameter and value pairs. Hand the label to playPatch.\n", argv[0]);
return 2;
}
const char *path = argv[1];
const char *label = argv[2];
FILE *in = fopen(path, "r");
if (!in) { fprintf(stderr, "SoundPatch: cannot read %s\n", path); return 1; }
int values[MAPPING_COUNT];
int seen[MAPPING_COUNT];
memset(seen, 0, sizeof(seen));
char line[512];
int unknown = 0;
while (fgets(line, sizeof(line), in)) {
char field[128];
double value;
// Every line of one of these is `"name": number`, with or without a trailing comma.
if (sscanf(line, " \"%127[^\"]\" : %lf", field, &value) != 2) continue;
int found = 0;
for (int i = 0; i < MAPPING_COUNT; i++) {
if (strcmp(field, mappings[i].field) == 0) {
values[i] = toByte(&mappings[i], value);
seen[i] = 1;
found = 1;
break;
}
}
if (found) continue;
for (int i = 0; i < IGNORED_COUNT; i++) {
if (strcmp(field, ignored[i]) == 0) { found = 1; break; }
}
if (!found) {
fprintf(stderr, "SoundPatch: %s has a field this does not know: %s\n", path, field);
unknown++;
}
}
fclose(in);
// A field this does not know is a patch format that has moved on, and writing a table
// that quietly leaves it out would make a sound nobody could account for.
if (unknown) {
fprintf(stderr, "SoundPatch: %d unknown field%s, so nothing was written.\n",
unknown, unknown == 1 ? "" : "s");
return 1;
}
// ---- A patch from before the level could be routed ----
//
// Those files have no voice_levelSource, and what it meant there was always envelope 0.
// Said out loud rather than left out, because a channel keeps its patch between notes and
// would otherwise carry a previous one's routing into this one.
int levelIndex = -1;
for (int i = 0; i < MAPPING_COUNT; i++) {
if (mappings[i].parameter == 0x50) { levelIndex = i; break; }
}
int assumedLevel = (levelIndex >= 0 && !seen[levelIndex]);
if (assumedLevel) {
values[levelIndex] = 1; // MOD_SOURCE_AMP_ENV
seen[levelIndex] = 1;
}
int count = 0;
for (int i = 0; i < MAPPING_COUNT; i++) if (seen[i]) count++;
FILE *out = stdout;
if (argc > 3) {
out = fopen(argv[3], "w");
if (!out) { fprintf(stderr, "SoundPatch: cannot write %s\n", argv[3]); return 1; }
}
fprintf(out, "; %s, converted from %s by SoundPatch.\n", label, path);
fprintf(out, "; Designed in soundThing, where it can be heard. Do not edit the numbers\n");
fprintf(out, "; here: change the patch and convert it again.\n\n");
// Data, and no base: a table of bytes belongs wherever the program including it has got
// to, the way every other included table does.
fprintf(out, "#Data\n\n");
fprintf(out, "%s:\n", label);
fprintf(out, " 0d%-24d; how many pairs follow\n", count);
for (int i = 0; i < MAPPING_COUNT; i++) {
if (!seen[i]) continue;
fprintf(out, " 0x%02X 0d%-3d ; %s%s\n",
mappings[i].parameter, values[i], mappings[i].note,
(i == levelIndex && assumedLevel) ? ", which this patch predates" : "");
}
if (out != stdout) fclose(out);
return 0;
}
+1 -1
View File
@@ -633,7 +633,7 @@ A `#Vectors` line that names a **handler** says this program implements that vec
`Boot` in a loadable program fills the **entry** field rather than being installed. Vector zero is where the whole machine starts, and a program being loaded into a running system has no business saying anything about that. A boot image is the one thing that does, so there it is installed like any other.
`Device` is named by the port it is plugged into, because that is what decides which vector it arrives through. `Device`, `Boot`, `SoftReset`, `BadOpcode`, `GuardViolation` and `BankFault` are matched **without regard to case**, the way mnemonics are: they are part of the language rather than names the programmer chose.
`Device` is named by the port it is plugged into, because that is what decides which vector it arrives through. `Device`, `Boot`, `SoftReset`, `BadOpcode`, `GuardViolation`, `BankFault`, `NoHandler` and `NoDevice` are matched **without regard to case**, the way mnemonics are: they are part of the language rather than names the programmer chose.
**A declaration and an implementation are the same entry.** `services.asm` says a service is called `osPrintString` and has number 16; `cosmos.asm` says `osPrintString` is handled by `handlePrintString`. Both sides include the first file, so the name is met twice and the second time fills in the handler. That is what lets one shared file serve both a program that calls a service and the system that implements it - and it is why the first pass declares and the second implements, a handler being an address and no address being known until every label has been placed.
+264 -10
View File
@@ -309,6 +309,8 @@ Backspace is 0x08 and always has been. It is a different key from Delete and doe
**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.
**But asking the status port in line mode does not throw one away.** A key line mode will not deliver is not the same as a key that is gone, because the mode can change: a program that looks at the status port and then asks for key mode - which is exactly what a system does before it reads a line - would otherwise find that the first key it was reaching for had been swallowed by the looking. So the console holds it and delivers it as soon as something is willing to take it. Reading the data port in line mode does discard it, and must: that read is the delivery, and a key held there would be met again forever.
**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:
@@ -545,7 +547,17 @@ If a device interrupts and its vector is empty, that is a fault: the machine sto
If the CPU reads a byte from Program Memory that does not decode to an instruction, it dispatches through Software Vector 2.
Faults get a vector each rather than sharing one. Vector 2 is the only cause defined so far, and vectors 3 through 15 are held back for the ones that come later, so that a handler always knows what happened from the entry it arrived through. That is why the machine has no fault cause register to read.
Faults get a vector each rather than sharing one, so that a handler always knows what happened from the entry it arrived through. That is why the machine has no fault cause register to read.
| Vector | What happened |
| --- | --- |
| 2 | A byte in Program Memory does not decode to an instruction. |
| 3 | A device refused a write that landed inside a raised fence. |
| 4 | A bank was named that has nothing in it, or an access ran past its end. |
| 5 | A software vector was dispatched through and had no handler. |
| 6 | A device interrupted and its hardware vector had no handler. |
Vectors 7 through 15 are held back for the causes that come later.
The address in the frame is the address of the offending byte itself, not the one after it. A handler can therefore read the byte that failed and say what it was. It also means a handler that returns with a bare RETI will meet the same byte again, because resuming past a fault means deciding where to resume, and only the handler knows that.
@@ -553,6 +565,18 @@ If nothing is installed at Vector 2, the CPU sets the Fault Flag and the Halt Fl
Stopping matters because the alternative is worse. A byte that means nothing is almost always a sign that execution has wandered into data, or that a program was built for a machine with instructions this one does not have. Stepping over it and carrying on turns a clear failure into a program that appears to run and quietly does the wrong thing.
### Nowhere To Go:
Vectors 5 and 6 are the fault of dispatching through an empty entry, and they were a long time coming, because the thing that would hand that fault over is the thing which has just found nothing to hand it to. Until they existed, a `SWI` naming a service the system does not implement stopped the machine and no program could do anything about it - and calling a service that is not there is an ordinary mistake.
**Which entry was empty arrives in Q**, and it is the only thing on this machine a handler is given in a register. That is not a fault cause register by another route: the vector still says *what* happened, and Q says which of the 256 entries it happened about, which is a parameter and not a cause. It costs nothing, because the frame already saved the Q the interrupted program had and RETI puts it back.
The two are separate entries because they are separate mistakes with separate fixes. A missing software vector is usually a program calling something that is not there; a device with nobody listening is usually a program that asked to be interrupted and forgot the handler.
**The frame's address is past the SWI**, unlike every other fault here, because the instruction did dispatch - it was the entry that was empty. A bare RETI therefore carries straight on, where a bad opcode or a refused port would meet the same instruction again.
If Vector 5 or 6 is *itself* empty, the machine stops the way it always did. It has run out of places to go, and looping there would be worse than stopping. A handler that commits the same fault it was called about recurses like any other, which is the same bargain as a Vector 2 handler containing a byte that does not decode.
## Refusing:
A device can refuse what it was asked to do. This is not the same as interrupting. An interrupt is a device asking for attention later, answered between instructions once the CPU is ready. A refusal is a device saying no to the instruction happening now, so the machine stops where it stands rather than carrying on as though the access had worked.
@@ -578,6 +602,7 @@ If nothing is installed for the vector a device refused with, the machine stops
| 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 |
| 0x60 - 0x6F | The game controllers. See Controllers. Four pads, polled, reporting what is held. | 0x17 |
| 0xE0 - 0xEF | The memory controller. See The Memory Controller. | 0x03 |
| 0xFF | The bus registry. See Asking What Is There. | 0x01 |
@@ -591,30 +616,152 @@ It follows that colour depth is free. The map is the same size whatever is behin
### 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.
**Two banks**, brought by the device and reached only through the memory controller, like the disk's buffer. They keep what is in them between frames, so a program writes the part that changed and the rest stays as it was.
| Address | Holds |
Two rather than one because the two halves of a screen are written at completely different rates. The **atlas** is tiles and colours: put there when a program loads and then left alone. The **screen** is the map: rewritten as often as anything moves.
| Bank | Address | Holds |
| --- | --- | --- |
| Atlas | 0x0000 - 0x3FFF | Tile page 0. 256 tiles of 8 by 8, one byte a pixel, so tile n begins at n times 64. |
| Atlas | 0x4000 - 0x7FFF | Tile page 1. |
| Atlas | 0x8000 - 0xBFFF | Tile page 2. |
| Atlas | 0xC000 - 0xCFFF | The sprite table. 256 entries of sixteen bytes - and tiles 0 to 63 of page 3. |
| Atlas | 0xD000 - 0xD27F | The depth buffer. One byte a screen column - and tiles 64 to 73 of page 3. |
| Atlas | 0xD280 - 0xFBFF | Free - and tiles 74 to 239 of page 3. |
| Atlas | 0xFC00 - 0xFFFF | The palette. 256 entries of four bytes: red, green, blue, and one unused - and tiles 240 to 255 of page 3. |
| Screen | 0x0000 - 0x3FFF | Free in a tile mode. |
| Screen | 0x4000 - 0xBFFF | The map. 128 rows of 256 bytes. |
| Screen | 0xC000 - 0xFFFF | The window. 64 rows of cells that do not scroll. |
| Screen | 0x0000 - 0xF9FF | In bitmap mode, the picture instead: 64,000 bytes, one to a pixel. |
**There are two screen banks and one atlas**, laid out identically, and the device shows one screen at a time. See Two Screens below.
**The bitmap is the same memory as the map**, which is what shared video memory has always been. Going to bitmap mode does not clear the text screen - it stops calling it one, and coming back finds the map holding whatever the picture put there.
**What a picture no longer costs is the font.** The tiles and the palette are in the other bank, where a bitmap cannot reach them, so a program can draw a picture and then put readable text back on the screen without asking the character generator for its glyphs again. While the two shared a bank, drawing anything destroyed them.
The palette is in the atlas, at the top and out of the way, because it is written when a program loads rather than per frame - and because being out of the screen bank is what leaves a bitmap the whole of one.
Which bank is which is a property of the **address**, never of the mode: tiles and the palette are always in the atlas, the map and a bitmap always in the screen.
### Naming Them:
A bank is registered by naming the port that owns it, so a device with two banks needs two ports that own memory. The screen has them:
| Port | Owns |
| --- | --- |
| 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. |
| 0x30 | The atlas. |
| 0x3A | Screen 0. |
| 0x3B | Screen 1. |
**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.
Nothing is read or written at 0x3A or 0x3B - they are names for banks, and the bus registry is where a program finds out they bring one. Asking the registry about the block gives an honest answer: those three ports say they bring memory and the other thirteen say they do not.
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.
### Two Screens:
There are two screen banks and the device shows one of them. **Port 0x3C says which**, 0 or 1, and reads back what it was told; a screen that does not exist is not taken, the same as a mode that does not exist.
That is a **back buffer**: a whole screen's worth of map written where nobody can see it, and then shown all at once. A screen drawn where it can be seen is seen half drawn, and a program that moves forty things and rewrites the map underneath them is wrong for as long as it takes to put them all right - which at a megahertz is long enough to look at.
**One register is enough, where real hardware needed two.** The other said which screen the CPU's window pointed at. There is no window here: a program reaches a bank through the memory controller by its number, so writing to the screen that is not being shown is a matter of naming its bank, and the device never has to be told.
**A flip cannot tear.** A frame is drawn from one bank in one go, so a flip either happened before that frame or it happens before the next one; there is no state of having flipped halfway. The machines this one imitates had to catch the few lines between one frame and the next to do the swap in.
**The console draws into whichever screen is displayed**, rather than into one of its own. A game that flipped and then faulted needs the message to land where somebody can read it, and the console has no way of knowing that happened.
What a second screen costs is a whole bank of somebody's memory, and nothing else: it is memory the device brought, so a program that wants it registers it and a program that does not never pays for it. `Programs/CosmOS/Apps/Flip.asm` is the shortest thing to read that uses one.
The base port owns the atlas because tiles have been at 0x0000 since there was a screen at all. `Programs/Examples/picture.asm` registers both, and is the shortest thing to read that does.
**Registering the second one moves DestBank**, which is worth saying because it is easy to be caught by: RegisterBank takes the number being handed out in DestBank, so a program that registers two banks and then writes without setting DestBank again writes into the second one.
**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.
### Sprites:
Things that move without the map moving. A cell is where it is; something between two cells meant rewriting both of them, and something moving a pixel at a time meant rewriting them sixty times a second - which is affordable for one thing and not for twenty. A sprite is put at a **pixel**, and the device draws it over whatever is behind.
A sprite's attribute is a cell's attribute, read the same way and by the same code - which is what lets one piece of art be a wall in one place and a moving thing in another with nothing rewritten.
**A sprite is m by n tiles**, taken in reading order from one index. That is the decision the rest follows from: it needs no pixel format of its own, no second kind of memory, and nothing its art can be that the map could not also show. A 16 by 16 character is four tiles, and a program that wants the same picture in the background just names the same four.
The table is 256 entries of 16 bytes at **0xC000 in the atlas**. Sixteen so that entry n begins at n times sixteen, which is a shift - the same reason a palette entry is four bytes.
| Byte | Holds |
| --- | --- |
| 0 | The top left tile, in the page its attribute names. The rest follow it in reading order, wrapping at 255 inside that page. |
| 1 | Attribute, which means exactly what a cell's does: low nibble the colour scheme, bits 4 and 5 the tile page. |
| 2, 3 | X, low byte first, **signed**. |
| 4, 5 | Y, the same. |
| 6 | Size: tiles across in the high nibble, tiles down in the low. |
| 7 | Flags. Bit 0 mirrors it, bit 1 turns it over, bit 2 puts it behind. |
| 8, 9 | Target width in pixels. Zero means the natural width, eight times the tiles across. |
| 10, 11 | Target height, the same. |
| 12 | Depth. Zero means no depth test. |
| 13 - 15 | Reserved. Leave at zero. |
The position is signed and sixteen bits because the larger mode is 640 by 400, so neither axis fits in a byte - and because a sprite has to be able to sit half off the left or the top rather than appearing whole at the edge.
**A pixel of zero is not drawn.** Without that every sprite is a rectangle. It is tested before the attribute is added, so a hole is a property of the art rather than of the colour scheme: a sprite drawn in indices 1 to 15 is transparent in the same places in all sixteen.
The same rule read the other way is what **behind** means. A sprite marked behind draws only where the background pixel was zero, so a thing can walk behind a pillar and in front of the floor in the same frame. One rule, applied to whichever layer is in front.
**A sprite of no width or no height draws nothing**, and that is the off switch: it saves a flag, it is per sprite rather than a global the whole table shares, and it means the table is already off when the machine starts, since the atlas wakes up cleared. Note that this is deliberately the opposite of what a length of zero means to the memory controller. The reason is the same both times - moving no bytes is a useless thing to ask for, so zero was free to mean 64K there, and drawing no sprite is the commonest state in this table, so zero has to mean nothing here.
**All of them are drawn, every frame.** Sprites here cannot flicker. Real machines dropped them per scanline because they had a fixed number of shift registers and a fixed time to fill them; this has a loop. The limit is how many entries the table has, which is a constant a program can count on rather than something that depends on what it happens to be drawing. Where two overlap, **the lower number is in front**.
Sprites are drawn over a bitmap as readily as over a map. A bitmap is what a program draws once and leaves; there is no reason the mode that cannot afford to redraw itself should be the one that cannot have things moving on it.
### Scaling:
**Bytes 8 to 11 say how big to draw it**, in pixels, and the device stretches the m by n tiles to fill that. Zero on an axis means the natural size, so every sprite written before scaling existed still means what it meant, and a thing drawn at the size it was drawn at costs nothing to say.
**A target in pixels rather than a multiplier**, which is the whole of why this is usable here. A billboard at distance *d* wants to be *k/d* pixels tall, and that is a number a program has anyway - out of a lookup table, most likely. A multiplier would have to be a fixed-point fraction, arrived at by dividing, and this CPU cannot divide.
The two axes are independent, so a sprite can be stretched one way and not the other. That shape - one tile wide at its own size, stretched to whatever height a distance says - is a wall column in a pseudo-3D game, and it is the reason such a game is possible at all on this machine. Drawing 640 by 400 pixels of wall from the CPU is 256,000 writes, which is fifteen frames of cycles for one frame of screen. Writing sixteen bytes a column and letting the device do the pixels is about three thousand.
### Depth:
**Byte 12 says how far away a sprite is**, and the **depth buffer** at 0xD000 says how far away the scenery is: one byte a screen column, written by the program. A sprite pixel is drawn only in the columns it is in front of.
Zero in a sprite's depth means no depth test at all, which is what every ordinary sprite wants and what a cleared table already says. Zero in a column means nothing is there, so a program that never writes the buffer has one of zeroes and every sprite draws - which is exactly the behaviour there was before the buffer existed.
**Per column, and that is the point.** A billboard can be nearer than the wall at one end of itself and further at the other, and no ordering of the sprite table can say that. Table order settles sprites against each other; the buffer settles them against the scenery.
The buffer belongs to the program. It is not cleared between frames, because a program that draws scenery rewrites all of it every frame anyway.
`Programs/CosmOS/Apps/Sprite.asm` moves one across the shell's own text without writing a byte of the map.
### The Window:
A layer that does not scroll. The map moves and this does not, which the map alone cannot express: the scroll registers move **all** of it, so a score printed into the map is a score that slides away, and one printed into whichever rows the view happens to be showing jumps a pixel at a time as the fine offset changes.
**Port 0x3D** is how many screen rows tall it is and **0x3E** is which row it starts at. Zero tall is no window, so a cleared screen has none and every program written before it existed means what it meant. A start row is a register because a status bar along the bottom is as common as one along the top.
Its cells are at **0xC000 in the screen bank**, 64 rows of 256 bytes, and a cell means exactly what a map cell means - same tiles, same pages, same colour schemes. **A window cell is at a screen position**, where a map cell is at a position in a world the screen is looking at part of; keeping those two coordinate systems apart is the whole of the feature.
**It has its own memory, and that is the argument for it.** The cheaper design draws the top rows of the *map* without the scroll applied, which needs no new memory at all - and makes those rows part of the playfield's ring, so a game that scrolls vertically has to route its world around its own scoreboard for ever. The point of a status bar is that it is not somewhere in the level.
Being in the screen bank means it is **per screen**: flipping to the other buffer flips the status bar with it, which is what a double-buffered game wants and would be surprising the other way round.
**It is drawn over everything, sprites included.** A sprite that could cover the fuel gauge would be a bug in every game that had both.
It is a tile-mode layer. In bitmap mode there is nothing to draw it from - the picture is using that memory - so a bitmap program that wants something pinned to the screen uses sprites, which are in screen coordinates for the same reason.
### 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.
**Bits 4 and 5 say which page of tiles the number is in.** A tile number is a byte and a byte reaches 256, which is not many once a font has taken 135 of them and a game wants a character, a background and a wall. Two bits that were already being written on every cell reach 1024.
Four pages of 16K is 64K, which is the whole atlas, so **the fourth page is the memory the sprite table and the palette are in.** That is not a hole in the design; it is the same answer shared video memory has always given. The atlas is 1024 tiles, and what a program spends on sprites and colours comes out of them. A program that wants no sprites may use page 3 for art, and one that wants sprites has 768 tiles and knows why.
The page is a property of the **cell**, not a mode, so one screen can show tiles from all four pages at once and a program never has to decide which page it is "in".
Bits 6 and 7 are still reserved and should be left at zero.
### Registers:
@@ -629,6 +776,12 @@ The high nibble is reserved and should be left at zero, so that a meaning can be
| 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. |
| 0x39 | Command. Bit 0 copies the font back, bit 1 the sixteen colour schemes. |
| 0x3A | Owns screen 0. Not read or written. |
| 0x3B | Owns screen 1. Not read or written. |
| 0x3C | Display. Which of the two screens is being shown. |
| 0x3D | Window height, in rows. Zero is no window. |
| 0x3E | Window start, which screen row it begins at. |
| Mode | Screen | Cells |
| --- | --- | --- |
@@ -711,6 +864,24 @@ The alternative was to let a write of 8 step the column and set the fine part to
**None of the four does anything in bitmap mode**, which has no map to slide.
### The Character Generator:
The font and the sixteen colour schemes come from a **ROM in the device**. Reset copies them into video memory, and Command port `0x39` copies them again on request:
| Port | Register |
| --- | --- |
| 0x39 | Command. Bit 0 asks for the font back, bit 1 for the sixteen schemes. Write only. |
**This used to be magic and now is not.** The glyphs were written into video RAM at reset and existed nowhere else, which looked harmless until something wanted the font *back*: RAM does not wake up with anything in it, and a program that redefined a glyph had destroyed the only copy there was. A machine with a character generator is what the machines this one is pretending to be actually had, and the copy into RAM is now a thing the device **does** rather than a state it mysteriously starts in.
The RAM is still RAM. A program may overwrite every glyph and every colour and should be able to - that is what makes this a tile engine rather than a text display. What changed is that doing so is no longer a one way door.
**Neither command clears what it does not own.** The font writes the 135 glyphs it has and stops, so a tile a program defined above them survives; the schemes write the two entries of each of the sixteen and stop, so a program's own colours in between survive. Asking for the font back must not cost a program the tile it was drawing with.
In bitmap mode the tiles *are* the picture, so asking for the font there draws glyphs across the top of it. That is not a case being ignored: it is what the memory means in that mode.
A system that wants a different font still loads one over the top. The ROM is the floor rather than the policy - it is what lets a machine with no disk say that it has no disk, and what lets a program with no system behind it put readable text on a screen.
### 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.
@@ -855,6 +1026,7 @@ The high nibble says which part of the voice, the low nibble which setting of it
| 0x30 - 0x3F | The modulation envelope. |
| 0x40 - 0x4F | The filter. |
| 0x50 | What shapes the channel's level. |
| 0x51 | Whether a note waits to be let go of. |
| 0x60 - 0x6F | LFO 0. |
| 0x70 - 0x7F | LFO 1. |
@@ -891,6 +1063,7 @@ The high nibble says which part of the voice, the low nibble which setting of it
| 0 | On, or off at zero. |
| 1 | Waveform, from the same six. |
| 2 | Rate. |
| 3 | 0 free, 1 starts over with every voice. See The Same Sound Twice. |
Anywhere a setting asks *what modulates* something, the answer is one of these:
@@ -936,6 +1109,45 @@ output, so an envelope routed somewhere useful - opening the filter, bending a p
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.
### Struck Or Held:
Setting 0x51 says whether a note waits to be let go of: **0 gated**, which is how it has always
been, and **1 triggered**.
A gated voice lasts as long as something keeps hold of it. Writing the gate port ends it, and
the release begins there. That is what a keyboard is, and it is right for anything a player
holds down.
A triggered voice is struck and then plays its own length. Nothing has to remember to end it,
and the gate port need never be written at all. Sustain and release have no meaning in one,
because both of them are answers to a question about a key that is not being asked - so in a
triggered voice the decay runs to nothing rather than stopping at the sustain level, or a
patch with any sustain at all would hold the voice open for ever.
**A game is nearly all one-shots.** A bang, a pickup, a door: not one of them wants its length
decided by how long a note was held, and every one of them would otherwise need a program to
come back later and let go of it.
### The Same Sound Twice:
Setting 3 of either LFO says whether it starts over when a voice does: **0 free**, which is how
it has always been, and **1 retriggered**.
A free LFO is one cycle running under everything, which is what vibrato across a held chord
wants. A retriggered one starts at the beginning of its shape every time a voice begins, and
the cycle belongs to the voice rather than to the device - so retriggering costs nothing to a
channel not using it.
This is the other half of a repeatable sound effect, and **neither half is sufficient alone**.
A triggered voice re-arms its oscillators, so a hit begins at the same point in its waveform
every time and a noise source draws the same noise. But an LFO left free is wherever the wall
clock happened to leave it, so the same drum caught at a different moment is still a different
drum. Set both and a one-shot is the same one-shot, sample for sample.
The consequence is worth knowing rather than fixing: a retriggered noise source is *bit*
repeatable, so every hit is literally the same noise, the way a sampler is. On a hi-hat that
can read as machine-gunny. Where variation is wanted, leave that LFO or that voice free.
### 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
@@ -1016,8 +1228,50 @@ One thing to be careful of: the registry remembers which port it was asked about
| 0x14 | Screen. |
| 0x15 | Sound. |
| 0x16 | Timer. |
| 0x17 | Game controllers. |
| 0x17 - 0xFF | Peripherals. |
## Controllers:
Four pads on ports 0x60 to 0x6F. Each one is **one byte, read, saying what is held right now**.
| Port | Holds |
| --- | --- |
| 0x60 - 0x63 | Pads 0 to 3. |
| 0x64 | Which pads are there, one bit each. |
| 0x65 - 0x6F | Reserved. |
| Bit | Button |
| --- | --- |
| 0x01 | Right |
| 0x02 | Left |
| 0x04 | Down |
| 0x08 | Up |
| 0x10 | A |
| 0x20 | B |
| 0x40 | Start |
| 0x80 | Select |
The four directions are the low nibble, so *which way* is an `AND` with `0x0F` and needs no shifting. The four buttons are the high nibble for the same reason.
### Why This Is Not The Console:
The console says **which key went down**. That is the right shape for typing and the wrong one for playing: a game wants to know what is being held, this frame, possibly several things at once, and a stream of presses cannot say that. A key that is down and staying down sends nothing at all.
A pad reports a **level** rather than an event. One read gives every button at once, holding is the natural thing to express, and two directions together cost nothing. **Reading does not consume it** - a game may ask twice in a frame and be told the same thing both times, which an event queue cannot promise.
**A pad is sampled once a frame**, and what it reports does not change in between. That is not an implementation detail: what is watching a real controller runs on its own clock - a window polls its keyboard once a *host* frame, which is not a machine frame - and read straight through, a pad's value could change in the middle of a frame and a program asking twice would get two answers. Real hardware latches a controller once a frame for the same reason.
Key-up on the console would have been the other way to do it, and it was rejected: a terminal hands over characters and can never report a key coming up however it is asked, so it would have been a thing that worked behind a window and silently did not down a wire. A separate device can honestly say it is not there.
**They never interrupt.** A game polls once a frame because that is when it draws, and an interrupt for every button would be exactly the event model a pad exists to avoid.
### When There Is No Pad:
A pad that is not there reads as nothing held, which is the same as a pad nobody is touching. The difference matters only to whoever wants to explain it, so 0x64 says which are really there and a game can ask for a controller rather than sitting silent while somebody presses things at it.
`Programs/testPrograms/padTest.asm` reads one twice a frame to show that looking does not take it away.
## Keeping Time:
A period, in cycles, and a bit that says when one has gone by.
+87 -8
View File
@@ -9,7 +9,7 @@ believe them.
## What The Suite Claims:
The suite is not one thing. It is eleven scripts making five different kinds of claim, and
The suite is not one thing. It is twelve 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.
@@ -37,7 +37,7 @@ Everything in between is somewhere on that line.
make test
```
Builds the four tools - and Voyager, where Raylib is installed - checks they compile under
Builds the five 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.
@@ -45,7 +45,7 @@ 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
The same suite with the five tools rebuilt under AddressSanitizer and
UndefinedBehaviorSanitizer. See [The Sanitizer Run](#the-sanitizer-run).
Individual scripts can be run on their own, from anywhere:
@@ -64,12 +64,58 @@ Individual scripts can be run on their own, from anywhere:
./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/replay.sh A recording of input, played back as itself.
./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.
`Tests/replay.sh` checks that `--record-pad` and `--pad` are the same format read two ways.
A recording is written one byte a frame and is only worth having if playing it back does what
was recorded, so the strongest form of that claim is checked: a recording is made **of a
playback**, and the bytes coming out have to be the bytes that went in.
**Every pad is recorded at once**, or-ed into one byte, rather than one pad chosen by number.
Which pad a controller lands on is an accident of the host: the first recording ever made with
this came back 1,766 frames of nothing, because it recorded pad 0 and the controller was
somewhere else, and the flight had to be flown again for nothing. A demo is a record of what
somebody *did*, and on a machine one person is playing the number it arrived on is not part of
that.
`Tests/input/landerDemo.pad` is the first fixture that was **played rather than written**: a
cyan-to-red cargo delivery in Lunar Porter, twenty five seconds of steering, recorded with
`--record-pad`. `video.sh` replays it and checks that the base answers with a *D*, which only
a delivery does. It is the only check that a cargo ever reaches anywhere.
It exists because some inputs cannot sensibly be written by hand. Flying a lander from one
base to another is a few hundred frames of steering that has to arrive somewhere eight cells
wide, and several attempts at authoring one by hand got within two columns and no closer. That
is a piloting exercise rather than a test; playing it once and keeping what happened is the
answer.
`Tests/break.sh` is not in it either, for the reason turned round: it checks that a check
works. Run it by hand when a check is written, not as part of `make test`.
```
./Tests/break.sh <file> <anchor> <replacement> <suite> [suite ...]
```
It replaces the anchor - which must appear exactly once - rebuilds, runs the suites, restores
the file, and reports whether anything failed. **A suite failing is the good outcome.**
A check that passes proves nothing until it has been seen to fail, and doing that by hand
went wrong twice in two days. Both times it looked like a result: the suite ran, went green,
and read exactly like "this check does not catch that". Once the edit did not compile, `make`
failed, the exit status was not looked at, and the previous binary ran the suite. Once the
anchor was right and the filename was wrong, so nothing was edited at all.
Neither had anything to do with the build system; headers have always rebuilt what depends on
them. What was missing was a harness that refuses to report a result it did not earn, so
every step is checked and anything unexpected is a hard error rather than a green run. **Not
finding the break is the answer this tool exists to give, and it is worthless if it can also
be the answer when the break never happened.**
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.
@@ -79,7 +125,7 @@ from `make`, not from here.
### 1. Recorded output
`Tests/run.sh` assembles each program named in `Tests/manifest`, runs it, and compares
everything it printed against a file in `Tests/expected`. 189 tests, of which 127 run, 35
everything it printed against a file in `Tests/expected`. 211 tests, of which 149 run, 35
only assemble, 16 are expected to fail to assemble, and 11 boot from ROM with no image
given at all.
@@ -350,7 +396,7 @@ 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 |
| `disks/name.img` | A fixture built by `makedisks.sh`, copied fresh for the run |
| `...: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 |
@@ -358,6 +404,15 @@ The `@N` form deserves a note. Every other test runs with the disk's answer avai
before the next instruction, which is the one condition under which failing to wait looks
exactly like working.
**Both kinds of disk are fresh for every test**, and the fixture was not always. It used to
be handed to each test where it lay, and twenty four tests name `disks/cosmos.img` while
several of them write to one. `romBoot` was the test that found it: its recorded output
described a directory that `selfBoot` had made earlier in the same run, so it passed in a
full run and failed on its own. **That is the worst way round for a test to be wrong** - the
form nobody runs is the one that tells the truth - and the shape of it is worth naming,
because it is the same shape as a program that works only because of what ran before it.
The whole suite was checked one test at a time afterwards, and that was the only one.
**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.
@@ -373,6 +428,23 @@ A keyboard file installs the same hook a window does, so the same path runs. It
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.
**pad** names a file in `Tests/input` to be held on a controller, one byte a frame, each
byte the buttons held during it. **Several, comma separated**, fill the pads in turn - so a
first fixture holding nothing and a second that does something puts the flying on pad one,
which is how a game that reads only the first controller gets caught. It exists for the same reason as **keys** and matters more:
a pad reports what is *held*, and a suite has no hands.
**A byte a frame, not a byte a read.** A pad is a level, so a game that asks twice in one
frame has to be told the same thing both times, and a file that advanced per read would
answer differently depending on how the program happened to be written. The frame is the
machine's own, so a recording plays back the same over the same cycles however fast the host
ran.
It is also the only way this device is reachable at all. Voyager reads a real controller and
its own tests run `--headless`, with no window and nobody holding anything - so without a
recorded path a pad would be exercised only by somebody playing, which is exactly the state
the console's line editing was in when it broke twice in two days.
## Fixture Disks:
`Tests/makedisks.sh` builds 27 images with SplitDisk before anything runs, into
@@ -434,9 +506,16 @@ than reading all of them:
## 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
file across the whole corpus - 33 file-and-rule pairs. It is checked on every run, and it
is checked **in both directions.**
The corpus holds none of `falls-into-subroutine`, and that is worth saying rather than
leaving as a gap in the file. It is the rule added after a bug in CosmOS's monitor walked
into `sayPrompt`, reached a `RET` that had no caller, and returned into the filesystem's
format routine - which formatted the disk the machine had booted from. A rule whose count is
zero everywhere is not a rule doing nothing; it is the shape of fault that is worth never
having again.
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
@@ -461,7 +540,7 @@ which stops the markers outliving the code they were about.
make sanitize
```
Rebuilds all four tools with `-fsanitize=address,undefined` and runs **the whole suite**
Rebuilds all five 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.
@@ -473,7 +552,7 @@ deliberate `calloc`. The machine's Program and Data memories are static arrays,
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
It runs everything because it used to not. It built all five 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
Executable
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env bash
# Breaks something on purpose and checks that a suite notices.
#
# ---- Why this is a script and not a thing to type each time ----
#
# A check that passes proves nothing until it has been seen to fail. Typing that by hand went
# wrong twice in two days, and BOTH TIMES IT LOOKED LIKE A RESULT: the suite ran, went green,
# and read exactly like "this check does not catch that".
#
# Once the edit produced code that would not compile. make failed, the exit status was not
# looked at, and the PREVIOUS binary ran the suite.
# Once the anchor was right but the filename was wrong, so nothing was edited at all.
#
# Neither had anything to do with the build system - headers have always rebuilt what depends
# on them. What was missing was a harness that refuses to report a result it did not earn. So
# every step below is checked, and anything unexpected is a hard error rather than a green
# run: NOT FINDING THE BREAK IS THE ANSWER THIS TOOL EXISTS TO GIVE, and it is worthless if it
# can also be the answer when the break never happened.
#
# Usage:
# ./Tests/break.sh <file> <anchor> <replacement> <suite> [suite ...]
#
# The anchor must appear EXACTLY ONCE in the file. The file is restored afterwards whatever
# happens, including on an interrupt.
#
# Written by Anachronaut
set -u
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
GREEN=$'\033[32m'; RED=$'\033[31m'; RESET=$'\033[0m'
[ -t 1 ] || { GREEN=""; RED=""; RESET=""; }
die() { printf '%sbreak.sh: %s%s\n' "$RED" "$1" "$RESET" >&2; exit 2; }
[ $# -ge 4 ] || die "usage: break.sh <file> <anchor> <replacement> <suite> [suite ...]"
FILE="$1"; ANCHOR="$2"; REPLACEMENT="$3"; shift 3
[ -f "$FILE" ] || die "no such file: $FILE"
# ---- The file comes back whatever happens ----
#
# Including a Ctrl-C in the middle of a suite, which is a thing that happens: a half broken
# working tree that looks fine is worse than any failing test.
KEEP="$(mktemp)"
cp "$FILE" "$KEEP"
restore() {
cp "$KEEP" "$FILE"; rm -f "$KEEP"
(cd "$ROOT" && make >/dev/null 2>&1 && ./Tests/makedisks.sh Tests/build >/dev/null 2>&1)
}
trap restore EXIT INT TERM
# ---- The edit, and proof it happened ----
python3 - "$FILE" "$ANCHOR" "$REPLACEMENT" <<'PY' || die "the edit did not apply"
import sys
path, anchor, replacement = sys.argv[1], sys.argv[2], sys.argv[3]
text = open(path).read()
seen = text.count(anchor)
if seen == 0:
sys.stderr.write("anchor not found in %s\n" % path)
raise SystemExit(1)
if seen > 1:
sys.stderr.write("anchor appears %d times in %s; it must be unique\n" % (seen, path))
raise SystemExit(1)
open(path, "w").write(text.replace(anchor, replacement))
PY
# ---- The build, and proof of that too ----
#
# And the disk images after it, because half the things worth breaking are SplitBit assembly
# rather than C, and those live on the fixture disks rather than in the binaries. Rebuilding
# only the emulator meant an edit to a .asm file changed nothing the suite could see and the
# tool reported that nothing caught the break - which is the exact lie it exists to prevent,
# turning up in a new place.
if ! (cd "$ROOT" && make) > "$KEEP.build" 2>&1; then
printf '%sbreak.sh: the broken version does not build, so nothing was tested%s\n' \
"$RED" "$RESET" >&2
tail -5 "$KEEP.build" >&2
rm -f "$KEEP.build"
exit 2
fi
rm -f "$KEEP.build"
if ! (cd "$ROOT" && ./Tests/makedisks.sh Tests/build) > "$KEEP.disks" 2>&1; then
printf '%sbreak.sh: the disks did not build, so nothing was tested%s\n' "$RED" "$RESET" >&2
tail -5 "$KEEP.disks" >&2
rm -f "$KEEP.disks"
exit 2
fi
rm -f "$KEEP.disks"
# ---- And then the suites ----
#
# A suite that fails is the GOOD outcome here, so the exit status is inverted: this reports
# success when the break was noticed.
NOTICED=0
for suite in "$@"; do
name="$(basename "$suite" .sh)"
if [ -x "$ROOT/Tests/$name.sh" ]; then
run="$ROOT/Tests/$name.sh"
elif [ -x "$suite" ]; then
run="$suite"
else
die "no such suite: $suite"
fi
printf -- '---- %s ----\n' "$name"
if "$run" 2>&1 | tee "$KEEP.out" | grep -E '^\s*\[FAIL\]' | sed 's/^ *//'; then
:
fi
if grep -qE '^[0-9]+ passed, [0-9]+ failed' "$KEEP.out"; then
NOTICED=1
grep -E '^[0-9]+ passed, [0-9]+ failed' "$KEEP.out" | tail -1
else
printf '%s%s did not notice%s\n' "$RED" "$name" "$RESET"
fi
rm -f "$KEEP.out"
done
if [ "$NOTICED" = "1" ]; then
printf '%sThe break was caught.%s\n' "$GREEN" "$RESET"
exit 0
fi
printf '%sNOTHING CAUGHT THE BREAK.%s\n' "$RED" "$RESET"
exit 1
Executable
+277
View File
@@ -0,0 +1,277 @@
#!/usr/bin/env bash
# Checks SplitDisk against the SBFS format.
#
# The tool and the SplitBit side are two implementations of one written specification,
# and nothing but that document keeps them the same. This checks the host half on its
# own: that a file put onto a disk comes back off it byte for byte, that the sizes which
# exercise the block and tail arithmetic all survive, and that the things the format
# says cannot happen are refused rather than half done.
#
# Written by Anachronaut
set -u
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
TOOL="$ROOT/SplitDisk"
WORK="$ROOT/Tests/build/disk"
PASS=0
FAIL=0
FAILED_NAMES=()
GREEN=$'\033[32m'; RED=$'\033[31m'; RESET=$'\033[0m'
[ -t 1 ] || { GREEN=""; RED=""; RESET=""; }
check() {
local name="$1"; shift
if "$@" >/dev/null 2>&1; then
PASS=$((PASS + 1)); printf " [%sok %s] %s\n" "$GREEN" "$RESET" "$name"
else
FAIL=$((FAIL + 1)); FAILED_NAMES+=("$name")
printf " [%sFAIL%s] %s\n" "$RED" "$RESET" "$name"
fi
}
# The opposite: the command is supposed to fail, and passing would be the bug.
refuses() {
local name="$1"; shift
if "$@" >/dev/null 2>&1; then
FAIL=$((FAIL + 1)); FAILED_NAMES+=("$name")
printf " [%sFAIL%s] %s (it was allowed)\n" "$RED" "$RESET" "$name"
else
PASS=$((PASS + 1)); printf " [%sok %s] %s\n" "$GREEN" "$RESET" "$name"
fi
}
if [ ! -x "$TOOL" ]; then
echo "SplitDisk is not built."
exit 1
fi
rm -rf "$WORK"; mkdir -p "$WORK"
cd "$WORK" || exit 1
echo "Checking SplitDisk against the SBFS format."
check "format a disk" "$TOOL" format work.img 64 2
refuses "refuse a disk with no room" "$TOOL" format tiny.img 2 4
refuses "refuse an unformatted disk" "$TOOL" list /dev/null
# The sizes that exercise every corner of blocks-plus-tail: nothing at all, less than a
# block, exactly a block, a part block, and an exact multiple.
: > empty.bin
printf 'x' > one.bin
head -c 256 /dev/urandom > exact.bin
head -c 700 /dev/urandom > part.bin
head -c 768 /dev/urandom > whole.bin
for f in empty.bin one.bin exact.bin part.bin whole.bin; do
check "put $f" "$TOOL" put work.img "$f"
done
roundTrip() {
"$TOOL" get work.img "$1" "got_$1" >/dev/null 2>&1 || return 1
cmp -s "$1" "got_$1"
}
for f in empty.bin one.bin exact.bin part.bin whole.bin; do
check "$f comes back byte for byte" roundTrip "$f"
done
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 file that is not there" "$TOOL" get work.img nosuch.bin out.bin
check "delete" "$TOOL" delete work.img one.bin
refuses "the deleted file is gone" "$TOOL" get work.img one.bin out.bin
check "the name can be used again" "$TOOL" put work.img one.bin
# Contiguous files mean a disk can have room without having room in one piece. That is a
# consequence of the format rather than a bug, so it is checked rather than worked around.
"$TOOL" format frag.img 16 1 >/dev/null 2>&1
head -c 1024 /dev/urandom > a.bin; cp a.bin b.bin; cp a.bin c.bin
"$TOOL" put frag.img a.bin >/dev/null 2>&1
"$TOOL" put frag.img b.bin >/dev/null 2>&1
"$TOOL" put frag.img c.bin >/dev/null 2>&1
"$TOOL" delete frag.img a.bin >/dev/null 2>&1
"$TOOL" delete frag.img c.bin >/dev/null 2>&1
head -c 2048 /dev/urandom > big.bin
refuses "refuse a file with no run long enough" "$TOOL" put frag.img big.bin
head -c 512 /dev/urandom > fits.bin
check "but one that fits the gap goes on" "$TOOL" put frag.img fits.bin
# ---- Directories ----
#
# Version two, which adds a parent to each entry and a flag bit saying an entry is a
# directory. Both come out of bytes the entry had already set aside, so nothing moved and
# a version one disk needs no converting: zero in those bytes means the root, which is
# exactly where every file on a flat disk is.
#
# The version is therefore a statement about what is ON a disk rather than about what made
# it, and these check that it is only raised when it becomes true.
"$TOOL" format tree.img 64 2 >/dev/null 2>&1
printf 'a file in the root' > root.txt
check "a fresh disk is flat" "$TOOL" put tree.img root.txt
version() { "$TOOL" list "$1" 2>/dev/null | head -1 | grep -q "version $2"; }
check "and says it is version 1" version tree.img 1
check "make a directory" "$TOOL" mkdir tree.img /Apps
check "which raises it to version 2" version tree.img 2
check "make one inside it" "$TOOL" mkdir tree.img /Apps/Source
check "put a file down a path" "$TOOL" put tree.img root.txt /Apps/Source/deep.txt
# The point of the whole exercise: a name means something different in each place, so the
# same one can be used twice without either being in the other's way.
check "the same name in two places" "$TOOL" put tree.img root.txt /Apps/root.txt
roundTripAt() {
"$TOOL" get tree.img "$1" got_deep.txt >/dev/null 2>&1 || return 1
cmp -s root.txt got_deep.txt
}
check "it comes back byte for byte" roundTripAt /Apps/Source/deep.txt
check ". and .. walk the path" roundTripAt /Apps/./Source/../root.txt
check ".. from the root is the root" roundTripAt /Apps/../../root.txt
# Each of these is a way the tree could be made to contradict itself, and each is refused
# rather than half done.
refuses "no file where a directory goes" "$TOOL" put tree.img root.txt /root.txt/x.txt
refuses "no putting into thin air" "$TOOL" put tree.img root.txt /Nowhere/x.txt
refuses "no duplicate in one directory" "$TOOL" mkdir tree.img /Apps
refuses "no getting a directory" "$TOOL" get tree.img /Apps out.bin
refuses "delete will not take a directory" "$TOOL" delete tree.img /Apps
refuses "rmdir will not take a file" "$TOOL" rmdir tree.img /root.txt
refuses "nor the root" "$TOOL" rmdir tree.img /
# THE REFUSAL THAT MATTERS MOST. Parents are entry indices and a freed index is handed out
# again, so removing a directory with things still in it would let the next file created
# adopt them. Emptying it first is the only safe order.
refuses "no removing an occupied one" "$TOOL" rmdir tree.img /Apps/Source
check "empty it first" "$TOOL" delete tree.img /Apps/Source/deep.txt
check "then it goes" "$TOOL" rmdir tree.img /Apps/Source
# A path is names with separators between them, and a name is still twenty two characters.
refuses "refuse a 23 character component" "$TOOL" mkdir tree.img /Apps/abcdefghijklmnopqrstuvw
refuses "refuse a path naming nothing" "$TOOL" mkdir tree.img /Apps/
# A directory costs an entry and no blocks at all, which is what keeps the flat array of
# entries the whole allocation map. If a directory ever took a block, this would drop.
blocksFree() { "$TOOL" list "$1" 2>/dev/null | tail -1 | sed 's/.*used, //; s/ blocks free.*//'; }
before=$(blocksFree tree.img)
"$TOOL" mkdir tree.img /Empty >/dev/null 2>&1
check "a directory costs no blocks" [ "$before" = "$(blocksFree tree.img)" ]
# ---- A directory no bigger than the parent field can name ----
#
# Eight entries to a block and the parent is an index plus one in two bytes, so entry
# 65535 has no parent number: adding one wraps to zero, and zero is the root. Such an
# entry does not refuse what is put inside it. It writes the thing into the ROOT while
# reporting the path that was asked for, and then cannot find it again - so the same
# create succeeds over and over, piling up entries of one name in one directory, which is
# the exact corruption rename exists to refuse.
refuses "no directory past the wrap" "$TOOL" format huge.img 65535 8192
check "the largest that fits" "$TOOL" format huge.img 65535 8191
# And a disk claiming one, which is what something that never checked would have written.
# The claim is in the superblock, so it does not need a disk that size to be made.
"$TOOL" format lying.img 64 2 >/dev/null
printf '\x20\x00' | dd of=lying.img bs=1 seek=10 conv=notrunc status=none
refuses "nor reading one that claims it" "$TOOL" list lying.img
# ---- A boot area, and the two halves of the superblock that describe it ----
#
# bootBlocks and directoryStart say the same thing from two sides, so a disk where they
# disagree is one where there is no way to tell which is wrong. Both are refused.
check "format with a boot area" "$TOOL" format boot.img 512 4 32
check "and it reads back" "$TOOL" list boot.img
refuses "no boot area bigger than a disk" "$TOOL" format small.img 32 2 64
check "and none at all is still fine" "$TOOL" format plain.img 64 2
bootField() { python3 -c "
import sys
f = open(sys.argv[1], 'r+b'); f.seek(int(sys.argv[2])); f.write(bytes.fromhex(sys.argv[3]))
" "$@"; }
cp boot.img lying.boot.img
bootField lying.boot.img 14 0010 # Claims 16 blocks a slot, directory says 32.
refuses "nor a boot area that disagrees" "$TOOL" list lying.boot.img
cp boot.img badslot.img
bootField badslot.img 16 07 # Names slot 7, and there are two.
refuses "nor a slot that does not exist" "$TOOL" list badslot.img
# ---- Writing a boot slot, and choosing between them ----
#
# Two commands rather than one, deliberately: writing a slot and starting from it are
# different decisions, and joining them would make every write a commitment.
printf 'not really a bootloader' > stage.bin
check "write a boot slot" "$TOOL" boot boot.img stage.bin 0
check "and the other one" "$TOOL" boot boot.img stage.bin 1
check "choose which one starts" "$TOOL" bootslot boot.img 1
refuses "no third slot to write" "$TOOL" boot boot.img stage.bin 2
refuses "nor a third to choose" "$TOOL" bootslot boot.img 2
refuses "no boot slot without an area" "$TOOL" boot plain.img stage.bin 0
# A slot holds what it holds. Something too big for one is refused rather than cut off,
# because half a bootloader is the failure with no way back.
head -c 9000 /dev/zero > toobig.bin # A slot on boot.img is 32 blocks, so 8192.
refuses "nor more than a slot holds" "$TOOL" boot boot.img toobig.bin 0
# THE WHOLE SLOT IS WRITTEN, not just the part the file fills. A slot still holding the
# tail of whatever was there before is one whose contents depend on its history.
printf 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' > long.bin
"$TOOL" boot boot.img long.bin 0 >/dev/null
"$TOOL" boot boot.img stage.bin 0 >/dev/null
check "and it is written whole" python3 -c "
import sys
d = open('boot.img','rb').read()
slot = d[256:256 + 32 * 256]
sys.exit(1 if b'aaaa' in slot else 0)"
# ---- How the last start went ----
check "the boot state reads" "$TOOL" bootstate boot.img
check "and can be set" "$TOOL" bootstate boot.img 2
refuses "but only to a state there is" "$TOOL" bootstate boot.img 7
check "a fresh disk is settled" python3 -c "
import sys
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
if [ "$FAIL" -eq 0 ]; then
echo "All $PASS disk tool checks passed."
exit 0
fi
echo "$PASS passed, $FAIL failed: ${FAILED_NAMES[*]}"
exit 1
+62 -2
View File
@@ -456,6 +456,50 @@ for name, text in (("the Programming Manual", pm), ("the Assembler Manual", am),
problems.append("%s still says \"%s\", and it has not been true since"
" directories arrived" % (name, claim))
# ---- The shell's words are a table as well as a chain of comparisons ----
#
# The dispatch is a run of "is the line this name" tests, which is fine to execute and
# impossible to WALK - so completing a half typed command needs the names as data too, and
# they are: fifteen strings packed end to end from ShellNames, each ending in the zero that
# says where the next begins.
#
# THE TWO CAN DISAGREE AND THE WAY THEY DO IS QUIET. A command added to the dispatch and not
# to the run simply never completes, which nobody would think to test by hand; something put
# BETWEEN the strings ends the walk early and takes the rest of the commands with it. So this
# reads both and compares them, and reads the count as well, because a run of strings does
# not say where it stops.
cosmosSource = open("Programs/CosmOS/Source/cosmos.asm").read()
# The dispatch, up to the point where the monitor's single letters begin - those are one
# character each and there is nothing to complete about them.
dispatchEnd = cosmosSource.find("SETD.0 Mode")
dispatched = re.findall(r"SETD\.1 (\w+Name)\b", cosmosSource[:dispatchEnd])
# The run, which ends at the first thing that is not a label and a string.
runAt = cosmosSource.find("ShellNames:")
packed = []
if runAt < 0:
problems.append("cosmos.asm has no ShellNames run for the shell's own words")
else:
lines = cosmosSource[runAt:].split("\n")[1:]
while len(lines) >= 2 and re.fullmatch(r"(\w+Name):", lines[0]) \
and re.fullmatch(r'"[^"]*"', lines[1]):
packed.append(lines[0][:-1])
lines = lines[2:]
stated = re.search(r"ShellNameCount:\s*\n\s*0d(\d+)", cosmosSource)
if not stated:
problems.append("cosmos.asm no longer says how many shell names there are")
elif int(stated.group(1)) != len(packed):
problems.append("cosmos.asm says there are %s shell names and the run holds %d"
% (stated.group(1), len(packed)))
if runAt >= 0 and sorted(packed) != sorted(dispatched):
missing = sorted(set(dispatched) - set(packed))
extra = sorted(set(packed) - set(dispatched))
problems.append("the shell's dispatch and its packed names disagree:%s%s"
% ("".join(" %s is dispatched and not in the run;" % n for n in missing),
"".join(" %s is in the run and not dispatched;" % n for n in extra)))
# ---- CosmOS fits in the half of the machine it says it does ----
#
# The memory map in the CosmOS README is a CONVENTION. Nothing in the assembler, the
@@ -599,7 +643,11 @@ else:
# file.
#
# The bullets now live in the Test Manual rather than the README, so that is what is read.
# makedisks.sh is not counted, because it builds the images rather than checking anything;
# makedisks.sh is not counted, because it builds the images rather than checking anything,
# and break.sh is not counted for the same reason turned round: it checks that a check works,
# is run by hand at the moment a check is written, and is not part of what "make test" means.
# Both are still described in the manual - what they are excluded from is the COUNT of the
# suite, not from being documented, and the check below enforces that.
# run.sh is counted, because the manual describes it alongside the rest.
rootReadme = open("README.md").read()
manual = open("SplitBit Test Manual.md").read()
@@ -607,8 +655,9 @@ manual = open("SplitBit Test Manual.md").read()
# lines. Every pattern below runs against a copy with its whitespace flattened.
flat = re.sub(r"\s+", " ", manual)
notSuite = ("makedisks.sh", "break.sh")
scripts = sorted(os.path.basename(p) for p in glob.glob("Tests/*.sh")
if os.path.basename(p) != "makedisks.sh")
if os.path.basename(p) not in notSuite)
# Spelled out, because that is how the documents say them. Kept a few ahead of the count so
# 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.
@@ -625,6 +674,17 @@ for name in scripts:
problems.append("Tests/%s runs in the suite and the Test Manual does not say what"
" it is for" % name)
# ---- And the two that are not in the suite are still described ----
#
# Being left out of the COUNT is not the same as being left out of the manual, and the gap
# between those two is exactly where a script goes undocumented for months. A tool nobody has
# written down is a tool nobody uses, which for break.sh would be a particular waste: it
# exists because the technique it automates was got wrong by hand twice.
for name in notSuite:
if ("`Tests/%s`" % name) not in manual:
problems.append("Tests/%s is a tool the suite does not count, and the Test Manual"
" does not say what 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
+54
View File
@@ -0,0 +1,54 @@
CosmOS
> set a hello
> if same $a hello
> echo taken
taken
> else
> echo not taken
> end
> if load nosuch.sbx
no such file
> echo loaded
> else
> echo did not
did not
> end
> else
else with no if above it
> end
end with no if above it
> if
if wants a command to decide by
> do blocks.script
> set colour red
> if same $colour red
> echo it is red
it is red
> if same $colour blue
> echo and blue
> else
> echo but not blue
but not blue
> end
> else
> echo $neverSetAnywhere
> end
> if same $colour blue
> echo wrong
> else
> echo right
right
> end
> if same $colour blue
> if same $colour red
> echo deep wrong
> else
> echo deep also wrong
> end
> end
> echo done
done
> exit
halted
Execution halted.
[exit 0]
+5 -5
View File
@@ -1,15 +1,15 @@
CosmOS
> load Break.sbx
loaded, starting at 4000
loaded, starting at 5000
> run
two stops, and what the registers were at each
break at 4016
break at 5016
A 11 B 22 Q 00 status 00
DP0 2030 DP1 2000 DP2 2037 DP3 4000 SP FFFF
DP0 3030 DP1 3000 DP2 3037 DP3 5000 SP FFFF
press a key
break at 4034
break at 5034
A 44 B 55 Q 00 status 00
DP0 2000 DP1 2037 DP2 2030 DP3 4000 SP FFF5
DP0 3000 DP1 3037 DP2 3030 DP3 5000 SP FFF5
press a key
carried on to the end
finished
+13 -2
View File
@@ -19,8 +19,19 @@ Snake.sbx 2164
Keys.sbx 664
Say.sbx 156
Break.sbx 149
Grid.sbx 559
Grid.sbx 571
Press.sbx 872
Mode.sbx 48
Flip.sbx 173
Sprite.sbx 442
Depth.sbx 672
Lander.sbx 9220
Pad.sbx 264
Crash.sbx 632
vars.script 50
blocks.script 343
loops.script 272
tune.sbx 306
notes.txt 21
Apps <dir>
hi.script 121
@@ -32,7 +43,7 @@ outer.script 376
inner.script 44
loop.script 35
crossed.txt 560
19 files, 1 directory
30 files, 1 directory
> exit
halted
Execution halted.
+13 -2
View File
@@ -9,8 +9,19 @@ Snake.sbx 2164
Keys.sbx 664
Say.sbx 156
Break.sbx 149
Grid.sbx 559
Grid.sbx 571
Press.sbx 872
Mode.sbx 48
Flip.sbx 173
Sprite.sbx 442
Depth.sbx 672
Lander.sbx 9220
Pad.sbx 264
Crash.sbx 632
vars.script 50
blocks.script 343
loops.script 272
tune.sbx 306
notes.txt 21
Apps <dir>
hi.script 121
@@ -21,7 +32,7 @@ nonl.script 38
outer.script 376
inner.script 44
loop.script 35
18 files, 1 directory
29 files, 1 directory
> drive 1
> dir
other.txt 28
+26 -8
View File
@@ -1,24 +1,42 @@
CosmOS
> load Edit.sbx
loaded, starting at 4000
loaded, starting at 5000
> run poem.txt
poem.txt, new file
> : : : : > : : > 1: alpha
> a
: alpha
: beta
: gamma
: .
> i 2
: INSERTED
: .
> l
1: alpha
2: INSERTED
3: beta
4: gamma
> : > > 1: CHANGED
> c 1
: CHANGED
> d 4
> l
1: CHANGED
2: INSERTED
3: beta
> written, 22 bytes
> finished
> w
written, 22 bytes
> q
finished
> run poem.txt
poem.txt, 3 lines
> 1: CHANGED
> l
1: CHANGED
2: INSERTED
3: beta
> there is no such line
> finished
> d 99
there is no such line
> q
finished
> exit
halted
Execution halted.
+4 -2
View File
@@ -1,10 +1,12 @@
CosmOS
> Edit hello.asm
hello.asm, 31 lines
> finished
> q
finished
> Edit hello.asm
hello.asm, 31 lines
> finished
> q
finished
> Status
the last program left 0, which is: it did what it was asked
finished
+26
View File
@@ -0,0 +1,26 @@
CosmOS
> echo remembered by the shell
remembered by the shell
> load Edit.sbx
loaded, starting at 5000
> run typed.txt
typed.txt, new file
> a
: alpahalpa alpha
: second
: .
> l
1: alpha
2: second
> w
written, 13 bytes
> q
finished
> run typed.txt
typed.txt, 2 lines
> q
finished
> exit
halted
Execution halted.
[exit 0]
+56
View File
@@ -0,0 +1,56 @@
CosmOS
> Crash opcode
that byte is not an instruction, at 5081
A 00 B 0F Q 00
the program was stopped
> Crash service
nothing is installed at service 28, at 5084
A 00 B 0F Q 00
the program was stopped
> Crash bank
a bank that is not there, or an address past its end, at 509E
A 01 B 0F Q 00
the program was stopped
> Crash device
nothing is installed for the device on port 00, at 50A5
A 02 B 0F Q 00
the program was stopped
> Crash sideways
Crash opcode | service | bank | device | blind
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 571
Press.sbx 872
Mode.sbx 48
Flip.sbx 173
Sprite.sbx 442
Depth.sbx 672
Lander.sbx 9220
Pad.sbx 264
Crash.sbx 632
vars.script 50
blocks.script 343
loops.script 272
tune.sbx 306
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
29 files, 1 directory
> exit
halted
Execution halted.
[exit 0]
+12
View File
@@ -0,0 +1,12 @@
CosmOS
> monitor
x examine, d disassemble, a assemble, s set, b bank, g go, exit leaves
* b program
bank 00
* s 4F00 00
* g 4F00
that byte is not an instruction, at 4F00
A 01 B 7F Q 00
that was the system itself, so there is nowhere to carry on from. Start the machine again.
Execution halted.
[exit 0]
+46
View File
@@ -0,0 +1,46 @@
CosmOS
> Say before Flip
it says: before Flip
finished
> Flip
A screen is drawn where you cannot see it. A key shows it, another comes back.
finished
> Say after Flip
it says: after Flip
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 571
Press.sbx 872
Mode.sbx 48
Flip.sbx 173
Sprite.sbx 442
Depth.sbx 672
Lander.sbx 9220
Pad.sbx 264
Crash.sbx 632
vars.script 50
blocks.script 343
loops.script 272
tune.sbx 306
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
29 files, 1 directory
> exit
halted
Execution halted.
[exit 0]
+13 -2
View File
@@ -16,8 +16,19 @@ Snake.sbx 2164
Keys.sbx 664
Say.sbx 156
Break.sbx 149
Grid.sbx 559
Grid.sbx 571
Press.sbx 872
Mode.sbx 48
Flip.sbx 173
Sprite.sbx 442
Depth.sbx 672
Lander.sbx 9220
Pad.sbx 264
Crash.sbx 632
vars.script 50
blocks.script 343
loops.script 272
tune.sbx 306
notes.txt 21
Apps <dir>
hi.script 121
@@ -28,7 +39,7 @@ nonl.script 38
outer.script 376
inner.script 44
loop.script 35
18 files, 1 directory
29 files, 1 directory
> exit
halted
Execution halted.

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