M4 report: the capture source
Run stamp: 2026-09-01, fifth commit of this repository, rustc 1.97.1,
cargo test --workspace 56 tests green, clippy clean, MUTATE=1 reddens
33. M5 puts the counts under a check-self-counts scan.
What closed
ntsc-source-cap: a raw waveform at a declared-but-not-trusted rate in,
a broadcast CompositeFrame on the grid out. The only source that must
earn its phase instead of deriving it. Recovery is four measurements:
- Sync. Falling edges at a threshold anchored to the measured sync tip (half of Table 1's 286 mV depth above the 1st percentile), walked at the nominal line period, then a least-squares period over every matched edge. That period against the declared rate IS the rate-mismatch measurement: an injected +50 ppm on a 13.5 MHz capture is recovered within 5 ppm.
- Fields. Mostly-low lines are the broad pulses; the first group anchors frame line numbering. (The RGB encoder grew line-granularity broad pulses on frame lines 4..7 and 266..269 for this, recorded in its module doc: without them the M2 encoder had no vertical structure for a detector to find.)
- Burst lock, per line. Resample the burst window onto the grid, project against the geometry's own target (burst = -sin at origin Phase(0)), correct the sub-sample offset by the measured phase residual (the algebra puts the residual at minus the offset, so the correction subtracts), iterate to under 0.01 grid samples. Burst-free and broad lines borrow the previous locked offset.
- Resample and re-reference. The whole line at the locked offset through 8-tap windowed-sinc interpolation, then DC removed by pinning the measured back porch to blanking.
capture_model is the spec's capture-card stand-in, rig code under
principle 5: an anti-alias lowpass (6.5 MHz, a real card has one), the
declared rate error, DC offset, and seeded deterministic noise, every
parameter stated at each test's call site.
The roundtrip gate
- Clean capture at 4 x f_sc: rate error measured under 3 ppm, burst residual under 0.05 grid samples, grid comparison mean under 4 mV (about half an IRE) with the worst 60 mV confined to bar edges, where the model's anti-alias filter removed real signal the original still carries: the instrument floor, stated as such.
- Dirty capture at 13.5 MHz (+50 ppm, +20 mV DC, 2 mV noise): the 50 ppm found within 5, DC re-referenced away, grid mean under 6 mV, and the recovered frame decodes to bars within 0.04 of the input video levels (looser than M2's 0.02 because this signal has crossed a capture card).
- Field structure found where the encoder put it: recovered lines 4 and 266 mostly sync-low, line 25 not.
The mutation that had to be thrown away
The first MUTATE perturbation shifted the whole capture a quarter
subcarrier cycle, reasoning that sync would barely move while chroma
went 90 degrees wrong. The burst lock absorbed the shift, exactly as
designed, and the test went red only marginally, from the shift's own
interpolation loss: a mutation the subject is built to survive proves
nothing. The replacement disables the burst lock in the subject
(recover_with, whose doc says nothing but that test may pass false):
recovery then rests on sync edges alone, and both roundtrips go red at
five to seven times their tolerances (29 and 38 mV means against 4 and
6). That is the proof the lock is load-bearing rather than decorative.
The open half of the gate
The spec's M4 closing gate has two parts. The synthetic roundtrip is
closed above. The real recording is open and requires hardware: no
captured NTSC waveform exists on this machine, and synthesizing one
anywhere else would launder this repository's own code through a file
and call it an oracle. The 6502 repository's touch lesson applies
verbatim: ask for a device before believing capture works. What is
needed, whenever hardware exists: any composite NTSC source recorded as
raw samples (WAV or f32) at a known nominal rate showing colour bars,
and the existing recover plus the M2 bar tests close the gate.
Carried forward
- M5: self-counts, the licence registry, the consolidated divergence list, and the spec's v0.3 draft.
- The real-recording gate above, pending hardware.
Addendum, 2026-09-02: the ingestion path, built and waiting
The director has a capture card, so the machine side of the real gate
is now complete (crates/ntsc-source-cap/src/ingest.rs,
examples/recover-real.rs, tests/real.rs):
- Readers for WAV (PCM 8/16, float 32; rate from the header) and raw f32/i16/u8 (rate declared).
- Auto-levelling from arbitrary ADC units to volts, the sync depth as
the ruler (Table 1: tip 40 IRE below blanking), the two levels found
as the two lowest histogram peaks. Proven before any real file
touches it: a synthetic capture rescaled to gain 0.31 offset 0.7
levels back and recovers within 7 mV mean on the grid
(
arbitrary_units_auto_level_and_recover). - The gate test SKIPS by name until
captures/real-bars.*exists; REQUIRE_REAL=1 insists. Stated tolerance for the real recording: each bar channel within 0.08 of video level (consumer gear sits looser than Table 1's studio +/-1 IRE); a clean capture outside that is a finding to investigate, not a number to widen.
The human side is one page: docs/capture-instructions.md. One file
plus one command closes the last gate.
Addendum, 2026-09-02 evening: first real console captures, and the NES profile the recovery was missing
Five captures from a real front-loader NES (Super Mario Bros. / Duck
Hunt cart) taken with tools/scope-capture.py on the family's DS1054Z:
12 M points each at 125 MSa/s, about 5.8 frames per record, backed up
off-repo. Three findings, each fixed and proven the same day:
- The scope ignores a memory-depth set while stopped and the capture tool issued it stopped; it stayed AUTO and the readback had no record length. Set while running and asserted now.
- The recovery's histogram combed on quantized data: integer u8 samples in sub-integer bins made the sync tip read as two peaks, and "blanking" was found one count above the tip. Bin width is clamped to the data's own quantization step now; the synthetic f32 proof capture could never have caught either of these.
- The recovery decoded every capture as broadcast NTSC, and the NES
is not broadcast NTSC. Its line is 227 and a third subcarrier
cycles (2728 grid samples, not 2730), so the burst phase advances a
third of a cycle per line where broadcast advances half, over 262
progressive lines anchored on vsync rows 245..=247. Decoded as
broadcast, every line landed two grid samples further rotated than
the last: a smooth hue roll down the whole frame, invisible on the
mostly-monochrome menu screen and unmissable on World 1-1. The
broadcast assumption also mismeasured the scope clock at -740 ppm
(2 in 2730 is 733 ppm); under the NES profile the same records
measure -7 to -19 ppm.
recover_nesis the fix: NES geometry, the burst target derived from the encoder's own wave-8 square, levels in the transcribed table's ABSOLUTE volts so the recovered frame decodes with the oracle's own constants. Proven on a synthetic NES capture (chained origins 8, 0, 4 so the anchored frame is the origin-0 one): chroma pointwise within 0.006, band luma means within 0.005, and the broadcast recovery on the same capture is the built-in mutation, which must miss by 10x or refuse.
The first region scored against the family's own synthesis
(examples/score-real-region.rs, the paused World 1-1 sky vs a solid
$22 frame through the identical decoder): luma within 0.003, hue
within 0.6 degrees, and saturation 28 percent HOT (0.573 vs 0.448).
That last number is a finding, not a tolerance to widen: either the
unterminated probe run flatters the chroma swing, or the real DAC's
AC swing genuinely exceeds the table's DC-measured levels, and one
75-ohm terminated re-capture decides which.
The bars gate stays open: the combo cart draws no SMPTE bars, so
captures/real-bars.* still waits on a test ROM. The five banked
captures (menu, paused World 1-1, title-plus-early-demo, Duck Hunt
menu, Duck Hunt in play) decode recognizably end to end, and the
paused 1-1 frame is the keeper: the $22 sky, flat and stable, off
real silicon through this repository's whole path.
Third addendum, 2026-09-04: the terminator
The designed experiment ran: the same console into a 75 ohm
feedthrough terminator at the scope input, CH3 DC, 200 mV/div, 12 Mpt
at 125 MSa/s (captures/loaded-a, the cartridge's menu on screen). The
figures and their numbers are tools/composite-figures.py on that
record beside the earlier unterminated paused World 1-1 record, written
to docs/composite-figures.json (the committed measurement; the
records themselves stay gitignored bench data).
Terminated, on every line of the record: sync tip -0.076 V, blanking 0.124 V, the burst 0.280 V peak to peak, so the burst swings 1.40 times the sync-to-blank step; the transcribed table's own ratio is 1.42. Unterminated the same console read 0.50 V sync to blank and 1.00 V of burst, a ratio of 2.0: the DC levels came up 2.5 times and the 3.58 MHz subcarrier 3.6 times, which is the probe run flattering the chroma by about 40 percent before the decoder's filtering, and the origin of the 28 percent hot saturation above. This console's absolute levels into 75 ohms sit below the table's (sync-to-blank 0.200 V against 0.264 V, and its sync tip below zero volts), which the recovery's normalisation onto the table's sync and blanking is exactly built to absorb. The line period measures 63.512 us, the NES's 2728-of-2730 grid (63.509 us), against broadcast's 63.556.
The terminated paused World 1-1 sky, scored through the identical path, is the confirmation this addendum does not yet carry: the console needs its Mario paused again.
Fourth addendum, 2026-09-06: the levels, read off the signal
The console's N6 capture gate (nes, capture-score: every flat region
of full_palette.nes through the card model and auto_level_nes,
scored against the console's own synthesis) found the re-referencing
a bin coarse: 256 histogram bins over a 1 V span are 4 mV each, 1.4
percent of the sync depth at either end, which read as a 3.7 percent
luma gain across the whole frame. And a solid dark colour, half of
whose picture samples sit below blanking (the table's row-1 low
level), filled the histogram's second band and was taken for blanking
outright. find_tip_blank now uses the histogram only to place a sync
threshold; the sync tip is the median inside every sync pulse and
blanking the median of the front porch before every pulse, which no
picture can occupy. On the model: blanking within 0.01 mV and the sync
depth within 0.003 percent (nes_auto_level_finds_the_levels_finer_than_a_histogram_bin,
which the old finder fails by 75 mV). The scored sky region above is
unchanged by construction: a u8 record's medians are its quantized
values, the same ones the crest gave. Tagged 0.2.4.
Pulled at build time from ntsc-crt/docs/m4-report.md; the repository is the one copy.