Skip to content

Latest commit

 

History

History
205 lines (142 loc) · 51.5 KB

DESIGN.md

File metadata and controls

205 lines (142 loc) · 51.5 KB

The design of p2-11

p2-11 is a PDP-11/40 for the Parallax Propeller 2, written in OctoGo. This is how it is made and why: the machine it is, what OctoGo makes of the code, how the parts work together on the cogs of the P2, how it is tested, and what it costs. README.md says what it does and how to run it, OCTOGO.md what writing it found out about OctoGo, and MIXED.md the plan its core was built by. CLAUDE.md is what the coding agent that works on it is told besides, and takes this file in.

Its purposes, in order: to be an idiomatic example of a non-trivial OctoGo application, OctoGo being pre-release and this the first real program written in it, so that how the code reads outranks how clever it is; to find what OctoGo is missing or gets wrong; and fun.

The emulated machine

Decided, 2026-09-27:

  • Written from scratch for several cogs, not ported from a single-threaded emulator. Devices are goroutines, so their work stays out of the processor's loop. The processor itself is sequential; more cogs do not make it faster. Since 2026-10-03 its common instructions are executed on a cog of their own, by the core, in the P2's own instructions, while the machine's cog waits: one at a time still, the machine's code being what executes the rest. Decided that day: the plan of MIXED.md, aimed at about an 11/40's speed and not at the fastest there could be.
  • Phase 1: the console is the serial line, so the user's terminal is the PDP-11's terminal. Phase 2: investigate a keyboard and a monitor attached to the board, which needs video support OctoGo does not have.
  • Phase 2, begun 2026-09-30: a VGA text console first, the keyboard last. Parallax's P2-ES accessory set came on 2026-10-05: its A/V breakout has the VGA socket and goes on a header that carries eight of the P2's pins, which eight being known when it is plugged in. What shows the screen is Eric Smith's tile driver, as it is, PASM on a cog of its own, reading vt100's cells of four bytes from hub RAM, which "The screen" tells of. ogo has for it, since 3535c9d of the same evening and in v0.47.0, a package that carries .spin2 files, whose PUB methods its functions without bodies call, as Go's assembly is declared: OctoGo's first foreign code, and vga its first user. The driver takes a cog, and another for a moment at its start.
  • A PDP-11/40-class processor, the base instruction set with EIS. No memory management until RT-11 boots, since RT-11 SJ and FB need none; then the KT11-D unit, which Unix V6 requires.
  • Devices: DL11 console, KW11-L line clock, RK11 disk controller with RK05 images on the SD card.
  • First guest: RT-11. Unix V6 afterwards, if it can be done.
  • The main package at the module root, beside an ogo.mod saying module modernc.org/p2-11, so that a visitor runs ogo run right after cloning.

What there is of it:

State
KD11-A instructions, KE11-E (MUL, DIV, ASH, ASHC) done
traps, the trace bit, the fixed stack limit at 400 done
kernel and user mode with a stack pointer each, MFPI and MTPI done
the status word at 177776, the switch register at 177570 done
DL11 console done
KW11-L line clock done
the SD card, and a file's place on its FAT32 volume done
RK11 disk controller, with files on the card for packs done
RT-11 V4, single job begins, and does in a talk of nine commands what it does in SimH
Unix V6 begins, logs in, compiles a C program and runs it, and does in a talk of fourteen lines what it does in SimH
KT11-D memory management, 248 KB done, 2026-09-29
a core in PASM, core, for the common instructions done, 2026-10-03: five times the machine's own speed, one and a half times an 11/40's
a VGA text console, phase 2 the VT100's screen, vt100, and the driver showing it, vga, 2026-09-30, its frames measured without a monitor; the monitor on the board waits to be tried, the hardware having come on 2026-10-05
FIS, the floating point processor not planned: their instructions trap as on a machine without them

What the 11/40 does in its own way, all of it in the vectors: a register source is read after the destination is decoded, so MOV R0,(R0)+ stores the stepped value; JMP and JSR to a register trap through 4, not 10; a program cannot write the trace bit at 177776; HALT in user mode traps through 10; the stack limit is fixed and only ever a trap after the instruction. Its memory management has an access control field of two bits, no SR1 and no trap that lets an access through, and aborts a read that will be written back before the read; SR2 is loaded at every fetch, the unit on or off, and held with SR0's page and mode while an error bit is set.

Reference facts. Addresses are 16-bit and octal; both 0o177560 and 0177560 compile.

Device Registers Vector Priority
DL11 console RCSR 177560, RBUF 177562, XCSR 177564, XBUF 177566 060 in, 064 out 4
KW11-L clock LKS 177546 100 6
RK11 disk RKDS 177400, RKER 177402, RKCS 177404, RKWC 177406, RKBA 177410, RKDA 177412, RKDB 177416 220 5
KT11-D memory management SR0 177572, SR2 177576; kernel PDR 172300 to 172316 and PAR 172340 to 172356; user PDR 177600 to 177616 and PAR 177640 to 177656 250, an abort

The I/O page is the top 8 KB of the address space, 160000 to 177777. Without memory management that leaves 56 KB of RAM; with the 18-bit KT11-D, 248 KB. An RK05 pack is 203 cylinders of 2 surfaces of 12 sectors of 256 words: 4872 blocks, 2,494,464 bytes. The PDP-11 and the P2 are both little-endian.

Written in OctoGo

