AltairSim

Building it

There are no dependencies. A C++20 compiler and CMake ≥ 3.20 is the entire list. The TOML parser, the JSON encoder and the line editor are all in the tree, so a fresh clone builds with nothing to download.

git clone https://github.com/deltecent/altairsim.git
cd altairsim
cmake -S . -B build && cmake --build build -j
ctest --test-dir build -LE slow      # drop -LE slow for the full 8080 exerciser
./build/altairsim                    # the default machine

If CMake is unfamiliar, the two cmake calls are two distinct steps — configure, then build — and you run both in that order:

  • cmake -S . -B build — configure. Reads CMakeLists.txt in the source tree (-S ., the current directory) and writes the generated build files into a fresh build/ directory (-B build). It downloads nothing and compiles nothing; it just works out how to build. You rerun it only when CMakeLists.txt itself changes — and even then the build step reruns it for you.
  • cmake --build build -j — build. Compiles what the configure step laid out under build/. -j runs the compiles in parallel across all CPU cores (drop it for a serial build, or -j4 to cap the jobs). This is the command you repeat after editing code.
  • ctest --test-dir build -LE slow — run the tests that the build produced. --test-dir build points at the same directory; -LE is label-exclude, so -LE slow runs everything except tests tagged slow (the ~10-minute 8080 exerciser). Drop it to run those too.

Everything lands in build/, which is disposable: rm -rf build and rerun the configure step for a clean slate.

That is not a boast about minimalism for its own sake. It is a property worth defending: the day this needs a package manager to build is the day it stops being something a person can pick up in ten years and compile.

After a git pull, the commands do not change. cmake -S . -B build && cmake --build build again — there is no separate incremental procedure and no need to delete build/. New .cpp files can't be missed because sources are listed in CMakeLists.txt rather than globbed, so adding one edits CMakeLists.txt and forces a reconfigure; roms/ and machines/ are globbed but use CONFIGURE_DEPENDS, so they re-glob on every build. The build directory does cache absolute paths, though, so rename the checkout or switch compilers and you must rm -rf build — it fails with a loud CMakeCache.txt directory is different error, not a silent wrong answer. If a build breaks — or a test that used to pass fails — immediately after a pull, suspect a stale build/ before suspecting the code: a rm -rf build and a clean reconfigure is the first thing to rule out, because a half-rebuilt object can walk an already-fixed bug back in. docs/building-linux.md §7 has the details.

Warnings as errors before a PR

The build always compiles with -Wall -Wextra -Wpedantic (GCC/Clang) or /W4 /permissive- (MSVC), but by default a warning is just noise and does not fail anything. CI configures every leg with -DWERROR=on, which on GCC and Clang adds -Werror and turns a warning into a build failure — so a PR that introduces one on either of those goes red before it can merge. Reproduce that gate locally before you open the PR:

cmake -B build -DWERROR=on && cmake --build build -j

It is off by default (option(WERROR … OFF) in CMakeLists.txt) so a casual build, or one on an unfamiliar toolchain whose newer diagnostics have not been chased down yet, still compiles. GCC and Clang have slightly different warning sets, so a clean local build on one is not proof the other is clean — that is what the separate CI legs exist to catch.

MSVC is not in the gate yet. /W4 carries a pre-existing backlog (intentional integer narrowing, which GCC/Clang do not flag, plus some variable shadowing) that must be cleared before /WX can go on. That is tracked in issue #238; until it lands, -DWERROR=on is a no-op on MSVC and its /W4 warnings stay visible in the log but non-fatal.

The optional video backend (SDL3)

Still no required dependency. The graphics boards — the VDM-1, and the Dazzler to follow — draw into a host Display (src/host/display.h). That interface has two backends: a headless NullDisplay (draws into memory, shows nothing) that is always available, and an SdlDisplay (a real window) compiled only when SDL3 is found. So a plain cmake -S . -B build on a machine with no SDL3 still configures, builds, and passes every test — the video boards are simply headless, which is exactly what CI and a scripted run want.

To get a window, install SDL3 and reconfigure:

# macOS
brew install sdl3
# Debian/Ubuntu (needs a recent SDL3 package)
sudo apt install libsdl3-dev
# vcpkg (any OS)
vcpkg install sdl3

cmake -S . -B build          # reconfigure -- watch the status line

The configure step reports which backend it chose:

-- SDL3 found -- video boards enabled (windowed)
        …or…
