Technical Reference · Revision 0.5
SEQ/OS
A bare-metal step sequencer for the IBM 5160. It owns the machine from the boot sector up: no DOS, no operating system underneath, no floating point. Assembly holds the hardware; a resident C kernel holds the policy. MIDI clock leaves the serial port every 20.8 ms.
- Target
- IBM 5160 (PC/XT)
- CPU
- Intel 8088 @ 4.772727 MHz
- Memory
- 256 KB
- Display
- IBM MDA → 5151
- Timing floor
- 838.0965 ns
- Boot image
zout/seq86.img- C kernel
KERNEL_C_ADDRtokernel_c_end- Network
- NE2000 @ 0x300, polled
- Song store
- 512 B pages, IDs not pointers
- Patterns
- 960 PPQN · 64 steps · 256-slot Gbar
- Build bounds
src/link/kernel.ld+zout/kernel.map
Chapter 0Using this manual
Some of what follows is established fact. Some is a guess about a computer that has not been opened yet. This manual marks which is which.
Every claim you might act on carries one of three marks. They are not severity levels — they say where the claim came from.
The distinction matters most in Chapter 2. Every timing number in this
project comes from tools/xt8088.py executing the real boot image on
a cycle-counting model of the 8088. That is good evidence, but it is not a
measurement — the 5160 has never been powered with this code, because
it has no keyboard.
Colour carries one more rule:
Anything on this black ground is what the 5151 actually displays, or output captured from the running image. It is never decoration. Everything else in this manual is ink on paper.
Chapter 1The hardware platform
An ex-laboratory IBM 5160, last calibrated in 1985, with a QA sticker dated 1987. Four serial ports, eight populated slots, one card that has not been identified.
1.1The machine
The 5160 is the IBM PC/XT: an 8088 at 4.77 MHz on an 8-bit ISA bus, eight expansion slots, no built-in video. What makes this unit useful is the set of cards a previous owner installed.
| Item | Value | Status | Evidence |
|---|---|---|---|
| Model | IBM 5160, Made in USA | Confirmed | Rear ID label |
| POST | 256 KB OK, then keyboard error 301 | Confirmed | Power-on photo, green text |
| RAM | 256 KB, four socketed banks | Confirmed | POST count + board marking |
| CPU | 8088 @ 4.772727 MHz | Inferred | Stock 5160; chip not read |
| 8087 FPU | Socket empty | Inferred | Left empty by design |
| Keyboard port | DIN present, intact | Confirmed | Rear photo |
| Keyboard | Missing. XT protocol required | Confirmed | Supplied board was PS/2 |
| Monitor | Green phosphor, IBM Personal Computer Display | Confirmed | Front badge, green POST |
| Monitor model | 5151 specifically | Inferred | PC label reads “Auxiliary Power to 5151” |
| Phosphor | P39 long-persistence | Inferred | 5151 spec; confirm by scroll test |
The blocking dependency
The 301 error is not a fault — it is the POST correctly
reporting that no keyboard answered. The XT uses a different, earlier
protocol than PS/2, so a modern board will not do. Until a Model F XT
keyboard or an AT‑to‑XT adapter arrives, no code in this
repository has run on the real machine, and every performance figure
here is Modelled.
1.2The clock chain
Every timing property of this project comes from one crystal. The 5160 has a single oscillator at 14.31818 MHz, chosen because it is four times the NTSC colour‑burst frequency. That mattered for CGA. Here it just means the divisions are awkward numbers.
The timer chip has three channels. On an XT all three are already in use, so SEQ/OS has to decide which ones to take.
| Ch | Wired to | Stock use | SEQ/OS use |
|---|---|---|---|
| 0 | IRQ0 | BIOS 18.2 Hz tick | Taken. One-shot event scheduler |
| 1 | DMA0 | DRAM refresh | Never touched. Stop it and RAM forgets |
| 2 | Speaker | Tone generation | Taken. Free-running 838 ns reference |
Channel 1 drives DRAM refresh. Reprogramming it corrupts memory, so it is
left alone. Channel 2 is gated by a bit in port 0x61: set the gate
bit and leave the speaker‑data bit clear, and it free-runs silently. That
gives a continuously counting reference clock with no audible output, which is
what lets SEQ/OS measure its own jitter without external hardware.
1.3The card cage
All eight slots are populated and all eight are visible from the rear. Read left to right:
The three identical serial cards in slots 5, 6 and 7 are the reason this machine is a good fit. They are probably left over from a lab rig that needed four RS-232 ports. A three-voice MIDI sequencer needs the same thing.
1.4The address space
The 8088 has 20 address lines, so it can see 1 MB. It reaches that with
segment × 16 + offset addressing, which means a
physical address like 0xB0000 is written B000:0000.
This machine has 256 KB of that megabyte filled with actual RAM.
B000:0000 and 0x7C00; the 256 KB RAM ceiling at
0x3FFFF is Confirmed by the POST
count.Why the video buffer matters so much
There is no graphics API, no frame buffer to flip, no driver. The screen
is 4000 bytes of ordinary memory at B000:0000. Storing
the 16-bit value 0x0FDB at offset 0 puts a bright filled block in
the top-left corner, immediately. Character in the low byte, attribute in the
high byte, one MOV per cell. This is why drawing is cheap enough
to do inside a real-time music loop — and why an accidental write to the
wrong segment is visible instantly.
1.5The I/O space
Separate from memory, the 8088 has a 64 KB I/O space reached only by the
IN and OUT instructions. Every peripheral SEQ/OS
touches lives here. This table is the complete list of ports the code currently
uses — all Confirmed by reading
src/asm/seq86.asm.
| Port | Chip | Purpose in SEQ/OS |
|---|---|---|
| 0x20 | 8259 PIC | Command — end-of-interrupt written at the close of every IRQ0 |
| 0x21 | 8259 PIC | Mask — set to 0xFC, leaving only IRQ0 and IRQ1 live |
| 0x40 | 8253 PIT | Channel 0 count — the one-shot scheduler reload |
| 0x42 | 8253 PIT | Channel 2 count — free-running reference |
| 0x43 | 8253 PIT | Mode/command register, and the channel-2 latch |
| 0x60 | Keyboard | Scan-code data, read by the IRQ1 handler |
| 0x61 | Port B | Bit 0 gates PIT channel 2 on (bit 1 left clear so it stays silent); bit 7 pulsed high then low to acknowledge each scan code |
| 0x2F8 | 8250 UART | COM2 — the debug monitor link and crash dump, 9600 8N1 |
| 0x3BC | MDA parallel | 8 latched TTL outputs — planned gate/trigger bank |
| 0x3F8 | 8250 UART | COM1 — MIDI out at 31250 baud |
1.6Getting MIDI out of a 1981 serial port
MIDI is just serial data at 31250 baud. An 8250 UART divides its input clock by 16, then by a programmable divisor. The stock IBM serial card runs on a 1.8432 MHz crystal, and that number does not cooperate:
1843200 / 16 = 115200 baud, the maximum
115200 / 31250 = 3.6864 ← not an integer. no divisor works.
The divisor register holds whole numbers only, so 31250 baud is simply not reachable. You cannot fix this in software. But swap the crystal for a plain 2.000 MHz part and the arithmetic falls out exactly:
2000000 / 16 = 125000 baud, the new maximum
125000 / 31250 = 4 ← exact. divisor latch = 4.
That constant appears in the source as MIDI_DIV equ 4. The debug
port keeps its original crystal and uses DBG_DIV equ 12, giving
115200 ÷ 12 = 9600 baud. Two ports, two crystals, two
divisors, one UART driver.
Why three separate outs, not one merged stream
A MIDI byte takes 320 µs on the wire, so a three-byte Note On occupies the port for nearly a millisecond. Merging three synths onto one cable serialises them: voice three waits for voices one and two. Three physical ports means three notes leave at the same time. The 8088 is not the limit here — at 4.77 MHz it executes on the order of 1500 instructions in the time one MIDI byte clocks out. The limit is the wire, so add wires.
1.7The display is a design constraint
The 5151 uses P39 phosphor, chosen in 1981 to reduce flicker at 50 Hz. It has long persistence: a lit pixel keeps glowing for a noticeable time after the beam has moved on. On a text terminal that is a minor annoyance. For a sequencer display it is useful.
The MDA is 1-bit — a cell is on or off, with a per-cell intensity bit as the only shading — so it cannot compute a fade. It does not need to. The tube performs a continuous analog decay in hardware. SEQ/OS draws a stepped trail on top of it: a dirty-list of moving cells plus a glyph-density ramp, smoothed by the phosphor.
SEQ/OS 0.3 - C KERNEL - IBM PC/XT + MDA BPM: 120 -------------------------------------------------------------------------------- ████ 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 ████ .... .... .... ████ .... .... .... ████ .... .... .... ████ .... .... .... ^^^^ RUNNING SPACE run/stop <- -> cursor ENTER toggle UP/DN tempo F10 monitor
Confirmed — this is not a sketch. It was
rendered by replaying the layout arithmetic in draw_ui,
draw_pattern, draw_cursor, draw_playhead
and draw_tempo from src/kernel.c, so the column
positions are the ones the machine will actually produce. Intense cells carry
attribute 0x0F, normal cells 0x07.
Three details fall out of the layout that are easy to get wrong by eye. Step
cells are five columns apart but only four wide, leaving a blank gutter column
between them. Step numbers are zero-padded (01, not 1)
because the code emits two digits unconditionally. And the playhead row is
blank whenever the transport is stopped — draw_playhead clears the
row and only redraws the block if playing is set.
Chapter 2The boot sequence
There is no operating system on this machine. The BIOS loads 512 bytes and jumps to them. Everything after that is code in this repository.
2.1Power to first instruction
The path from the power switch to SEQ/OS goes through the BIOS and then leaves it behind almost immediately:
INT 10h that sets the
video mode, no BIOS service is used again. Confirmed
— each box corresponds to a labelled block in
src/asm/seq86.asm.2.2Stage 1: the 512-byte problem
The BIOS reads exactly one sector — 512 bytes — checks that its last two
bytes are 0xAA55, and jumps to it. That is the entire contract.
512 bytes is not enough for a sequencer, so the boot sector's only real job is
to go and fetch the rest of itself.
stage1:
cli ; no interrupts while the segments are inconsistent
cld ; string ops count upward — assume nothing about BIOS state
xor ax,ax
mov ds,ax
mov es,ax
mov ss,ax ; every segment to 0: flat 64 KB view of low memory
mov sp,0x7C00 ; stack grows DOWN from the boot code, away from it
sti
mov [boot_drive],dl ; DL is the BIOS's one gift. we will need it.
Three details in those nine instructions matter, because each one encodes an assumption that would be a bug if it were wrong.
Zeroing every segment register
The BIOS does not promise the state of DS, ES or
SS. Setting all of them to 0 means that from here on, a 16-bit
offset is a physical address in the first 64 KB — no segment
arithmetic anywhere in the program. The far jump at the end of stage 1
(jmp 0x0000:stage2) exists for the same reason: it forces
CS to 0 as well, so even the code segment is normalised.
Putting the stack immediately below the code
SP = 0x7C00 places the stack top exactly at the first byte of
the boot sector. Since the stack grows downward, pushes move away from
the code into free memory below. Had the stack been placed above, it would have
grown down straight through the program that is executing.
Saving DL
The BIOS passes the drive it booted from in DL. Nothing else is
guaranteed. Stage 1 needs it for every INT 13h call, and the value
is stored before any code can clobber the register.
2.3Walking the disk geometry
Floppy sectors are not addressed linearly. They are addressed by cylinder, head, sector — where the arm is, which side of the disk, and which sector around the track. On 360 KB media that is 9 sectors per track, 2 heads, 40 cylinders. Sector numbers start at 1, not 0.
Stage 1 reads one sector per INT 13h call. The
BIOS can read several at once, but a multi-sector read that runs off the end of
a track fails on some controllers. Reading singly is slower and always correct
— a good trade when the code runs once at boot.
inc cl ; next sector
cmp cl,10 ; 360 KB media: sectors are 1..9
jb .next
mov cl,1 ; wrapped past 9 — back to sector 1
xor dh,1 ; flip head 0 ↔ 1
jnz .next ; became 1? still this cylinder, other side
inc ch ; became 0? both sides done — step the arm
That is a three-level odometer — sector, then head, then cylinder — in six instructions. It produces the walk below.
STAGE2_SECTORS), starting at cylinder 0, head 0, sector 2 —
sector 1 being the boot sector itself. With the C kernel folded in, the read
spans six track segments and crosses through a third cylinder.
The figure shows the first 30 reads; the same CHS walk continues for 18
more. The raw image size is emitted by each build rather than copied here.Why the image is padded to 368,640 bytes
Stage 1 asks for 48 sectors even when the image is smaller. Those extra reads
still have to land on real, formatted sectors or INT 13h
returns an error and the machine halts. So the Makefile pads the raw output to
a full 360 KB floppy image:
FLOPPY_BYTES = 368640 ; 360 KB DD 5.25" — the 5160's native format
The 48-sector request is oversized on purpose, so stage 2 can grow without anyone having to remember to change a constant. It costs a slower boot and removes one way to break the build silently. The headroom is not theoretical: this number was 24 before the C kernel was folded into the image, and stage 2 has since grown from 5 sectors to 22.
2.4Stage 2: taking the machine
Stage 2 re-establishes the same flat segment state (it must not assume stage
1's registers survived the jump) and sets the video mode. That
INT 10h is the last BIOS service the system ever calls.
Everything after it takes hardware away from the firmware:
call install_unexpected_vectors ; 14 stubs: divide error, NMI,
; breakpoint, overflow, IRQ2-7...
; Own both timing and keyboard interrupts. The foreground C kernel
; consumes the bounded scan-code queue filled by IRQ1.
mov word [0x08*4],irq0 ; IVT entry 8 ← the scheduler
mov word [0x08*4+2],0
mov word [0x09*4],irq1 ; IVT entry 9 ← raw keyboard
mov word [0x09*4+2],0
mov al,0xFC ; 1111_1100 — only IRQ0 and IRQ1 live
out PIC_MASK,al
Writing four bytes to address 0x0020 redirects every timer
interrupt from the BIOS's 18.2 Hz tick routine to irq0 in this
program. The BIOS clock stops advancing, which is fine — nothing else is
running that reads it. Four bytes at 0x0024 do the same for the
keyboard.
The PIC mask is the other half. 0xFC is 11111100
in binary — a 1 masks an interrupt off, a 0 lets it through — so only IRQ0 and
IRQ1 survive. Everything else the machine could interrupt about is silenced.
Reading the keyboard without the BIOS
Earlier revisions kept the BIOS keyboard handler alive and read keys through
INT 16h. That is gone. The system now services IRQ1 itself, and
the split between assembly and C follows the same rule as everything else:
assembly does the timed hardware transaction, C does the thinking.
in al,0x60 ; take the scan code
mov dl,al
in al,PORT_B ; acknowledge: pulse bit 7 high, then low
mov ah,al
or al,0x80
out PORT_B,al
mov al,ah
out PORT_B,al
; ...then push one byte into a 16-entry ring and EOI. That is all.
The handler makes no decisions. It captures one byte, acknowledges the
hardware, and drops it in a 16-entry ring buffer. Every question that requires
judgement — is this a make or a break code, is 0xE0 a prefix or a
key, is Shift held, what does this scan code mean — is answered in
src/platform/keyboard.c, from the main loop, where taking a few microseconds
costs nothing.
That decoder is a real state machine, not a lookup: it tracks the
0xE0/0xE1 prefixes, the six-byte Pause sequence, and
nine modifier flags, and emits a keyboard_event carrying key,
modifiers, ASCII, and press/release. The sequencer only cares about seven keys
today, but the decoder is complete, so the eventual pattern editor will not have
to revisit it.
Why the ring buffer is the interesting part
The queue is what makes the split safe. IRQ1 can fire while the main loop is mid-redraw, and without a buffer that scan code would be lost or would have to be handled at interrupt time. Sixteen entries is far more than the ~30 ms worst-case main-loop pass needs, and the producer drops bytes rather than overwrite when full — a dropped keystroke is a much smaller failure than a corrupted queue.
2.5Memory after boot
KERNEL_C_ADDR: NASM output below, GCC
output above. The exact address and linked end are intentionally read from
the assembly constant and zout/kernel.map, not duplicated in
this manual.2.6The running data flow
Once STI executes, two things run at once: an interrupt handler
with a hard deadline, and a main loop with none. Keeping them separate is the
core of the design.
This split is the answer to a specific hazard. If drawing happened inside the interrupt handler, every screen update would delay the next MIDI byte. If MIDI happened in the main loop, a slow redraw would delay the beat. Instead the handler does the minimum that must happen on time, sets a flag, and leaves.
The flag is a counter rather than a boolean deliberately: if the main loop is slow enough to miss a step, the count records it, and the sequencer catches up rather than dropping the step silently.
What the model measured today
Running the real boot image on the cycle-counting 8088 simulator
(make jitter-scope, 2026‑07‑22):
- Event period 24857.9586 counts actual against 24857.9514 ideal — a +0.29 ppm drift, under a millisecond per hour.
- Jitter spread 47.65 counts (39.93 µs) at the UART write, 48 counts (40.23 µs) as the guest measures itself at handler entry — two independent measurements agreeing to a third of a count.
- Zero scheduler overruns across 396 fitted events;
stack pointer stable at
0x7C00on every pass.
For scale, one MIDI byte occupies the wire for
320 µs — eight times the worst jitter above. The 8088 is not the
bottleneck. Modelled, not measured on hardware:
the simulator now does charge the 8088's 4-byte prefetch queue, which costs
about 35% of instruction throughput. What remains approximate is bus-cycle
interleaving inside one instruction — treat figures as roughly
±15% (docs/timing.md).
2.7When it crashes
On a machine with no operating system, a wild jump does not produce an error message. It produces a hang, or a screen of garbage, or nothing at all — and on a floppy-based edit cycle, “nothing at all” is close to undebuggable. So the system claims the interrupt vectors it does not expect, and turns a crash into a report.
install_unexpected_vectors points fourteen vectors — divide
error, NMI, breakpoint, overflow, and IRQ2 through IRQ7 — at stubs that each
record their own vector number and fall into one common recorder. That recorder
captures the interrupted machine state off the stack and does three things with
it:
tests/test_monitor.py
includes test_unexpected_vector_crash_record, which triggers a
vector and checks the record.Three delivery routes for one record looks redundant until you consider the
failure modes. If the crash happened inside the drawing code, the screen is
untrustworthy but COM2 still works. If the serial cable is unplugged — the
normal state for a machine on a bench — the screen is all you have. And if the
crash killed both, the record still sits in low memory, where RAM contents
survive a warm restart long enough to be read back with peek.
Note the ordering: render, transmit, then halt. The HLT
is deliberate and final — no attempt to continue, because continuing after an
unexpected interrupt on a machine with no memory protection means corrupting
state that the crash report was supposed to preserve.
Chapter 3Toolchain and workflow
Nothing is built on the target machine. An Apple Silicon Mac produces the 8088 code, runs it on a simulated XT, and talks to the running image over a serial link.
3.1Two tracks, one image
The build has two independent halves that never call each other's tools.
NASM assembles the hardware layer to a flat binary. GCC compiles and links the C
kernel to a second flat binary. They meet in exactly one place: NASM pads its
output to KERNEL_C_ADDR and swallows the C binary whole with
incbin.
make x86 run today, and the pipeline steps are the
actual Makefile rules.Two NASM flags carry most of the weight on the assembly side.
-f bin: no object format at all
NASM's bin output writes exactly the bytes you assembled — no
ELF header, no relocations, no linker step. That is essential, because the BIOS
loads sector 1 into memory and jumps to the first byte. Any header would be
executed as code.
CPU 8086: making the assembler the guard rail
The 8088 and 8086 share an instruction set (the 8088 differs only in having
an 8-bit external bus). Declaring CPU 8086 means any instruction
introduced later — a 186's PUSHA, a 386's 32-bit register —
fails to assemble rather than producing a binary that crashes
on the real machine. The risk it catches is habit: reaching for an instruction
that exists on every processor since 1982. There is no debugger on the target,
so catching it at build time is worth the constraint.
3.2The C path
Assembly owns the machine boundary — boot, interrupt entry and exit, PIT/PIC/UART registers, the deadline scheduler. Ordinary logic (pattern operations, UI state, file formats, protocol handling) does not need to be written by hand, and 16-bit C is a perfectly good language for it.
Your host compiler cannot do this
Neither Clang nor host GCC can produce 8088 code, and GCC's
-m16 flag is not a substitute: it emits 386-era
instructions wrapped in a 16-bit mode prefix and uses the wrong data model.
The result assembles and links and then fails on an 8088. The only real
option is ia16-elf-gcc, a GCC fork with a genuine 16-bit x86
backend.
No ia16-elf-gcc package exists for macOS on ARM, so the
repository pins an x86-64 Ubuntu container around the maintainer's PPA and runs
it under emulation. make toolchain builds it once;
tools/ia16.sh wraps every invocation after that.
The linker script does the hard part
C code cannot simply be told “live at KERNEL_C_ADDR.” That
is the linker's job. The symbolic form of the contract is shown below; the
numeric values live only in src/link/kernel.ld and
src/asm/seq86.asm.
. = RESIDENT_START; ; same boundary as KERNEL_C_ADDR
.entry : { KEEP(*(.entry)) } ; the ABI header, first, always
.text : { *(.text .text.*) }
.rodata : { *(.rodata .rodata.*) }
.data : { *(.data .data.*) }
.bss : { *(.bss .bss.*) *(COMMON) }
kernel_c_end = .;
ASSERT(SIZEOF(.entry) == 16, "kernel entry header ABI changed")
ASSERT(SIZEOF(.bss) == 0, "kernel C must not rely on a zeroed BSS")
ASSERT(kernel_c_end <= RESIDENT_CEILING,
"kernel C consumed its reserve")
Those three assertions are the interesting part, because each one turns a runtime disaster into a build failure.
- Header exactly 16 bytes. The assembly reaches into the C
image at fixed offsets —
KERNEL_C_INIT equ KERNEL_C_ADDR+6and friends. Add a field to the header and every one of those offsets silently points at the wrong function. The assert catches it at link time. - No BSS. Ordinary C assumes zero-initialised statics,
because a normal runtime clears BSS before
main. Here nothing does — the image is loaded off a floppy and jumped to. Rather than write a clearing loop and hope everyone remembers it, the build refuses to link if a single uninitialised static exists. - Resident ceiling. The kernel must remain within segment
zero while retaining the reserve required by later plans. The exact linked
end is reported by
zout/kernel.map; the assertion makes a budget overrun a build failure.
Two build gates on the compiled output
@test -z "$$($(IA16) ia16-elf-nm -u $<)"
@! grep -Eq '[[:space:]](div|idiv|mul|imul)[[:space:]]' zout/kernel.dis
The first rejects any undefined symbol. Freestanding code has no libc, no
heap and no startup runtime, but the compiler will still happily emit a call to
memcpy for a struct assignment or a division helper for a
/ on a long. On bare metal those become jumps into
nothing, so an empty undefined list is checked rather than assumed.
The second is the one worth stealing. Chapter 2's timing rule — no
MUL or DIV while the transport runs — is enforced
mechanically, by grepping the disassembly. A single
DIV r/m16 costs up to 38 µs of jitter, more than the
entire interrupt handler. Rather than trusting everyone to remember that, the
build greps for it and fails. That is why draw_tempo converts BPM
to digits by repeated subtraction, and why step_column computes
step × 5 as step + (step << 2):
a written rule became a build failure, and the code changed shape to match.
Both gates pass on the current tree Confirmed — 0 undefined symbols, 0 multiply or divide instructions in the kernel.
The object file lies about itself, and that is fine
ia16-elf-objdump reports the container format as
elf32-i386. That is normal for this backend and does
not mean the code is 32-bit. But it does mean you must disassemble
with -Mintel,i8086, or objdump decodes the operands at the wrong
width and the listing is confident nonsense.
3.3The assembly–C boundary
This is the most consequential design decision in the current system, and it is worth understanding in detail. Two separately compiled worlds, built by different toolchains that share no symbol table, have to call each other on a machine with no dynamic linker. They do it through one fixed address and one versioned struct.
S86K, header version 5, and the
structure advertises assembly/C ABI 16.How the layout hazard is closed
The red box above is a real risk: irq0 increments
step_pending by writing to a hard-coded assembly label, while C
reads state->step_pending by struct offset. Insert one field in
the C struct and they refer to different bytes — with no compiler error, no
linker error, and a symptom that looks like a timing bug.
The fix is a compile-time assertion built out of an illegal array size:
/* IRQ0 and the C kernel share this prefix; fail the build if it drifts. */
typedef char kernel_state_step_pending_at_19[
__builtin_offsetof(kernel_state, step_pending) == 19 ? 1 : -1];
If the offset is right the type is char[1], harmless and unused.
If it is wrong the type becomes char[-1], which is not a type, and
the compiler stops. Five of these pin playing,
step_pending, ppqn_phase, tempo_ix and
keyboard to the byte offsets the assembly assumes. C99 has no
static_assert, so this trick — old enough to be period-appropriate
— does the job.
It is worth noticing what these three mechanisms have in common. The linker script, the disassembly grep, and the negative-array assert are all the same move: take a rule that a person would otherwise have to remember, and make the build fail when it is broken. On a machine with no debugger, where the edit-test cycle runs through a floppy image, that is worth a great deal more than it would be on a normal target.
ABI 12 also gives foreground C a second code segment. Stage 2 loads and
checksums the S86F bank at 2800:0000; a near tail gate
expands the caller's return to IP:CS and enters medium-model C
while DS and every ordinary data pointer remain in segment zero.
Monitor writes and resident copy-out/fill helpers reject every normalized
range overlapping 0x28000–0x2FFFF. ABI 13 through 16 append
the structural deadline queue, the variable-pattern summary and bounded
compiler state, the structural inference cursor, and the live structural
editor — the machinery of Chapter 6.
3.4Live modules
The resident kernel is one of two ways C runs here. The other is a live module: a small binary pushed into a running sequencer over the debug link and called as a subroutine, without rebooting and without interrupting the transport.
These use a second linker script, src/link/module.ld, with a
different contract:
| Resident kernel | Live module | |
|---|---|---|
| Script | kernel.ld | module.ld |
| Load address | near image + S86F at 2800:0000 | 2000:0100 |
| Header magic | S86K | S86M; S86C retained as a loader test |
| Entered by | Assembly, through the header | Monitor RUN, as a far call |
| Returns | Never unloaded | RETF |
| Result read at | — | SEG:0120, a fixed block |
| BSS allowed | No | No |
| Capacity | 32 KiB protected far bank | 32 KiB including workspace |
The fixed result block at SEG:0120 is the detail that makes this
usable: the host does not need to parse anything or know where the module put
its output. It loads the module, runs it, then peeks a known
address. The module's own header even records its size, so the loader can check
what it sent.
Managed modules use S86M ABI 2 — the loader accepts 2.0 and 2.1: image plus declared workspace must end by
offset 8000h. A separate S86O ABI owns
30000–37FFF only while transport is stopped. Its checksum,
entry and workspace are validated before a foreground far call, and its
header is erased on return so playback can reclaim the bank.
The same rules that apply to the kernel apply here — no BSS, no undefined
symbols — plus the runtime contract from the monitor: enter with
CS = DS = SEG, interrupts enabled, all general
registers volatile; do not replace IRQ0, do not touch PIT channel 1, do not
leave interrupts off.
3.5The development loop
The running OS carries a debug monitor that answers over a serial port. The same client program talks to the emulator and to real hardware, because the protocol does not know which one it is attached to.
load is the command that changes the workflow. It transfers a
binary into memory in chunks and can invoke it as a far subroutine, so a new
module can be tested in a running sequencer without rebooting and without
stopping the transport. That imposes a contract on any live module:
- enter with
CS = DS = SEG, interrupts enabled, on an ordinary OS stack; ESand all general registers are volatile;- return with
RETF; - must not replace IRQ0, must not touch PIT channel 1, must not leave interrupts off.
3.6The monitor protocol
Frames are a sync byte, a header, a payload, and an XOR check. There is no framing library on either end — the parser has to fit in a few hundred bytes of 8088 assembly.
docs/development.md
and the passing tests in tests/test_monitor.py, which execute
these packets against the assembled image.make x86 # assemble + pad -> zout/seq360.img
make test-monitor # run the protocol against the real image
make run-monitor # boot it; COM2 becomes TCP :8687
# ...then, in another shell, against the running machine:
python3 tools/seqctl.py --tcp 127.0.0.1:8687 ping
python3 tools/seqctl.py --tcp 127.0.0.1:8687 state
python3 tools/seqctl.py --tcp 127.0.0.1:8687 peek 0000:7c00 32
3.7The verification gates
The target hardware cannot run anything yet, so correctness has to be established another way. The gates fall into two layers: things the build refuses to produce, and things that are checked by execution.
Layer 1 — the build refuses
These cost nothing to run because they are not separate steps; failing any of them means there is no output binary at all.
| Gate | Catches |
|---|---|
CPU 8086 (NASM) | Any instruction the 8088 does not have |
nm -u must be empty | An implicit memcpy or division helper the compiler emitted |
grep for div/mul | A multiply or divide in the kernel — the timing rule, mechanised |
ASSERT(SIZEOF(.entry) == 16) | ABI header growth silently shifting every fixed offset |
ASSERT(SIZEOF(.bss) == 0) | A static that assumes zero-init nobody performs |
ASSERT(kernel_c_end <= resident ceiling) | The C kernel consuming its required reserve |
Five offsetof asserts | C struct layout drifting from the assembly's byte offsets |
%error on stage-2 overlap | Assembly growing past KERNEL_C_ADDR into the C kernel |
%error on sector overflow | Stage 2 exceeding the 48 sectors stage 1 actually reads |
Nine of them, and not one requires a person to remember anything. The last two are worth calling out because they guard the seam between the toolchains from the other side: NASM checks that its own output has not grown into the address the C linker was told to use, and that the whole image still fits in the sectors the boot loader was told to read.
Layer 2 — execution checks
Three of these run the real assembled artifact rather than a description of
it. All pass on the current tree Confirmed:
make test-monitor reports 16 tests OK — the original ping/state,
poke-then-peek, full-size peek, IRQ1-queue-to-C-decoder, crash-record, and
live-module coverage, joined by structural pattern queries and edits, deadline
underflow diagnosis and recovery, running activation with tempo
recompilation, fixed-far write rejection, stack-guard failure, native
inference prompt composition, S86M header rejection, and the stopped-overlay
lifecycle.
| Gate | Method | What it can prove | What it cannot |
|---|---|---|---|
model_scheduler.py |
Reimplements the scheduler's 16- and 32-bit wraparound arithmetic from scratch, then runs 200,000-event simulations | The scheduling maths is right — it carries four deliberate mutants and asserts all four are caught | Nothing about the actual instructions, or about time |
jitterscope.py |
Executes the real boot image on a cycle-counting 8088 model and fits a least-squares line to the event timestamps | Real jitter and drift of the assembled binary; which instruction causes the worst delay | Bus-cycle interleaving inside one instruction (roughly ±15%), real BIOS behaviour, anything electrical |
The first gate uses mutation testing. A test suite that no plausible bug can break is not testing anything, so the model carries four broken variants of the scheduler — a halved bound, a dropped fractional carry, a reload computed from “now” instead of the ideal deadline, and a signed comparison where it must be unsigned. The gate fails if any of the four goes undetected, or if the correct version trips.
The last mutant is a bug that was found this way. Below 91 BPM the period
exceeds 32,767 counts, so a signed 16-bit compare reads a valid interval as
negative and “corrects” every event. The fix is one instruction,
JA instead of JG, and the bug is invisible at
120 BPM.
Since the last revision the model itself got harder to please: it now
charges the 8088's 4-byte prefetch queue (cf107ef), additively
— billing only the bytes the execution unit wanted before the bus interface
had fetched them, since Intel's tables already assume a full queue. Modelling
the queue costs about 35% of instruction throughput but moves jitter by only
5–13%, because jitter is set by the worst single blocking instruction,
and those — DIV and the idle spin — were already expensive for
reasons the queue does not change. --no-prefetch reproduces the
old figures exactly, so the two models stay directly comparable
(docs/timing.md).
--- MODEL TRUTH (emulator UART timestamps, least-squares fit) --- events fitted 396 period actual 24857.9586 counts = 20.83334 ms ideal 24857.9514 counts DRIFT +0.0072 counts/event = +0.29 ppm JITTER (residual) min -12.40 max +35.25 spread 47.65 counts = 39.93 us --- GUEST-MEASURED (miditest.asm's own numbers, read from its RAM) --- err_min 55 err_max 103 spread 48 counts = 40.23 us overruns 0 probe_cost 15 reload_bias 31 --- CROSS-CHECK --- model (at UART write) 47.65 vs guest (at ISR entry) 48.00 gap -0.35 counts = 0.30 us of ISR body between the two sample points CONSISTENT stack balance at main: SP stable at 0x7c00 OK
The cross-check works because the two numbers come from different instruments. The guest reads PIT ch2 at handler entry and keeps its own min and max; the model timestamps the UART write with virtual time the guest cannot see. The two spreads land 0.35 counts apart — less than one instruction. Two independent measurements agreeing that closely is better evidence than either figure alone.
Chapter 4The network stack
The XT gets an Ethernet card so a program on another machine can propose edits to a pattern. Nothing about that is allowed to delay a MIDI byte. This chapter is about what had to be given up to make that true.
4.1Why the machine needs a socket
There is already a link to the outside: the monitor of §3.6, reachable over UDP port 8686, carrying ping, peek, poke, load and run. That is a debugger's channel. Every exchange is one request and one reply, each fitting in a single datagram, and it exists so a person can reach into a running machine.
The plan in plans/inference-integration.md needs something
different. A model running on another machine proposes sequencer edits;
requests carry state snapshots and replies carry typed operations, and neither
reliably fits in one datagram. That needs a stream, and a stream needs
sequencing, retransmission and flow control.
So SEQ/OS opens exactly one outbound TCP connection, to one configured gateway, on port 8688. It does not speak HTTP, JSON or TLS Confirmed — those live in the gateway on the modern machine, which is also where documentation and build symbols are kept so they never consume XT memory.
The two links are kept separate on purpose. If the TCP client wedges, the UDP monitor still answers, because it shares no state and no code path with it. A debugger that fails whenever the thing it debugs fails is not a debugger.
| Monitor | Inference client | |
|---|---|---|
| Transport | UDP, port 8686 | TCP, gateway port 8688 |
| Direction | Inbound request, outbound reply | Outbound connection only |
| Implemented in | Assembly (ne2000.inc) | C (net_tcp.c) |
| Privilege | Raw memory: peek, poke, load, run | Typed operations only, no memory access |
| State kept | None between datagrams | 560 bytes, one connection |
4.2An NE2000 that never interrupts
The card sits at I/O 0x300. The ordinary way to service a NIC
is to let it raise an interrupt when a frame lands. SEQ/OS writes zero to the
interrupt mask register during initialisation and never enables one
Confirmed:
mov dx,NE_IMR
xor al,al ; deliberately polled
out dx,al
Two reasons, and the second is the one that decides it. The XT bus cannot share IRQ lines between cards (§1.3), and the serial cards have first claim on the lines that are free. More importantly, an interrupting NIC is a machine that stops sequencing under load: every arriving frame would preempt the deadline path, and the arrival rate is set by whoever else is on the network.
That argument is not new here. The same arithmetic already ruled out taking a byte interrupt from the UART — three MIDI ports at 31250 baud would need about 9,400 interrupts per second and 20–30% of the CPU in entry and exit overhead alone. A NIC under flood is the larger version of the same problem, so it gets the same answer: poll it from the foreground, where there is no deadline to miss.
net_poll therefore runs in the main loop, between
monitor_poll and the C kernel, in the band of
Fig 2.4 marked as having no deadline. The cost is latency:
a frame waits until the foreground reaches it. §4.8
measures that wait.
4.3One frame per pass
Polling only helps if each poll is bounded. net_poll reads the
card's current page pointer, compares it against its own, and returns
immediately when they agree. When they differ it reads the four-byte ring
header, checks the frame length against a 600-byte ceiling, copies at most one
frame, and hands that one frame to C.
The line that makes the bound hold is at the end of the routine Confirmed:
.done: cmp byte [net_did_rx],0
jne .restore
call net_tcp_poll
If a frame was processed this pass, the timer poll is skipped entirely. One pass does one thing: it handles a received frame, or it advances the connection's timers, never both. Under saturation, receive work crowds out retransmission — which is the right way round, since a link delivering frames is not one whose retransmit timer is urgent.
net_poll. A pass handles
one received frame or one timer transition, never both. C is called with the
frame buffer and returns a single word saying what to do with it; every port
write stays in assembly.The header check is not decoration. If the next-page pointer read out of the ring falls outside the ring's own bounds, the code resets the ring rather than following it. A corrupt pointer followed is a walk through NIC memory at DMA speed, and the frame that produced it arrived from the network.
4.4Where the stack is cut
Assembly owns the card; C owns the protocol. The seam is one call, through
the resident kernel header at KERNEL_C_NET
(§3.3), passing the connection state, the current
tick, the frame buffer and its length.
What makes the seam cheap is the return convention. C answers with a single
16-bit word, and the top bit means “transmit this” while the low
fifteen carry the length. C never touches an I/O port. It composes a reply into
memory, returns a length, and assembly clocks it out to the card. Every
OUT instruction stays on the side of the boundary where the build
enforces the timing rules — NASM's CPU 8086 directive and the
grep for div and mul of
Table 3.2.
The reply is built in the buffer the request arrived in. When C returns a
transmit length, assembly sends from net_rx directly — there is no
copy into a separate transmit buffer, because a 600-byte frame that arrives
becomes the 54-byte acknowledgement that answers it. The idiom shows up in the
C as passing the frame pointer where a payload pointer is expected, with a
length of zero: never read, but a valid pointer.
4.5A TCP that cannot allocate
There is no heap and no allocator. There is one connection, and it is 560 bytes of storage reserved in assembly.
That number is asserted in three places, which is what it takes when two
toolchains have to agree on a size neither can see the other declare
Confirmed: TCP_CONNECTION_SIZE equ 560
reserves the bytes in seq86.asm; a negative-array typedef in
net_tcp.h fails the C compile if sizeof disagrees;
and a Makefile rule reads both files with sed and refuses to build
if they differ. This is the same guard as NTEMPI, added after the
tempo-table mismatch of §3.7 showed what an
un-gated duplicated constant costs.
| Given up | Consequence | Why it is acceptable here |
|---|---|---|
| Reassembly queue | An out-of-order segment is dropped and the expected sequence re-acknowledged | One connection over a LAN to one gateway; reordering is rare and recovery is a retransmit |
| Multiple send flights | tcp_tx_write refuses while a flight is unacknowledged |
Request/response traffic; the caller has nothing to send until the reply lands |
| Window scaling, SACK, timestamps | 256-byte windows in both directions | The gateway is one hop away and the payloads are small typed operations |
| Listening sockets | Outbound connections only | Nothing on the network has any business opening a connection into the sequencer |
The receive ring shows the constraint most plainly. The buffer is 256 bytes
and its head and tail indices are u8. There is no wrap check
anywhere in the enqueue path:
connection->rx[connection->rx_tail++] = *data++;
The index wraps because the byte wraps. A 200-byte buffer would need a
modulo or a compare-and-reset on every byte received, and
DIV r/m16 is 144–184 clocks on the 8088 and cannot be
interrupted — one of them in a per-byte loop costs more jitter than the whole
interrupt handler. The buffer size is 256 because that is the number that makes
the arithmetic disappear.
Overflow is handled before that loop is reached rather than inside it: a
separate 16-bit count tracks occupancy, and a segment larger than the remaining
window is dropped and re-acknowledged without ever entering
queue_data.
Closing a connection increments the local port and folds it back to 49186 when it would leave the ephemeral range. A reconnection therefore uses a different port than the one before it, so a delayed segment from the previous connection cannot be accepted by the new one.
4.6Checksums without MUL
The Internet checksum is one's-complement addition, so no multiply is needed to compute it. The part that needs care is the end-around carry, because C cannot read the carry flag. It is recovered by comparison instead:
static u16 sum_word(u16 sum, u16 word)
{
u16 old = sum;
sum += word;
if (sum < old)
++sum;
return sum;
}
An unsigned sum smaller than either input is a sum that carried. IA-16 GCC
compiles this to an add and a conditional increment, with no call to a runtime
helper — which matters, because the build requires nm -u to come
back empty and a helper that the compiler emitted on its own would not link
(Table 3.2).
Verification avoids arithmetic the same way. Rather than clearing the
checksum field, recomputing, and comparing, the code sums the header with its
checksum still in place and tests the result against 0xFFFF. A
correct header including its own checksum sums to all ones. The field is never
saved, cleared or restored.
The TCP pseudo-header gets the same treatment: its fields are folded into the running sum one at a time instead of being assembled in a scratch buffer. Twelve bytes that are never written anywhere.
4.7Time measured in MIDI clocks
TCP needs a clock to decide when a segment was lost. This machine has no wall clock. PIT channel 0 is the event scheduler, channel 1 refreshes DRAM and must never be touched, and channel 2 is the measurement reference (§1.2). There is no spare timer.
So the network borrows the one that is already running. A counter is
incremented inside irq0, on the path taken by every scheduler
event, whether the transport is playing or stopped
Confirmed:
.advance_time:
%ifdef NET
inc word [net_ticks]
%endif
The retransmission timeout is 120 of these ticks. Not 120 milliseconds — 120 scheduler events. And the interval between scheduler events is the MIDI clock period, which is set by the tempo.
| Tempo | Scheduler period | RTO (120 ticks) | Connection dropped after 5 retries |
|---|---|---|---|
| 50 BPM (slowest) | 50.0001 ms | 6.00 s | 30.0 s |
| 120 BPM (default) | 20.8334 ms | 2.50 s | 12.5 s |
| 300 BPM (fastest) | 8.3333 ms | 1.00 s | 5.0 s |
Changing the tempo retunes the TCP stack. Periods are read from the generated table of §3.1, so these are exact Confirmed; what they mean on a real network is not Inferred. Six seconds at the bottom of the range is slow but harmless. One second at the top is short enough that a gateway which pauses to run a model could be retransmitted at while it is still working, and five such timeouts close the connection after five seconds.
The mechanism costs one INC on a path that was already
executing, and it needs no second timer, no calibration, and no
interrupt. Whether an interval that moves with the tempo is the right clock
for a network protocol is a separate question, and an open one
(Appendix A).
4.8What the gate proves
make network-scope runs the real network-enabled boot image on
the cycle-counting 8088 and an NE2000 model, with the MIDI clock running, in
six conditions: no card present, a card connected and idle, a flood of
malformed 600-byte IPv4 frames, and a flood of valid full-size TCP
segments, a stream of maximum-size inference frames, and an accepted pattern
proposal with ghost rendering.
SEQ/OS resident network timing scope (cycle-counting 8088 model) 240 MIDI clocks per case; model accuracy is approximately +/-15% workload jitter worst IRQ loop max slope ppm frames/s over absent 19.71 pc 18.00 pc 27845 pc +0.000 0.0 0 PASS idle 23.12 pc 22.00 pc 28293 pc -0.481 0.0 0 PASS invalid-ip-flood 8.25 pc 11.00 pc 27851 pc -0.685 90.6 0 PASS valid-tcp-flood 21.54 pc 21.00 pc 30224 pc -0.237 36.2 0 PASS inference-frame-stream 21.27 pc 19.00 pc 33588 pc -0.580 11.5 0 PASS pattern-proposal 11.85 pc 11.00 pc 28078 pc -0.498 0.4 0 PASS gate: jitter <= absent + 20 PIT counts; foreground loop <= 40000 counts; slope delta <= 1 ppm; no overruns units: 1 PIT count = 0.838 us; foreground gate = 33.52 ms resident C image: 21138 bytes of 51968-byte ceiling guest time modeled: 15,285,342 instructions; CPU 4.7727 MHz, PIT 1.1932 MHz OVERALL: PASS
The image size and reserve come from the built artifact and
src/link/kernel.ld; the scope does not carry a second
hard-coded copy of that bound.
The column that carries the claim is the last one. Zero scheduler overruns in every condition, including a link saturated with valid TCP segments: no deadline was missed Modelled.
Read the jitter column as a statement about instruction mix, not about
load. Jitter is set by the longest uninterruptible instruction in flight when
IRQ0 arrives. The malformed flood reads quietest of all — 8.25 counts against
19.71 with no card — because discarding a bad frame replaces the foreground
mix, including the display code's DIV, with byte-DMA loops built
from short instructions. Before Phase 2 the no-card case was the quiet one;
now every idle pass also runs the pattern compiler and runtime of
Chapter 6, so “absent” is no longer a quiet
baseline. On hardware the divides are still there every time the screen
redraws.
The number to watch is the foreground loop
Under sustained inference framing the longest interval between two visits to the top
of the main loop is 33,588 PIT counts, or 28.15 ms, while
parsing maximum-size inference frames in 32-byte slices. The
MIDI clock period at 120 BPM is 24,858 counts, or 20.83 ms. One
foreground pass under that workload takes 135% of one MIDI-clock
period Modelled — and the floor has
risen: even with no card installed, the Phase 2 pattern engine makes the
worst pass 27,845 counts, 112% of a period. A pass that consumed a network
frame now does nothing else — kernel_poll defers all model and
view work to the next pass — which is why saturating the link adds only
about 2,400 counts to that floor instead of stacking a frame on top of a
full model pass.
That passes, because the gate permits 40,000 counts. But 40,000 counts is
33.52 ms, which is 1.6 clock periods at 120 BPM and 4.0 periods at 300
BPM — so the gate as written does not forbid the foreground from missing
steps. It would not desynchronise the transport: MIDI clock is emitted from
the interrupt handler regardless, and step_pending is a counter
rather than a flag, so missed steps are made up rather than dropped
(§2.6). The visible symptom would be a stuttering
playhead, not a drifting sequencer. The gate should probably be expressed in
scheduler periods rather than in absolute counts; it is listed in
Appendix A.
The remaining columns are regression thresholds rather than claims about
hardware. Fitted period moves by less than one part per million between
conditions, so the presence of network traffic does not bend the tempo. Worst
interrupt entry latency stays at or below 22 counts. The malformed flood is
processed at 90.6 frames per modelled second, the valid TCP flood at 36.2,
and the sliced inference stream at 11.5 —
the spread between them being the cost of checksumming and acknowledging a
frame that survives validation instead of discarding it at the IPv4 header.
All three rates are lower than the pre-Phase-2 baselines recorded in
docs/timing.md: frames are handled one per foreground pass, and
every pass now carries the pattern engine.
What this gate cannot prove
Everything above is Modelled. The 8088 model charges published cycle counts, the 8-bit bus penalty, DRAM refresh steal, and the 4-byte prefetch queue; the queue is settled per instruction rather than per bus cycle, so treat figures as roughly ±15% (§3.7). The NE2000 is a model of a card, not a card. Whether the PicoMEM 2 presents a working NE2000 to this particular 5160 is unverified (Appendix A), and as everywhere else in this manual, nothing has run on the machine.
The protocol logic is checked separately and on the host, where it can be
run against adversarial input: make test-network exercises
net_tcp.c directly, and make test-network-image
drives the assembled boot image through connect, transfer and teardown. Both
pass on the current tree Confirmed.
Chapter 5The song as a database
The sequencer no longer keeps the song in variables. It keeps it in a storage engine: 512-byte pages, B‑tree indexes, copy-on-write commits, and a crash story — running on a CPU with no multiply worth using and 256 KB of RAM.
5.1A song larger than memory
An eight-minute recorded performance is about half a million timed records — roughly 9.4 MB encoded. The machine has 256 KB, and the resident C kernel can address only the first 64 KB of it with ordinary near pointers. So the design rule is absolute: no resident pointer ever points at song data. Canonical objects live in a page graph on a storage backend and are reached through 32-bit IDs. RAM holds a cache of recently used pages, the current transaction's dirty state, and the small bounded projection the interrupt handler actually plays from.
This splits the sequencer into four kinds of state, and the boundaries between them are the architecture:
| State | Lives in | Reached by | May contain |
|---|---|---|---|
| Canonical song model | Storage pages | IDs, via queries | Musical meaning only — no MDA coordinates, glyphs, or cursor positions |
| Edit transactions | Resident, bounded | Typed commands | Mutations against named object revisions |
| Playback projections | Resident, bounded | IRQ0, directly | Pre-resolved data safe for the deadline path |
| View projections | Far view pages | Read-only queries | Summaries shaped for one editor or inspector |
Neither the MDA renderer nor a hot-loaded module can make a private
structure canonical by writing it directly. The renderer reads through
song_query_pattern and song_query_transport; it never
receives a cache handle or a canonical page pointer. That rule is what lets the model grow past RAM without any consumer noticing.
5.2The 512-byte page
Every canonical object is encoded into 512-byte pages: a 20-byte header, then 492 bytes of payload. 512 because it is the sector size of everything this machine will ever store to — a floppy, an XT-IDE, a PicoMEM — so one page is one storage transaction, never a partial one.
| Offset | Field | Notes |
|---|---|---|
| 0 | magic | S86P — offset reserved; the encoder does not write it yet |
| 4 | type | superblock / internal / leaf / summary / detail |
| 6 | flags | |
| 8 | generation | 32-bit, wrap-safe comparison |
| 12 | payload length | |
| 14 | entry count | |
| 16 | checksum | 32-bit, add/shift only |
Page 1 is the superblock. Its payload carries the ASCII signature
S86S and two independently checksummed 20-byte
root slots. Each slot names a generation, a root page, feature bits, and a
song length, and closes with its own checksum. Boot reads both, discards any
slot that fails its checksum, and follows the newer surviving generation
— the comparison is wrap-safe, so the scheme does not die at
232. Why two slots becomes clear in §5.5: the pair is what
makes commit survivable.
The checksum obeys the same law as everything else in this project: it is computed incrementally, at most 64 bytes per foreground work unit, using an add-and-shift state seeded from two constants. A full page costs eight quiet visits to the checksummer, never one long one. Capacity is bounded by design rather than discovered by crash: 65,536 pages, 32 MiB of song, B‑tree depth at most 4.
5.3Indexes without MUL
Four different indexes hang off the root — resources, arrangement,
pattern detail, control points — and they differ only in key width.
Every key and record is fixed-width, which buys the crucial property: finding
entry n inside a page is a shift and an add, and binary search within
a page needs no multiply at all. One shared search primitive,
song_fixed_search, serves all four shapes; one page-local editor,
song_btree, performs insert, delete, split, and merge for all of
them. Splits are deterministic two-page splits; parent propagation and page
allocation belong to transaction policy, not to the codec.
The layout is not asserted by hand — a tool derives it and a gate refuses the build if the derivation and the C constants disagree:
SEQ/OS canonical song format v1 page: 512 bytes = 20 header + 492 payload indexes kind key internal/leaf fanout int/leaf depth comparisons resource 6 10/14 49/35 3 7 arrangement 12 16/20 30/24 3 6 pattern detail 12 16/20 30/24 3 6 control 8 12/16 41/30 3 7 required eight-minute fixture timed records: 500,000 encoded: 19,208 pages / 9,834,496 bytes / 9.38 MiB max depth: 3 page reads
Read the last line the way the deadline path has to: reaching any record in
a 9.38 MB song costs at most three page fetches, and each fetch is
bounded work through the cache below. Depth 3 measured against a designed
ceiling of 4 — the eight-minute song does not even use the tree's last
level. Confirmed by make
test-song-layout on the current tree.
5.4The page cache
Between the resident kernel and the page store sits a cache of 128 far
slots — 64 KB at 0x10000–0x1FFFF, one page per
slot, with an 8-byte metadata entry per slot in a separate table at
0x3C000. Replacement is the classic clock: a hand sweeps the
slots, gives every referenced page a second chance, and evicts the
lowest-priority unreferenced, unpinned victim. Priorities are fixed —
view pages evict first, transaction pages next, playback pages last —
so a burst of editor scrolling can never push out the page the music is
standing on. If two full sweeps find nothing evictable, the cache says so
(ALL_PINNED) instead of stalling.
On a miss, a cold fill runs a
sixteen-state machine, and the page's checksum is verified — in 64-byte
slices, like everything else — before the slot's tag is
published. No consumer can ever observe a half-loaded or corrupt page under a
valid tag; a bad sector surfaces as CORRUPT, not as a wrong
note. Lookup itself is iterative: the cursor pins one page, follows one
child, releases, and returns to the foreground loop — a tree descent is
three bounded visits, not one long recursion.
Behind the cache, the page store speaks a polled contract shaped exactly
like the NE2000 driver in Chapter 4: one far read or one far write
per foreground pass, staged through a 64-byte bounce buffer. Two backends
implement it today — a far-RAM store (8 pages on the 256 KB boot
machine, 768 pages at 0x40000 once a 640 KB machine is
detected) and a host-side sparse-file store used by the test gates. A real
disk backend slots in behind the same contract without touching a single
consumer.
5.5Committing without stopping the music
Edits arrive as typed transactions: at most 8 operations, at most 240 encoded bytes, each naming the object revision it believes it is editing. A stale revision is rejected, not merged. Accepted transactions do not modify pages in place — nothing ever modifies a live page in place. Instead the commit machine builds the next generation alongside the current one.
The order carries the safety argument. Detail page, then pattern page, then resource root are copied into the alternate set — read, patched, re-checksummed, written, every step one bounded work unit of at most one 64-byte far copy or one page-store phase per foreground poll. Only when all three children are durable does the machine write the superblock: the new root into one slot, the old root preserved in the other, then a flush. Power can fail at any instant and boot still finds either the complete old song or the complete new one, because a torn superblock write corrupts at most one slot's checksum and the other slot still validates.
The claim is checked by enumeration.
test_song_commit re-runs the commit and kills it after poll 0,
after poll 1, after poll 2, every single cut point through the entire
sequence, and after each cut asserts that superblock selection on the
resulting image yields a fully consistent generation, never a hybrid.
Confirmed on the current tree.
Two consequences fall out for free. Undo is not a rollback: the previous generation is sitting intact in the just-vacated page set, so undo is simply another forward commit whose payload is the old content — same machine, same crash story, generation N+2 that happens to sound like generation N. And revisions stay honest: the song generation advances on every accept and every undo, but a pattern's own revision advances only when its content actually changed, so a transport edit does not spuriously invalidate a pattern edit in flight.
5.6What IRQ0 actually plays
None of the machinery above is allowed anywhere near the interrupt handler. IRQ0 remains pure assembly and consumes pre-resolved residents only. When this chapter was first written that meant a fixed 16-step velocity array; since Phase 2 it means the structural deadline queue and effective-step banks of Chapter 6, with the 16-step array retained as the pattern-v1 compatibility mirror. Either way the rule is the same: IRQ0 never walks a B‑tree, never touches the cache, never waits on the store — and now, never traverses a pattern record.
The projections are filled by cooperative machines that run in the
foreground loop's spare passes: resolve the pattern through the lookup
cursor, validate its pages through the cache, decode — each pass
one bounded step. kernel_poll dispatches at most one unit of
model work per pass: the commit machine if a commit is pending, else the
pattern compiler and runtime, else a plain cache/store poll. The pattern the
listener hears switches only at a publication boundary — when the
commit's final flush completes, the kernel republishes queries onto the new
generation, invalidates the cache (an alternating page ID must not resurrect
stale bytes), and recompiles the playback projection. An edit becomes audible
once, whole, at that boundary; a commit in progress adds no delay to the
music. What counts as a boundary became finer-grained with variable patterns
— §6.4 gives the full activation table.
5.7The INT 60h module gate
Chapter 3's live modules got a real service boundary. A loaded
S86M module reaches the song exclusively through
INT 60h: opcode in AX, a caller buffer in
DS:SI, capacity in CX. Thirteen opcodes exist —
the original seven (ABI query, song summary, pattern page, submit
transaction, transaction status, invalidate view, yield) joined by ABI
2.1's structural summary, structural step pages, structural submit,
structural status, and transform-provider registration and status
(§6.5). The gate accepts buffers only inside the
module window 0x20000–0x27FFF, copies them through bounded
resident scratch, and copies back values only. After validating the caller
range it re-enables interrupts — the real-mode INT entry
cleared them — so the bounded copies and the C service itself remain
preemptible by the scheduler. No kernel function pointer,
cache handle, page pointer, display address, or IRQ callback ever crosses.
Authority is the deliberate restriction: a module's SUBMIT_TRANSACTION is
accepted only as a preview. It rides the same
propose‑then‑confirm path as an inference proposal from Chapter 4
— a human at the local controls promotes it or it dies. The demo module
proves the contract from the hostile side, deliberately submitting a
stale-revision transaction first and asserting the rejection, then a valid
one and asserting it parks as a preview. On the 8088 model, the
worst-case legal envelope — 8 operations, 91 encoded bytes —
previews and accepts inside the 40,000-count foreground work-unit bound
Modelled, so even a maximal module transaction
cannot stutter the playhead.
One honesty note: on an 8088 this is a trust boundary, not a hardware one. There is no protected mode; a malicious module can still write anywhere. The gate's value is that a correct module cannot accidentally depend on kernel internals, so the kernel stays free to move them.
5.8What the gates prove
make test-song-model runs twelve host binaries plus the
layout derivation — one gate per subsystem. All pass on the current
tree Confirmed. The ones that carry the load:
| Gate | What it proves |
|---|---|
test-song-layout | The byte-level format: a tool re-derives every offset, fanout, and depth from first principles and refuses the build on disagreement with the C constants |
test_song_commit | Power-loss safety after every commit work unit (§5.5), and that undo is a correct forward commit |
test_long_song | A 19,208-page (9.38 MiB) song on the sparse backend: page 19,208 is fetched directly — page IDs are not secretly 16-bit-near — and a watched detail page is read from the store exactly once, so the cache is not quietly re-fetching |
test_song_page_cache | Clock eviction, pin overflow, and that a corrupt page is reported, never published |
test_module_service | Eleven of the thirteen INT 60h opcodes — the two status polls lack direct coverage, found while checking this table — plus stale rejection and preview-only module authority |
model-scope | On the cycle-counting 8088 model: the maximal 8-op transaction stays under the 40,000-count work-unit bound Modelled |
The long-song gate closes the loop this chapter opened. The fixture is the eight-minute performance from §5.3 — half a million timed records, thirty-seven times the machine's total RAM — and the same code path that boots the demo song walks it: three page reads to any record, through a 128-slot cache, on a store that moves 64 bytes per pass. The song outgrew the machine, and nothing that consumes it had to be told.
Chapter 6Variable patterns
Plan 02 removes the fixed 16-column bar. A pattern now owns its length, its step order, its timing grid, and its place in a global bar. The interrupt handler that plays it did not grow to match: it consumes absolute deadlines computed ahead of the beat.
6.1Escaping the 16-column bar
The pattern-v1 model was a 16-byte velocity array indexed by position.
Deleting a step would renumber every step after it, so any edit protocol
built on positions races its own history. The variable model gives each
pattern and each step a stable 32-bit ID and revision. A pattern record is
294 bytes: 1–64 ordered step IDs, a Last Step named by ID, a timing
grid of 1–65,535 pulses per step at 960 PPQN, and a Gbar — a
global bar of 1–256 structural playback slots. A step record is 36
bytes and today interprets two fields: a SKIP flag and the
pattern-v1 compatibility velocity. Reordering moves IDs, never contents, and
a step keeps its identity when its neighbours are deleted.
Confirmed — the record sizes are static
asserts in src/song/pattern_transaction.h.
The foreground validator refuses a candidate whose Last Step names no owned step, whose step content or ownership is invalid, whose grid or Gbar time is zero, whose operations are duplicated or stale, or which leaves no effective step at all. What survives validation defines playback: the non-skipped prefix of the step list through Last Step is the local cycle, and it repeats until the Gbar's slot count is consumed. A partial final pass stops exactly at the Gbar boundary.
song_pattern_cycle_next on the host, not drawn from
the specification Confirmed.The Gbar is what makes pattern length a local property. Without it, a five-step pattern against a sixteen-slot neighbour would free-run for the 80 slots their least common multiple needs. Cutting the partial pass at the boundary is the design decision that prevents it: the Gbar is an absolute re-synchronisation point, so patterns of different lengths can never drift apart for longer than one global bar — polymeter that always comes home.
6.2Compiling time ahead of the beat
Chapter 2's scheduler rule — compute the reload from the ideal
deadline, never from “now” — survives unchanged. What Plan
02 adds is a second stream of deadlines on a different grid. Structural time
is defined at 960 PPQN, so the build generates PIT counts per pulse for
each of the 21 tempos as unsigned 16.16 fixed point
(tools/gen_tempo_table.py →
src/song/tempo_table_generated.h). At 120 BPM one pulse is
621.4488 counts, held as 0x026D.72E4; a 240-pulse step — a
sixteenth — is accumulated by shift-and-add DDA with the fractional
word carried, so the grid cannot drift, and no multiply appears anywhere in
the path. Converting one step interval costs 1,132 counts of portable C,
inside the 2,000-count leaf budget Modelled.
The product of compilation is absolute: the 32-bit PIT count at which the next structural boundary falls. That number exists because the MIDI accumulator's high word grew a 16-bit epoch — the low word still wraps in lockstep with the 16-bit counter exactly as §2.6 relies on, and the epoch extends the ideal deadline into an integer a queue token can carry.
The compiler is a foreground state machine bounded like everything else
in the kernel: one step record per pass. Each pass reads one 36-byte record
from the committed far bank into the near step buffer, evaluates Skip and
Last Step, and appends to an effective-step projection in one of two far
banks at 3B000/3B100; the active bank flips at
publication, so a half-built projection is never live. A projection is
admitted only if its tempo and grid can sustain the queue horizon of
§6.3, and publication records the source song and pattern revisions
separately from the committed revision — the fact §6.4's
compatibility bridge depends on. One effective-step scan, far transfers
included, costs 1,499 counts Modelled.
Near memory holds none of this in bulk. A full pattern state —
record plus 64 step records — is 2,602 bytes, and the writable near
arena finished Plan 02 effectively full, 118 bytes against the forecast. So
the step sets live in the fixed far scratch map: dirty publication pages at
38000, committed step records at 39000, structural
previews at 3A000, the two effective banks at 3B000
and 3B100. Resident near state keeps the 224-byte runtime and
the 782-byte editor — cursors, one staged step, and the UI value
mirrors. The measured far-scratch high water is 4,768 bytes
Modelled.
6.3A queue of deadlines
Compiled boundaries cross into interrupt context through a
single-producer, single-consumer ring: foreground C publishes tokens with
deadline_push, and IRQ0 inspects and removes at most one per
entry. Each token is 8 bytes, fixed by assembly/C ABI 13:
| Offset | Field | Notes |
|---|---|---|
| 0 | deadline, low word | absolute 32-bit PIT count (§6.2) |
| 2 | deadline, high word | |
| 4 | Gbar slot | 16-bit, 0–255 today |
| 6 | effective-step index | into the published bank |
| 7 | boundary flags | bit 0 GBAR_START, bit 1 LOCAL_START |
The sizing carries the concurrency argument. The usable horizon is 32
tokens, but the backing ring is 64 slots — 512 bytes — so a
replacement token can be staged in a slot the consumer cannot be reading,
then published by a single word store that rewrites head and tail together.
There is no CLI anywhere in the path: head belongs to IRQ0,
tail to the foreground, both are monotonic bytes, and their unsigned
difference admits all 32 entries without a sentinel slot. The separate count
byte is diagnostic only. Publication costs 159 counts of assembly
Modelled.
IRQ0's side stays minimal. If the head token is due it copies the Gbar
slot, effective index, and flags to the boundary mirrors, increments the
boundary serial, pops the head, and bumps the saturating
step_pending — the same counter Chapter 2 described, fed
from a new source. It then reloads ch0 with the earlier of the ideal MIDI
deadline and the next token. Deadlines within 16 counts of each other are
coalesced into one interrupt, because independently rounded pulse and MIDI
accumulators can otherwise schedule a back-to-back interrupt the machine
cannot take; 16 counts sits inside the 20-count jitter gate.
An empty queue while playing is recorded, not repaired. The saturating
deadline_underflows diagnostic increments and no structural
event is synthesised, while MIDI clock continues from its own ideal deadline
— a starved pattern engine must not bend the tempo. Start and restart
publish two tokens, the immediate boundary and its neighbour, before
exposing playing=1, which gives IRQ0 the horizon it needs at
300 BPM without ever traversing a pattern record in interrupt
context.
6.4Editing a pattern that is playing
Structural edits ride the same envelope law as Chapter 5: at most 8
operations, at most 240 encoded bytes, each naming the revision it believes
it is editing. The operations are create and delete pattern, insert, delete,
and move step, set Last Step, set grid duration, set Gbar length, set or
clear Skip, and set velocity. One editor state machine serves every caller.
It loads the committed record, streams the step set through one near buffer,
validates, applies, and stages the complete candidate — record and
steps — in the preview bank at 3A000. Accepting publishes
incrementally, in bounded passes: step records to the committed bank, then
the model, then recompilation.
When an accepted edit becomes audible depends on what it changed:
| Change | Activates at |
|---|---|
| Anything, while stopped | Immediately |
| Velocity | The next structural boundary |
| Skip, Last Step | The next Gbar |
| Grid duration, Gbar length | Restart |
The table is the musical contract. A velocity tweak lands on the next step without disturbing the cycle. A change to which steps exist waits for the bar to come around, so the listener hears a new bar rather than a torn one. A change to time itself waits for a restart, because re-deriving every in-flight deadline mid-bar would move the playhead under the listener.
The pattern-v1 world is still there — Chapter 5's transport, tempo,
and 16-step velocity paths did not change — so a bridge keeps the two
models coherent. It copies the first 16 compatibility velocities into the
legacy projection and permits only a newer revision to replace an older one,
so a concurrent transport or tempo publication cannot roll structural state
back. The regression that proves it runs against the assembled image in
make test-monitor Confirmed.
6.5Three doors, one validator
Three interfaces reach the editor, and all of them enter the same validation and revision machinery. There is no privileged path, and every door serializes IDs and copied values only — no far address crosses any of them.
The monitor gained four commands: 19h
returns the variable-pattern summary, 1Ah pages the ordered
step records at most four per frame, 1Bh reports queue and
compiler diagnostics — pending count, high water, underflows —
and 1Ch submits a local structural transaction.
Modules got the six ABI 2.1 opcodes of §5.7, including registration as a transform provider. A module transaction still carries MODULE authority and parks as a preview; a human at the local controls promotes it. The reference transform is deliberately awkward: it spans four separate RUN invocations — legacy query, structural summary, step page, submit — yielding between each, and restores its image checksum after every phase because its inspectable result block lives inside the validated binary. It exists to prove the cooperative rule from the hostile side, the way Chapter 5's demo module proved stale rejection.
Inference gained a second contract,
seq86.pattern-structure/v1. The positional 16-velocity contract
of Chapter 4 cannot express “make it a five-step cycle”, so the
resident streams a structural snapshot — ordered step IDs with flags
and velocities, grid and Gbar values, the selected step, remaining capacity,
and the active playback revision — across multiple 240-byte frames; a
64-step pattern does not fit in one, and the upload cursor is resident state
(ABI 15). Prompts mentioning length, Skip, Last Step, cycle, Gbar,
polymeter, or duration route to the structural contract; compatible prompts
keep the positional one. The gateway normalises the model's reply into the
same typed operations the monitor and the module gate submit.
6.6What the pattern gate proves
make pattern-scope runs the assembled NE2000 boot image on
the cycle-counting model — the modelling caveats of
§4.8 apply here too — and forges only the
initial canonical fixture. Compilation, publication, activation, deadline
production, inference preview, module execution, and MIDI clock output all
run the production resident paths.
SEQ/OS pattern-time scope (cycle-counting 8088 model) 120 steady MIDI clocks per structural case workload compile/step jitter drift loop max queue under 64-effective 4327.5 pc 10.52 pc +0.142 10982 pc 31/32 0 PASS 63-skipped 3968.1 pc 9.36 pc +0.489 11244 pc 31/32 0 PASS 5-in-256 3380.8 pc 14.61 pc +0.568 11845 pc 31/32 0 PASS tempo-rebuild 4327.5 pc 10.57 pc -0.972 12649 pc 31/32 0 PASS network workload jitter delta slope delta loop max under absent +0.00 pc +0.000 27845 pc 0 PASS structural-proposal -6.47 pc -0.556 28555 pc 0 PASS module-transform-flood -10.81 pc -0.137 37250 pc 0 PASS portable/assembly leaves: pulse_to_pit=1132 pc, effective_scan=1499 pc, queue_publication=159 pc resident text=30225 bytes; far scratch high water=4768 bytes gates: jitter <= 20 pc, drift <= 1 ppm, foreground <= 40000 pc, leaves <= 2000 pc, zero underflows/overruns OVERALL: PASS
Read the queue column first: across every structural workload the horizon never dips below 31 of 32 tokens and underflows stay zero — the one-token-per-pass refill keeps pace with consumption even through five consecutive tempo publications and restarts. The 64-step compile performs 64 direct far-step reads and zero page reads; there is no cache to hit or miss in the active scratch projection. And the three named leaves — pulse-to-PIT conversion at 1,132 counts, an effective-step scan with its far transfers at 1,499, queue publication at 159 — all sit under the 2,000-count leaf budget Modelled.
The number to watch is the same one §4.8 watches: the four-phase module transform under a maximum valid TCP flood reaches a 37,250-count foreground pass, 93% of the 40,000-count gate. That is the closest any modelled workload comes to the bound, and it strengthens the appendix's case that the gate should be stated in scheduler periods rather than absolute counts. The gate also reports resident text — 30,225 bytes at this capture — and far-scratch high water on every run, so a later commit cannot silently trade memory for timing or the reverse.
Host-side, make test-pattern-time runs five sanitised
binaries — cycle model, transactions, compiler, runtime, editor
— and the structural regressions in make test-monitor
drive the same behaviour through the assembled image: non-16 patterns, short
Gbars, running activation, tempo recompilation, forced queue underflow and
recovery. All pass on the current tree
Confirmed.
Appendix AOpen questions
What this manual does not yet know, roughly in the order that answering it would unblock the most work.
| Question | Blocks | How to resolve |
|---|---|---|
| An XT keyboard — Model F or an AT-to-XT adapter | Everything on real hardware. Every figure in this manual stays Modelled until this is solved | Purchase. XT protocol, not PS/2 |
| What is the slot-2 card? | Whether CV output needs a custom ISA card or is already onboard | Remove it and photograph both sides square-on. Look for 8255, DAC80, AD7541, ADC0809, or an NI / Keithley / MetraByte logo |
| Which UART family is on the serial cards? | The 2.000 MHz crystal swap, and therefore MIDI output at all | Read the chip markings; assess whether the crystal is socketed or soldered |
| Is the monitor really a 5151, and is the screen streaking burn-in or coating wear? | How much to trust the phosphor-persistence display design | Rear label. Then the two-photo test: off under room light, versus on and fully blank. Text-shaped shadow means burn-in; a directional streak means coating |
| Does the PicoMEM 2 work in this machine? | The deployment path, and RAM beyond 256 KB | Install outside slot 8, enable disk and conventional RAM only at first, keep the floppy controller as recovery media |
| Do the interactive keys actually work end to end? | Confidence in the whole UI layer | The IRQ1 queue and the C decoder are now covered by a test against the assembled image, so the software path is exercised. What remains unverified is a real key on a real board producing a real scan code |
| Does the C kernel fit once it does something real? | Whether the resident linker reserve holds as later plans land | The linker asserts the ceiling and memory-scope charges every remaining plan. After Plan 02 the answer is nearly settled by construction: 118 near bytes remain against the forecast, so later subsystems are budgeted as small near dispatchers with their substantive code in the fixed-far bank (docs/development.md) |
| What actually stores the pages? | Songs surviving power-off on the real machine. The page-store contract (§5.4) has two backends — far RAM and a host-side sparse file — and neither is a disk | Write a floppy or PicoMEM backend behind the same polled read/write/flush contract. The 512-byte page was chosen to equal the sector size, so the mapping should be one page, one sector; the long-song and power-loss gates already exist to run against it |
| Should the foreground-loop gate be expressed in scheduler periods? | Whether network-scope can catch a stuttering playhead under network load |
The gate is an absolute 40,000 PIT counts (33.52 ms). That is 1.6 MIDI clock periods at 120 BPM and 4.0 at 300 BPM, so it permits missed steps at every tempo. Measured worst pass is 33,588 counts under inference framing, 135% of one period at 120 BPM — and the Phase 2 pattern engine raised the no-card floor to 27,845 counts, 112% (§4.8). Restate the bound as a fraction of the current period rather than a constant |
| Is a tempo-coupled retransmit timeout the right clock? | How the inference client behaves when the gateway is slow | net_ticks counts scheduler events, so the RTO is 6.0 s at 50 BPM and 1.0 s at 300 BPM (§4.7). Decide whether that is acceptable or whether the tick needs deriving from a tempo-independent divisor. Needs a real gateway and a real network to answer |
Closed since the last revision
Two items left this list. The GCC IA-16 toolchain is built
and the C kernel is linked into the boot image — that entry described a
scaffold that no longer exists. And a tempo table bounds mismatch
(C permitted 26 tempo indices against a 21-entry table, sending the scheduler
into a 3.35 µs interrupt storm at index 21) was found while
cross-checking this manual against the built image, and fixed in
55ee070. The stale 6,144-byte timing-report ceiling is also
closed: networkscope.py now derives both addresses from
src/link/kernel.ld. The tempo-count duplication is closed too: the
count still lives in both gen_tempo_table.py's output and a
#define in sequencer_controller.c, but the
CHECK_NTEMPI Makefile rule (c214b66) refuses to
build an image while they disagree — the same guard
§4.5 describes for
TCP_CONNECTION_SIZE. And the cycle model gained the 8088's
4-byte prefetch queue (cf107ef), retiring this manual's former
±30% disclaimer; the residual estimate is ±15%
(§3.7).
The claim most likely to be wrong
The number most likely to be wrong on real hardware is the interrupt-acknowledge latency, modelled at 13–20 µs. The model now charges the prefetch queue's flush on interrupt entry, but it settles the queue per instruction rather than per bus cycle, and the register pushes and vector fetch land exactly where that approximation is weakest. The spread figures should hold, since constant latency cancels out of a spread. The absolute minimum will move.