First real-hardware RR-Net end-to-end for the ip65 product: 15 independent hardware checks on real CS8900a silicon - #200
Merged
Conversation
`c64-https-ip65-onchip.prg` is one of the three products `make package` ships, and until now no ip65 build had ever run on real CS8900a silicon: every ip65 result in this repo, including the 36-minute stock-clock e2e in CLAUDE.md, came from `tests/rig_vice_https_macos.py` driving the same PRG inside a pcap-patched VICE against a feth pair. VICE's CS8900a is a model of the chip. This rig is the chip. Four pieces, and the split between them is the point: * `tools/ip65_hw_checks.py` — every verdict, as pure functions over bytes, returning a three-state Verdict (an inconclusive one fails closed). The rig supplies the pcap and the DMA reads; it decides nothing. * `tools/test_ip65_hw_checks_unit.py` — red-green for every one of them, off-device, in milliseconds, in pytest's testpaths. Several red cases are paired with the NAIVE checker someone would plausibly have written and assert both halves: the naive arm passes the bad input, the real one fails it. `test_every_check_has_a_red_case` enumerates the module by introspection, so a verdict cannot be added without a failing case. * `tools/rig-up-rrnet-macos.sh` — the segment, adapted from c64-wireguard's script of the same name (attribution in its header). 10.0.66.0/24 deliberately, so the feth rig keeps 10.0.65.1 and both can be up at once. Ours adds DNS, because this client resolves HTTPS_HOST before it connects and a DHCP-only segment stops at DNS RESOLVE FAILED. * `tests/rig_ip65_rrnet_hw.py` — the hardware rig, `rig_*` per #109/#111. The assertions that matter, and why they are shaped this way: there are exactly two stations on that cable and the Mac is one of them, so "the capture contains HTTPS traffic" is satisfied in full by the Mac's own frames, by a capture of the wrong interface, and by a run with the cartridge unplugged. Every wire assertion therefore discriminates by Ethernet source address, and a third MAC is a hard failure. The lease is read from the C64's own memory and rejects ip65's build-time cfg_ip default by value (192.168.1.64 is what a machine that never ran DHCP reads). The absence-of-cleartext claim refuses to report absence until it has found a positive control in the same corpus — the SNI hostname, which TLS 1.3 leaves in the clear in the same frames. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two defects, both found by running the rig against the device rather than by reading it. THE LOAD. The first hardware attempt aborted at the image verification, which is what that check is for. Measured on the U64E (fw v3.15-78-g71480a9d, n=2): a single 47,103-byte `write_memory` lands in 0.22 s and reads back with EXACTLY ONE wrong byte, at a different offset each time — 2715 ($12BC) on one attempt and 3181 ($146E, $C8 read back as $AB) on the next. Not truncation and not a size cap: a sporadic single-byte corruption somewhere in the bulk path. The 84-byte chunked `write_bytes` path takes ~33 s and came back byte-exact, twice. So the loader takes the slow path, verifies, and retries once on a mismatch. A one-byte flip inside 6502 code is exactly the fault that surfaces forty minutes later as a crash or a failed handshake and gets written up as "ip65 does not work on real silicon". The verify is the difference between that and knowing. Also reproduced independently here: the $0801/$0802 zeroing event c64-wireguard measured on this device, ~2-5 s after READY. is drawn, with no write involved. The head is rewritten after the body and re-checked past a settle window. THE STREAMS. `tcp_stream` assembled every matching frame into one sequence space keyed on the absolute TCP seq. Two connections on the cable — which a retry produces, and ip65 retries whenever it has no next-hop MAC yet — have unrelated ISNs, so that zero-fills the gulf between them and destroys the record framing of both: a completed handshake would read as "no TLS records". Now `tcp_streams` groups per (src, sport, dst, dport) and `tcp_stream` returns the longest, with a red-green case that fuses two connections and requires the ClientHello still to be found. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The facts that change what you type: the segment and where its addressing is read from, that the rig decides nothing and tools/ip65_hw_checks.py is where the verdicts live, the two-station discrimination, the chunked-and-verified load with its measurement, the $A000 ROM-read trap, and why this is the one rig that does not call enable_uci. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…othing
The unit suite claims every verdict alarms on a known-bad input. This adds
the thing that CHECKS that claim -- tools/mutate_ip65_hw_checks.py breaks
one verdict at a time and requires the suite to go red -- and fixes what it
found on its first run: FOUR of the red cases passed whether or not the
checker worked.
* check_dhcp_lease: every red case also passed `subnet=`, and ip65's
build-time default 192.168.1.64 fails the subnet test on its own, so
deleting the default-rejection branch changed nothing observable. Now
asserted with no other constraint in play.
* check_shadow_ram_readable: a real $A000 read carries BOTH ROM markers,
so losing either arm still rejected the realistic input. Each arm is
now asserted alone.
* check_http_response: every red case had a wrong length AND wrong
content, so a checker comparing only a four-byte prefix passed them
all on the length test. A same-length near-miss now isolates the exact
compare.
None of the three was visible by reading the suite. They were visible by
breaking the module.
One mutant is reported as KNOWN-EQUIVALENT rather than suppressed:
removing check_http_response's length test cannot be detected, because
resp_buf[:resp_len] already has the wrong length whenever resp_len is
wrong. That test is kept for its message, not its coverage, and the
harness says so.
The harness also carries a trap it hit itself. Python caches bytecode on
(mtime, size), and a mutation run rewrites one path many times within the
same second; two mutants of equal length then execute the FIRST one's
bytecode. The first run attributed three verdicts to a test that does not
cover any of them. The subprocess now runs with PYTHONDONTWRITEBYTECODE=1
-- a harness that certifies other checks must not itself be the unreliable
part.
13/13 detectable mutants caught.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ogic) Both made AFTER the passing hardware run, so the rig file in this PR is not byte-identical to the one whose sha256 the run's provenance stamp recorded (cde610a1…). Neither touches what the run did. 1. The firmware string in the loader comment said "v3.15-78-g71480a9d", which I took from a fleet note rather than from the device. `/v1/info` on this box reports fw 3.15, commit 4011c97c, fpga 125. Citing a note instead of reading the machine is precisely the mistake this rig exists to stop, so the corrected string stands with the reason next to it. c64-test-harness#231 has the same correction. 2. stage_wire picked the lexicographically first non-C64 MAC as the host. On a two-station cable that is the right address, but with a third station present it could pick the intruder and leave check_c64_originated naming the real Mac as the unexpected one. It would still fail — it must — but a diagnostic that points at the wrong box is worse than one that points at nothing. Now the busiest non-C64 source. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
JC-000
added a commit
that referenced
this pull request
Sep 6, 2026
…dation gap actively Four review fixes to the RR-Net sub-bullet. Nothing about the run changed; every wire fact was re-verified against the capture in #200 directly rather than against the rig's summary, and all were exact. 1. The 8% comparison was confounded by the library pin, and said so nowhere. engineering-notes.md:2178 puts all four VICE rows at the libs/nistcurves v0.6.0 pin; the hardware run is at v0.11.2. Verify is 1,416.7 s of that 2,159.7 s VICE run, so the pin is not a rounding error -- and README's own first Known Issues bullet, six lines below, already warns about exactly this and prices v0.7.0's key validation at ~1.6%. A bare "about 8%" was a derived figure silently coupled to a number the same file invites re-measuring: it rots with no pointer, which is the failure class this whole PR exists to fix. Kept the number rather than dropping it, but bounded it in the direction the confound actually runs. The newer pin is SLOWER, so real silicon beat the emulator despite carrying the more expensive crypto: ~8% is a LOWER BOUND on the advantage, not an estimate of it. "At least" is both more defensible and a stronger claim than "about", and it now points at the bullet holding the figure and the caveat. 2. n=1 moved onto the timing sentence. It is the one claim in the bullet that generalises poorly and it was the one stated most confidently; it was sitting in the scope sentence, which is not where the number is. 3. "It does not touch the name-validation caveats above" was true but passive, and a reader arriving at "HTTP 200, 24/24" could take away more assurance than is there. Now says `ip65-onchip` still does neither -- X509_VERIFY_NAME is UCI-only (Makefile:86-95), so that product does no server-name validation at all. Reinforcement, not new information: the same fact is already plain at lines 61 and 186. 4. "the first measurement anyone has taken" -> "our first". The original was an unfalsifiable absolute about the world rather than a statement about this project. Deliberately NOT broadened: no "one device, one cartridge" hedge. The pass/fail claims are existence proofs -- it works on real silicon at all -- and "a first, 2026-09-05" already frames them. The RR-Net MK3 EEPROM-MAC caveat belongs in the rig's own docs, not here. Prose only, so no red-to-green. One hunk, one line, confined to the Project Status region; a bare `pytest` at the root is green before and after (build BACKEND=uci first, per CLAUDE.md). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Six corrections from adversarial review. The first matters for the security release; the rest are all the same species — a true statement written more broadly than the evidence. 1. NEITHER CLAUDE.md NOR tests/README.md SAID ip65 DOES NO SERVER-NAME VALIDATION. It was in the PR body, but a release author reads the merged docs, not a PR description. src/x509_name.s is UCI-only, this rig fetches from a self-signed local listener and asserts nothing about the name in it. Both files now say so, and the CERTIFIES string the rig PRINTS says it too. 2. "The rig decides nothing" is FALSE AS WRITTEN, in my own added docs. Counted: 15 RES.verdict() calls delegate to the library; 18 RES.check() calls are the rig's own judgment, with no red case, and invisible to the introspection backstop because it enumerates only check_* in the module. Several fire on the green path. The wire and memory verdicts DO delegate, which is the part worth claiming, so all three places now claim that and not more. 3. The headline over-attributed to silicon: 8 of the 24 checks in the passing run involve no cartridge and no 6510 (four off-device selftests, a PRG file read, a labels.txt read, a config write, a Mac->Mac loopback probe). Recorded so the number is not quoted as 24 facts about the cartridge. 4. check_mac_on_wire contributes NO independent information as the rig calls it: c64_mac is a constant from the rig script, so all six value-rejections are statically false and the only live branch duplicates check_c64_originated's frame count over the same corpus against the same threshold. Kept — those rejections go live the moment a caller passes a MAC read back from the DEVICE — but the docstring now says to count it as one verdict, not two. 5. A SILENT SKIP in the new suite: test_the_build_exports_every_symbol_ the_rig_reads returned early and printed PASS when build/labels.txt was absent. That is the exact shape CLAUDE.md flags on test_x509_name.py. Absent labels.txt is now a failure, opt-out-able with C64_ALLOW_NO_BUILD=1 and loud even then. Both arms verified. 6. The "~1.7x cartridge-port I/O" figure was unsourced here. It IS in c64-wireguard (tools/test_ip65_rrnet_hw.py:144) — but attributed there to "the user measured" with no measurement record, so it is second-hand on both sides. Now cited AND flagged as not a measurement. Also recorded: the 2,159.7 s VICE comparator is at libs/nistcurves v0.6.0 while this ran at v0.11.2, so it is not a like-for-like binary and any speed-up is a LOWER BOUND, not an estimate — v0.7.0's public-key validation makes a current-pin emulator run slower. Say "at least", never "about". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
c64-https-ip65-onchip.prgis one of the three productsmake packageships, and no ip65 build had ever run on real CS8900a silicon. Every ip65 result in this repo — including the 36-minute stock-clock end-to-end in CLAUDE.md — came fromtests/rig_vice_https_macos.py, which drives the same PRG inside a pcap-patched VICE against a feth pair. VICE's CS8900a is a model of the chip. This PR runs the chip.The PRG is loaded over the U64's REST interface the usual way (DeviceLock, write +
SYS), but nothing about the network path goes through the Ultimate: the C64 reaches the world through the cartridge, and the Mac is the only other station on the cable — DHCP server, DNS server and HTTPS origin at once.The result
24/24 checks passed, 0 failed, 0 inconclusive, exit 0 — and the honest reading of that number is below, because it is not 24 facts about the cartridge. U64E (fw 3.15, commit
4011c97c, fpga 125), stock 1 MHz, no turbo, no REU.BACKEND=ip65 USE_NISTCURVES_ONCHIP=1 HTTPS_PORT=4433, PRG sha2569d33683a…, 47,105 B.What the 24 actually decompose into
build/labels.txtread, a U64 config write, and a Mac→Mac loopback probe of our own listener.check_mac_on_wirecannot fail unlesscheck_c64_originatedalso fails (the rig passesc64_macas a constant from the rig script, so all six of its value-rejections are statically false and its only live branch duplicates the other's frame count over the same corpus). It is kept because those rejections go live the moment a caller passes a MAC read back from the device, but it should be counted once.So the defensible headline is 15 independent hardware-attributable checks, not 24. Each line of the rig's own output is labelled honestly; it is the total that invites over-reading.
'G'toCONNECTION CLOSED: 1,979 s (33.0 min), against 2,159.7 s (36.0 min) for the same profile in VICE at honest 1 MHz — faster on real silicon by at least the ~8% the raw figures show. "At least", not "about":docs/engineering-notes.mdrecords all four ip65 VICE rows at thelibs/nistcurvesv0.6.0 pin, and this ran at v0.11.2 — two binaries about five months apart, so the boundary alignment does not by itself isolate the cause to silicon-versus-emulator. The direction is in our favour, which is why the number survives as a bound: v0.7.0's public-key validation costs ~1.6% on a verify that is 1,417 s of the 2,159.7 s, so a current-pin emulator run would be slower and the true gap wider.tls_statewas sampled over DMA throughout, so the phases are the client's own state machine and not a screen scrape:tls_statetls_close)The ~5 min to CH is the X25519 keypair; the 1,298 s in
PROCis the ECDSA P-256 verify. Both are in line with the VICE model.What is actually proven
Every wire assertion discriminates by Ethernet source address, because there are two stations on that cable and the Mac is one of them:
10.0.66.200, read from its ownnet_local_ipover DMA, not from dnsmasq's lease file (which would only prove our server answered). Rejected by value:0.0.0.0, ip65's build-time192.168.1.64, link-local, the host's own address, a pool address. Taken on the boot auto-attempt, zero retries.00:0e:3a:64:64:64, 210 from the Mac, 0 from any third MAC.www.foo.invalid.www.foo.invalid.http_resp_buf;net_last_error$00.Capture accounting:
/tmp/rrnet-https.pcap39,734 → 114,149 B across the run (22:53:23 → 23:27:23), 352 frames total, 238 inside the run window, 114 correctly excluded as older.What is here
tools/ip65_hw_checks.pytools/test_ip65_hw_checks_unit.pytestpathstools/mutate_ip65_hw_checks.pytools/rig-up-rrnet-macos.shtests/rig_ip65_rrnet_hw.pyThe split is the point. A first-ever hardware result is the one nobody re-reads, and this repo has three recorded instances of a suite passing for the wrong reason (#158, #161, #176). c64-wireguard wrote their equivalent module after shipping a tool cited for two days as "verified byte-for-byte" whose verification function was defined and never called and whose
main()returned 0 unconditionally.The rig is not judgment-free, and an earlier draft of this PR and of the docs said it was. Counted: 15
RES.verdict()calls delegate to the library; 18RES.check()calls are the rig's own opinion, with no red case, and invisible to the introspection backstop because that enumerates onlycheck_*in the module. Several fire on the green path (the boot-menu and DHCP screen scrapes, the cartridge-preference write, the listener probe, the zero-fill tail, the four selftests). The wire and memory verdicts do delegate — that is the part worth claiming — and CLAUDE.md,tests/README.mdand the printedCERTIFIESstring now claim that and not more.Red-green, and then a check on the red-green
The unit suite: 26 tests, 47 negative assertions, 14 verdicts each with at least one known-bad input, 12 paired with a NAIVE checker asserting both halves — the naive arm passes the bad input (the trap is live), the real one fails it.
test_every_check_has_a_red_caseenumerates the module by introspection, so a verdict cannot be added without a failing case.That was not enough on its own.
tools/mutate_ip65_hw_checks.pybreaks one verdict at a time and requires the suite to go red, and on its first run four red cases survived — they passed whether or not the checker worked:check_dhcp_leaseaccepts ip65's build-time defaultsubnet=, and192.168.1.64fails the subnet test on its owncheck_shadow_ram_readableloses the ROM-prefix arm$A000read carries both ROM markers, so either arm alone still rejected itcheck_http_responsecompares only a prefixcheck_mac_on_wirenever looks at the wireEach is now asserted in isolation. 13/13 detectable mutants caught, and one is reported as KNOWN-EQUIVALENT rather than suppressed: removing
check_http_response's length test cannot be detected, becauseresp_buf[:resp_len]already has the wrong length wheneverresp_lenis wrong. That test is kept for its message, not its coverage, and the harness says so.The harness also carries a trap it hit itself. Python caches bytecode on
(mtime, size), and a mutation run rewrites one path repeatedly within the same second, so two mutants of equal length execute the first one's bytecode; its first run attributed three verdicts to a test covering none of them. It now runs the subprocess withPYTHONDONTWRITEBYTECODE=1— a harness that certifies other checks must not itself be the unreliable part.The most instructive defect:
tcp_streamfusiontcp_streamassembled every matching frame into one sequence space keyed on the absolute TCP sequence number. Two connections on the cable — which any retry produces, and ip65 retries whenever it does not yet have the next-hop MAC — have unrelated ISNs, so that zero-filled the gulf between them and destroyed the record framing of both. A completed handshake would have read as "no TLS records": a false negative in a checker whose entire job is to avoid false results, latent until the first retry, and this run's own capture would have triggered it had the first connect been refused.tcp_streamsnow groups per(src, sport, dst, dport)andtcp_streamreturns the longest. The red case fuses a short high-ISN stub with the real low-ISN connection and requires the ClientHello still to be found:Two device findings, filed rather than absorbed
write_memorylands in 0.22 s and reads back with exactly one wrong byte, at a different offset each time (2715/$12BC, then 3181/$146E,$C8→$AB); the 84-byte chunked path takes ~33 s and is byte-exact. Not truncation, not a size cap — and the harness's own docstring says the POST form is "verified at 2048 bytes", with both bad offsets past that. Without the verify, a one-byte flip in 6502 code surfaces 40 minutes later and gets written up as "ip65 does not work on real silicon".$0801/$0802post-reset zeroing from a different lane and code path: those bytes go to00 00between ~2 and ~5 s afterREADY., with no write involved.client.run_prg(a different endpoint) and everywrite_memoryintools/uci/is under 64 bytes. But none of them verifies either, so the class of fault is invisible to our tooling. Proposed there, deliberately out of scope here. Issue REU preflight: read the harness item map, and fail closed when it cannot (#179) #181's validation never loaded a PRG and is untouched.Notes for review
HTTPS_PORT=4433— expected, not a mixed build.hw.rig_const) rather than copying it, so the two cannot drift; a red case proves it refuses to invent a value for a name the script does not define.$0801-$9FFFis verifiable from the host. The image's$A000-$BFFFtail is BSS zero fill and a host read of it returns the BASIC ROM untilboot.sbanks it out —check_shadow_ram_readableis the discriminator and ran green on the device, so every$A000+read in this run is RAM.enable_uci: the UCI command interface is a second consumer of the same expansion bus and an RR-Net run does not need it. Its value is read and reported (Disabled), never written.Cartridge PreferencewasAuto, set toExternalfor the run and restored; the rig refuses to set a value it could not read back.pytestat the root: 128 passed (the 3 skips aretest_uci_data_acc.pyon an ip65 build, via the documentedC64_UCI_TESTS_OPTIONAL=1).What this does NOT prove
Spelled out:
c64-https-ip65-onchip.prginHTTPS_PORTalone. An earlier draft of this PR said "the configuration it ships in", which was wrong by exactly that flag.src/x509_name.sis UCI-only, so the ip65 product still does no server-name checking. This run says nothing about that gap; it is the gap. Now stated in CLAUDE.md,tests/README.mdand the rig's printedCERTIFIESstring, so it survives outside this description.check_body_not_on_wiretakes noc64_mac; it relies oncheck_c64_originatedrunning in the same stage to establish that C64 frames were in the corpus at all. Its control needle is genuinely a C64 artefact in this capture (www.foo.invalidappears as contiguous ASCII in exactly one frame, the ClientHello — DNS length-prefixes labels, so it never appears contiguously in the query or the response), but the coupling is structural and worth knowing.🤖 Generated with Claude Code