OctoGo (ogo) is a Go-like language for the P2. Its compiler emits C and compiles that to a P2 binary with an embedded copy of the flexspin C backend. What differs from Go and shapes this project:

  • There is no package clause. A directory is a package, named by the directory. Sources end .ogo, tests _test.ogo.
  • There is no heap: no new, no maps, no closure that captures its scope, no run-time string concatenation, and make only for a slice of constant capacity. Storage is declared, mostly at package scope. A reference that could outlive its referent is a compile error.
  • go starts a physical cog. There are eight, main runs on one, and nothing schedules them.
  • A channel is an unbuffered rendezvous guarded by a hardware lock. Its declaration creates it (var c chan T); make(chan T) is refused. A blocked cog spins.
  • int and uint are 32 bits. float64 has the precision of float32.
  • printf and println are builtins. There is no fmt.
  • The standard library is p2, strings, bytes, math and testing. p2 wraps about thirty intrinsics: pins, smart pins, timing, the serial line and the hardware locks. It has no SPI, no video and no USB.
  • Locals are cog registers from a pool of 480 longs. Running out fails the build with fit 480 failed, which names assembly rather than source. What spends them is a string or a slice passed at many call sites of one function; the CLAUDE.md of OctoGo's repository says more under "A TEMPORARY IS A COG REGISTER".
  • A goroutine's stack is a slot of what the backend's listing says the deepest goroutine needs, 256 longs at least, unless --gostack sets it, for every goroutine at once. Since v0.50.0: before, every slot was 256 longs and nothing checked it, and the cog of the disk, which needs 432 at every boot, finding its packs on the card, ran 176 longs into the next slot, which no goroutine claims after it (OCTOGO.md). Its need is 579 by the listing, the print of a panic among it, in slots of 592.

Architecture

Package What it is Imports p2
the root the program: the cogs, the serial line, what is loaded and run yes
pdp11 the machine: processor, memory, bus, traps and interrupts no
dl11 the DL11 serial line unit, which is the console no
kw11 the KW11-L line clock no
rk11 the RK11 disk controller and its RK05 drives no
sd an SD card's blocks, read and written over SPI yes
card a second program: puts a file of this machine into a file on the card and sums one there, over the serial line, for scripts/card.py yes
prof a third program: runs each shape of instruction in a loop, eight copies to a pass and then sixteen, and says from the difference what one costs in clocks, by the machine alone and with the core yes
core the core: what the machine executes of the instructions in PASM, on a cog of its own, core.spin2 the cog's own
fat where on a disk a file of its FAT32 volume is no
mac the PDP-11 programs the emulator carries, source and assembled no
vt100 a VT100's screen: what a host's characters and sequences do to it, in ANSI and VT52 mode, and what the terminal answers no
vga a screen of characters on a VGA monitor, 640 by 480, and the font vt100's cells want yes

Only the root, card, core, prof, sd and vga know the Propeller 2. The others are Go once they have a package clause, which is what scripts/twin.sh relies on, and it leaves out a package that imports p2 or carries a Spin2 object. sd, fat, vt100 and vga know nothing of the emulator, and are written to be of use without it.

The screen is vga's, which does not draw it: Eric Smith's tile driver does, vga/vga_tile_driver.spin2, PASM on a cog of its own, which reads the cells and the font from hub RAM a line at a time for as long as it runs. vga writes the driver's block of parameters, the addresses of the cells and of the font among them, made numbers through unsafe.Pointer, and calls the driver's start and stop, functions without bodies that its PUB methods are. The cells are vt100's, four bytes each as the driver reads them in its mode of four bytes a character, and 24 lines of 16 are the middle 384 of 480, the other 96 being blank lines more before and after the frame's. The monitor is on five pins from a base: the horizontal sync and the three colours, which are DACs, and the vertical sync.

The card is driven by the code, pin by pin, with no smart pin and no cog of its own. What is read is checked against the card's checksum and what is written is checked by the card, which is how a loop that read too early was found: the compiler had made it faster. The time between the card's clock falling and its bit being read is in the source since, settle in sd/sd.ogo, and what it was measured to have to be. fat does not read files: a file written once to an empty volume is one run of blocks, fat says where it begins and refuses one that is in pieces, and what uses the image reads and writes the card's blocks. Of a volume whose tables are no mirrors of each other it reads the one the volume names, which is what says where a file is; one that names a table it has not is damaged.

A file goes onto the card through the board: card/ is a program of its own, which scripts/card.py builds and loads in place of the emulator and speaks to in lines through the loader's terminal. A block is its 512 bytes after a header that sums them, escaped where a byte is one the terminal would take for itself, and four blocks are on their way at once; a cog reads the line into a ring and main writes the card from it, says of each block whether it arrived as summed and is written, and afterwards sums every block of the file back for the script to compare. It cannot make a file: the file must be on the card already, and fat says where it is.

The disk does a transfer on the cog of Serve, or on the machine's where no cog serves, which is how the tests of rk11 run under the twin. What the cog of the disk reads it stores to memory when the machine's lends it the bus, as "Between cogs" says, and the sectors' headers, which are nothing read of the pack, the machine's cog reads itself. A seek takes no time and ends when the controller has been looked at twice, the controller being ready before the drive as it is with an arm to move. A pack that cannot be read is the checksum error of the RK11, which a program tries again, and one that cannot be written its drive error. mac/boot.mac is the bootstrap, the project's own: it leaves in the registers what the bootstraps of a PDP-11 leave there for what a pack begins with.

Six cogs. main steps the machine with Machine.Run, and the core executes what it can of its instructions, on a cog core.Attach starts with coginit, outside OctoGo's goroutines, while main waits for it. receive does nothing but read the serial line, since nothing is buffered behind p2.ReadByte and a byte arriving while the cog is elsewhere is lost. transmit writes it, seven bits of each byte, as SimH's console does: V6 sends a character with its even parity in the eighth bit, which a terminal of today shows as another character. line counts the cycles of the power line for the clock, hz of them a second, which is 60. It times each from when its second began, so that what one is late by is not added to the next, and in microseconds, which is good to one and forgives a cycle that is late. turn opens the card, puts the packs it finds in their drives, and then moves blocks between the card and memory as the controller orders.

