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_ADDR to kernel_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.

Confirmed Read off the hardware, a photo, or a command that was actually run.
Modelled Produced by the cycle-accurate simulator, not by the machine.
Inferred Deduced from the platform. Plausible, unverified, may be wrong.

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:

Green means the tube said it
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.

Table 1.1 — System inventory
ItemValueStatusEvidence
ModelIBM 5160, Made in USAConfirmedRear ID label
POST256 KB OK, then keyboard error 301ConfirmedPower-on photo, green text
RAM256 KB, four socketed banksConfirmedPOST count + board marking
CPU8088 @ 4.772727 MHzInferredStock 5160; chip not read
8087 FPUSocket emptyInferredLeft empty by design
Keyboard portDIN present, intactConfirmedRear photo
KeyboardMissing. XT protocol requiredConfirmedSupplied board was PS/2
MonitorGreen phosphor, IBM Personal Computer DisplayConfirmedFront badge, green POST
Monitor model5151 specificallyInferredPC label reads “Auxiliary Power to 5151”
PhosphorP39 long-persistenceInferred5151 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.

14.31818 MHz system crystal ÷3 ÷12 Intel 8088 CPU 4.772727 MHz 1 clock = 209.5 ns Intel 8253 PIT 1.193182 MHz 1 count = 838.0965 ns every deadline in this system is a multiple of 838.0965 ns no 8087. integer and fixed-point only.
Fig 1.1 The clock chain. One crystal, two divisions. The timer's 838.0965 ns count is the finest interval SEQ/OS can schedule — the timing floor for the whole instrument. Confirmed by arithmetic: 14.31818 ÷ 12 = 1.19318167 MHz, and 1 ÷ 1193181.67 = 838.0965 ns.

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.

Table 1.2 — 8253 channel assignment
ChWired toStock useSEQ/OS use
0IRQ0BIOS 18.2 Hz tickTaken. One-shot event scheduler
1DMA0DRAM refreshNever touched. Stop it and RAM forgets
2SpeakerTone generationTaken. 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:

ISA CARD CAGE — REAR VIEW, LEFT TO RIGHT 1 RS-232 serial DB-25 M 2 ? unknown internal no connector 3 Floppy adapter DB-37 F 4 MFM disk adapter blank bracket 5 RS-232 serial DB-25 M 6 RS-232 serial DB-25 M 7 RS-232 serial DB-25 M 8 IBM MDA video + parallel DE-9 F DB-25 F three identical cards → three independent MIDI outs after the 2.000 MHz crystal swap (§1.6)
Fig 1.2 The card cage. Rear connectors are Confirmed from photographs; the identification of slot 4 as the MFM adapter is Inferred from its blank bracket. Slot 2 has no rear connector at all and remains unidentified — see the appendix.

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.

BIOS ROM 64 KB · POST, INT 10h/13h/16h option ROM space 192 KB · PicoMEM lands here MDA text buffer 4 KB · 80×25×2 = 4000 bytes CGA / EGA window not present in this machine unpopulated 384 KB · no RAM fitted conventional RAM 256 KB fitted, 4 banks SEQ/OS lives down here BIOS data area · 256 B interrupt vector table · 1 KB FFFFF EFFFF B0FFF AFFFF 9FFFF 3FFFF 005FF 004FF 00000 ES points here for every draw. Writing a word paints a character and its attribute in one store. Boot sector at 0x7C00, stage 2 at 0x7E00, stack growing down from 0x7C00. Detail in Fig 2.3. 256 vectors × 4 bytes. SEQ/OS overwrites exactly one: INT 08h.
Fig 1.3 The 1 MB address space. Layout is the standard IBM PC memory map Confirmed against the code's use of 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.

