The console: where the family's chips meet. The plan is the end-to-end
sketch, ratified 2026-09-02 and now at home here
(docs/nes-end-to-end-v0_2.md; the copy in
nes-bus stays as the pointer
it was). The parts it plugs together live in their own repositories:
nes-bus (the contracts),
6502,
2a03,
2c02 and
ntsc-crt. The rule that
makes this work: a chip crate never knows what is on the other side of
its pins; this repository is the only thing that plugs anything into
anything.
The boards the console has are NROM (mapper 0), MMC1 (1), UxROM (2), CNROM (3), MMC3 (4), MMC2 (9) and GxROM (66). Between them they take every one of the twenty cartridges dumped from this desk with the OSCR reader: two NROM, nine MMC1, three UxROM, two CNROM, two MMC3, one MMC2 and one GxROM.
MMC1, UxROM and CNROM arrived 2026-09-20 (nes-bus v0.1.4), MMC2 with
them (v0.1.5). tests/mappers.rs runs a program on the die through
each of them and holds what it reads back; the boards' own logic is held
a register at a time in nes-bus's contract suite. The one that needed a
console to test is MMC1's serial port: a write to its window carries
ONE bit, and
two writes on CONSECUTIVE CPU cycles are one write, which is what an
RMW instruction on the window is. The dot that decides it is the
console's, so the cartridge trait gained cpu_write_at and Board
hands the dot over with every write.
MMC2 is the other one a console has to test, for the opposite reason: its CHR bank is not chosen by the CPU at all. Each half of the pattern table has two bank registers and a latch saying which answers, and the PPU's own fetches flip it, so the tile that draws the top of Little Mac's head is also the switch. The console test sets the four registers from outside, runs a program that fills a nametable with one tile and turns rendering on and then writes nothing more, and holds that the latch moved and no register did. It checks its own program first: a nametable it never filled would draw tile $00 and move nothing.
Nineteen of the twenty draw, and the twentieth is not a game. Dump
54, listed as Super Mario Bros., is the first bank of the Super Mario
Bros. and Duck Hunt multicart read alone; its reset is the board's own
bank switch, so as a plain NROM image it loops there forever
(docs/boards-report.md). Nothing here has been played past its title screen with a
controller, so "plays" is "draws its own picture from its own program",
which is what the console can say on its own.
MMC3 arrived 2026-09-20 for the games that bank, Super Mario Bros. 2 and 3 among them: PRG and CHR in banks either way up, mirroring under software, and the scanline counter that watches PPU A12 and pulls /IRQ low, which is how those games split the screen. Getting there needed the 2C02's stepper to fetch the sprite slots it will not draw, since a line with no sprites still moves A12 on the part (2c02 @ 10b9089).
Five of blargg's six mmc3_test_2 ROMs pass. The sixth tests the other
chip revision (his Crystalis; this board is the one his Super Mario
Bros. 3 is on, and 5-MMC3 holds it), and tests/mmc3.rs records what
it says rather than tolerating it.
4-scanline_timing joined them on 2026-09-20 and took two things,
because it brackets the interrupt's arrival to ONE PPU clock and the
console was wrong by more than that in two independent ways. The A12
filter was one dot too permissive: nine dots of A12 low is exactly three
CPU cycles, so the third falling edge of M2 lands on the rise rather
than before it, and with the background at $1000 a frame came to 242
clocks on alternate frames where the part makes 241 (nes-bus v0.1.6, and
examples/mmc3-probe now runs its own cartridge in either mode so the
count reads straight off). And the cartridge's /IRQ reached the core
with no delay at all, as a level read at whatever CPU half-cycle came
next. It is a LINE: CART_IRQ_DELAY holds it behind the board by
seventeen master half-steps, twelve to a CPU half-cycle and eight to a
dot, which is the only grain fine enough to hold a one-clock bracket.
Seventeen is a fit, labelled as one. examples/irq-sweep runs the ROM
at every delay and prints what each reports: the ROM allows fourteen
through twenty-one and no further, and seventeen is the middle. It is
also more than half a CPU cycle, which is far too long for a wire from
pin 15, so most of what it stands in for is likely where inside its
cycle the core samples IRQ rather than anything the cartridge does.
What would settle it is a scope on pin 15 against the CPU's phi2, which
is in nes-bench's open items, and so is the filter's own phase
question: counting M2's falls is the rule the part has, and a console
knows the alignment a constant in dots cannot express.
Five of those games were stuck for an afternoon on what looked like a
vertical-blank regression, and were not. blargg's ppu_vbl_nmi 01, 02
and 03 had started failing where docs/n5-report.md records them
passing, 01-vbl_basics saying "VBL period is way off"; Paperboy,
Goonies II and Blaster Master never turned rendering on, and the Legend
of Zelda and Battle Chess drew one flat colour. The flag itself was
exact the whole time: examples/vbl-probe puts its rise at (241, 2),
its fall at (261, 2) and its period at 29780 or 29781 CPU cycles, with
rendering on or off. What was wrong was one branch. 01-vbl_basics
reads $2002 and does jpl test_failed, which is bmi over a jmp, and
rung 3 had stopped taking a BMI that is taken, forward, on its page:
the commonest branch on the chip, dropped by the selector mask search
hours earlier in tinymachines/6502 while fixing a different branch bug.
The walk from the flat screen to the instruction was
examples/where-it-sits and examples/vbl-probe, and the fix is 6502 @
3805107. All five draw now, and ppu_vbl_nmi is back to the 5 of 10
the n5 report records, which was never stale.
N8 (the shell) is built and gated as far as a box without a screen can
gate it (docs/n8-report.md): nes-shell rom.nes puts the console in
a window with the three-line comb decode and the five CRT stages as
compute passes on the GPU, held to ntsc-crt's CPU chain on every
component of every pixel (worst 4.8e-7 with the authored parameters,
2.8e-5 with the mask and geometry on, against 1e-3 stated; about a
millisecond a frame where the CPU took 83); the console on its own
thread paced by the wall clock through ntsc-wasm's drift policy,
duplicates and drops counted; the sound through cpal; the keyboard and
a gamepad (gilrs) as controller 1. It ran under a virtual display here. nes-wasm is the
browser target: the console with its sound behind wasm-bindgen, 91
frames a second under node. The desk items are the real display, a
speaker and a hand.
N7 (the sound) has its machine half closed (docs/n7-report.md):
Sound takes the 2A03's five output codes after every CPU half-cycle
through the two DACs (the nesdev table, now v2a03-dac) and the
NES-001's audio stage read off the schematic (the 100 ohm pulldowns,
the 20K and 12K summing resistors the table's two constants already
carry, C23 into the 74HC04 inverter with R6 and C21 around it:
a high-pass at 21 Hz, a gain of 2.35 inverted, a low-pass at 15 kHz),
resampled to 48 kHz. Held to the schematic's arithmetic and to
blargg's four mixer ROMs cancelling through the whole console within
5 percent of the beep (the linear approximation is red at 34), with
his real-hardware recordings measured the same way beside: triangle
and noise agree with the console to a fraction of a percent, square
and dmc carry twice the console's residual, which is the real DAC
curves' question for the scope. The AUDIO_OUT record is the bench
item.
N6 (the picture) has step 1 closed and step 2's machine half recorded
(docs/n6-report.md): Picture takes the console's frames in order
through ntsc-crt's NES source, Rung C and the CRT stages, the
subcarrier phase carried by each frame's parity; a console frame
through it is the standalone PPU rung's own through it on every decoded
sample, and the phase after twelve frames is the grid's arithmetic
(forcing every frame Even is red). full_palette.nes, the bars
cartridge, paints through the console now that the 2C02's picture with
rendering off is measured (the palette entry v addresses, the $2006 and
$2007 timings, the emphasis bit). capture-score runs a bars cartridge
through ntsc-crt's capture-card model and recovers it the way the real
capture is recovered, the synthesis through the same front end, every
region scored a decoder-derived settling distance in from its edges:
on this repository's own bars cartridge (export-testrom bars,
thirty-two-dot cells of the twelve hues at each luma row) every region
holds the plan's tolerances at all four rows (worst luma 0.0001, hue
0.3 degrees, saturation 0.0012). The first runs found the instrument
three times (a level re-referencing a histogram bin coarse, a dark
picture taken for blanking, a chroma trough taken for a sync edge),
each fixed in ntsc-crt; blargg's full_palette bars are too narrow for
a hue verdict at the decoder's resolution and say so. The real bars
record is the bench item, and the cartridge for it exists now.
N5 (the console) has gates 1 and 2 recorded and gate 3 open
(docs/n5-report.md): nes-console runs the 2A03 core on rung 3 and
the 2C02 on the fast rung on one master half-step counter through the
glue, at the alignment measured off the two switch-level chips' own
dividers, 2.1x to 2.3x real time. Gate 1 holds three ways: the
plumbing; the PPU's real NMI landing around a BRK at eight offsets,
the console's CPU against the switch-level 6502 half-cycle for
half-cycle; and the $2002 race, the console's reads under all
twenty-four alignments against the table measured on the switch-level
2C02 with the console's own access shape, set side and clear side.
Gate 2 is recorded in full: cpu_timing_test, all sixteen instr_test
ROMs, all eleven sprite_hit tests (the double-height one since the
fast PPU's 8x16 rule was measured on the switch-level chip) and five of
ten ppu_vbl_nmi tests
pass; the five that do not are one question, named in the report: the
documented console's NMI reaches the CPU about two dots later than the
two chips, held to their own measurements, allow, and a scope on the
real board is what settles it. All eight apu_test ROMs pass, the six
that had failed each measured on the switch-level 2A03 and authored
(the $4017 write's parity jitter and immediate clock, the status read
latched a half-step after the bus is asked, the IRQ flag's three-cycle
set, the DMC's byte counted off where its read lands). Running real
programs found eight misses in rung 3 (a seam bit, a shift carry,
three bus-fight opcodes, the interrupt sample point twice over, and a
read latched later than the bus is asked) and four in the fast PPU,
each now held by a fixture in its own repository.
N4 (the glue, authored) is closed (docs/n4-report.md): nes-glue
is the NES-001 mainboard's handful of parts, each a few lines held to
its datasheet with its own test and labelled authored, nothing through
a netlist: the 74LS139 decoder (both halves as wired; the M2 term in
/ROMSEL is the test), the 74LS373 PPU address latch (transparent high,
held from the fall, with the case where a rising-edge sample would
differ shown, and an A12 watcher over a synthetic line of the PPU's
measured fetch schedule seeing one rise per line), the two TMM2115
SRAMs (ideal, eleven address lines, a visible power-on fill, the access
time recorded and unused), the 74LS368 controller port buffers with the
4021 controller behind them (inverting, open bus on every undriven bit),
the 74HC04 behind PPU /A13, and the reset chain, whose hold is a
labelled placeholder until the scope capture the sketch names replaces
it. 16 tests.
python3 tools/check-self-counts.py # every number this repository writes
# about ITSELF, against the thing it
# describes: the constants prose spells
# out in words, the boards `cart()`
# builds, the ROMs the MMC3 test walks,
# and whether the /IRQ band stays
# consistent with the constant sitting
# inside it. Built after CART_IRQ_DELAY
# moved from 16 to 17 and a test's own
# header went on saying sixteen.
# REQUIRE_ALL=1 makes a skip a failure.
cargo test --workspace # every part against its datasheet,
# and the console's gates: the
# plumbing, the NMI replay against the
# switch-level 6502 (a dev-dependency,
# git-pinned), the race replay under
# every alignment, the picture (a
# frame through ntsc-crt equal to the
# rung's own, the phase across the
# parity sequence; MUTATE=1 red), the
# sound (the stage's arithmetic,
# blargg's mixer ROMs cancelling, his
# recordings beside; MUTATE_SOUND=1
# red, its own variable because the
# 2A03 rung reads MUTATE itself)
cargo run --release -p nes-console --example where-it-sits -- rom.nes [frames]
# a game that never draws, located:
# the opcode fetch addresses counted
# (TOP=n rows, COUNT=addr one by
# name), WRITES=1 or WRITES=xxxx the
# register writes, TRAP=xxxx the
# hundred fetches before an address is
# first reached, BUS=a-b every CPU
# cycle in a half-cycle range. Super
# Mario Bros. 2's crash was walked
# back to one branch this way
cargo run --release -p nes-console --example irq-sweep -- [rom.nes] [max]
# blargg's 4-scanline_timing at every
# /IRQ delay from 0 to max master
# half-steps, and what each reports:
# the measurement behind CART_IRQ_DELAY
# and the band the ROM allows
cargo run --release -p nes-console --example vbl-probe -- [rom.nes] [frames]
# every edge of the PPU's vblank flag:
# where it fell in the frame and how
# many CPU cycles since the last rise.
# With no ROM it runs its own (turn
# rendering off and spin; RENDER=1 for
# on), so the flag is the only thing
# moving. READS=1 the CPU's own $2002
# reads beside the edges, NEAR=1 only
# the ones landing on the set dot,
# REPORT=1 the last forty reads dumped
# when a blargg ROM stops running.
# The flag being right and the read
# being wrong are different faults and
# this is what tells them apart
cargo run --release -p nes-console --example run-rom -- rom.nes [frames] [out.ppm]
# a ROM on any of the six boards through the
# console: the
# last frame as PPM, the rate, and
# blargg's $6000 report if there is one;
# ALIGN=cpu,ppu picks another power-on
# alignment; CRT=out.ppm the picture
# through Rung C and the CRT stages,
# DECODED=out.ppm the decoded grid;
# WAV=out.wav the sound at 48 kHz
cargo run --release -p nes-console --example export-testrom -- out.nes [bars|pad|pad-dmc|pad-paint|cal]
# the test cartridge, the bars
# cartridge, or the bench's polling
# cartridge (eight reads a frame, with
# or without a DMC loop; pad-paint
# colours its band with the byte it
# read, for B3's bisection), as an
# iNES file: nobody's game, the
# console's own. `cal` is the bench's
# calibration cartridge (src/cal.rs:
# eight measured screens under a strip
# that names every frame, its regions
# written beside it as out.json from
# the same generator); tests/cal.rs
# reads the strip off the model's own
# frames, MUTATE=1 one tile off is red
cargo run --release -p nes-console --example cal-screens -- out_dir
# the calibration cartridge's eight
# screens as decoded PPMs, stepped by
# Select as a hand would; VARIANT=n
# parks the palette and bars screens
# Every runner below takes KNOBS=runs/<stamp>/knobs.toml, the bench's
# knobs file (nes-console/src/knobs.rs): the model's alignment and its
# work RAM's power-on pattern (a fill byte or a seeded pattern: the model's
# blank RAM is a knob, not a fact), and the part's warmth ([warmth]
# seconds on, measured off the head's logs, on [warmth_curve], the
# picture gain fitted to the bench's warm-up series: capture-score
# scales the model's encoded picture about blanking by it), each with
# where it came from (measured, authored or fitted, and by what), read
# at the start and printed, so a run's report carries its sources; a
# key or table the reader does not know is refused by name, a fitted
# knob without its residual too. tests/knobs.rs: the shape parses, the
# refusals fire, and the alignment knob moves the scheduler (MUTATE=1
# feeds both runs one alignment and must go red), and the warmth reaches
# the picture and not the sync or the burst (MUTATE_WARMTH=1 must go red).
cargo run -p nes-console --example knobs -- runs/<stamp>/knobs.toml
# the file read back and described,
# or refused (exit 1); what nes-bench's
# tools/knobs.py check runs
PAD=a5 cargo run --release -p nes-console --example pad-log -- rom.nes [frames] [script]
# the controller port's poll log, one
# line per latch (L index byte clocks),
# the line the bench's bridge streams
# from the part; a script of AT frame hh
# lines sets the pad. On pad-dmc some
# polls take nine reads: the DMC's double
# clock, measured on the 2A03's die and
# held on its rung; tests/pad_log.rs
# records the count, MUTATE_HELD=1 red
cargo run --release -p nes-console --example mmc3-probe -- rom.nes [frames]
# where an MMC3 board's counter is
# clocked, in the PPU's own frame: the
# position of every filtered A12 rise
# with the counter and latch it left.
# A game's split rides on this, and
# blargg's 4-scanline_timing measures
# the same thing from inside
cargo run --release -p nes-console --example bench-script -- rom.nes script.txt [tail]
# a bench script played with its time:
# WAIT s S runs s seconds of master
# clock, RESET holds the CPU's /RESET
# half a second (the head's pulse) and
# releases it (Console::reset_button,
# the CPU's warm reset; the PPU has no
# reset here), latches counted from each
# RESET as the bridge counts them; prints
# each latch's poll line after the last
# one (menu 119, game 250).
# tests/warm_reset.rs, MUTATE_RESET=1 red
cargo run --release -p nes-console --example capture-score -- rom.nes [frames] [record.u8 rate]
# the capture path: the ROM's frames
# through the card model (or a real
# record) and back, every flat region
# scored against the console's own
# synthesis; the synthetic roundtrip
# is held to the plan's tolerances
# (exit 1 on a miss), a real record
# is recorded. The bench's B1: SCRIPT=
# (the bench script's SET and AT lines),
# LATCH=n (the model's frame is the
# picture the part's recovery hands back
# for a trigger at latch n, placed by
# where the latch fell against the
# vertical sync's onset, row 244 dot 280:
# Console::run_to_picture_after_latch,
# tests/latch_frame.rs, MUTATE=1 red),
# TRIGGER_SAMPLE=i (the record sliced
# from the trigger on, so the recovery's
# first full frame is that frame on the
# part);
# SYNTH_TRIGGER=1 is the tool's own
# green run on the synthesis, and
# MUTATE_TRIGGER=1 (one frame late) is
# red across the bars cartridge's
# luma-row step, frames 122;
# SYNTH_OUT=path writes the synthesis
# as a u8 record with its .toml, which
# the bench's fake scope serves;
# PROFILE=$cc reports one colour's luma
# row by row on both sides, over the
# dots the model draws clear of anything
# else: a tilt inside one colour is the
# picture's, a step between colours at
# the same rows is the colour's (which
# is what the part's low luma turned out
# to be, nes-bench open-items)
cargo run --release -p nes-console --example split-score -- rom.nes [frames] [record.u8 rate]
# the split: every picture row's
# horizontal shift between two frames,
# on the model and on a triggered
# record, the still rows the status
# bar and the moving rows the level;
# then which of the model's frames the
# record's triggered frame is (F+0 on
# the first scrolling record once the
# frame was placed from the latch's
# position; F+1 under the earlier rule,
# which is how the rule was found). SCRIPT,
# LATCH, TRIGGER_SAMPLE as above, GAP=n
# frames apart; the synthetic roundtrip
# is held (MUTATE_FRAME=1 and
# MUTATE_STILL=1 red), a real record
# recorded; nes-bench/tools/split-score.py
# drives it from a run.
# It also scores the candidates F-2..F+2
# two ways where they differ: on the dots
# they draw differently (what moved), and
# on the decoded picture (the colour
# phase, which alternates with the PPU's
# frame parity and so names a parity, not
# a frame). A still screen can only
# answer the second, which is how Duck
# Hunt's field read F-1 and F+1 alike
cargo run --release -p nes-console --example frame-motion -- rom.nes [frames]
# how much of the picture moves, frame
# by frame, under SCRIPT's own AT lines:
# the probe that picks the latch for a
# capture that has to tell one frame from
# its neighbour. LATCH=n also names the
# picture that latch lands on, SHOW=dir
# with MARK=a-b writes those frames
# decoded
cargo build --release -p nes-shell && target/release/nes-shell rom.nes
# the console in a window (a display
# session, a GPU): arrows, Z and X for
# B and A, Enter and right Shift for
# Start and Select, Escape to quit;
# counters on exit. NES_SHELL_TICKS=n
# exits after n redraws (the smoke run
# under Xvfb)
cargo test --release -p nes-shell # the GPU picture against the CPU chain
# (SKIPs without an adapter,
# REQUIRE_GPU=1 insists; MUTATE=1 drops
# persistence, red), the paced loop and
# the ring on a synthetic clock
cargo run --release -p nes-console --example picture-bench -- rom.nes
# where a frame's time goes on one core
cargo check --target wasm32-unknown-unknown -p nes-console
wasm-pack build crates/nes-wasm --target nodejs --out-dir /tmp/nes-wasm --release
node tools/wasm-bench.mjs /tmp/nes-wasm rom.nes [frames]
# the browser target, measured under node
cargo run --release -p nes-wasm --example record-replay -- rom.nes frames "f:pad,..." out
# a run recorded as the page records one
# (every pad change, reset and picture's
# digest from power-on: record.rs), then
# replayed with the trace on, every
# picture held to the log; out.log and
# out.trace, the roof's flow tools read
# the second. tests/record.rs: a changed
# pad or a moved reset is refused, and
# the registers at each opcode fetch
# hold every LDA #imm before it
cargo test --release -p nes-console --test state
# saved states (state.rs): the whole
# console saved where a CPU cycle ends
# and loaded into one powered on from the
# same ROM runs on as the one that never
# stopped, pins, pictures and sound, the
# splits landing in stalls and a reset's
# hold, and in blargg's MMC3 test with its
# IRQ up; MUTATE_STATE=1 must go red. A
# recording can start from one
# (nes-wasm's record_start_here)
NES_STATE_ROM=rom.nes cargo test --release -p nes-console --test state -- --ignored
# the same on any cartridge, by hand
cargo test --release -p nes-console --test breakpoints
# run_frames_until: the console stops as
# the CPU begins fetching an opcode at a
# chosen address, once a frame on the NMI
# handler, never on an address not run, and
# a run broken into stops makes the same
# pictures; MUTATE_BREAK=1 must go red
cargo run --release -p nes-console --example trace-cpu -- rom.nes
cargo run --release -p nes-console --example flat-cpu -- rom.nes <half-cycles>
# the instruments: the CPU's bus through
# the console, and rung 3 alone on a flat
# image of the same ROMROMs are never committed; blargg's tests are read from a checkout of the nes-test-roms collection.
MIT. Nothing here embeds die data; the chip crates this repository will depend on carry their own NonCommercial and ShareAlike obligations, and a console binary built with them inherits those.