Between cogs there is no lock and no channel. Every variable two cogs share is written by one of them only: dl11.Line has a ring whose head the receiving cog writes and whose tail the machine's cog writes, and a count of bytes written by the program beside a count of bytes sent by the transmitting cog; kw11.Clock has a count of cycles the counting cog writes beside the count of them the register knows of; rk11.Controller has an order with its number, which the machine's cog writes, and a report with the number of the order it is of, which the cog of the disk writes. Memory is the exception, as it is on a bus that a device can take: the cog of the disk reads words of it through Machine.Fetch while the program runs, and writes them through Machine.Store when the machine's cog lends it the bus, looking at the controller between two instructions, and waits until it is done. A PDP-11 holds the bus between an instruction's read of a word and its writing back, DATIP and DATO, and what the disk wrote between the two was lost, until a review of 2026-10-06 found it. The machine's cog and the core's are the other exception: both write the machine's registers, status word, SR2, page registers, pages, traps and mapped, and the first long of the block they share, in turns. The machine's cog hands the state over by writing how many instructions the core is to execute into that long, and does nothing but read it until the core has cleared it, which hands the state back. A channel's rendezvous would stall the cog that reads the line.

A device implements pdp11.Device and is attached at an address range and a priority. The machine reads and writes its registers through Read and Write(a, v, mask), the mask saying which bits a byte write touches. It learns of interrupts by asking: Request answers the vector the device wants, Granted that it was taken. The devices are asked from the highest priority down, every pollEvery instructions and as soon as the program has touched a device or the status word, and again after every interrupt taken, until none is due that the status word lets in. That is where the machine is no 11/40, which takes an interrupt after the instruction during which it was asked for: here it is taken up to pollEvery instructions later, coreEvery with the core, which neither RT-11 nor V6 minds; when a device asks is the emulator's to say anyway, as it is SimH's. The review of 2026-10-06 asked whether that is so on purpose; it is, and README.md says so. What a device's other cogs have done is brought into its registers when the machine next calls it.

What is handed to another cog is not called back. A device's Reset runs on the machine's cog and may not write what another cog owns. So the console's does not cancel a byte that is on its way: the transmitter is ready when the byte has left. There is one byte on its way at most, and a program that writes while the transmitter is busy overwrites it, as in a UART. The disk's does not leave a transfer to go on into memory that is the program's again: it says which order is abandoned, which the other cog looks at between two blocks, and waits.

A pin is the cog's that drives it. What the cogs drive is ORed, so a pin one cog holds high is not another's to pull low. The card is opened by the cog that uses it, turn, and a test that uses the card on its own cog closes it, which lets go of the pins.

Time is counted in instructions where a device has none of its own. Machine.Now says how far the machine has come, a machine that waits coming as far as one that runs, and the console hands the program what has arrived no closer together than apart instructions, 1000 as in SimH. Without that RT-11 took a line that was sent at once the wrong way round: its receiver's interrupt goes on at priority 0 after ten instructions, and counts on the next character taking a character's time. A terminal at 230400 baud leaves it five instructions.

Traps are bits in Machine.traps, the most urgent lowest. An access that fails requests one and reads as zero, there being nothing to unwind with, and what follows asks m.traps&aborts != 0 before it does anything else: before the instruction changes anything, and before the next access of a sequence, a trap's two pushes among them. attention is a bit among the traps that is no trap: whatever needs the instructions to stop following one another for a moment sets it, so that the loop in Run tests one word.

The order of things inside an instruction is the 11/40's, down to what is left behind when an access fails halfway, and that is what the vectors hold the code to. SimH's pdp11_cpu.c was the reference for behaviour; nothing of it is copied.

Memory management is the KT11-D, in pdp11/mmu.ogo: what a page's two registers come to is worked out when they are written, into a page with where it begins and what offsets and cycles it admits, so that read and write do their arithmetic once, inline; space says whose pages an access goes through, the current mode's but while MFPI and MTPI reach into the previous mode's and a trap into the kernel's. A read that will be written back asks the page for writing, as the bus cycle DATIP does, so that an abort comes before the read. Run keeps SR0 and the page the program is in in registers, since a write to a register of the unit and a change of mode ask for attention, which ends its run of instructions; that is what took the unit's cost from a third to an eighth. The console, Examine and Deposit, reads memory as it is on the bus, whatever the map says, and so do Fetch and Store, whose addresses are the bus's 18 bits. There is no SR1: 177574 answers nothing, as on the 11/40. A page that goes past the end of the bus goes on at 0, the carry out of 18 bits being lost, and RESET in the kernel's mode turns the unit off and clears what it recorded of an abort, as the INIT it sends does.

