AltairSim

Serial I/O

Every card that moves characters — the 88-2SIO, the 88-SIO, the ACR cassette, the line printer, the paper-tape reader, the PMMI modem, the Turnkey's onboard 6850 — is built on the same three layers, and the whole point of them is that a card only ever touches the top one.

A board knows it has a serial line. It does not know, and must never learn, what is on the other end of it.

That one sentence is why CONNECT sio:a socket:2323 needs no code in the 2SIO, why a card written next year is cross-platform, scriptable and replayable for free, and why there is a whole chapter here instead of a paragraph in each board's source.

The three layers, top to bottom:

Layer Who Owns
The card a Board (src/core/board.h) port decode, register bits, polarity, interrupt straps — everything the manual describes
The stream a ByteStream (src/host/stream.h) non-blocking byte movement, the modem pins, the line rate
The endpoint resolveEndpoint (src/host/endpoint.h) the grammar: console, socket:2323, serial:, in:/out:, …

A card reaches down to the stream. It never reaches down to the endpoint — it does not know the grammar, and it must not learn it.

The ByteStream contract, and the one discipline

src/host/stream.h is the seam. Read it; it is short and every comment in it is load-bearing. The interface a card actually uses:

virtual size_t read(uint8_t* buf, size_t n) = 0;   // NON-BLOCKING
virtual size_t write(const uint8_t* buf, size_t n) = 0;
virtual bool readable() const = 0;  // -> drives RDRF / RxRDY
virtual bool writable() const = 0;  // -> drives TDRE / TxRDY
virtual std::string describe() const = 0;   // round-trips the operator's spec

The discipline is one line and the whole subsystem stands on it:

THE BOARD NEVER BLOCKS. It asks readable()/writable() and moves on.

A UART that blocked would stop emulated time, and stopping emulated time to wait for a human is precisely how an emulator comes to drop characters. So read() returning 0 is not an error and not an end of file — it is a quiet line, a poll that found nothing, exactly what an IN from a real UART's data port gives you when nobody has typed. A stream never signals EOF up into a board; a board polls forever, because that is what the silicon does.

Two more methods complete the contract, and both exist so a board stays pure:

  • pump() is called once per time slice by the run loop, never by the board. It is the one place a stream may talk to the host: accept a socket, drain a keyboard, poll a file. A card's pump() is a one-liner that forwards to stream_->pump(). Everything a board does in read()/write() is pure computation over its own state; anything that touches the outside world happens here, at a known point in emulated time. That seam is what would let a recorded session replay identically.
  • drainLog() is what the far end wants said out loud — a print job that could not be spooled, a serial port that cannot do the baud it was asked for. The board drains it off its units' streams (the default Board::drainLog() does exactly this) and the monitor prints it after every command and run.

The unconnected line is not a special case

class NullStream : public ByteStream {
    bool readable() const override { return false; }
    bool writable() const override { return true; }   // TDRE set forever
    size_t write(const uint8_t*, size_t n) override { return n; }  // consumed, gone
};

An unconnected unit is bound to a NullStream — TDRE set, RDRF clear, forever, and a write goes nowhere without complaint. That is exactly how an unconnected 6850 sits on a real card. Because of it there is no null pointer anywhere in a board's stream path, and therefore no if (nothing is plugged in) branch in any board. Bind the stream to a NullStream in the constructor and never let it be null again.

Two more streams are worth knowing because they are how you test:

  • LoopbackStream jumps TX to RX and loops the modem pins back too (RTS→CTS, DTR→DCD/DSR). It is the one endpoint that tests modem control with no hardware — the plug in the drawer.
  • ScriptedStream keeps the two directions separate: a test types into feed() and reads what the guest printed out of out(). This is what makes a guest program a unit test — a monitor's banner and its answer to a command are just bytes it wrote to a serial port, and a test that can see them asserts on them with no terminal, no thread, and no timing in the picture. It also counts empty polls (hungry()), which is how the MCP run loop knows a prompt is spinning on input versus a loader that is merely quiet while it works.

The endpoint grammar lives in exactly one place

std::unique_ptr<ByteStream> resolveEndpoint(const std::string& spec, std::string& err);

