The part and the model under identical inputs, compared at the scope.
The console family (6502, 2a03, 2c02, ntsc-crt, nes-bus, nes)
is a switch-level NES that runs cartridges. Everything it does not yet
know about the part waits on a bench: a scope on the console's video
and audio, an original pad, a hand pressing reset. This repository is
the bench made scriptable, so those items close unattended and repeat.
The bridge sits inline between the console's controller port and an original pad. A shift register on the bridge is the pad the console clocks; a microcontroller sets its eight inputs between polls and counts the console's latch and clock pulses in hardware; a Raspberry Pi 4 on the LAN is the head, taking scripts from the workstation, driving the reset and power relays, and triggering the scope. Nothing on a microcontroller is in the nanosecond path: that is the shift register's job, as it is in the pad.
As built (v1, complete 2026-09-17). The bridge is v1b, an Arduino
UNO with everything at 5 V (docs/bench-v1b-uno.md): a 74HC595 sets
the 74HC165's eight inputs, Timer1 counts the console's latches and
INT0 its clocks. It was joined to an unmodified NES-001 on 2026-09-15
and the console read every byte back; the Pi's hands on reset and
power held on 2026-09-17, tools/bench-check.py green twice from the
workstation (docs/milestone-2026-09-15-rig-and-bridge.md). The v1
ESP32-C6 design in docs/wiring.md and docs/bench.svg was never
built; v2 and v2b are drawings. The pad adapter, a separate board, is
built as USB on an ESP32-P4 and reached a host on 2026-09-27
(docs/pad-usb-protocol.md).
-
docs/bench-plan.md: the milestones B0 to B3 with their gates, written before any firmware. -
docs/wiring.md: the v1 (ESP32-C6) pin table and the measure-first list. The C6 wiring was never built; the console-port pinout and the poll measurements there are the bench's. -
docs/bench-report.md: the bench's running report, B0 to B3 on the machine side (the firmware compiles, the head and every tool run against fakes with red mutations, the model logs polls) and the part's side, which closed from 2026-09-15 with the bridge on the console. The die answered B0's DMC question first and the model changed for it. -
docs/procedures/: one working document per cycle. What to touch, in what order, what the tools then run, and the operator's own observations, which nothing else here has a place for. Read on GitHub at the bench, updated as the cycle goes. Measurements do not live here; they reach the notebook from the log. -
docs/build-guide.md: the build as five sittings, one command each, written out. What to wire, what the command checks, which photographs to take, and where each sitting currently stands. Generated bytools/build-guide.pyfrom the step table intools/bringup.pyand the log, so it cannot drift from the procedure or from the state. -
docs/trace-plan.md: a console run in the 6502 stack's own forms: the CPU at the pins, a recorded bus so the transistor-level chip runs the console's program, the site's pages fed a console, and the overlays the console adds (pad, stack, PPU, picture). T0 to T4, before the code. -
docs/open-items.md: what was seen and not closed, dated, with what closes each; struck through when done. -
docs/exercise.mdwithexercise-stack.svg: the v1 bench exercised: the two stacks as one logical diagram with every flow typed, the dialect layer by layer, a regime of six steps each with its gate, and the three programmes on top (the model's knobs, games learned from the pad, the x-ray behind an encyclopedia of code patterns). E2 played 2026-09-18, twice, its script inexercise/: the first failing region named, the hue miss repeatable, the luma miss one scope level wide. -
docs/encyclopedia.md: the NES code patterns the x-rays add up to, each with its signature astools/xray.pymeasured it, a window on the Halfshot page and the mechanism; code only from ROMs whose source is ours. Seven entries: the poll routine, the bank switch, the loop inside the interrupt, the sprite-0 split, the VRAM buffer, the jump engine, the state dispatch. -
docs/mario-dissection.md: Super Mario Bros. taken apart on the model withtools/dissect.pyand the x-ray: the loop inside the NMI, the frame scanline by scanline, the routine tree through the jump engine, RAM, the VRAM pipeline, and the pad's byte to a jump. Shape only. -
docs/closed-cycle-plan.md: one turn of the whole loop, the cartridge to the first disagreeing frame, mapped onto what is built and what is not, and the order it closes in (C0 to C3 over B0 to B3). -
docs/card-plan.md: the reader's SD card on the Pi through a USB reader, so the database goes on and dumps come off over ssh; the label, the mount, the tool and its refusals, written before building. -
docs/cartridge.md: the bench's cartridge from the reader to the model, why the reader guessed wrong, the dump that matched its database, and the mapper-66 board the model grew to run it. -
docs/eyes-vs-scope.md: the Roxio grabber's picture against the decoded scope record of the same composite, one tool for both captures and the score (tools/eyes.py). -
docs/cheat-sheet.md: the two breakouts pin by pin (port pin, NES harness colour, breakout lead, where it goes on the bridge), the head's four jumpers, and every pin of every chip on the sheet with what it does on the part and what it is wired to here. Generated bytools/cheatsheet.pyfrom the schematic, the lab log and the bring-up tool's own lead table; the pin purposes are the datasheet's, kept in one place there and refused if the schematic names a pin differently. The same rows are sheets 5 and 8 to 10 of the v1b drawing package. -
docs/lab-notebook.md: the build as it actually happened, generated fromdocs/lab-log.jsonlbytools/lab-notebook.py. Nothing in it is typed:tools/bringup.pywalks the build one step at a time, measures something at each stop, and appends what it found. Photographs live indocs/lab/. -
docs/wiring-v1b.svg: the v1b wiring as a diagram to build from, the packages as they sit and every wire at right angles, one track per net. Derived from the schematic bytools/wiring-diagram.py. -
docs/bench-v1b-uno.mdwithbench-v1b-1.svg,bench-v1b-2.svgandbench-v1b-3.svg: the all-5V UNO bridge, the version to build first, and its sketch infirmware/bridge-uno/. -
docs/bench-build-v1-v2.mdwithbench-v1.svg,bench-v2.svg,logical-timing.svg,pad-adapter.svg: the electronics review's schematics, parts lists and build order for v1 (the C6 version, never built) and v2 (atomic bytes, two ports, a sync separator, drawn only), one poll as timing lanes, and an original pad as a BLE or USB pad for a phone. Drawn bytools/draw-schematics.py;tools/check-sheets.pyholds the v1 sheet to the wiring tables. -
docs/bench.svg: the v1 (C6) bench as one drawing, the loop above and the bridge's chips with every pin below. Derived:tools/draw-bench.pyreads the pin tables indocs/wiring.md, so the drawing cannot disagree with the document (--checkrefuses a stale one).
Commit, then rebuild the drawing packages, then check, then push.
git commit ...
python3 tools/make-package.py # rebuilds all three
python3 tools/make-package.py --check # must exit 0
git push
The site copies the package PDFs out of this working directory byte for
byte and never builds them, because docs/package/ is gitignored and a
fresh checkout has none. So a commit that is not followed by a rebuild
leaves PDFs that were built from different sources than the checkout
describes, and the site's pull refuses: no record beside the PDF, a
file that does not hash to its record, a record naming a commit that is
not the checkout's head, or a record written from a dirty tree. Each
refusal names --check as the command that says the same thing here.
EVERY DOCUMENT THE SITE PULLS HAS A JAPANESE SHADOW, AND ITS HEADINGS
ARE AN INTERFACE. The site keeps a translation of each pulled file at
docs/ja/nes/<name>.md and its deploy refuses when the two disagree on
HEADING COUNT. So adding a section, renaming one or dropping one stops
somebody else's build, in any file they pull, not only the generated
ones.
It caught two different files on 2026-09-25. First docs/parts.md,
which gained ## pad-ble-p4 and lost two rows. Then, after I had
written this paragraph naming only parts.md, docs/pad-ble-build.md
gained six headings in one commit and stopped the deploy again. The
rule was broader than the example, which is the usual way a note like
this is wrong.
The shadow is named after their slug, not our filename, so
pad-ble-build.md is held against pad-ble.md over there.
Nothing here can check the other side: those files live in the other repository and a fresh checkout has no sight of them. But this side can know when one of ITS pulled documents changed heading count, which is exactly the moment somebody has to speak. So that is a gate now, not a thing to remember:
python3 tools/check-pulled-headings.py # every pulled file and its slug
python3 tools/check-pulled-headings.py --check # in check-all.sh
python3 tools/check-pulled-headings.py --update # after you have told them
tools/pulled-docs.json holds the list, the slug each maps to, and the
count last agreed. The authority for the list is DOCS in their
web/scripts/pull-nesdocs.mjs and it grows, so this catches a
change to what they pull today and cannot catch a newly pulled file.
Only headings block their deploy, and that is exactly the danger with
a CORRECTION. A fixed fact changes no heading, so it blocks nothing
and flags nothing, and the translation keeps the wrong one. Rev E's
three wrong lead colours were corrected in English in rev F; the
Japanese pad-ble table kept them through revs F, G, H and I and was
live with them until the site session found it on 2026-09-26. So when
a commit CORRECTS something in a pulled document, name the document
and the corrected text in the message to the site, as a correction,
separately from any heading change. The gate cannot see this; only the
announcement can.
Run every check, not the ones you would have picked:
tools/check-all.sh # every check, about 3 seconds
Choosing a subset by hand is how a stale docs/parts.md reached a push
on 2026-09-23: four gates run, fourteen available, and the one that
would have caught it was not among the four.
A hook can do the two middle steps for you, and on this bench it is installed:
tools/install-hooks.sh # install
tools/install-hooks.sh --check # say what is installed, change nothing
It symlinks .git/hooks/post-commit at tools/post-commit-hook.sh, so
the tracked file is the one that runs. It rebuilds the packages and then runs
check-all.sh after every commit, about 10 seconds, printing one line
when everything agrees and only the disagreements when it does not. It skips during a rebase or a merge,
where HEAD churns per commit and only the end state matters, and says
that it skipped. It cannot block a commit and does not try: git ignores
a post-commit hook's status and the commit already exists, so it
reports and never amends. Cloning does not install it; a hook that
runs code on every commit should be somebody's decision, so it stays a
command you type.
--check before the commit cannot pass, and that is not a bug. The
record names the commit its sheets came from, and before the commit
that commit does not exist; the tree is also dirty by definition. A
clean record is only obtainable afterwards, which is why the order
above is the order.
Any commit makes all three stale, including one that touches no sheet.
That is a plain equality rather than a list of sources two repositories
would have to keep in step, and it is cheap because the builds are
reproducible: SOURCE_DATE_EPOCH comes from the commit's own time, so
a package built twice at one commit is byte-identical.
firmware/bridge-uno/: the bridge as built, on the Arduino UNO (tools/build-uno.shcompiles it;tools/test-uno-schedule.shholds its poll schedule on the desk).firmware/bridge/is the v1 ESP32-C6 sketch, which compiles and was never flashed to a bench.firmware/pad-usb/: the other direction, and standalone. An original NES pad as a USB keyboard, so it drives a phone, a tablet or a browser emulator with no app and no bench: the bridge's ownpoll_padwith a host interface behind it instead of a shift register (docs/pad-adapter.svg, and section 4 ofbench-build-v1-v2.md). Built on a Waveshare ESP32-P4-Module-DEV-KIT and proven on 2026-09-27: an original pad answers at 3.3 V, the P4's full-speed USB controller presents it on the Type-C socket marked USB, and a Linux host enumerates it and receives all eight keys (docs/pad-usb-protocol.md, layer by layer). Not yet: a phone, a browser page, latency.firmware/pad-ble/is the same adapter as a Bluetooth keyboard; BLE crashes on the P4 (the module's onboard C6 does not answer the host stack) and the C6 devkit never accepted a flash, so it has met no host.firmware/pad-diag/holds the latch high so a meter can watch one button, andfirmware/header-probe/names the header hole a wire is in. The report descriptor and the key mapping are plain C inpad-ble/keymap.h, one file both builds share, held on the desk bytools/test-pad-keymap.sh, which parses the descriptor the way a host parses it. The build document, with the drawings, the wire list and the two measure-first items it rested on, isdocs/pad-ble-build.md, with three drawings from one netlist: the schematic, the right-angle wiring, and the breadboard sheet that says which hole. The devkit's header order is measured and lives once, asC6_HEADERintools/breadboard.py. The part moved on 2026-09-24: the C6 never accepted a flash and a Waveshare ESP32-P4-Module-DEV-KIT on the same bench took one first try and reached the air, sotools/p4_header.pycarries that board's header P6 as read from Waveshare's schematic, with the two counts that catch a one-row-out misreading, and the firmware builds for both parts.docs/esp32-part-choice.md: which ESP32 does BLE, which does USB HID, and which does both, measured out of the vendor's ownsoc_caps.hfor eight targets rather than recalled. The short of it: the C6 on hand is the one part in the family that can never be a USB keyboard. Written 2026-09-24 while the C6 was refusing to flash, so it also records what a different part would and would not change about that. Amended the same day when the bench's other board turned out to be an ESP32-P4-Module: the P4 die has no radio, the module carries a C6 as one, the core's BLE classes are gated to allow exactly that, andfirmware/pad-blecompiles foresp32p4with no edit at all.head/99-nes-bench-serial.rules: keeps ModemManager off the bench's serial instruments. It probes every tty that appears with AT commands for tens of seconds looking for a cellular modem, which is exactly when a flash or a bring-up wants the port. Installed on this head on 2026-09-23, with ModemManager disabled beside it; the file says why and how to install it. It was NOT the cause of that day's silent ESP32, which is worth knowing before reaching for it as one.head/: the Pi's daemon, the bench under one script (UDP in, the bridge over serial, relays, the scope over SCPI, runs served back over HTTP);docs/script.mdis the script's words, shared with the model.head/pad.pyis a gamepad on the Pi's own Bluetooth as the console's hand: BlueZ pairs it, the kernel gives it up as an event device, and every change of the eight buttons becomes oneSET hhdown the path the bridge already proved. A session takes a run directory like any other, sotools/b3.pyturns a minute of real play into a script the bridge replays latch for latch. Held bytools/check-pad.py, which needs neither a pad nor a bridge.tools/:eye.py(the camera on the board: grab, sweep, named views),cal.py(the calibration cartridge's reader for every eye),board-overlay.py(the wiring state drawn on the photograph, fromdocs/board-map.json),nesprep.py(an iNES file into the two flash images, tiled),bench.py(the workstation's client),b1-score.py(a run's triggered capture against the model's frame at the same poll, through the roundtrip),b2-align.py(the alignment class off a three-channel capture, the histogram over power-ons, its self-test),b3.py(record a run as a script, replay it with captures, the part against itself, bisect to the first divergent latch),warmth-fit.py(the part's picture gain against its seconds on, fitted to a warm-up series, and--checkto score the series with it),knobs.py(a run's knobs file:init,show,check, andwarmthto add the seconds on and the curve to an older file),fake-scope.py(the head's SCPI subset with synthesised records, and with--videothe model's own frames at the bridge's trigger latch, a divergence plantable),sniff.py(the bridge over serial alone),compare-logs.py(two poll logs, latch for latch),fake-bridge.py(the protocol with no part behind it, for running the head on a box without a bridge),draw-bench.py.
Captures and dumps of cartridges are never committed (captures/, roms/ and *.nes are ignored); the family's
own test and bars cartridges are the only ROMs any repository carries.
MIT.