The core executes what it can of the instructions, in PASM, core/core.spin2, which core.Attach starts and binds to a machine. pdp11.Machine.Places says where the machine keeps its registers, status word, SR0, SR2, pages and memory, made addresses with unsafe, and the core reads and writes them there, but that it holds the registers and the status word in its own while a batch runs, and reads the pages from a copy in its LUT; core.Attach refuses a machine whose page is laid out otherwise than the core is written for. Run hands it a batch of instructions through Machine.Accelerate's interface, pdp11.Core, and waits; the core loads the registers, executes, writes them back with SR2, and answers how many it executed. One it would rather not execute it hands back, unexecuted and with nothing changed, and Run executes it itself: whatever touches the I/O page but for PS and a word of a page register; a byte written to PS, and a word written to it that changes the mode; traps, EMT, TRAP, RTI and RTT, HALT, WAIT and RESET, MARK, MUL, DIV, ASH and ASHC; a word at an odd address, memory that is not there, an access a page does not admit, the first write to a page, a push below the kernel's stack limit or onto the I/O page, MFPI and MTPI of the other mode's stack pointer; and everything while a trap is due, which the trace bit makes so. PS and the page registers the core reads and writes where the machine keeps them, as setPSW and writePage do: a write to PS keeps the trace bit, and one that lowers the priority ends the batch with attention set in Machine.traps, for Run to ask the devices before the next instruction; a write to a page register clears the page's written bit and works out what the page comes to, in the machine's pages and the core's LUT, and counts the change in mapped. Run looks up the page the program is in again after every batch, the core having perhaps changed it. Until it is sure an instruction completes it notes what its operands do to the registers, and a hand-back undoes that; the writes to memory come last, when nothing can fail. With a core the devices are asked every coreEvery instructions, 256, not every 32: a round of asking the three costs about 2,800 clocks, which at the core's speed was half the time. A hand-back costs about 710 clocks besides the machine's own. The core is 940 instructions: 334 in the cog, which with its registers and tables fill 493 of its 496 longs; 457 in its LUT, which with the pages of both modes at its end, as the machine keeps them, three longs a page, fill 511 of its 512; and 149 in hub RAM, which executes them slower, for what is rare: PS, the page registers, and binding the core to a machine. The hub code is reached through registers hubinit sets from where coginit started the cog, PTRB, so that where the image lands matters not, and within it every jump is relative. More code goes there. The core reads its pages again when the machine's count of changes to them, mapped, has moved. In the talk with V6, counted under the twin on 2026-10-03, 1.9% of the instructions are handed back, of RT-11's 0.7%: the devices, SR0 and the switches 0.67%, ASH 0.59%, the rest of the EIS 0.17%, the first write to a page 0.17%, RTI 0.12%, and a PS written with another mode 0.10%. Before PS and the page registers were the core's, the same day, V6's was 5.8%, and its talk took 34 s where it takes 27. MIXED.md is the plan the core was built by; where the core differs, this file has it as it is. The twin cannot build the core: it is tested on the board, against the machine's code and against SimH's vectors (see Tests).

The code is shaped by what a call costs (OCTOGO.md, 1 and 3). Run fetches where it stands and finds the instruction's function by the instruction's upper bits, what comes most often first: the two-operand instructions, which are half of what a program does, then the branches and SOB; an operand in a register is dealt with where it is met, an immediate, #n, is found where it stands, and read, write and address are called for the other operands in memory only. There are no helpers that only test something. Do not "tidy" that into small methods without measuring: the first version was written that way and ran a fifth as fast; and execute, a function of the dispatch alone that Run called for every instruction, which found SOB after fifteen comparisons, cost a sixth of the speed until 2026-10-02, when prof/ measured it. What a call costs, what a jump costs and what a hub access costs are in "What it costs, measured".