src/host/endpoint.cpp is the only file in the program that knows the endpoint grammar. endpointHelp() prints the full list. HELP CONNECT, the "no endpoint" error, the MCP connect tool and the generated reference all show that list. Do not copy the list by hand. A hand-typed copy in this page and in the MCP schema kept file:PATH after in: and out: replaced it (#784). Examples: socket:2323, serial:/dev/ttyUSB0, out:printout.txt.

The scheme words (console, socket:, in:, …) are in one table, kSchemes, at the top of endpoint.cpp. canonicalEndpoint() folds each word in the table to lower case, once, before the resolver reads the spec. The resolver names a scheme with isScheme() or hasScheme(). These take a Scheme, which has a consteval constructor, so a word that is not in the table does not compile. To add a scheme, add its row to kSchemes. The new word is then case-insensitive, as every other scheme is (#778). The sink column marks the words that can follow a | as a mirror. isMirrorSink() reads that column for the resolver, for rebaseEndpointPaths and for --mirror.

CONNECT and MOUNT are generic monitor commands, not per-board ones (DESIGN.md §7.7): the monitor opens the endpoint, the board decides what the bytes mean. That division is why a serial card gets every backend the day it lands without one line changing in the monitor.

It never guesses. CONNECT sio:a consle is an error carrying the list of what it could have meant, not a silent NullStream that leaves you wondering why the terminal is dead.

Two helpers exist so a board can use the grammar without reimplementing it:

  • rebaseEndpointPaths / rebasingResolver rewrite only the PATH-bearing specs (in:/out:) so a machine-file relative path is resolved against the machine file's directory, not the shell's cwd. The grammar of which specs carry a path stays in endpoint.cpp; the board only supplies its config dir. Remember the path as the operator wrote it for describe(), and rebase only the copy you hand the resolver — otherwise a relative path double-rebases on CONFIG SAVE + reload.
  • parsePort / parseHostPort let a board validate a HOST:PORT string (the PMMI's dial/answer settings do this) using the same split the resolver uses.

The capture tap is a decorator ByteStream

ENDPOINT|FILE taps a line to a hex log — a poor man's protocol analyzer. It is worth studying because it shows how far a ByteStream decorator gets you with zero board or monitor changes.

TeeStream (src/host/tee_stream.h) wraps an inner stream, copies every byte past in both directions to a log file, and forwards everything else — readable/writable, the modem pins, the line rate, pump, pacesItself — unchanged. It is the same shape as FilterStream (src/host/filter.h), with one difference that matters:

A filter mutates bytes, so there is exactly one of them and it lives on the console (a strip7out on a binary transfer corrupts it silently). A tee never touches a byte — it observes and forwards — so it is 8-bit clean by construction, and the "one filter, on the console" rule does not apply. You may tap any line: a socket, a real serial port, a tape.

The grammar is a single character. resolveEndpoint checks for a | before the prefix dispatch (so socket:23|cap.hex is not mistaken for a socket spec), splits on the first one, recurses on the left for the wrapped stream, and parses the right as FILE[?opts]. Because the inner is resolved by the same function, the tap composes with everything — in:tape.tap?cps=300|trace.log taps a paced paper-tape reader. describe() returns inner->describe() + "|" + fileSpec, so the whole tap round-trips through SHOW and CONFIG SAVE, and the | re-triggers the branch on reload. Two consequences fall out of the grammar living in one place: rebaseEndpointPaths had to learn to split on | and rebase both sides (a machine-file relative log path is config-relative like any other), and the timestamps come from an injectable host wall clock (the printer/tape pattern), never the emulated Clock — a trace whose timestamps freeze at a monitor prompt is a trace of nothing.

fmt=text is a transcript, not a trace. It writes the bytes the inner line accepted on write(), raw, and flushes at once so tail -f is live. It writes no header, no timestamps, no pin edges and no read side — the guest echoes what it reads, so logging RX would double each typed key (the mirror's rule, for the same reason). The resolver opens the file in binary mode for this format only, so Windows does not turn LF into CR LF, and refuses ts/width/gap/pins with it instead of ignoring them. append (any format) opens the file to add to it.

Sinks stack, and the spec splits on the LAST |. scripted|socket:2323|s.log?fmt=text is a tap on a mirror on a scripted line: the last sink is the outermost decorator and the left side recurses. resolveEndpoint and rebaseEndpointPaths both use rfind('|'). This is what --mcp --mirror … --log … builds (mcp/server.cpp, console()): Filter → Tee → Mirror → Scripted. The MCP server adds append because console() runs again after a CONFIG LOAD; main.cpp truncates the file once at startup. Anything that peels decorators to reach the scripted line (asScripted, mcpScriptedConsole in the monitor) must peel TeeStream too.

A mirror's sink is a MirrorSink. MirrorStream (host/mirror_stream.h) owns the rules — output is dropped with no watcher, the queue is bounded and drops its oldest bytes, typed keys are injected, ro discards them — and the sink is only the wire: poll(), attached(), read(), write(). SocketMirrorSink is the TCP listener; PtyMirrorSink wraps platform::Pty (|pty, |pty:LINK, issue #683). SerialMirrorSink wraps a platform::SerialPort (|serial:DEVICE[?baud=N]). A new sink is one more class and one branch in the resolver.

The serial sink is not a HostSerialStream. HostSerialStream is a line for a board: it owns the modem pins, a board reprograms it, and it accepts every byte into its own queue because a board must never see a short write. A mirror has no board, so the sink opens the port at 8N1 and 9600 baud (or ?baud=), with flow control off, and write() returns what the driver took. MirrorStream keeps the rest in its own bounded queue. attached() is always true: DCD and DSR are not reliably driven by a terminal, so nothing says when one is there. Reads and writes that fail return 0, as in HostSerialStream, so an adapter that is unplugged makes the mirror go quiet without an error. The test uses a fake SerialPort, and one case uses the slave of a pty as the device.

pty alone is the line itself, not a mirror. PtyStream (host/pty_stream.h, issue #685) holds the same platform::Pty as a plain ByteStream, and is built like TcpStream: a program that has the link open is carrier (DCD and DSR), and a full kernel buffer drops CTS, so the guest waits and loses no byte. One thing differs from the socket. With nobody on the line, CTS stays up and write() discards the bytes, so a board with wired CTS does not stop on a line that nobody reads. The name of the link reaches the operator two ways: CONNECT prints takeNote() at once, and a machine file gets it through drainLog().

Machine::drainBoardLog() drains each serial line too. A board that overrides drainLog(), as the 2SIO does, returns the messages of its chips and never asks the stream. The machine therefore walks units() and unitStream() after each board, and prints what the stream has to say as id:unit: message. A board that uses the default Board::drainLog() has already taken those messages, and the walk finds none.

The pseudo-terminal has three traps, all in platform/posix/pty_posix.cpp. (1) A master whose slave was never opened reads EAGAIN, the same as "open and quiet", so openPty() opens and closes the slave once; after that POLLHUP means nobody is there. (2) macOS puts the slave back in cooked mode with echo at every reopen, and an echo would return the guest's output as typed keys, so poll() reads the settings through the master on every call while a program is attached, and sets raw mode when it finds echo or line mode. It does not wait for the edge where a program arrives: a close and a reopen between two polls show no edge (issue #687). (3) The name is a symbolic link claimed with symlink(), which fails if the name exists, so two simulators cannot take the same /tmp/altairsim{n}. A test reaches the slave side through platform::openPtyPeer, because the platform lint covers tests/ too.

runMcp pumps while it waits, but only with a mirror. Between requests the worker used to block in cv.wait, and nothing called pump(): the tail of a long listing stayed in the mirror's queue until the next run. With --mirror it now wakes every 10 ms and calls m.pump() on the worker thread. Without a mirror the wait is unchanged.

Installing the resolver — in both mains

The grammar travels to a board as a function it is handed, never as knowledge it holds:

static void setResolver(EndpointResolver r);   // on the board (or the Sio2Port section)

Install it once in each composition root: src/main.cpp (the CLI) and tests/main.cpp (the test binary). Miss the test main and every unit test that connects an endpoint gets a board whose resolver is null. A card that embeds a Sio2Port inherits the section's one resolver and needs no setResolver of its own.

The modem pins go both ways, and the chip owns polarity

struct LineStatus  { bool carrier=true, cts=true, dsr=true; bool ring=false; };  // far end -> card
struct LineControl { bool rts=false, dtr=false, brk=false; };                    // card -> far end

One rule governs every pin on every stream:

true is always "asserted".

The pin-level inversions are real — the 6850's carrier and CTS pins are /DCD and /CTS, active low — but that is a fact about that chip's pins, and it stays inside the chip that has them. A stream that reported "carrier = true, meaning the pin is high, meaning there isn't one" would be exporting one chip's polarity to every other card in the machine, and the 88-SIO would be wrong for free. The wire carries a level in true sense; the honoring chip decides what it means — the same division as PHANTOM*.

A level has no memory. The stream says "carrier is down" for as long as carrier is down; the chip does the latching (a 6850 holds its DCD flag after the pin returns and clears it only on a status-then-data read). Put the latch in the stream and every stream re-implements it slightly differently.

A console or a file has none of these pins in any real sense, so it asserts them all — which is exactly what strapping DCD and CTS to ground on the connector does, and what period installers did constantly. That, again, is why there is no "what if nothing is plugged in" branch anywhere.

The line rate is the card's, and there is only one

struct LineParams { long long baud=9600; int dataBits=8, stopBits=1; LineParity parity=None; };
virtual bool setParams(const LineParams&, std::string& err);

There is exactly one line rate in a serial card and it is the UART's clock — a jumper. When a unit is connected to a real host serial port, the card programs the port: SET sio0:a BAUD=300 restraps the card and the host UART follows. Only a serial: endpoint honors setParams, and it is the only stream that may refuse — an FTDI cable that cannot do 76800 baud is a fact about the world, and the card says so out loud through drainLog() rather than run at the wrong speed in silence. A false return does not mean the connection failed: the card stays strapped to what it is strapped to and goes on pacing the guest at that rate, because that pacing is the half the guest can actually measure.

A second, independent baud rate on the endpoint was considered and struck. It could only ever configure a mismatch, and a 6850 strapped for 300 driving a terminal set to 9600 does not give you a fast link on real hardware — it gives you garbage. Every stream but a real serial port ignores setParams entirely: a socket has no baud rate, and pretending it did would pace an emulation against a fiction.

One related flag closes the loop. pacesItself() returns true for a stream that carries its own cadence — a cassette releases bytes at a wall-clock rate the CPU's speed cannot drag. When it is true the UART must not also impose its emulated line-rate gate, or the two clocks fight (see Uart1602::poll and host/tape.h). Every other line has no cadence but the one the UART's baud gives it.

A chip is not a card, and a serial section is not one either

theory.md states the chip/card seam: a chip is modeled from its data sheet, a card from its manual, and the inverting buffers and interrupt-enable flip-flops that live between the chip's pins and the S-100 bus are the card's. Serial adds one more object in the middle.

A concrete UART is a part in src/chips/: Mc6850 (both halves of the 2SIO), Uart1602 (the COM2502 — the 88-SIO and the ACR), Intel8251 (the SBC console). A chip knows nothing about S-100; it has a clock, some pins, and a ByteStream.

A board with no UART at all, where the guest moves each bit with an OUT and a delay loop, uses BitSerialLine (src/chips/bitserial.h). It models the UART at the far end of the wire. It turns the levels the guest writes into bytes, and the bytes from the stream into levels the guest reads. Its levels are the wire's (true = MARKING). Which way up the bit is on the bus is the board's: the CompuPro Disk 1 inverts it on port 3 D7.

But the glue that turns IN 10h into "read channel A's status register" — decode, the even→ status/odd→data dispatch, the single card-owned Clock deadline, interrupt aggregation, connect/ units/properties, SNAPSHOT — is needed by every card that carries a 6850, and copying it onto each is how a bug gets fixed on one card and stays wrong on the others. So it lives once, in Sio2Port (src/chips/sio2port.h), a reusable serial section:

struct ChannelDef { std::string name; uint8_t offset; };   // the 2SIO has {"a",0},{"b",2}
Sio2Port(std::vector<ChannelDef> channels, std::function<void()> onIntChanged);

Sio2Port is not a Board — no type(), no id, no bus. The owning card holds one as a member and forwards the bus and lifecycle calls to it (see src/boards/mits-turnkey.cpp, one channel, and the 2SIO, two). It cannot reach the card's protected clock_ or intChanged(), so it is handed both at construction. Parameterize it by the channels the card has — one or four — and the whole serial mechanism comes with it.

TDRE is a deadline, not a flag

A UART does not know a character has finished going out because the guest asked — it knows because time passed. So a serial section sets a Clock deadline for the moment the byte clears the shift register, and writable()/TDRE reflects that deadline, not an instantaneous flag. pump() takes an arriving byte off the line; if the line has not yet had time to deliver it, the deadline covers the gap. This is the single most common thing a naive UART gets wrong — read §4.4.1 of DESIGN.md before you put any work inside assertsInt() (theory.md explains why the poll must never be doing the card's clocking).

Interrupts

The full interrupt model — assertsInt()/assertsVi() as pure levels, the wire-OR the bus keeps, intChanged() after any pin move, irqJumperProperty() for the none|int|vi0..vi7 vocabulary — is in theory.md and is not serial-specific; a parallel card wires interrupts the same way. For a serial board, the only things to remember:

  • Give the board its strap with irqJumperProperty("interrupt", …, irq_) and it gets the ten choices, the spelling, tab completion and the SHOW BUS IRQ line for free.
  • Override assertsInt()/assertsVi() to report the settled pin from the chip's state — a character waiting with the jumper installed asserts, and keeps asserting until the guest reads the character. It is a level; there is no queue to lose.
  • Call intChanged() wherever the pending flag can move: a register written, a byte taken off the line, a deadline coming due. A missing one hangs the guest forever waiting for an interrupt that already happened — and it presents as "the emulator locks up sometimes".

An embedded Sio2Port aggregates its channels' pins and drives the callback the card bound to its own intChanged(), so a card that forwards to a section gets this right by construction.

Two shapes of serial board

Everything above supports two ways to build a serial card, and knowing which you are writing tells you where the code goes.

The chip-backed card emulates a specific UART. It embeds a Sio2Port (or a bare chip), forwards bus/lifecycle/connect to it, and adds only the card-specific glue: the base-port jumper, the inverting buffers, the interrupt straps. The 88-2SIO, the Turnkey, the SBC are this. The manual tells you the polarity and the port map; the data sheet tells you the chip. This is the right shape when you are modeling real silicon — and the project rule stands: never give the hardware a behavior it never had to make software happy.

The strap-configurable card does not model a chip's register map at all. StrapSerialBoard (src/boards/strapserial.h) — the chip-less strap engine — holds a std::unique_ptr<ByteStream> per channel and synthesizes each status byte from the stream:

uint8_t s = 0;
if (ch.stream->readable() != ch.straps.inverterGate) s |= (1u << ch.straps.davBit);   // XOR = inverter-gate inversion
if (ch.stream->writable() != ch.straps.inverterGate) s |= (1u << ch.straps.tbmtBit);  // both bits, one knob

Two boards ride this engine. GsioBoard (src/boards/gsio.h, type() == "gsio") is the Generic SIO, which carries two independent serial channels — units a and b, configured under [board.unit.a] / [board.unit.b]. It makes no claim to be a specific card; a documented board that needs programmable word length, parity, stop bits or framing/overrun status is a separate emulated board, not a strap on this one. PropIoBoard (src/boards/propio.h, type() == "propio") is the S100Computers Console IO Board, a single channel; being single-channel, its straps surface as board-level properties and its one unit is named serial. The operator describes each channel with straps rather than picking a chip: which port is status/control, which port is data, which status bit is dav (data available) and which is tbmt (transmit buffer empty), and whether the inverter gate is engaged — one knob, because both status bits pass through the same inverting buffer and so always share a polarity. Control-port writes are accepted and ignored — there is no chip to program. To make common cards turnkey it ships built-in profiles in one table (serialBuiltins() — sior1, sior0, tuart, imsai-sio2, compupro-if2, compupro-ss1), each just a bundle of those straps and trivial to extend: add one struct and its name becomes a profile choice and appears in the generated docs. The default profile is sior1 (MITS SIO Rev 1), what the SSM 8080 monitor expects on its console. These cards are polled, with no interrupts — a deliberate first phase, because without a working control/interrupt-enable register a strapped TX-empty interrupt would storm (TBMT is asserted at idle). This is the right shape when the goal is to reach an abstract serial interface some software expects, not to reproduce a particular board's silicon.

Adding an IO-4 profile

The chip-backed io4 (src/boards/io4.{h,cpp}, the SSM IO-4) is worth its own note, because the cheapest useful change you can make to a serial board lives there: teaching it a new host personality. Unlike gsio's strap presets — a dav/tbmt bit pair — an IO-4 profile is a richer bundle, because the real card's W1/W2 headers strap six status signals to any data bit, through a buffer of either polarity, with the status and data ports optionally swapped. A profile is that whole map:

struct Io4Profile {
    const char* name;
    const char* help;
    int         stat[Io4Board::kNumStat];  // Dav, Ror, Rpe, Rfe, Teoc, Tbmt; -1 = not jumpered
    bool        invert;                     // 74368 negative sense if true, 74367 positive if false
    bool        pr;                         // S1/S2-PR: swap the status/data addresses
};

They live in one table, io4Profiles() (anonymous namespace in io4.cpp), transcribed from the IO-4 manual's applications section. To add a personality, add a row — for example the Rev-1 Altair console the SSM 8080 monitor wants:

{"altair-rev1",
 "Altair 88-SIO Rev 1 / the SSM 8080 monitor console: DAV=D0, TBMT=D7, status inverted",
 {/*Dav*/ 0, /*Ror*/ -1, /*Rpe*/ -1, /*Rfe*/ -1, /*Teoc*/ -1, /*Tbmt*/ 7}, true, false},

and its name becomes a profile choice, appears in SHOW, tab completion and the generated board reference, and needs no other edit. applyProfile() copies the row onto a channel's live straps; profileName() runs the comparison the other way — it reports whichever profile the current straps match, or custom if none does, so there is no stored profile field to drift out of sync with a hand-tweaked strap. That last point is the discipline: the straps are the truth, the profile name is a read-back. A row is only ever a preset, never state.

Two things to respect when writing a row. Strap an error flag (ROR/RPE/RFE) if the host's status layout has one — i8251 does — even though the emulated UART reports it always-inactive: a driver that masks the bit still sees the right byte shape, and inventing line-noise to make it toggle is exactly the behavior the hardware never had. And pr: true swaps the two port addresses for that channel (data first, status last) — imsai needs it — which the addressing code reads back out of the same strap, so a profile and a hand-set port_reversal cannot disagree.

How to add a serial board

The general playbook is adding-a-board.md; the serial-specific steps on top of it:

  1. Own a stream, never null. Hold a std::unique_ptr<ByteStream>, bind it to a NullStream in the constructor. Or embed a Sio2Port and let it hold the streams.
  2. readable()→RDRF, writable()→TDRE. Synthesize your status byte from those; apply your card's polarity here, in true sense at the pins.
  3. Expose one serial unit per channel (units() → UnitKind::Serial), wire connect/disconnect/unitStream, and route pump() to the stream.
  4. Install the resolver in both mains — src/main.cpp and tests/main.cpp — via your board's (or Sio2Port's) setResolver.
  5. If you touch a real serial port, push LineParams through setParams on connect and on any baud change, and surface a refusal through drainLog().
  6. If you model real silicon, put the chip in src/chips/ from its data sheet, keep the inverting buffers and interrupt-enable bits on the card, and reuse Sio2Port if it is a
  7. If instead you want a strap-configured abstract interface, StrapSerialBoard already exists (behind gsio and propio) — add a profile, do not write a new board.
  8. Test with a ScriptedStream bound through the real connect() path: feed() bytes at the card, assert on out(), and check the status bits land where the straps put them.

Then regenerate the board reference (cmake --build build --target docs-reference) so the generated ref/boards.md picks up your properties, and add the board to the changelog and the prose board chapter — neither is test-enforced, so both drift silently.