Table 1.3 — I/O ports used by SEQ/OS
PortChipPurpose in SEQ/OS
0x208259 PICCommand — end-of-interrupt written at the close of every IRQ0
0x218259 PICMask — set to 0xFC, leaving only IRQ0 and IRQ1 live
0x408253 PITChannel 0 count — the one-shot scheduler reload
0x428253 PITChannel 2 count — free-running reference
0x438253 PITMode/command register, and the channel-2 latch
0x60KeyboardScan-code data, read by the IRQ1 handler
0x61Port BBit 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
0x2F88250 UARTCOM2 — the debug monitor link and crash dump, 9600 8N1
0x3BCMDA parallel8 latched TTL outputs — planned gate/trigger bank
0x3F88250 UARTCOM1 — 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:

The stock crystal cannot reach MIDI
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:

With a 2.000 MHz crystal
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.

MDA 80×25 mode 7 — all 25 rows, transport running, playhead on step 5
  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:

IBM BIOS — NOT OUR CODE power on CPU resets to FFFF:0 POST 256 KB OK · err 301 build IVT + BDA INT 10h/13h/16h live INT 19h bootstrap read drive sector 1 512 bytes loaded to 0000:7C00, DL = boot drive control transfers to our code here STAGE 1 — src/asm/seq86.asm, the boot sector set up the world CLI · CLD DS=ES=SS=0 SP=0x7C00 save boot drive DL is the only thing the BIOS tells us, and we need it to read more read 48 sectors INT 13h, one at a time to ES:BX = 0000:7E00 walking CHS (Fig 2.2) far jump 0000:stage2 CS forced to 0 STAGE 2 — the sequencer, at 0x7E00 MDA mode 7 INT 10h, AX=0007 ES = 0xB000 last BIOS call seize the vectors 14 crash stubs IVT[08h] → irq0 IVT[09h] → irq1 init hardware PIC mask = 0xFC UARTs: div 4, div 12 PIT ch2 free-running kernel_init first call into C through 0000:A006 C draws the UI sched_start arm first deadline STI
Fig 2.1 Power to running sequencer. The BIOS band at the top is code we did not write; after the 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.

src/asm/seq86.asm — stage 1 entry
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.

src/asm/seq86.asm — the CHS advance
        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.