Tests

  • The vectors, pdp11/vector_test.ogo over pdp11/vectors_test.ogo: 2902 cases of a machine before and after one or two steps, the after being what SimH's 11/40 made of it, with whether it halted. They go through every instruction and addressing mode, the traps, the status word at its address, the stack limit, and 300 cases of whatever sixteen bits came up. Every case begins with the registers of the memory management holding a background, each page where its address says and the unit off, and names the registers it sets and those that changed, SR2 among them after every case; one case in eight has the unit on. The 522 cases that are the unit's put operands in pages that are elsewhere, through every addressing mode; abort in every way there is, a page not there, read-only or shorter than the address, growing either way, on a read, a write and a read that will be written back, in both modes, and see in the second step what the trap left frozen; fetch an instruction or the word after it from such a page; reach into the previous mode's space with MFPI and MTPI; read and write every register, in words and bytes; go past the end of the bus, where a page at 777700 goes on at 0, reading, writing and fetching either side of it; and RESET with the unit on, which turns it off in the kernel's mode and does nothing in the user's. The last two groups have random choices of their own, so that adding them left the cases before them as they were. SimH's 11/40 has an SR1 at 177574 that no 11/40 had, so the generator keeps cases off it as it keeps them off the console.
  • By hand: pdp11/machine_test.ogo for the bus, interrupts, the console's switches, what Run answers and that a program run ends where the same program stepped ends, with a device of its own, and for what the vectors cannot reach of the memory management: the console reading memory beneath the map and a trap from the user's space taking its vector and its stack through the kernel's; pdp11/host_test.go, under the twin only, for the console on machines of 65,535 words and more, up to all that 18 bits reach, which no test binary on the board has the room for beside the vectors; dl11/dl11_test.ogo for the line by itself and for the line as console of a machine that runs mac/demo.mac, whose output is compared with what it says in SimH; kw11/kw11_test.ogo for the clock by itself and for the clock on a machine, which runs mac/ticks.mac to where it ends in SimH, and keeps the cycles that pass while the processor's priority holds them back for one interrupt, as SimH does.
  • The card: fat/fat_test.ogo finds files on a disk that is made up block by block as it is read, with a directory in two clusters that are not neighbours and files in one piece and in several, and on a volume whose two tables are no mirrors of each other, under the twin and on the board. sd/sd_test.ogo reads the card in the slot and writes nothing. card_test.ogo in the root reads all of TEST.DSK and compares it, and writes seventeen blocks of it, reads them and puts back what they had. The last two run on the board only, and pass on a board with no card, or no such file, saying that nothing was tested.
  • The disk: mac/disk.mac has the controller do 35 things, among them every function, every error a program can cause, interrupts, and registers written a byte at a time, and writes the registers after each into a table. Memory ends for it where the bus ends, at 760000, which the extension bits of the control register reach, so SimH is given 248 KB for it and the machines that run it have as much. scripts/rk.py has SimH make that table, and rk11/rk11_test.ogo compares: with the controller doing the transfers itself, and with the test as the cog of the disk after every 1, 7, 49 and 343 instructions. What the program cannot ask is tested by hand: a pack that fails, at once or after a block, the bus address moving or not, a drive that is busy, a pack taken out, and the bootstrap, which leaves in SimH what it leaves here. disk_test.ogo in the root runs the program with the cog of the disk and TEST.DSK on the card, on the board only; and has the machine increment the first word of a block the disk reads into memory, over and over, for a hundred blocks with the core and a hundred without, the word to end up as the block's number with what came after it added. Before the disk waited for the bus, 10 of the hundred with the core and 45 without ended up as the word with all of them added, what the disk wrote lost; none does since.
  • A system: rk11/host_test.go is Go, for the twin only, and is skipped where guest/RK0.DSK or SimH is not. It has the machine begin with the pack and holds a talk with RT-11 in which a file is copied, compared and deleted, has SimH do the same, and compares what the two consoles said and what the two packs then hold. Each command waits for the prompt after the one before it, so that nothing is typed into what the system says. It found the console's pace, which no test of the project's own programs had: they wait for a character and have done with it before the next.
  • A system, Unix: TestV6 in the same file holds a talk of fourteen lines with Unix V6, begun from guest/UNIX0.DSK: it logs in, lists, writes a C program with echo, compiles it with cc and runs it, removes it, sums a file and syncs. What the two consoles said is compared; the packs are not, every file read having its time of access written, and the two machines keeping time differently. Nothing the talk says depends on the time or on what a talk before it left: the compiler's temporary files give /tmp the time of the talk, so ls / shows no times and ls -l /lib shows the kit's. Two things the talk with RT-11 had not taught: a talk types a moment after the prompt, a quarter of a second of the board's, because getty sets its terminal up again just after prompting and that flushes what came before, which on the board is the first letter of the name; and a talk ends with sync, twice, since the next load resets the board while V6 still holds its last writes in the buffer cache, which left the pack on the card with a directory from the middle of a compile.
  • A system on the board: scripts/talk.py holds that talk with the board, through the loader's terminal, and with SimH, and compares what the two have said. It compares the board's bytes as they came since 2026-09-29: it had dropped their eighth bit, as the talks under the twin drop it, and so did not see V6's parity there, which the user's terminal did, and made garbage of; the talk with V6 then failed until transmit sent seven bits. On 2026-09-28 they said the same 1558 bytes, in the three builds, and the pack on the card was then what SimH's copy was: a program made for the day read the file from the card and summed it, and the sums were those of the copy. A talk leaves the pack so that the next one says the same. With -talk v6 it holds the talk with Unix V6, whose root pack is then to be on the card: on 2026-09-29 the board said what SimH said, 1357 bytes, in 96 s and then in 94 s, the second time being the proof that a talk leaves the pack so. It said so again that evening with ogo v0.46.0, in 94 s, and then with the other three packs of the kit on the card as well, which V6 mounts and SimH is not given, in 95 s. What the pack on the card holds is compared with the file by scripts/card.py sum RK0.DSK guest/RK0.DSK, which names the blocks that differ: after a talk, blocks 8 to 9 and 38 to 45, the second directory segment and the swap area, and 1423 to 1458, where the copied file was.
  • The terminal: vt100/vt100_test.ogo, from chapter 3 of the VT100 User Guide, which vt100.net has as text: the controls, the cursor at the margins and in origin mode, autowrap and the column it waits in, scrolling inside the margins, erasing, the renditions and the reverse screen, the character sets, the cursor saved and restored, the answers, the modes, sequences broken off and those the VT100 has not, the reset, and VT52 mode; under the twin and on the board. It found an octal escape before a digit, which OCTOGO.md has among what v0.47.0 closed: the board had its ESC 7 as one character where the twin had two.
  • The screen on the board: vga/vga_test.ogo starts the driver on the pins from P16, where nothing is, with a VT100's screen that says hello, and times frames by the vertical sync, which any cog can read: 60 of them in 1,008,000 µs, which is 525 lines of 800 pixels at 25 MHz to the microsecond, at 160 and at 200 MHz, and the sync 61 to 63 µs of its 64, what finding its edge took being the rest; measured with ogo 35d9456 on 2026-09-30, and so again with v0.47.0 and v0.47.1 the next day, the block of parameters written in OctoGo. It times 600 since, which hold the screen ten seconds, because the monitor it is to be watched on, a Philips 272S1AE, takes a while to trust a new input: 10,080,000 µs at 160 MHz, twice, and 10,079,999 at 200, with v0.47.1. The horizontal sync is a DAC, whose input does not follow it. What the screen shows wants a monitor. The test imports vt100, whose constant params beside vga's variable of the name is how it found a fault OCTOGO.md has among what v0.47.1 closed; it keeps the shape.
  • The core, on the board only: core/vector_test.ogo runs pdp11's 2902 vectors with the core attached, core/vectors_test.ogo being a link to pdp11's table, through the machine's exported methods, a stack pointer of either mode being set and read by changing the mode as the console would; and core/lockstep_test.ogo gives two machines, one with the core and one without, the same random state and instruction and compares registers, status word, both stack pointers, SR0, SR2, the page registers and memory, 5,000 instructions one at a time and 300 random programs run in batches; an address is now and then PS, SR0 or a page register, and the eighth page of each mode now and then the I/O page. A hand-back makes an instruction the machine's on both, so each test says how many the core executed itself: on 2026-10-03, with PS and the page registers the core's, 2,163 of the vectors' 3,432 steps, 1,939 of the random instructions and 25,775 of the 67,406 the programs ran, with nothing differing. 10,000 random instructions no longer fit in the two minutes ogo test gives a package on the board. The root's tests run with the core attached since, the clock's sixty interrupts and the disk's program among them, and so do the talks.
  • Between cogs, cogs_test.ogo in the root: 20,000 bytes each way through the console with the cog of the other end running at once, and resets with a byte on its way; and the clock with the program's own cog counting, by which mac/ticks.mac is to take a second. The tests of dl11 are one cog taking the part of three in turn; this is the only place that asks whether a cog sees what another wrote, in the order it was written. It runs on the board only.
  • A sweep is a larger table made elsewhere and run under the twin only: scripts/vectors.py -n 12000 -scale 2 -o FILE takes a quarter of an hour; put FILE in place of pdp11/vectors_test.go, under a package clause, in a twin kept with scripts/twin.sh -k DIR, and run GOARCH=386 go test ./pdp11 there.

