The codebase is layered "machines as hardware, components as chips" — see
docs/re-architecture.md for the full rationale and docs/adding-a-machine.md
for the new-machine checklist. Dependencies point strictly downward and the
boundaries are enforced mechanically (npm run depcheck, zero exceptions).
src/cores/— commodity silicon only: chips that shipped in more than one machine (Z80, AY-3-891x, TMS9918A, CRTC 6845, uPD765A, WD179x/1772/1793, Z80 CTC, i8255). Pure — imports only other cores,src/media/types,src/utils/. Custom silicon that only ever existed in one machine (the Spectrum's Ferranti ULA, the CPC's Amstrad gate array, the microdrive) is not here — it lives in that machine's folder.src/machines/<name>/— one folder per machine = a motherboard. Everything specific to that machine: the machine class (extendsbase-machine.ts), its custom silicon (spectrum/ula.ts,cpc/gate-array.ts),contention.ts,variants/,memory.ts,io.ts(port decode if-chain — hot, load-bearing),keyboard.ts,peripherals/,snapshots/,tape-loader.ts, per-familymodels.tshelpers,descriptor.ts(pure metadata + factory),services/(the service surface), andui/(Solid contributions — the ONLY machine files allowed to import solid-js and the shell/state layers). A machine folder is an island: it never imports another machine folder, nor UI/state/store/shell.src/machines/machine.ts— the SPI:Machine,MachineServices, the service interfaces (media/roms/tape/disks/snapshots/debug/input/probe),MachineDescriptor+MachineUiCapabilities,MachineHost,MachineEntry.src/machines/base-machine.ts— shared driver loop (frame pacing, turbo pump, audio back-pressure, lifecycle, debug-field storage). Untouched by machines.src/machines/registry.ts— the parts catalog: the only file besidessrc/models.tsallowed to name every machine. Stays headless-safe.src/media/— format codecs → neutral models:floppy/(DskImage, DSK/HFE/SCP/MGT/TRD/SCL,disk-detect.ts,floppy-sound.ts),tape/(TAP/TZX/CSW deck +.cas),zip.ts. Pure — imports only media + utils.src/shell/— the host:context.ts(shared machine handle + managers),lifecycle.ts(create/switch/destroy, pause/turbo, stepping, refresh state),media.ts(zip/picker/dispatch/persistence + transport wrappers),settings.ts(SettingsView pump),rom.ts(fetch/cache/persist). Reaches machines only throughmachine.servicesand the SPI/registry — never a concrete machine folder.src/state/— Solid reactive stores (machine, debug, disk, tape, activity, microdrive). Shared flat signal bags; the writers are generic (shell + probe).src/store/— settings + IndexedDB persistence.src/ui/— generic UI:panes/hosts panes andcomponents/hosts reusable elements. Bind tomachine.servicesand the descriptor'suicapabilities; never import a concrete machine or branch on machine kind.machine-ui.tsis the UI-side manifest mapping a kind → its lazily-importedui/contributions (the sole sanctioned exception).src/frame-bridge.ts— the generic per-frame consumer of each machine'sFrameProbe; owns presentation policy (LED latch, formatting, diffing).src/managers/—debug-manager+rom-manager: generic orchestration.src/models.ts— theMachineModelunion manifest + leaf helpers (isCpcModel, …). Per-family helpers (is128kClass,isPlusDCapable, …) live in each machine's ownmodels.ts.src/debug/— machine-agnostic debug tools (BASIC parser) plus per-CPU-family debug substrate insrc/debug/<family>/(z80/:disasm.tsdisassembler +service.tsZ80DebugService/z80Cpu(), step-over/out logic, register surface — shared by every Z80 machine). A machine may import its family module — that's substrate, not another machine. Imports only cores, utils, and machine SPI types. These tools takeUint8Array, notByteReader.src/ocr/— machine-specific screen OCR engines, each returning neutral text and styling data from display memory.src/display/— Canvas and WebGL renderers with HQx/xBR upscaling shaders.src/emulator.ts— a thin compatibility shim (re-exports shell + state), retained only becauseframe-bridge.tsand its module-mock tests still import from it. New code imports from@/shell/*and@/state/*directly.mcp/— MCP server (persistent Node process;mcp/server.tsentry, tools undermcp/tools/). Binds tostate.spec.services;mcp/concrete.tsis the single sanctioned module that narrows to a concrete machine.
Everything above the machine layer reaches machine internals through
machine.services (§3.3 of the re-architecture) and the descriptor's static
ui capabilities — never by narrowing to a concrete machine or testing
machine.kind. A machine that lacks a piece of hardware returns null for that
service (or omits an optional SPI hook) and the pane hides/disables itself. The
only concrete narrowings left are the two sanctioned seams: mcp/concrete.ts
(bench-probe machine-specific MCP tools) and machines/spectrum/ui/active.ts
(the Spectrum's own ui/ contributions reaching their machine).
- Tiers 1–2 are untouchable (per-t-state exec + memory access, per-scanline
render). No interface sits between a machine and its chips, memory, or port
handlers; inside a machine, code refers to concrete
this.ula/this.fdcfields, never through its own service interfaces. - The
MachineSPI is consumed only on tiers 3–4 (once-per-frame probe + user actions).FrameProbe.sample()overwrites one preallocatedFrameIndicatorsstruct and allocates nothing. Services may allocate freely.
SpectrumMemory lives at machines/spectrum/memory.ts. Each of the 8 × 16KB
RAM banks is the single authoritative source for its data. The Z80 address space
is a 4-slot view into those banks (and ROM pages), updated O(1) on each bank switch.
- Z80 execution — must go through
memory.readByte(addr)/memory.writeByte(addr, val). These do the slot/paging lookup. - Debug tools and UI — use
Uint8Arraydirectly via the SPI:machine.memory.snapshot()for a full 64KB view, ormachine.memory.getRamBank(n)for a specific bank. memory.snapshot()allocates a fresh 64KBUint8Array— don't call it from hot paths (e.g. per traced instruction). The trace path goes throughmachine.services.debug(disassembly reads just a few bytes).- Multiface / VTX overlays use
memory.setSlot0(overlay)/memory.restoreSlot0()to temporarily replace slot 0. PassskipSlot0 = truetobankSwitch()while an overlay is active.
npx tsc --noEmit # type-check (no output = clean)
npx vite build # production build
We do not struggle past broken tools — we fix the tool. If a tool (the MCP
server and its type/ocr/screenshot helpers, a build step, a test harness,
a script) misbehaves or gives unreliable output, stop and fix the tool at its
root cause before continuing the task that surfaced it. Squinting at garbled
output, retrying, or hand-compensating for a flaky tool wastes time and hides
real bugs. A reliable toolchain is a prerequisite, not a nice-to-have.
Tests must be written critically against a known-correct specification, not as a mirror of the current implementation.
- Don't blindly assert existing behaviour. Before writing an assertion, verify the expected value is correct — check the hardware spec, reference docs, or a trusted external source. If the code under test is wrong, the test should catch it, not encode the bug.
- Derive expectations independently. Work out the correct result yourself (or from spec) and hard-code that value. Never call the function under test to generate the expected value.
- Prefer edge cases over happy paths. The interesting bugs live at boundaries: overflow, underflow, wrap-around, flag interactions, off-by-one errors. Cover those first.
- One clear failure message. Each test should have a single, obvious reason to fail so the diagnostic points directly to the broken behaviour.
- Tests that can never fail are useless. If an assertion can only fail when you've already broken the test itself, delete it.
-
mainis the integration branch. All PRs branch from and merge intomain. There is no long-liveddevbranch. Merges tomaindo not deploy; production is cut by pushing avX.Y.Ztag (see Releasing below). -
Starting new work: sync
main, then create a sibling worktree from its tip. First bringmainup to date (git fetch origin && git checkout main && git pull), then create a new sibling worktree branched off the freshly-synced tip (git worktree add ../<branch> -b <branch> main). Never edit or commit in the shared working tree, and never base work offdev. -
Branch naming mirrors the Conventional Commit types (
feat:,fix:, …).<name>is short kebab-case (e.g.feature/tape-fast-load,fix/gx4000-palette):Prefix Use for feature/<name>New features fix/<name>Bug fixes chore/<name>Maintenance: deps, tooling, build, config docs/<name>Documentation only refactor/<name>Internal restructuring, no behaviour change perf/<name>Performance work
Production is tag-driven. Cloudflare no longer auto-deploys main; instead,
pushing a vX.Y.Z git tag triggers the Deploy to Cloudflare GitHub Action
(.github/workflows/deploy.yml), which builds that exact commit and runs
wrangler deploy. The most recently pushed release tag is what Cloudflare
serves. main is a pure integration branch — merges to it never touch
production.
The app version lives in two places that must stay in sync:
package.json— theversionfield (single source injected into the app as__APP_VERSION__viavite.config.ts, rendered as the version superscript).src/ui/panes/ChangelogPane.tsx— the top entry of the hand-maintainedCHANGELOGarray. This one does not derive frompackage.json, so it silently drifts if you forget it.
Steps:
- Bump
versioninpackage.json(e.g.0.7.3→0.7.4). - Add the matching new entry at the top of the
CHANGELOGarray insrc/ui/panes/ChangelogPane.tsx. - Verify locally:
npx vitest run(green) andnpx tsc --noEmit(clean). - Commit both files (
git commit -F <tempfile>). - Tag and push:
git tag v0.7.4 git push && git push --tags - Watch the Actions tab — the
Deploy to Cloudflarerun publishes production. Confirm the live version superscript and changelog match the release.
Version numbers follow the existing MAJOR.MINOR.PATCH scheme.
The library catalog (R2) is deployed separately and only when catalog data changes — it is unaffected by app releases:
npm run deploy:catalog- No
cdin commands. Don't prefix commands withcd /path &&. It breaks the permission model. Qualify file paths on the command itself (e.g.npx tsc --noEmitrun from the project root). - Never commit. Do not run
git add,git commit, orgit pushunless the user explicitly asks. The user manages their own commits. - Run the full test suite before any commit, and only commit when it's green. Before running
git commit, runnpx vitest runand confirm0 failed. If anything fails — even a test unrelated to your change — stop and surface it; do not commit over red tests.npx tsc --noEmitshould also be clean. - When asked to commit, first write the commit message to a temp file, then use
git commit --only -F <file> -- <files>. The argument order is strict: options first (--only,-F <file>), then--, then file paths. Never place-Fafter--— it won't be recognised as an option there. Multiple Codex instances may share this working tree, so the index can already contain files staged by another instance.git addfollowed bygit commitsweeps those in.git commit --only -F $env:TEMP\commit-msg.txt -- AGENTS.md src/foo.tscommits only the listed paths with the given message, leaving everything else in the index untouched. Always rungit diff --cached --name-onlyfirst as a sanity check. - For PRs, write the description to a temp file and use
gh pr createwith--body-file. The full command isgh pr create --title "feat: short title" --body-file <tempfile> --base main. Never inline multi-line descriptions in the shell command; they will be truncated or corrupted. - Present options for non-trivial features. If there are multiple reasonable approaches, describe them and let the user choose — don't silently pick the smallest diff.
-
Port 0xFE is shared: keyboard reads and tape EAR reads both hit the ULA port. Distinguish by the high byte of the port address (0xFF = no row selected = EAR-only read; anything else selects keyboard half-rows).
-
Memory access layer: only the Z80 execution path uses
readByte/writeByte. Debug tools (src/debug/), UI components, and snapshot code useUint8Arraydirectly — either asnapshot()or a specific bank array. Don't addByteReaderparameters to debug tool functions. -
Contention models differ: Ferranti ULA (48K/128K/+2) vs Amstrad gate array (+2A/+3) have different contention patterns, different contended banks, and different IO contention rules. Check
machines/spectrum/contention.tsandtimings.mdbefore touching timing-sensitive code. -
Port decode is hot:
machines/spectrum/io.ts(and each machine'sio.ts) wires CPU port I/O to the cores as an ordered if-chain whose early returns prevent double-decode. It's a tier-1 hot path — no interfaces, no logic changes beyond the wiring. -
FDC drive aliasing: on the +3, units 2/3 alias to physical drives 0/1 (
physUnit = unit & 1). Use the alias for all physical resource access (disk images, track positions); keep the original logical unit for ST0/ST3 result bits. -
romPagesindexing: for +2A/+3 (4 ROM pages), the 48K BASIC ROM is page 3. For 128K/+2 (2 ROM pages), it's page 1.spectrum.romFont(onmachines/spectrum/spectrum.ts) handles this correctly — use it rather than indexingromPagesdirectly.