A beat a program sets for itself

The only regular thing on this machine was the screen finishing a frame,
sixty times a second and not negotiable - a clock a program BORROWS rather
than one it sets. Every duration became a multiple of 16,667 cycles, so a
sixteenth note at 120 beats a minute, which is 125,000, is seven and a half
frames and cannot be asked for at all. The way round it was to choose a
tempo whose subdivisions happen to land on whole frames, which is making
the music fit the machine. Examples/tune.asm says so in its own header.

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
This commit is contained in:
Anachronaut
2026-08-31 17:06:28 -04:00
co-authored by Claude Opus 5
parent 3e48e9690d
commit 6b51d6391f
11 changed files with 393 additions and 2 deletions
+120
View File
@@ -948,11 +948,121 @@ void deviceTick(unsigned long now) {
// And the sound, which makes whatever samples are due by now. On the machine's clock,
// so the same program makes the same sound in the same cycles.
soundTick(now);
// And the timer, which is the only beat a program can choose for itself.
timerTick(now);
if (diskPending && now >= diskReadyAt) {
diskSettle();
}
}
// ---- A beat a program sets for itself ----
//
// COUNTED IN CYCLES, which is this machine's unit of time everywhere else: it is what the
// cost model counts and what a frame is measured in. A timer counting anything else would be
// a second thing to remember, and a prescaler would buy range that twenty four bits already
// covers - one cycle at one end and sixteen point seven seconds at the other, with 120 beats
// a minute sitting at 500,000 in the middle of it.
static uint32_t timerPeriod = 0;
static uint32_t timerLeft = 0;
static uint8_t timerControl = 0;
static uint8_t timerTicked = 0;
static unsigned long timerLast = 0;
void timerReset(void) {
timerPeriod = 0;
timerLeft = 0;
timerControl = 0;
timerTicked = 0;
timerLast = 0;
clearInterrupt(PORT_TIMER);
}
// ---- Caught up rather than counted ----
//
// The same shape as the screen's frame: the machine runs in batches, so more than one period
// can pass between two looks. What is owed is worked out from how far the clock moved rather
// than by being told once per cycle, and several periods at once still mean one tick - a
// missed one is missed, which is what missing one is.
void timerTick(unsigned long now) {
const unsigned long moved = now - timerLast;
timerLast = now;
if (!(timerControl & TIMER_CONTROL_RUN) || timerPeriod == 0) {
return;
}
if ((unsigned long)timerLeft > moved) {
timerLeft -= (uint32_t)moved;
return;
}
timerTicked = 1;
if (timerControl & TIMER_CONTROL_INTERRUPT) {
raiseInterrupt(PORT_TIMER);
}
if (timerControl & TIMER_CONTROL_REPEAT) {
// What is left over carries into the next period, so a timer asked for 1,000 cycles
// gets a tick every 1,000 and not every 1,000 plus however late anybody looked.
const unsigned long over = moved - timerLeft;
timerLeft = timerPeriod - (uint32_t)(over % timerPeriod);
} else {
timerControl = (uint8_t)(timerControl & ~TIMER_CONTROL_RUN);
timerLeft = 0;
}
}
static uint8_t timerWrite(uint8_t value, uint8_t port) {
switch (port) {
case TIMER_CONTROL: {
const uint8_t wasRunning = timerControl & TIMER_CONTROL_RUN;
timerControl = value;
if ((value & TIMER_CONTROL_RUN) && !wasRunning) {
// Starting loads the period. Asking it to run while it already is does not,
// so a program that sets the interrupt bit half way through a period does not
// silently move the beat it was keeping.
timerLeft = timerPeriod;
}
if (!(value & TIMER_CONTROL_INTERRUPT)) {
// Asking to stop being interrupted takes down whatever was already asked for,
// the same as the screen and for the same reason.
clearInterrupt(PORT_TIMER);
}
}
break;
case TIMER_PERIOD_HIGH:
timerPeriod = (timerPeriod & 0x0000FFFFu) | ((uint32_t)value << 16);
break;
case TIMER_PERIOD_MID:
timerPeriod = (timerPeriod & 0x00FF00FFu) | ((uint32_t)value << 8);
break;
case TIMER_PERIOD_LOW:
timerPeriod = (timerPeriod & 0x00FFFF00u) | value;
break;
default: break;
}
return 0;
}
static uint8_t timerRead(uint8_t port) {
switch (port) {
case TIMER_STATUS: {
uint8_t status = 0;
if (timerTicked) status |= TIMER_STATUS_TICKED;
if (timerControl & TIMER_CONTROL_RUN) status |= TIMER_STATUS_RUNNING;
if (timerControl & TIMER_CONTROL_INTERRUPT) status |= TIMER_STATUS_INTERRUPT;
// Looking is what answers it, the same as every other status port here: a beat
// that has been noticed is not still waiting to be, and the line goes down with
// the flag because a program that polls is not one that will answer a handler.
timerTicked = 0;
clearInterrupt(PORT_TIMER);
return status;
}
case TIMER_CONTROL: return timerControl;
case TIMER_PERIOD_HIGH: return (uint8_t)(timerPeriod >> 16);
case TIMER_PERIOD_MID: return (uint8_t)(timerPeriod >> 8);
case TIMER_PERIOD_LOW: return (uint8_t)timerPeriod;
default: return 0;
}
}
// ---- A device that brings memory ----
//
// The simplest thing that owns a bank. Writing to its port fills its memory with the
@@ -1011,6 +1121,7 @@ static const DeviceRecord deviceTable[] = {
{ PORT_DISK, DEVICE_DISK, DEVICE_FLAG_HAS_MEMORY },
{ PORT_VIDEO, DEVICE_VIDEO, DEVICE_FLAG_HAS_MEMORY },
{ PORT_SOUND, DEVICE_SOUND, 0 },
{ PORT_TIMER, DEVICE_TIMER, 0 },
{ PORT_REGISTRY, DEVICE_REGISTRY, 0 },
};
static const int deviceCount = (int)(sizeof(deviceTable) / sizeof(deviceTable[0]));
@@ -1046,6 +1157,9 @@ static const DeviceRecord *deviceOnPort(uint8_t port) {
if (port > PORT_SOUND && port <= PORT_SOUND_TOP) {
return deviceOnPort(PORT_SOUND);
}
if (port > PORT_TIMER && port <= PORT_TIMER_TOP) {
return deviceOnPort(PORT_TIMER);
}
for (int i = 0; i < deviceCount; i++) {
if (deviceTable[i].port == port) {
return &deviceTable[i];
@@ -1082,6 +1196,9 @@ uint8_t OutputHandler(uint8_t DataByte, uint8_t Address) {
if (Address >= PORT_SOUND && Address <= PORT_SOUND_TOP) {
return soundWrite(DataByte, Address);
}
if (Address >= PORT_TIMER && Address <= PORT_TIMER_TOP) {
return timerWrite(DataByte, Address);
}
// This function sends the DataByte to the appropriate place based on the Port Address.
switch(Address) {
case CONSOLE_DATA:
@@ -1216,6 +1333,9 @@ uint8_t InputHandler(uint8_t Address) {
if (Address >= PORT_SOUND && Address <= PORT_SOUND_TOP) {
return soundRead(Address);
}
if (Address >= PORT_TIMER && Address <= PORT_TIMER_TOP) {
return timerRead(Address);
}
switch(Address) {
case CONSOLE_DATA:
// If data is sent here, it should be read from STDIN.