The twin is for a fast answer about the emulator's logic and for what a board has not the time for. It is not a verdict on what the board does; the board is. ogo help test states OctoGo's position. A .go file beside the .ogo files of a package goes into the twin as it is: pdp11/host_test.go has the vectors look at all of memory after every case, for a word written that no case names, where the board looks once at the end.

A program that is compared with SimH must not depend on how long a drive takes, which is SimH's guess and not this machine's: it waits for what it wants to see. SimH's sector counter is a random number, and is left out of the table.

A case the generator makes must not depend on what SimH has and the machine under test has not. SimH keeps its console and its clock whatever it is told, so the generator sets breakpoints on their registers and drops a case that hits one; and it clears all of memory before each case and lists every word that is not zero after it.

A review by the Codex agent on 2026-09-27 found four faults the tests had not, all of them in what happens between instructions or between cogs and none in an instruction: a reset with a byte on its way, a trap's second push after its first had failed, an interrupt let in by the one before it, and what Run answers when its last instruction is a WAIT. Each has its test now. That is where to look next: combinations of events, not more instructions.

Another, of 2026-09-30, found five more of the kind, none of them in what an instruction does by itself: a volume whose tables are no mirrors of each other, whose table that does not count could have had a file written over another; a page that goes past the end of the bus; RESET with the unit on; the console of a machine of 64K words or more; and a read into one address that failed after its first block. Each has its test now, which failed before the fault was mended; the fourth's runs under the twin only.

A third, ChatGPT's of 2026-10-06, found one more of the kind: a word the disk wrote between an instruction's read of it and its writing back was lost, which the bus of a PDP-11 does not let happen. And fat added and multiplied what a volume says in 32 bits, so that a file of almost 4 GiB was of no blocks, and a volume that says what cannot be had its clusters on its tables. It took the one count of looks for the seeks of all drives for a fault, which it is not, two drives never seeking at once; asked how many registers the core's undo log needs room for, which is two; and asked whether an interrupt taken up to 256 instructions late is so on purpose, which it is (see "A device"). Each fault has its test now, which failed before, and so has the seeking of two drives.

What is generated, and by what

File Made by From
pdp11/vectors_test.ogo scripts/vectors.py SimH, one to two minutes
mac/demo.ogo, and so on for every .mac scripts/mac.py mac/demo.mac the MACRO-11 source beside it
mac/disk_table.ogo scripts/rk.py SimH running mac/disk.mac with two packs the script makes, a second
vga/font.ogo scripts/font.py tools/unscii-16.hex, Unscii 2.1's font of 8 by 16, public domain, and seven glyphs the script draws
logo.svg scripts/logo.py nothing: the name drawn as strokes, its ones the toggle switches of a front panel
v6-demo.gif scripts/record.py, then agg --idle-time-limit 120 --last-frame-duration 5 v6-demo.cast v6-demo.gif, then scripts/poster.py v6-demo.gif, which puts the last frame first, held 3 s, since what does not play a GIF shows its first frame, and a session's first is an empty terminal a session with Unix V6 on the board, its root pack on the card, a minute; made again on 2026-10-03 with the core and ogo v0.48.0, 45 s where it was 95

scripts/tools.sh fetches and builds SimH and the macro11 assembler into tools/, which git ignores, each at the revision the repository's files were made with, and fetches the font scripts/font.py reads, checked by its sum. Nothing needs them but these four scripts. Made again with the same tools and arguments, the files come out as they are. The recording is the exception: made again, it has the timing of its own session; and agg, asciinema's converter, is not among the tools, nor asciinema, which plays the .cast. scripts/poster.py wants Pillow and numpy, and checks that every frame it writes is the frame it read: Pillow's quantize comes near a colour, not to it.

What it costs, measured

P2-EC, mac/bench.mac: 303,004 instructions, of which a third each are ADD R2,(R1)+, INC R3 and SOB.

Build Instructions a second, with the core the machine alone
checked, 160 MHz 753,741 123,272
--unchecked, 160 MHz 759,408 138,231
--unchecked --clock 200MHz 949,855

That is with ogo v0.50.0 on 2026-10-08, with the core by the program and alone by prof/, which runs the benchmark alone and with the core, and with it said 753,741 and 761,316; with v0.51.0 the next day prof/ said 753,741 and 759,408 with the core and 123,272 and 138,294 alone, and the program 949,855 at 200 MHz. The core is PASM, so whether the build is checked hardly matters with it. On 2026-10-03, with v0.47.2-0.20261003025343-83dd0ac06067+dirty, the five were 755,620, 761,316, 952,842, 123,776 and 137,979. In clocks at 160 MHz, --unchecked, with the core: NOP 107, SOB taken 115, BR 123, MOV R1,R2 167, INC R3 203, ADD R1,R2 195, MOV #1,R2 219, MOV (R1)+,R2 275, MOV R2,(R1)+ 267, MOV 2(R1),R2 295, ADD R2,(R1)+ 395, MOVB (R1)+,R2 283, JSR PC,@#sub and RTS PC 237 each, MOV R3,-(SP) and MTPI (R1)+ 307 each; with the unit on, MOV R1,R2 184, MOV (R1)+,R2 340, ADD R2,(R1)+ 507, which is a translation of each access, about 30 clocks, and the page the program is in looked up when it changes. MUL, which the core hands back, costs 1644 where the machine alone takes 1025. The status word and the page registers, which the core reads and writes from hub RAM since the evening of 2026-10-03: MOV @#PS,R2 386 where the machine takes 2124, MOV R3,@#PS 422 where 4227, MOV @#KPAR0,R2 432 where 2252, and MOV R3,@#KPAR6 646 where 4611; the machine's writes cost it a round of the devices, which a write that leaves the priority where it was spares the core. The checked build's numbers are within two clocks of these, but for what the core hands back. On 2026-10-03 the core's were the same or 2 to 13 clocks fewer.