CYL 0 HEAD 0 S1 boot S2 · S3 · S4 · S5 · S6 · S7 · S8 · S9 8 sectors read ← reads 1–8, then the head flips CYL 0 HEAD 1 S1 · S2 · S3 · S4 · S5 · S6 · S7 · S8 · S9 9 sectors read ← reads 9–17, then the arm steps CYL 1 HEAD 0 S1 · S2 · S3 · S4 · S5 · S6 · S7 · S8 · S9 9 sectors read ← reads 18–26, head flips again CYL 1 HEAD 1 S1 · S2 · S3 · S4 4 sectors read S5 · S6 · S7 · S8 · S9 ← first 30 shown; 18 more follow. 48 sectors requested = 24 KB loaded after stage 1 current sectors hold real code remainder reads as zeros
Fig 2.2 The sector walk. Stage 1 asks for 48 sectors (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:

src/asm/seq86.asm — seizing the interrupt vectors
        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.

src/asm/seq86.asm — the whole keyboard ISR policy
        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

interrupt vector table — 256 × 4 bytes BIOS data area — keyboard buffer lives here free — the stack grows down into this STAGE 1 — boot sector, 512 bytes STAGE 2 — assembly hardware layer ISRs, scheduler, UART, state, tempo table RESIDENT C KERNEL foreground policy and bounded model services linker-enforced resident reserve far cache, module, projection and I/O banks 0x00000 0x00400 0x00500 0x07C00 0x07E00 KERNEL_C_ADDR kernel_c_end resident ceiling 0x3FFFF entry 8 (offset 0x20) now points at irq0. that one write is how SEQ/OS takes the machine. SP starts here and moves up- screen. Checked every pass of the main loop: an unbalanced routine assembles fine and returns to garbage on hardware. The C linker and NASM share KERNEL_C_ADDR; NASM pads to it address before incbin. Two toolchains, one agreed constant. 0xB0000 — MDA buffer, a separate island far above all of this.
Fig 2.3 Low memory once stage 2 is running. Two separately compiled worlds meet at 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.

INTERRUPT CONTEXT — ASSEMBLY ONLY, HARD DEADLINE PIT ch2 free-running reference PIT ch0 one-shot, mode 0 IRQ0 irq0 — the scheduler 1. reject a duplicate IRQ (sched_early) 2. MIDI wake due? → 0xF8, deadline += period 3. head token due? → mirrors, step_pending++ 4. next wake = earlier of the two deadlines 5. reload ch0, write EOI, IRET COM1 — 31250 baud poll THRE, then OUT MIDI synth XT keyboard Set-1 codes, port 0x60 IRQ1 irq1 — capture only 1. IN one byte from port 0x60 2. pulse port 0x61 bit 7 to acknowledge 3. push into the ring; drop if full 4. write EOI, IRET no decoding, no policy, no decisions step_pending one byte, a counter deadline_queue 32 boundary tokens, SPSC key_queue 16-entry ring buffer all that crosses the line MAIN LOOP — NO DEADLINE, INTERRUPTIBLE AT ANY POINT monitor_poll COM2 debug link (assembly) kernel_poll — the C kernel key_pop() → keyboard_feed() → handle_key() take_step() → advance playhead → redraw write 0xB0000 MDA text buffer one word per cell loop forever
Fig 2.4 The two-context split. Both interrupt handlers are assembly and do only what must happen on time; everything requiring judgement happens in C in the main loop. Three structures cross the boundary, each with one producer and one consumer: a counter and a keyboard ring upward into C, and the structural deadline queue downward into IRQ0 (§6.3), plus the small boundary mirrors IRQ0 publishes back.

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 0x7C00 on 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:

unexpected interrupt one of 14 vectors crash_record 'S86!' + vector IP, CS, FLAGS AX BX CX DX SI DI SP 1. kernel_crash_render — C paints the screen vector, IP, CS, FLAGS in hex on the MDA 2. dumped raw over COM2 prefixed A5 'CRASH' — the Mac can log it 3. left in low memory, then HLT survives for peek after a warm restart
Fig 2.5 The crash path. The same record is rendered locally, transmitted, and retained — because on real hardware any one of those three channels may be the only one working. Confirmedtests/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.

ASSEMBLY TRACK — NASM seq86.asm monitor.inc tempo_table.inc nasm -f bin CPU 8086 flat binary, no headers, no linker, no relocations C TRACK — ia16-elf-gcc inside a pinned amd64 container kernel.c keyboard.c kernel_entry.S kernel.h gcc → ld → objcopy -T kernel.ld 3 link-time ASSERTs kernel.bin linker-gated flat image incbin at KERNEL_C_ADDR NASM pads, then embeds seq86.img boot image; padded separately pad to 360 KB seq360.img — 368,640 B three targets, identical bytes DOSBox-X · xt8088.py model the real 5160 via PicoMEM third one blocked: no keyboard
Fig 3.1 The two-track build. Confirmed — every size here was read off a from-clean 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.

kernel.ld — symbolic contract (numeric bounds omitted)
. = 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+6 and 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

Makefile — enforced on every build of zout/kernel.bin
@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.

ASSEMBLY CALLS C — through the 16-byte S86K header at KERNEL_C_ADDR S86K magic, 4 B ver = 1 base+4 kernel_init base+6 kernel_poll base+8 monitor_cmd base+10 net_classify base+12 crash_render base+14 Five near pointers. Assembly does call word [KERNEL_C_POLL] — an indirect call through a constant address. C CALLS ASSEMBLY — through kernel_api, passed as the only argument kernel_api — 48 bytes abi_version, struct_size — checked by C on entry state → the shared struct tempo_bpm, sched_overruns midi_tx, tempo_reload monitor_overlay key_pop, take_step io_in8, io_out8 irq_save_disable, irq_restore far_peek8, far_poke8 every privileged act C never executes IN or OUT. C never executes CLI or STI. C never touches a far pointer outside the video segment. It asks assembly to do it. kernel_state pattern[16], cursor, playpos, playing, step_pending, ppqn_phase, tempo_ix, keyboard one struct, two languages the layout hazard Assembly addresses these by byte offset. C addresses them by name. Nothing in either language checks they agree.
Fig 3.2 The boundary, both directions. Assembly calls into C through five near pointers at fixed addresses; C calls back into assembly through a 48-byte struct of function pointers. Confirmed — the built header has magic 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:

src/kernel.h — a static assert without static_assert
/* 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:

Table 3.1 — The two C link targets
Resident kernelLive module
Scriptkernel.ldmodule.ld
Load addressnear image + S86F at 2800:00002000:0100
Header magicS86KS86M; S86C retained as a loader test
Entered byAssembly, through the headerMonitor RUN, as a far call
ReturnsNever unloadedRETF
Result read atSEG:0120, a fixed block
BSS allowedNoNo
Capacity32 KiB protected far bank32 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.

seqctl.py ping · state peek · poke load · run the Mac TCP 127.0.0.1:8687 DOSBox-X nullmodem — today /dev/cu.usbserial-* physical RS-232 — the real XT UDP :8686 NE2000 via PicoMEM — planned inside the running image monitor.inc polled from the main loop never from an interrupt transport-neutral parser One frame format, three wires. Swapping transports changes no command code.
Fig 3.3 The development loop. The command core is deliberately separated from the transport, so moving from emulator to real hardware — and later to network — is a wiring change rather than a rewrite. Confirmed for TCP (tests pass against the assembled image); Inferred for the physical and UDP paths, neither of which has been exercised.

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;
  • ES and 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.

REQUEST — Mac to machine A5 sync cmd 01..1C seq match reply len lo / hi little-endian payload 0 to 256 bytes xor check the XOR covers everything after the sync byte RESPONSE — machine to Mac 5A sync cmd|80 echo + flag seq echoed back status ok / error len lo / hi payload xor Different sync bytes in each direction: a half-read stream cannot be mistaken for the other side's traffic.
Fig 3.4 Monitor frame layout. Confirmed against docs/development.md and the passing tests in tests/test_monitor.py, which execute these packets against the assembled image.
The loop, in practice
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.

Table 3.2 — Build-time gates
GateCatches
CPU 8086 (NASM)Any instruction the 8088 does not have
nm -u must be emptyAn implicit memcpy or division helper the compiler emitted
grep for div/mulA 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 assertsC struct layout drifting from the assembly's byte offsets
%error on stage-2 overlapAssembly growing past KERNEL_C_ADDR into the C kernel
%error on sector overflowStage 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.

Table 3.3 — What each runtime gate proves
GateMethodWhat it can proveWhat 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).

make jitter-scope — captured 2026-07-22
--- 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.

Table 4.1 — Two transports, two jobs
MonitorInference client
TransportUDP, port 8686TCP, gateway port 8688
DirectionInbound request, outbound replyOutbound connection only
Implemented inAssembly (ne2000.inc)C (net_tcp.c)
PrivilegeRaw memory: peek, poke, load, runTyped operations only, no memory access
State keptNone between datagrams560 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.

FOREGROUND — ONE PASS OF net_poll read CURR page 1 register CURR = net_next? ring empty test no DMA header, validate len ≤ 600, page in range DMA frame → net_rx one frame, then stop yes — nothing arrived, fall through to the timer poll THE CUT — assembly above, C below net_tcp_frame(connection, now, frame, length) ARP · IPv4 · TCP · monitor classification rewrites net_rx in place — no second buffer length > 0 length = 0 RETURN VALUE — ONE 16-BIT WORD DECIDES WHAT ASSEMBLY DOES NEXT 0 — nothing drop it 1 — ARP request assembly replies 2 — monitor assembly dispatches 8000h | length transmit net_rx as-is net_send_from — the only OUT instructions are on this side
Fig 4.1 One pass of 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.

Table 4.2 — What a fixed 560 bytes buys, and what it costs
Given upConsequenceWhy 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.

CLOSED port++ on entry SYN SYN_SENT 5 retries max SYN|ACK ESTABLISHED one flight in the air FIN CLOSE_WAIT peer done sending LAST_ACK our FIN sent ACK — or 5 timeouts, or RST from any phase Five phases. No LISTEN, no TIME_WAIT.
Fig 4.2 The whole state machine. A textbook TCP has eleven states; this one has five, because it never listens and never needs to hold a closed connection open to absorb stragglers — the incrementing local port does that job for free.

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.

Table 4.3 — The retransmit timeout is a function of tempo
TempoScheduler periodRTO (120 ticks)Connection dropped after 5 retries
50 BPM (slowest)50.0001 ms6.00 s30.0 s
120 BPM (default)20.8334 ms2.50 s12.5 s
300 BPM (fastest)8.3333 ms1.00 s5.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.

make network-scope — captured 2026-07-22
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:

Table 5.1 — The four kinds of state
StateLives inReached byMay contain
Canonical song modelStorage pagesIDs, via queriesMusical meaning only — no MDA coordinates, glyphs, or cursor positions
Edit transactionsResident, boundedTyped commandsMutations against named object revisions
Playback projectionsResident, boundedIRQ0, directlyPre-resolved data safe for the deadline path
View projectionsFar view pagesRead-only queriesSummaries 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.

Table 5.2 — Page header, 20 bytes
OffsetFieldNotes
0magicS86P — offset reserved; the encoder does not write it yet
4typesuperblock / internal / leaf / summary / detail
6flags
8generation32-bit, wrap-safe comparison
12payload length
14entry count
16checksum32-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:

tools/song_layout.py --check — captured 2026-07-21
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.

SET A — pages 2, 4, 5 generation N — live resource root · pg 2 pattern · pg 4 detail · pg 5 stays intact until the flip — and after it, becomes the undo image SET B — pages 6, 7, 8 generation N+1 — being built 1. detail · pg 8 2. pattern · pg 7 3. resource root · pg 6 written child-first, 64 bytes per work unit SUPERBLOCK · pg 1 slot A ← gen N+1, root 6 slot B ← gen N, root 2 4. written last, then flushed the next commit builds in set A again — the two sets alternate forever
Fig 5.1 One copy-on-write commit. Children are written into the alternate page set first; the superblock root flip is the single atomic instant, and the old root survives in the second slot.

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:

Table 5.3 — The song-model gates
GateWhat it proves
test-song-layoutThe 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_commitPower-loss safety after every commit work unit (§5.5), and that undo is a correct forward commit
test_long_songA 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_cacheClock eviction, pin overflow, and that a corrupt page is reported, never published
test_module_serviceEleven 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-scopeOn 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.

Gbar slot 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 100 101 102 103 104 100 101 102 103 104 100 101 102 103 104 100 local pass 0 local pass 1 local pass 2 pass 3, cut Gbar boundary Gbar pass 1 — local position resets, playback re-enters step 100 at slot 0 LOCAL_START flags fire at slots 0, 5, 10, 15; GBAR_START additionally at slot 0.
Fig 6.1 A five-step local cycle in a 16-slot Gbar plays 5, 5, 5, 1. Slot 15 begins a fourth pass and is cut at the boundary; the next Gbar pass re-enters at step 100. The mapping was regenerated by executing 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.pysrc/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:

Table 6.1 — The 8-byte boundary token
OffsetFieldNotes
0deadline, low wordabsolute 32-bit PIT count (§6.2)
2deadline, high word
4Gbar slot16-bit, 0–255 today
6effective-step indexinto the published bank
7boundary flagsbit 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:

Table 6.2 — When an accepted edit becomes audible
ChangeActivates at
Anything, while stoppedImmediately
VelocityThe next structural boundary
Skip, Last StepThe next Gbar
Grid duration, Gbar lengthRestart

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.

make pattern-scope — captured 2026-07-22
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.

Table A.1 — Outstanding unknowns
QuestionBlocksHow 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.