-- SDL3 not found -- video boards build headless (null display). Install SDL3 (see docs/devguide) for a window.

CMake auto-detects with find_package(SDL3 CONFIG); found → it compiles the src/host/*_sdl.cpp files, links SDL3::SDL3, and defines ALTAIRSIM_ENABLE_SDL. Force a headless build even where SDL3 is installed with -DALTAIRSIM_ENABLE_SDL=OFF. That flag is what a macOS universal build needs, because a Homebrew SDL3 is single-arch and cannot link into an x86_64;arm64 fat binary. CI passes it nowhere. One leg — macOS — installs SDL3 from Homebrew and builds native arm64, so it is the only place the *_sdl.cpp files are compiled at all, and the workflow fails that leg if it comes up headless. Linux and Windows take the not-found path above and build against the null display. Only the src/host/*_sdl.cpp files (display, joystick, audio) and the composition root (src/main.cpp) are macro-gated; the boards themselves #include no SDL and compile in every configuration. Then:

altairsim vdm1               # a VDM-1 with a banner-drawing demo (roms/VDM1DEMO)

What actually got built

Built and tested on Linux, macOS, and Windows. The code is written to be portable — C++20, no dependencies, and every OS difference confined to src/platform/ behind a header with zero conditionals — and that portability is now proven, not asserted: Linux (Ubuntu/GCC), macOS on Apple Silicon (and on Intel, built natively there rather than in CI), and Windows on MSVC all build and pass the suite. The Windows platform layer, once merely written, is field-proven.

CI runs the suite on every push — on as many platforms as the change needs. tools/ci-changed-code.sh sorts a change into one of three kinds, and ci.yml picks the legs:

Change Pull request Push to master
docs — docs/, reference/, .claude/, a top-level .md, any README.md Linux Linux, or nothing if the PR tested the same files
code — src/boards, chips, cli, mcp, tests/, the data the tests read, tools/ Linux Linux, macOS, Windows
core — src/core, cpu, isa, platform, host, config, util, main.cpp, the build, CI, any path not listed Linux, macOS, Windows Linux, macOS, Windows — build only, no tests, if the PR tested the same files

A CMakeLists.txt change that only adds non-core sources, comments and add_test blocks — what a new board does — counts as code, not core. Run the script against any commit to predict a run: bash tools/ci-changed-code.sh <base>.

Most PRs are code: a board, a command, a test. Those are portable C++ above the platform layer, so the PR builds them on Linux, and the run on master after the merge builds all three. That run keeps master's compiler cache warm — a PR can only read its own cache and master's — and it is where an MSVC- or Clang-only warning in a board appears: minutes after the merge, not at the release. A red leg on master is fixed before the next change merges. A core change builds all three before the merge, because that is where the platforms really differ.

When a core merge changes no files — its tree is the PR head that CI passed — the run on master builds all three platforms but does not run the tests or upload the binaries. The PR run already passed the suite on each platform with those files; the build is kept because it saves master's compiler cache.

Each leg uploads the binary it built. To fetch them:

tools/fetch-ci-binaries.sh          # newest CI run on the current branch
tools/fetch-ci-binaries.sh 42       # ...on PR 42

It waits for the run if it is still going and refuses to download from a red one. A run that built only Linux leaves one file; the master run after a code merge leaves all three. A build-only run leaves none, and the script tells you to fetch from the PR. They land in ./artifacts (git-ignored, replaced on every fetch) with the executable bit restored, since the artifact zip does not carry POSIX modes. Nothing in the build or the tests reads from there — these are CI's binaries, kept for running or handing to someone, not a build output of this tree.

The tests

ctest --test-dir build -LE slow -j 8   # unit + acceptance, in parallel. Under a minute.
ctest --test-dir build              # ...plus 8080EXM, the full exerciser.
ctest --test-dir build -L hw        # a real null-modem cable, and a real TNFS server.

The acceptance tests are not unit tests. They boot period software on the whole machine through the real CLI and check what lands on the terminal — 4K and 8K BASIC off a cassette, MITS Programming System II polled and interrupt-driven, CP/M off a minidisk. Several ship with a negative control: the same script against a machine that ought to fail, marked WILL_FAIL. If a control ever passes, the test it guards was passing for the wrong reason and is worthless. That is the only reason to believe any of them.

The CPU exercisers are gated three ways, on purpose. cpu-8080exm is ~2.9 billion instructions and cpu-zexdoc/cpu-zexall ~5.8 billion each, so they are labelled slow and excluded from the per-push matrix. cpu-exerciser.yml runs them on one Linux runner only when CPU/ISA code changes — an opcode bug is a logic bug and shows up on any host, so one architecture is enough to catch it, and paying for three would be paying three times for the same answer. A release then runs them on all four build machines (tools/release-worker.sh, ctest -L slow), through the binaries that ship, because the one thing that is not architecture-independent is the compiler: the cores are full of shifts, masks and half-carry arithmetic, and until a release nothing has ever driven billions of instructions through the MSVC build. cpu-exerciser-release.yml runs them on the three CI platforms on demand only.

The hardware tests (-L hw) run against an actual null-modem cable between two USB serial ports, because a claim about a cable deserves a cable. They are opt-in, pointed at your ports with ALTAIR_SERIAL_A / ALTAIR_SERIAL_B, and they skip loudly when the hardware is absent — a hardware test that quietly passes with no hardware is a green tick that means nothing. Unset, serial-hw exits 77 and ctest reports it Skipped; that is the expected result on any machine without the cable, not a failure.

The two device paths are per-machine — list yours, then point the test at two ports joined by the cable:

# macOS:   ls /dev/cu.usbserial-* /dev/tty.usbserial-*
# Linux:   ls /dev/ttyUSB* /dev/ttyACM*
# Windows: Device Manager -> Ports (COM & LPT), or run  mode

ALTAIR_SERIAL_A=/dev/ttyUSB0 ALTAIR_SERIAL_B=/dev/ttyUSB1 ctest --test-dir build -L hw

The cable is not just any null-modem cable. serial-hw drives the modem-control lines, so the pins have to actually cross — and it needs the full-handshake wiring in which one end's DTR fans out to both DSR and DCD on the other. A 3-wire cable (TxD/RxD/GND) carries the bytes but leaves every control pin dead; a partial null-modem that loops DTR/DSR/DCD back locally instead of crossing them looks identical to the eye and fails the same checks. That is the usual cause of a serial-hw that fails rather than skips — the byte check passes and the pin checks do not.

Wire it as (a DE-9 DTE pinout in parentheses, both ends):

A end B end
TxD (3) → RxD (2)
RxD (2) ← TxD (3)
RTS (7) → CTS (8)
CTS (8) ← RTS (7)
DTR (4) → DSR (6) and DCD (1)
DSR (6) and DCD (1) ← DTR (4)
GND (5) ↔ GND (5)

So B raising DTR is a carrier appearing at A — to a 6850 strapped dcd=wired, indistinguishable from a modem, which is the whole point of the test. The wiring is restated at the top of tests/serialtest.cpp.

tnfs-hw needs a TNFS server instead of a cable. It boots the CP/M example with its floppy mounted from a real de-tnfsd (a POSIX TNFS server, not part of this tree), SAVEs a file, and reads it back through a fresh mount. The script starts and stops the server itself, on a local port, serving a copy of the image. It runs when de-tnfsd is on PATH and skips (77) otherwise. On Windows it is not registered at all: de-tnfsd is POSIX-only. tests/test_tnfs.cpp, in unit, covers the same client against a fake server on every push.

Every Windows executable has an 8 MB stack

The if(MSVC) block of CMakeLists.txt links every executable with /STACK:8388608. That is the main-thread stack of Linux and macOS. The Windows default is 1 MB. MSVC gives Monitor::exec a frame of about 49 KB, because it does not let the locals of separate blocks share stack space. DO nests exec, so a nested-DO test overflowed 1 MB twice while it was green on the other platforms (commit 4644401f, and PR #780). Do not lower the value or remove it.

For a crash on Windows only, check the stack first. Give a copy of the executable a bigger stack and run the same suite:

editbin /STACK:33554432 altair_tests_copy.exe
altair_tests_copy.exe cli

If the copy passes, the cause is the stack. dumpbin /disasm of the object file shows a function's frame: the mov eax,N before its call __chkstk.

Catching memory bugs: -DSANITIZE=on

A crash on one platform only, with the stack ruled out, is usually a use-after-free or use-after-scope that one allocator tolerates and another does not. No compiler warning fires for it.

-DSANITIZE=on finds it locally. It threads AddressSanitizer + UndefinedBehaviorSanitizer (just ASan on MSVC) through the whole binary, so the crash names its own file:line instead of you guessing from a block-buffered stack trace. Configure it in its own build directory — an instrumented binary is slower and is not what you ship:

cmake -S . -B build-asan -DSANITIZE=on
cmake --build build-asan --target altair_tests
ASAN_OPTIONS=detect_leaks=0 UBSAN_OPTIONS=print_stacktrace=1 ./build-asan/altair_tests

It is off by default and not in CI — CI catches a platform-only crash by running on that platform, and this is the tool you reach for once it does.

The documentation is part of the build

Two documents come out of this tree:

Target Is
User Manual (docs/manual/) Ships in the package. Self-contained — it may not name a single file the reader does not have. No src/, no CMake, no DESIGN.md.
Developer Guide (docs/devguide/) This. Repo only. Free to talk about the source, because you cannot write a board without it.

Some of the manual is generated from the binary, and this matters if you touch a board:

cmake --build build --target docs-reference

That rewrites docs/manual/ref/*.md — the command dictionary, the per-board parameter tables, the machine list — by walking Board::properties() and the CommandDef table. Those files are committed, and a test (docs-reference) fails if they are stale. So if you add a property, change a default, or reword a HELP string, run it and commit the result alongside your change.

This is not a docs chore bolted on at the end. A board's properties are its TOML schema; a hand-written parameter table would be a second schema, and the first draft of one in this very project got three of the memory board's eight defaults wrong. The reference is printed rather than retyped for the same reason the rest of the program has one source of truth for anything.

The PDFs need pandoc and any Chromium-based browser, and are built by tools/build-docs.sh. Neither tool is a build dependency, and neither goes anywhere near the simulator.

Why a wrapped paragraph pastes as several lines (and why we don't "fix" it)

A recurring report (issue #246): copy a paragraph out of one of our PDFs and it pastes as several lines instead of one. This is a reader behavior, and the investigation is worth recording so it is not re-run from scratch.

There are no hard line breaks in the paragraphs. In the PDF content stream each wrapped line is painted at a new baseline position; there is no newline character anywhere. When a paragraph pastes as three lines, the reader is inventing those breaks from the vertical gaps at copy time. Nothing in the build put them there.

The only thing that tells a reader "the real text of this span is this exact string, ignore the geometry" is ActualText. Hand-built PDFs extracted with poppler make the picture exact:

PDF Copies as
Wrapped paragraph, plain geometry 2 lines (break invented)
Wrapped paragraph inside a real <P> structure tag still 2 lines
Wrapped paragraph with paragraph-spanning ActualText 1 line

The middle row is the one that closes off the easy fixes: the structure/accessibility tree does not join wrapped lines — only ActualText does. Our PDFs are already tagged (pdfinfo → Tagged: yes), and Chrome emits ActualText only for ligatures (the fi/fl spans), never for paragraphs. Every mainstream HTML/Markdown→PDF engine (WeasyPrint, Prince, wkhtmltopdf, Typst, LaTeX) conveys paragraphs through the structure tree, not through paragraph-level ActualText — so swapping the PDF engine changes the producer name and nothing about the copy behavior. Do not propose one as the fix.

Readers split on this: Word and Acrobat honor the structure tree, so the paragraph comes across whole; Preview and Chrome's built-in viewer fall back to pure geometry and invent the breaks. Worse, macOS Sequoia's Preview/PDFKit regressed — it stamps a paragraph break on every visual line regardless of the document, surviving even a text-only paste — so it would ignore ActualText too.

A custom ActualText post-pass is buildable — Chrome already brackets each paragraph in one marked-content span — but it is a real tool, not a shell snippet: the paragraph text is emitted as subsetted hex glyph-IDs, so each glyph has to be reverse-mapped through the font's ToUnicode CMap, with TJ kerning arrays and nested ligature/italic spans handled, then the stream recompressed. For a cosmetic gain that the reader in the original report (Sequoia Preview) would not even honor, it has not been worth doing.

The Markdown can't fix it either: wrapping is decided at render time from the column width, and there is no "soft wrap that copies as one line" a source file can encode. The only source-side lever is keeping the things people copy short enough not to wrap — which the commands already are (mono, ≤79 columns, in a column sized for 79). The case that wraps is prose. For copy-a-command / copy-a-prompt workflows, copy from the Markdown, which ships beside every PDF and is plain text; the PDF is the read-it version.