Against an 11/40. Appendix C of the PDP-11/40 Processor Handbook of 1972 gives the instruction times: MOV R1,R2 0.90 µs, INC R3 and ADD R1,R2 0.99, MOV (R1)+,R2 2.30, MOV R2,(R1)+ 2.42, MOV 2(R1),R2 2.92, ADD R2,(R1)+ 2.66, a branch taken 1.76, SOB taken 2.36, JSR 3.44 and RTS 2.42, and 0.15 µs more a memory cycle with the KT11-D fitted. The benchmark's three are 2.00 µs on average, 500,000 instructions a second, and with the KT11-D 444,000. With the core the machine is about one and a half times that: faster with an operand in memory, MOV (R1)+,R2 1.72 µs, and much faster at a branch, 0.77, and a little slower register to register, MOV R1,R2 1.04 µs where an 11/40 took 0.90.

The machine without the core, which is what the twin runs and what a program gets when there is no cog for the core:

Build Instructions a second
checked, 160 MHz 129,766
--unchecked, 160 MHz 144,701
--unchecked --clock 200MHz 181,005

That is with ogo v0.47.2-0.20261002211137-ad6edefafc2b on the evening of 2026-10-02, the console, the clock and the disk on the bus, the machine counting how far it has come, and its memory management off, with the pages where their addresses say. That morning, with v0.47.2-0.20261002103426-b80a9c3f7bab+dirty and the dispatch of an instruction still a function of its own, execute, the three ran 104,089, 118,360 and 147,951; with the dispatch in Run, the two-operand instructions asked for first and SOB third, 120,526, 137,292 and 171,673, a sixth faster; with the evening's compiler, which writes narrow arithmetic with fewer conversions and leaves out of a checked build the checks the program proves, 128,883, 143,399 and 179,292; and with an immediate operand found in double without address, as the table has it, which is the same within the noise of where the build has put things, the benchmark's loop having no immediate in it. The unit on cost an eighth more when it was last measured so, on 2026-09-29 with v0.44.0: 89,778, 103,379 and 129,212 against 104,556, 118,685 and 148,385; what it costs an instruction is in the profile below.

Where the time goes. prof/ runs each shape of instruction in a loop, eight copies of it to a pass and then sixteen, and the difference is what the eight cost; the loop, the devices asked every pollEvery instructions and whatever else the machine does on the way are the same in both and fall out. In clocks at 160 MHz on the evening of 2026-10-02, checked and --unchecked: NOP 772 and 696, SOB taken 524 and 448, BR 700 and 512, MOV R1,R2 924 and 832, INC R3 1020 and 936, MOV #1,R2 1408 and 1271, MOV (R1)+,R2 1704 and 1552, MOV 2(R1),R2 2230 and 2066, ADD R2,(R1)+ 2904 and 2647, MOVB (R1)+,R2 1736 and 1576, JSR PC,@#sub and RTS PC 1649 and 1470 each, MUL R1,R2 1116 and 976; and with the unit on, MOV R1,R2 978 and 887, MOV (R1)+,R2 2006 and 1791, ADD R2,(R1)+ 3738 and 3291. That morning, with the dispatch in Run and the morning's compiler, unchecked: NOP 737, SOB 481, BR 545, MOV R1,R2 865, MOV #1,R2 1551, MOV (R1)+,R2 1543, ADD R2,(R1)+ 2663; and with the dispatch in execute before it: NOP 867, SOB 747, BR 763, MOV R1,R2 1043, MOV (R1)+,R2 1735, ADD R2,(R1)+ 2872. The immediate operand found in double took MOV #1,R2 from 1529 to 1271, one call fewer, and put 6 to 30 on every other two-operand instruction, the test of the mode being a taken jump on the way of a source in memory; a third of what Unix V6 and RT-11 read are immediates. So an instruction costs Run's loop, about 350 clocks, which is five hub accesses, some sixty instructions and four taken jumps with the dispatch; a call of its function, about 155 besides the call itself for one that calls others, as double and single do, and about 40 for a leaf, as branch is; and its body. An operand in memory costs two calls more, address and read, about 700 in all, and one that is written back three, write as well, about 1800 for ADD R2,(R1)+. The unit on costs about 60 at the fetch and 250 to 300 an access, which is five hub reads and two taken jumps in read.

What the talks execute was counted under the twin on 2026-10-02, by a copy of it that counted in Run. The talk with Unix V6 is 5,592,608 instructions, 96% of them with the unit on: MOV 28%, the branches 15.5%, of which 79% are taken, SOB 9.2%, CMP 8.7%, MTPI 6.2%, JSR 4.4%, ADD 4.4%, CLR 4.3%, TST 2.4%, RTS 2.3%, MFPI 2.2%, CMPB 1.9%, JMP 1.4%, BIC 1.3%, and nothing else above 1%. Of its source operands 30% are registers, 30% immediates, 15% indexed and 5% more relative to the PC, 12% (R)+ and 7% -(R); of its destinations 42% are registers, 16% -(R), 13% relative to the PC, 9% (R), 8% indexed and 5.5% (R)+. The talk with RT-11 is 5,738,000 instructions, the unit off: the branches 35%, MOV 13.5%, TSTB 12.7%, CMP 5%, CMPB 4.5%, JSR 3.5%, BIC 3%, RTS 3%, ADD 2.8%, INC 2.7%, MOVB 2.7%.

