The QA rig

The bench's eyes are fixed to a metal frame over a backing board, and this file is what "fixed" means: where each thing is, so a moved camera or board is a number to check against rather than a map to re-read. Dimensions from the user, 2026-09-15; pixel scales MEASURED off the frames named. tools/board-overlay.py --read reads the hole map off a frame at a known pose; docs/board-map.json names the pose it was read at.

The backing board and the breadboards

itemsizewhere
backing board12 by 18 inunder everything; the frame's uprights at its long edges
breadboard block6.5 by 10 in (three boards and a rail strip, side by side)flush against the backing board's right short edge, centred, 3 in of board above and below it
the chip boardone of the threethe one nearest the backing board's edge (position as photographed: read off the frame, docs/board-map.json band)

The breadboard's hole pitch is 0.1 in, which is what every pixel scale below is measured against.

The cameras

cameradevice (by-id)jobmountframescale
Logitech BRIOusb-046d_Logitech_BRIO_1C8D6975the board eye: the hole map, the named close-ups, the timed board grabson the frame's cross-bar over the breadboards, straight down, raised and levelled 2026-09-15, gaffer-taped and velcroed1920 by 1080, MJPG (USB 2: 4K is not on offer)LOCKED 2026-09-15 (captures/b15-all.jpg): 11.1 px per hole at zoom 100, the same along columns and rows (level: no foreshortening), 28 at zoom 250, 53 at zoom 500; focus 18; the whole backing board in frame. The map is read per board off a zoom-250 frame (docs/board-map.json names each board's frame, aim and band) and the as-built photograph is drawn on those frames
Logitech QuickCam Pro 9000usb-046d_0990_08DF0A45the side eye across the board: the three chips and the UNO's leads in profile from the Pi's sideon the backing board's Pi side, low, looking across the breadboards toward the console1600 by 1200, MJPGLOCK BROKEN 2026-09-24: taken off, then put back in the Communicate's place to cover the pad-ble work, so no scale here is current. It refused to enumerate on port 1-1.4 that evening (can't set config #1, error -71, twice, and unchanged by freeing two devices of hub power); a cold reboot cleared it and it has been clean since. The old lock, for the old pose only: LOCKED 2026-09-15 (docs/lab/rig-side-eye-9000.jpg): the chips at about a third of the frame's width; a housing's pins show in profile at about 4 px each
Logitech QuickCam Communicate Deluxe (OFF THE RIG 2026-09-24)usb-046d_09a2_ABAD8310was the second side eye, along the chip board's rails side from the cable end: the probe clips, the console cable's housing, U3's rails-side landingson the frame's upright at the cable end, low1280 by 960, MJPGLOCK BROKEN 2026-09-24: moved to cover the pad-ble work and not yet re-measured, so no scale here is current. The first frame after the move (captures/side-eye-2026-09-24.jpg, not committed) has the subject against the left edge and the auto exposure metering off the wall behind it. The old lock, for the old pose only: LOCKED 2026-09-15 (docs/lab/rig-side-eye-communicate.jpg), column numbers legible to about column 25, the Q lines' arc and the housings in profile
Roxio capture (em28xx)usb-1b80_Roxio_Video_Capture_USB_...the console's pictureon the splitter with the scope's CH3720 by 480 NTSCnot a camera

Which camera can be aimed in software, measured

Asserted wrong once on 2026-09-24 and then measured, because a camera was swapped on the strength of the wrong answer. v4l2-ctl --list-ctrls on each, after the reboot that day:

camerapan / tiltzoomfocusframe
Logitech BRIOpan_absolute, tilt_absolutezoom_absolutefocus_absolute, focus_automatic_continuous1920 by 1080
QuickCam Pro 9000nonenonenone1600 by 1200
QuickCam Communicate Deluxenonenonenone1280 by 960

Only the BRIO can be aimed without touching it, which is why eye.py is written against the BRIO alone and carries presets in zoom, pan and tilt. Both side eyes are framed by hand and offer nothing but exposure, gain, white balance, sharpness and backlight compensation. The Communicate Deluxe adds privacy and that is the whole difference between the two.

So a side eye is chosen for its sensor, not its reach: the 9000 is 1600 by 1200 against the Communicate's 1280 by 960, and neither can be pointed from here.

The bench, photographed

Four phone photographs of the whole rig, 2026-09-15, after the lock (phone metadata stripped; the TV's picture blurred, since the site carries no commercial game screenshots):

the bench from the front: the console and its TV at the left, the frame over the backing board, the scope behind it

the frame: the bench light on its cross-bar, the BRIO under it at the middle, the side eyes on the lower rail

the console's side: the mainboard with the cartridge in, the modulator, the probe clips, the breadboards beyond

the bird's eye: the whole backing board as the BRIO sees it, the UNO and the Pi at the bottom, the QuickCam 9000 at the right

The pose, locked

All three cameras and the boards were gaffer-taped and velcroed on 2026-09-15 night, the BRIO levelled (the tilt of the first raised pose, 9.2 px a hole along the rows against 11.1 along the columns, is gone: both read 11.1). Every named close-up lands on its target and the map's rings sit on the holes across both boards to column 56 (captures/views-20260915T152127). This is the baseline: a moved thing shows as a frame that differs from captures/b15-all.jpg by more than sensor noise (the timed grabs' worst 40 px block against it), and the fix is --read again, not a new tool.

What a pose costs and buys

At the earlier height the BRIO's close-ups put 120 px on a hole and a wire end reads beside its column number without effort; at the raised height they put 53 px on a hole, half that, and the column numbers are still legible. What the raised pose buys is the whole rig in one frame: the UNO, the Pi, the console's mainboard and the modulator, which is what the timed grabs want as a record. What it costs is the fine read of a housing across two columns, which the side eye is for.

Where the addresses live

Three keys, in .env beside the tools: BRIDGE (the UNO through the Pi's serial bridge, host:port), SCOPE (the instrument, no port) and PI (user@host for the tools that run something over there). The file is gitignored, as bench.local.md is; .env.example carries its shape and is committed. A flag beats the environment, the environment beats the file, and the file beats bench.local.md, so tools/rig-check.py and tools/bench-check.py now take no addresses at all on a machine that has one. On the Pi itself BRIDGE is the loopback, since the bridge is local there.

After every change: the regression check

python3 tools/rig-check.py --pi HOST

Six checks, each PASS or FAIL with the number that decided it: light (the whole-board frame's level near the baseline's, the exposure under the camera's cap: a dark room pins it at 312, seen 2026-09-15 when the sliding bar took the light with it), still (the frame differs from the baseline frame by sensor noise only: the worst 40 px block under 60, where noise measures about 20 and a moved board or camera 120 and up; a FAIL names the region in map coordinates), board right and board middle (each read afresh off a zoom-250 frame at the aim the map records, and held to the map within half a hole; a FAIL means the map is stale), and the two side eyes (lit, and correlating with their baseline frames at 0.85 or better). Exit 1 on any FAIL. The frames it took are in captures/rig/, so a FAIL can be looked at.

When a change was meant (a board added, the camera slid), the sequence is: the check (it fails, and says where), the map re-read off the frames it just took (tools/board-overlay.py --read-boards --frames right=captures/rig/right.jpg,middle=captures/rig/middle.jpg, with --aims and --bands when a board no longer sits in its frame), the overlay (tools/board-overlay.py captures/rig/all.jpg) looked at with the rings on the holes, and then the check again with --baseline, which refuses if any check fails on the frames the baseline would be made of. The baseline's measurements go to docs/rig-baseline.json, dated; its frames to captures/rig/baseline-*.jpg. Neither exists yet: no baseline has been taken (2026-09-28).

The signal paths, the same way

scripts/hands-head.sh          # the Pi's hands; the console's switch OFF
scripts/hands-manual.sh        # your hand on the panel; the switch ON
python3 tools/bench-check.py   # the same tool, without the hands

The two scripts are the same check with a different hand on the button. They read the bridge's and the scope's addresses out of .env, then bench.local.md, say which switch position they want before they start, and pass anything else through. The manual one skips the register walk unless given --walk, since the head's run covers it.

The eye's check says nothing about the electrons. This one runs the measurements the bring-up and the register walk held: the bridge answers STATUS, the scope answers *IDN?, the console's polls come at about sixty a second with eight clocks in every one (B0's gate 1), TRIG 20 stops the scope on CH1, and fifteen bytes set into the register read back off D0 at the eight mid-slots, pressed LOW, every one as set. It needs the console on with a game polling; without polls the checks that need them are SKIPPED with that reason, not failed. The scope's settings are read first and put back after. A byte that differs is taken again before it counts, because the game does not clock every poll alike (one poll in fifteen came with its eight clocks in 68 us against the usual 98, 2026-09-15), and a retake is reported. Screenshots in captures/bench/. First run 2026-09-15 23:35: bridge, scope, polls (1212 in 20 s, all eight clocks), trigger and walk 15 of 15, no regression.

With --hands manual or --hands head it also checks the two hands, reset and power, by the poll stream: the game stops polling while the CPU is held or the power is off and polls again after, and the polls are counted per quarter second (the Pi's bridge hands lines over in batches of about 100 ms, so arrival times cannot see the 17 ms cadence). Manual means your hand on the front panel, the button held two seconds, the switch off for a count of three; head means the Pi's GPIO17 through OK1 and GPIO27 through K1, the same two measurements. The front panel's own button stays wired in parallel with OK1 and its switch in parallel with K1 (J3 brown and red, metered 2026-09-17), so a wiring that works by hand and not from the head shows as exactly that. Manual runs with the relay resting open; head runs with the front switch off, and closes K1 before it listens. The relay module's input is active low: the head daemon drives it so (fixed 2026-09-16), and the Pi's GPIO27 rests as an input with a pull-down until something claims it, which is the relay ON. A gpio=27=op,dh line in the Pi's config.txt is the cure, to be set once the relay is in and its rest state measured. The relay has been in and driven since 2026-09-17; whether that line was set is not recorded here (bench.local.md is where it would be).

When something moves

  1. python3 tools/eye.py sweep NAME --pi HOST --from 10 --to 40 --step 4: the focus.
  2. python3 tools/eye.py grab bN-all --pi HOST --preset board: the frame.
  3. python3 tools/board-overlay.py --read captures/bN-all.jpg: the map and the views, if the pose is the rig's (straight down, frame_rotate set and the boards in their bands); otherwise the bands first.
  4. python3 tools/eye.py views --pi HOST and tools/view-rings.py on the set: are the rings on the holes.
  5. python3 tools/board-overlay.py captures/bN-all.jpg: the as-built photograph.

Pulled at build time from nes-bench/docs/rig.md; the repository is the one copy. The reports use some working words of their own: Words the reports use.