What the backend does with a call, a jump and a switch, measured by a program of loops on 2026-10-02 with the same compiler, --unchecked at 160 MHz, from hub memory as the emulator runs: a function that calls another saves all its locals on the way in and restores them on the way out, through the hub stack and three returns to hub memory, 150 to 160 clocks a call besides the callee's work, and a leaf costs its call and return, about 40; a taken jump costs 19, and 4 from cog or LUT memory; a switch is a chain of them, 19 for every case it passes; a hub read 25 to 37 and a write about 10. The programs and what else was tried, -O2, a function placed in LUT memory, which took 7% off the benchmark by itself, and the backend inlining read, which it would not, are in OCTOGO.md, 3.

The history before that, by compiler: with ogo v0.44.0 on 2026-09-29 the three ran 104,556, 118,685 and 148,385, and the day before, without the unit, 114,904, 131,113 and 163,874, which is what the unit costs with the unit off: a tenth, of which the paragraph on memory above says what is what. With the compilers of 2026-09-28 before it, 120,287, 134,369 and 167,962 with the console alone, 119,811, 133,071 and 166,302 with the console and the clock, 116,629, 133,364 and 166,760 with the disk as well, and 116,540, 131,455 and 164,318 with the count: a device more to ask every pollEvery instructions costs about 1%, and so does where the build has put things. With v0.46.0, which inlines a small function where it is called, 104,484, 117,900 and 147,375, and 104,376, 117,762 and 147,232 with --no-inline: what it inlines, stackCheck, the register pairs of the EIS, the disk's Request and finish, the clock's update, and helpers of sd and fat, is called seldom or never while the benchmark runs, and the binary is 371,924 bytes with it and 365,320 without.

Sixty interrupts of the clock take 985 to 999 ms from when the program enables them, the first cycle being under way by then.

The talk with RT-11, from the loader's first byte to the last prompt, of which six seconds are the loader, the benchmark and the clock's second: 36 s checked, 32 s --unchecked, and 26 s --unchecked --clock 200MHz, before the memory management; with it and the binary of 365 KB, 45, 39 and 32 s. With the core, on 2026-10-03, checked: the talk with RT-11 14 s, and the talk with Unix V6 33 and 34 s, its compile among it, and 27 s once PS and the page registers were the core's as well; on 2026-10-06, with ogo v0.48.2-0.20261006170053-37cb8dcce3fb+dirty and the disk waiting for the bus to store to memory, V6's 28 s where the build without that took 27 s the same evening, and RT-11's 14 s, as without; on 2026-10-08, with v0.50.0, 28 s and 14 s, and so the next day with v0.51.0; and on the morning of 2026-10-03, with the build the core was in and the core not attached, RT-11's 38 s and V6's 84. The talk with Unix V6, checked, 94 to 96 s with v0.44.0 as with v0.46.0, and on 2026-10-02 what the processor makes of it: 95 s with the dispatch in execute, 88 s with it in Run, 82 s with the evening's compiler, 80 s with the immediate operand found in double, and 59 s --unchecked --clock 200MHz. Most of the talk is the compiler compiling.

A pack onto the card with scripts/card.py: 4872 blocks in 113 s, which is the rate of the line at 230400 baud with a block's header and its escaped bytes, and 15 s more for the build, the load and the reading back. To sum the pack on the card against a file takes 15 s in all.

A block of the card, with its command and its checksum:

Build Microseconds to read a block
checked, 160 MHz 1610
--unchecked, 160 MHz 1239
--unchecked --clock 200MHz 1050

To write one takes 2.5 to 5 ms, most of which is the card's. All of TEST.DSK is read and compared in 9.6 s by the tests, which are built checked. With settle at 0, 1 or 2 the first thing of any length the card sends fails its checksum, at 160 MHz and at 200 MHz alike, and with 3 to 6 all of 500 blocks are read; it is 8. The checked build's block was measured again with v0.44.0 on 2026-09-28, 1605 µs, and with v0.46.0 on 2026-09-29, 1624 to 1640 µs, TEST.DSK being read in 9553 to 9605 ms; the other two were measured with ogo 3875205f89d4, of the day before v0.44.0, as was the talk with RT-11: the card was not there when v0.44.0 was.

In clocks at 160 MHz, --unchecked, measured on 2026-09-28 in loops the backend had put in cog memory: a call and its return 75 to 115; a field of the machine read and tested, 30; a switch, about 5 for every case it passes. From hub memory, where the emulator runs, the numbers are those above under "Where the time goes". A function with a branch in it is never inlined by the backend. The measurements and their programs are in OCTOGO.md. Measure again after an ogo upgrade before relying on any of it: the compiler of 2026-09-28 made the emulator 6 to 8% faster by making a named constant its value, where the three before it had agreed to within 2%.

Memory is a slice the program gives the machine with Memory, and the program's is a 248 KB array at package scope, which is part of the binary image: 372 KB to load, 365 KB with --no-inline. It was an array in the Machine of 56 KB, which made a word access one rdword; a Machine of 248 KB does not fit beside the vectors in a test binary, and the tests want machines of their own size. With the slice an access reads its pointer and its length, and the enable bit of the memory management before them, and every instruction writes SR2: measured one by one on 2026-09-29, --unchecked, SR2's write is 2.4% of the speed, the enable bit 2.1%, the length 1.3% and the slice the rest of the tenth.