From 52856aa37c50c8e436c6dc374fe296ed67852667 Mon Sep 17 00:00:00 2001 From: JC-000 <3798556+JC-000@users.noreply.github.com> Date: Sat, 15 Aug 2026 08:15:41 -0500 Subject: [PATCH 1/3] chore: finish the pytest-boundary rename, and correct CLAUDE.md against measurement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two deferred follow-ups, both left with reasons recorded by earlier lanes. PR #111 renamed tests/test_*.py -> tests/rig_*.py because those files wear pytest's discovery convention while collecting zero tests, so a bare `pytest` overstates coverage. It explicitly deferred the identical problem in tools/uci/, whose blast radius runs through CLAUDE.md. This is that change: six scripts renamed, following #111's precedent exactly. `norecursedirs` already closed the default-invocation path, but the rename is what holds from an arbitrary working directory, since `testpaths` only applies at the rootdir. Both halves are now pinned by the guard rather than one. tools/test_pytest_boundary.py grows a RIG_DIRS tuple covering both directories and a new test_norecursedirs_covers_every_rig_dir. Probed in both directions: a stray tools/uci/test_*.py fails test_rig_dirs_hold_no_pytest_named_files, and dropping tools/uci from norecursedirs fails the new check. Bare `pytest` is now 31 passed / exit 0; `pytest tests/` and `pytest tools/uci/` both exit 5 with an explanation naming the right README. New tools/uci/README.md mirrors tests/README.md. Reference sweep covers CLAUDE.md, README.md, conftest.py, pytest.ini, tests/README.md, both phase_f docs, tools/https_e2e/, tools/package/listener/, and the cross-invocations (rig_https_print_body and rig_https_local_p384 both delegate to rig_https_local; import-checked after the rename). Zero references exist in c64-test-harness or any other sibling c64-* repo, and no open issue or PR in the org names any of the six. README was audited claim-by-claim in #104; CLAUDE.md never was. Every item below was confirmed by running a command, not by reading: - The ip65 blob section contradicted itself and the Makefile. It claimed `ip65-blob` is phony with "no rule connecting the two" and that a fresh clone dies at the `.incbin`. Measured: deleting the blob and running plain `make` rebuilds it byte-identically (6,951 B, cf1a5ff7...) and links the usual 47,105 B PRG. The real fresh-clone blocker is the missing ip65 .lib archives, which fail at the blob's ld65 step. `make ip65-blob` is not a required step. Fixed here and in README. - "the committed blob" / `touch ip65-build/ip65-c64.bin` advice: the blob is gitignored, so on a fresh clone there is nothing to touch. - Fence macro: 17 bytes per site, not 14 (counted from the macro body). 26 sites, not 24 — 11 write + 14 read + 1 pre-loop settle at net.s:199. - uci_drain_resp/uci_drain_status: 22 call sites in net.s, not 13. All 22 still `bcs` out, so that half was right. - CIA1 TOD read order is HOUR -> TENTHS; MIN and SEC are never read and have no equate in uci_cmd.s. - UCI memory table: NET_CODE is $2000-$3B65 and NET_BSS_TAIL $3B66-$41FF (cfg grew NET_CODE by $40 for the C64U WiFi iface-fallback loop). UCI_BSS_REGION is size 0, so quoting a 512 B span contradicted its own label and overlapped NET_BSS_TAIL. - src/exports.s no longer exports ip65_init/ip65_process; those moved to src/net/ip65/exports.s. Named the real backend-agnostic set instead. - KEEP_DEBUG_ON_PASS is the env var; UCI_DEBUG_KEEP_ON_PASS is only the Python variable name, so the documented spelling did nothing. - rig_https_local_p384.py defaults to 90 minutes (5400 s), not 30. - C64_SKIP_BUILD: 15 scripts, not 14 (ran the recipe the file itself quotes). - EMBED_P256_OVERLAY's stated mutual exclusions have no $(error) guard: one is a silent auto-disable, the other is unguarded. Only the USE_NISTCURVES_ONCHIP exclusions are hard errors. - "All in-tree VICE-driven tests go through default_vice_config()" is false: 8 suites do, 15 other files still build ViceConfig directly — including run_all_tests.py, which hand-spells the REU flags. - Smoke-test list is 8 entries, not 7, and the 97/97 total predates test_finished_verify.py joining it. - pytest counts 30 -> 31; src/boot.s:107-114 -> 110-117. Wall-clock figures are labelled rather than re-measured, per instruction: a pin-vs-commit table resolves 2ceb5b1 / f0127a0 / cb6eab4 to libs/nistcurves v0.6.0 (verified with git ls-tree), every table states the pin it was taken at, and "at HEAD" captions that no longer refer to HEAD are gone. No hardware was used and no benchmark was re-run. Deliberately NOT included: the x25519 sibling failure. It is measured and understood (both backends exit 2 on a duplicate reu_mul_tables_init export, not the overflow CLAUDE.md records), but PR #113 rewrites the same spans and fixes the underlying collision, so the prose belongs there. Details handed to that lane. docs/library-ingestion-architecture.md's two stale references are owned and taken by the contract lane. Python and docs only — no .s, no .cfg, no Makefile, so no PRG changes. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 308 +++++++++++------- README.md | 70 ++-- conftest.py | 14 +- docs/phase_f_part1_root_cause.md | 2 +- docs/phase_f_part1_trace_analysis.md | 6 +- pytest.ini | 14 +- tests/README.md | 5 + tools/https_e2e/certs/README | 6 +- tools/https_e2e/ensure_certs.py | 2 +- tools/package/listener/README.md | 2 +- tools/package/listener/listener.py | 8 +- tools/test_pytest_boundary.py | 71 +++- tools/uci/README.md | 80 +++++ tools/uci/_memory_policy.py | 2 +- tools/uci/bench_ecdsa_u64e.py | 2 +- .../{test_http_live.py => rig_http_live.py} | 0 .../{test_http_local.py => rig_http_local.py} | 0 ..._finished.py => rig_https_bad_finished.py} | 8 +- ...test_https_local.py => rig_https_local.py} | 4 +- ..._local_p384.py => rig_https_local_p384.py} | 24 +- ..._print_body.py => rig_https_print_body.py} | 6 +- 21 files changed, 438 insertions(+), 196 deletions(-) create mode 100644 tools/uci/README.md rename tools/uci/{test_http_live.py => rig_http_live.py} (100%) rename tools/uci/{test_http_local.py => rig_http_local.py} (100%) rename tools/uci/{test_https_bad_finished.py => rig_https_bad_finished.py} (98%) rename tools/uci/{test_https_local.py => rig_https_local.py} (99%) rename tools/uci/{test_https_local_p384.py => rig_https_local_p384.py} (88%) rename tools/uci/{test_https_print_body.py => rig_https_print_body.py} (98%) diff --git a/CLAUDE.md b/CLAUDE.md index 62f5cfb..dbf9099 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,39 +19,42 @@ First build in a fresh clone or worktree (ip65 backend only — the UCI backend needs none of this): git submodule update --init --recursive - make ip65-libs # once per clone - make ip65-blob # once per clone — `make` will NOT do this for you - make + make ip65-libs # once per clone — `make` will NOT do this for you + make # builds the ip65 blob on demand, then the PRG `ip65-build/ip65-c64.bin` is a **gitignored local build artifact** (`.gitignore` line `ip65-build/*.bin`; `git ls-files ip65-build/` returns only `ip65.cfg` and `ip65_stub.s`), *not* a committed file. -**A plain `make` does not build it and cannot.** `ip65-blob` is a phony -target, and `src/net/ip65/ip65_blob.s` pulls the file in through a ca65 -`.incbin` that make's dependency graph never sees — so there is no rule -connecting the two. A fresh clone therefore fails at assembly, before any -link, with: - - src/net/ip65/ip65_blob.s(22): Error: Cannot open include file - '../../../ip65-build/ip65-c64.bin': No such file or directory - -and it fails identically whether or not `make ip65-libs` has been run. -Running `make ip65-blob` *without* the libs is what produces the other -error you may see, since the blob's link step consumes ip65 `.lib` -archives that the submodule ships sources for rather than binaries: - +**A plain `make` does build it — the step you actually need is +`make ip65-libs`.** `$(IP65_BIN)` is a real prerequisite of the PRG +(`Makefile:263`, `PRG_DEPS := $(ALL_OBJS) $(IP65_BIN) ...`) with a real +rule (`Makefile:468`), and make orders it ahead of the `.incbin` in +`src/net/ip65/ip65_blob.s`. Measured 2026-08-15 by deleting the blob and +running plain `make`: it was rebuilt byte-identically (6,951 B, +`cf1a5ff7...`) and the link produced the usual 47,105 B PRG. + +What a fresh clone is missing is not the blob but the ip65 `.lib` +archives, which the submodule ships sources for rather than binaries. +The blob rule depends only on `ip65_stub.s` + `ip65.cfg`, so make runs +it and it dies at the link. Measured the same day, submodules +initialised but `make ip65-libs` never run: + + cd ip65-build && ld65 -C ip65.cfg -o ip65-c64.bin ... ip65_stub.o \ + ../ip65/ip65/ip65_tcp.lib ../ip65/drivers/ip65_c64.lib c64.lib ld65: Error: Input file '../ip65/ip65/ip65_tcp.lib' not found + make: *** [ip65-build/ip65-c64.bin] Error 1 -Both orderings are recoverable by running the four commands above in -order. Verified end to end on 2026-08-14 from a clean `git clone` of -master: plain `make` fails with the `.incbin` error, still fails after -`make ip65-libs`, and succeeds after `make ip65-blob` — yielding the -6,951 B blob (`cf1a5ff7...`) and a 47,105 B ip65 PRG. +So the recovery is `make ip65-libs` once, then plain `make`; +`make ip65-blob` is a convenience for forcing a rebuild, not a required +step. (An earlier revision of this section said `ip65-blob` was phony +with "no rule connecting the two" and that a fresh clone fails at the +`.incbin` with `Cannot open include file`. That mechanism is wrong, and +it contradicted the `make ip65-blob` bullet below, which was right.) `make clean` only removes `build/`, so once built the blob survives and is never rebuilt; that persistence, not a committed file, is why the rebuild -targets are normally invisible. The rebuild is deterministic: 6,951 B, +target is normally invisible. The rebuild is deterministic: 6,951 B, sha256 `cf1a5ff7809af4e4655e385b378b936054f41046ff2b7604828af3240c2d90dd` — rebuilt byte-identically in three independent worktrees on 2026-08-13, and identical to a local copy built 2026-05-06. Three months and four @@ -114,13 +117,15 @@ is `make clean && make` twice and comparing hashes, which holds whatever the layout. Fresh-checkout gotcha: right after `git submodule update --init ip65`, -plain `make` tries to *relink the blob* — the freshly checked-out -`ip65-build/ip65_stub.s` is newer than the committed -`ip65-build/ip65-c64.bin`, so the `$(IP65_BIN)` rule fires and dies on -`ld65: Error: Input file '../ip65/ip65/ip65_tcp.lib' not found`. -`touch ip65-build/ip65-c64.bin` restores the intended "committed blob -is reused" path; `make ip65-libs` is the alternative if you actually -want to rebuild it. +plain `make` fires the `$(IP65_BIN)` rule — either because the blob is +absent (the usual case; it is gitignored) or because the freshly +checked-out `ip65-build/ip65_stub.s` is newer than an existing one — +and dies on `ld65: Error: Input file '../ip65/ip65/ip65_tcp.lib' not +found`. The fix is `make ip65-libs`. (An earlier revision suggested +`touch ip65-build/ip65-c64.bin` to restore a "committed blob is reused" +path; there is no committed blob — `git ls-files ip65-build/` returns +only `ip65.cfg` and `ip65_stub.s` — so on a real fresh clone there is +nothing to touch.) Variables: - `BACKEND=ip65|uci` — select networking backend cfg @@ -131,9 +136,16 @@ Variables: ip65 overflows CRYPTO_OVERLAY** — see "Known issues". Off by default either way. - `EMBED_P256_OVERLAY=1` — stage the P-256 verify image into the - CRYPTO_OVERLAY slot at PRG-load (UCI; mutually - exclusive with USE_X25519_SIBLING / - USE_OVERLAY_P384_EMBED) + CRYPTO_OVERLAY slot at PRG-load (UCI). Its + exclusions are NOT `$(error)`-guarded, unlike + the USE_NISTCURVES_ONCHIP ones below: vs + USE_OVERLAY_P384_EMBED it silently wins + (`Makefile:194` forces `USE_OVERLAY_P384_EMBED + ?= 0`), and vs USE_X25519_SIBLING there is no + guard at all — the combination is simply + untested. The only `$(error)` guards in the + Makefile are lines 91/94/97 (all keyed on + USE_NISTCURVES_ONCHIP) and 245 (bad BACKEND). - `USE_NISTCURVES_ONCHIP=1` — link the libs/nistcurves FP_ONCHIP_MUL turbo-profile P-256 verify archive (no REU row-fetch DMA; ~22 MHz @@ -155,8 +167,8 @@ Variables: Test harness expectations: - Most `tools/test_*.py` scripts run `make clean && make` themselves before launching VICE. Set `C64_SKIP_BUILD=1` in the environment to - reuse the already-built PRG. 14 scripts honor it as of 2026-08-13 - (13 under `tools/`, plus `tests/rig_vice_https_macos.py`); the + reuse the already-built PRG. 15 scripts honor it as of 2026-08-15 + (14 under `tools/`, plus `tests/rig_vice_https_macos.py`); the current list is `grep -ln 'environ.*C64_SKIP_BUILD' tools/test_*.py tests/rig_*.py` rather than a number that goes stale here. - Use the `c64-test-harness` Python package to launch VICE; never run @@ -291,7 +303,7 @@ the starting point is prose, not an interface. Measured 2026-08-14: surface TLS/HTTP/boot actually import is `net_init`, `net_dhcp`, `net_poll`, `net_print_ip`, `net_dns_resolve`, `net_tcp_connect`, `net_tcp_close`, `net_tcp_send`, `net_send_len`, `net_recv_byte`, - `net_banner_str` (`src/boot.s:107-114`, `src/http.s:61-67`, + `net_banner_str` (`src/boot.s:110-117`, `src/http.s:61-67`, `src/tls_record_io.s:28-30`, `src/tls13.s:96`). Five of those are absent from the header; six of the header's are imported by nobody. - **The ip65 backend does not provide half of what the header @@ -355,7 +367,7 @@ TOD) per the design note below. On timeout they return C=1 with callers (`net_poll`, `net_dhcp_acquire`, `net_tcp_connect`, `net_tcp_send`, `net_tcp_close`) `bcs` out to surface the failure rather than letting the C64 hang indefinitely on a wedged FPGA. All -13 `uci_drain_resp` / `uci_drain_status` call sites in `net.s` also +22 `uci_drain_resp` / `uci_drain_status` call sites in `net.s` also `bcs` out — on timeout the routine skips its companion drain + ack, forces the appropriate `net_tcp_state` (ERROR for poll paths, CONNECT_FAIL for connect, CLOSED for close, untouched for DHCP/send @@ -404,8 +416,9 @@ corrupt the UCI command protocol. registers `$DF1C-$DF1F`. Parameters: `UCI_FENCE_OUTER = 5`, `UCI_FENCE_INNER = 217`, yielding ~5450 cycles (85.2 us at 64 MHz — 35% margin over the C64 Ultimate's empirically-bracketed floor; -113.5 us at 48 MHz). 14 bytes per fence site, 24 fence sites total -(11 write + 13 read). At 1 MHz the same loop costs ~5.5 ms per +113.5 us at 48 MHz). 17 bytes per fence site, 26 fence sites total +(11 write + 14 read + 1 pre-loop settle at `net.s:199`, which follows +no register access). At 1 MHz the same loop costs ~5.5 ms per access — negligible for networking. The C64 Ultimate (firmware 1.1.0, core 1.49) needs MORE inter-access @@ -438,10 +451,10 @@ that this codebase now handles: the NEXT pushed command is silently lost (reproduced 2x as UCI_ERR_NO_SOCKET on the first TCP_CONNECT after a 1→64 switch, even with a 100 us fence; a 1→48 switch happened to survive). - `tools/uci/test_https_local.py` now sets turbo BEFORE + `tools/uci/rig_https_local.py` now sets turbo BEFORE reset/run_prg so the machine boots at target speed and never switches mid-session. Mirror that pattern in new scripts - (`test_https_local_p384.py` still uses the old late-switch order — + (`rig_https_local_p384.py` still uses the old late-switch order — fix when the P-384 build unblocks). - **Wider fence floor** — see the delay-loop fence section above (INNER=217 accommodates both devices). @@ -475,10 +488,10 @@ Post-W1 the UCI cfg is the reference; see the "Memory layout" section below for the full table. Headline differences from the (similarly post-W1) ip65 layout: - - `NET_CODE` ($2000-$3B25) is much smaller because the UCI adapter + - `NET_CODE` ($2000-$3B65) is much smaller because the UCI adapter is ~1.7 KB vs ip65's ~6.95 KB blob. The tail carries `LOADER_OVERFLOW`, `TLS_CODE`, `CRYPTO_AUX_CODE`. - - `NET_BSS_TAIL` ($3B26-$3FFF) absorbs UCI_BSS + + - `NET_BSS_TAIL` ($3B66-$41FF) absorbs UCI_BSS + `LIB_NISTCURVES_P256_BSS` spill. - `CRYPTO_OVERLAY` is a real 7.5 KB slot ($4200-$5FFF) used for the P-384 SHA-384/curve overlays, the W3 P-256 overlay embed, and the @@ -486,11 +499,20 @@ post-W1) ip65 layout: - `CRYPTO_HOT` + `CRYPTO_COLD_SHADOW` are the W1 hot/cold split of the historical `CRYPTO_RESIDENT` (see "Memory layout" below). -### UCI test scripts +### UCI rig scripts Scripts under `tools/uci/` require a U64E (default 192.168.1.81, overridable via the `U64_HOST` environment variable) and use -`DeviceLock` + `enable_uci`/`disable_uci`. +`DeviceLock` + `enable_uci`/`disable_uci`. `tools/uci/README.md` is the +short version of this section. + +They are named `rig_*.py`, not `test_*.py`. Six of them were renamed in +the follow-up to #111 for the reason #109 gives: a hardware `main()` +script wearing pytest's discovery convention collects zero and reports +nothing, which reads as coverage. `boot_check.py`, `phase2_check.py`, +`phase3_tcp_echo.py` and `bench_ecdsa_u64e.py` never carried the prefix +and were not renamed. See "`pytest` is not the runner" under Smoke +tests. They also require the `c64-test-harness` package, which is a **separate public repo, not vendored here** — `requirements.txt` lists only @@ -507,8 +529,8 @@ UCI path mentioned it, so anyone who built with `make BACKEND=uci` and went straight to these scripts never crossed it. **REU preflight (issue #97).** Every script here that exercises the -crypto path — `test_https_local.py` (and its `test_https_print_body.py` -/ `test_https_local_p384.py` wrappers), `test_https_bad_finished.py`, +crypto path — `rig_https_local.py` (and its `rig_https_print_body.py` +/ `rig_https_local_p384.py` wrappers), `rig_https_bad_finished.py`, `bench_ecdsa_u64e.py` — calls `preflight_reu()` from `tools/uci/_reu_preflight.py` under the DeviceLock, right after `enable_uci` and before the reset. A REU-profile build meeting a device @@ -524,7 +546,7 @@ all (measured 0.001-0.002 s vs 0.073-0.081 s for the REU path). It writes persist until power cycle, so auto-enabling would swap a legible error for a mystery on someone else's branch. `C64_SKIP_REU_PREFLIGHT=1` bypasses. Scripts that never touch the REU (`boot_check`, `phase2`, -`phase3_tcp_echo`, `test_http_local`, `test_http_live`) are deliberately +`phase3_tcp_echo`, `rig_http_local`, `rig_http_live`) are deliberately not guarded. The scripts: @@ -536,16 +558,16 @@ The scripts: image; `BOOT_TIMEOUT` the menu budget. - `phase2_check.py` — DHCP acquire + local IP readback - `phase3_tcp_echo.py` — TCP connect/send/recv against a local echo server - - `test_http_local.py` — HTTP GET against a local test server - - `test_http_live.py` — HTTP GET against a real internet host (requires + - `rig_http_local.py` — HTTP GET against a local test server + - `rig_http_live.py` — HTTP GET against a real internet host (requires internet access from the U64E) - - `test_https_bad_finished.py` — the client must ABORT on a forged server + - `rig_https_bad_finished.py` — the client must ABORT on a forged server Finished. Uses the hand-rolled `tools/https_e2e/evil_listener.py` rather than stock `ssl`. `FINISHED_MODE=good` is the control and must be run first. See "Negative-path coverage — the server Finished" under Smoke tests. - - `test_https_local.py` — HTTPS e2e scaffolding against a local TLS 1.3 + - `rig_https_local.py` — HTTPS e2e scaffolding against a local TLS 1.3 listener (ECDSA-P256 cert from `tools/https_e2e/certs/`). DMAs a 6502 stub that calls `http_get`, flips the U64E to the @@ -555,8 +577,9 @@ The scripts: and has been validated end-to-end on real U64E hardware), and captures full diagnostics on pass or timeout. - `DEBUG_CAPTURE=1` enables a bounded 6510 - bus stream for post-mortem. + The bounded 6510 bus stream for post-mortem + is ON by default (`DEBUG_CAPTURE` defaults + to `1`); set `DEBUG_CAPTURE=0` to disable. `EXTERNAL_LISTENER=1` (+ `EXTERNAL_HOST`, `EXTERNAL_PORT`, default 4433) skips the inline listener + repo-cert load and points @@ -574,8 +597,10 @@ The scripts: TLS state snapshot (`tls_state_dump.json`), the listener's `server_result.json`, and `run_info.txt`. Rotation keeps the last 5 - dirs; `UCI_DEBUG_KEEP_ON_PASS=1` preserves - PASS runs. The TLS state snapshot now + dirs; `KEEP_DEBUG_ON_PASS=1` preserves + PASS runs (the Python-side variable is + spelled `UCI_DEBUG_KEEP_ON_PASS`, but the + env var actually read is the shorter name). The TLS state snapshot now includes the full 548 B `tls_rec_buf` (handshake plaintext is parsed in place there — see Known issues below for the @@ -630,7 +655,7 @@ Disabled ⇒ stalls at RX (issue #97). cost an outside contributor a wasted cold power cycle. The REU half of that ambiguity is now caught before the run: see - "REU preflight" under "UCI test scripts". If a guarded script got as + "REU preflight" under "UCI rig scripts". If a guarded script got as far as printing `RX`, the REU cause has already been excluded. - ClientHello → ServerHello (X25519 key share) @@ -663,8 +688,8 @@ the transcript-hash function stays SHA-256 because c64-https offers exactly one cipher suite, TLS_CHACHA20_POLY1305_SHA256 (0x1303, `src/tls_handshake.s:85`, echo-verified at :380), whose hash is SHA-256 — Phase 5 Fix A). The -end-to-end test is `tools/uci/test_https_local_p384.py` (mirrors -`test_https_local.py` with P-384 cert profile via swapping CERT_PATH +end-to-end test is `tools/uci/rig_https_local_p384.py` (mirrors +`rig_https_local.py` with P-384 cert profile via swapping CERT_PATH / KEY_PATH to `tools/https_e2e/certs/server-p384.{pem,key}`); see the "ECDSA P-384 verify wall-clock" subsection for the wall-clock expectation. Negotiation plumbing test @@ -748,7 +773,7 @@ Five latent bugs and three new ones were cleared to get here: else. Body text therefore renders correctly regardless of case (all letters appear as uppercase glyphs; case is dropped). Verified end-to-end on U64E at 48 MHz by - `tools/uci/test_https_print_body.py` with a mixed-case response + `tools/uci/rig_https_print_body.py` with a mixed-case response body. `http_resp_buf` still holds raw ASCII — only the render pipeline is translated. - **X25519 sibling (`libs/x25519@v0.6.0`)** — @@ -1025,12 +1050,18 @@ Five latent bugs and three new ones were cleared to get here: Cascade: wrong `w=s^-1 mod n`, wrong `u1`/`u2`, wrong computed `R`, `R.x != r`, verify returns C=1. **Use the helper at `tools/_vice_helpers.py::default_vice_config()`** (PR #53) — it - pre-applies the mandatory `-reu -reusize 512` flags. All in-tree - VICE-driven tests (`test_x509.py`, `test_ecdsa_kat_oracle.py`, - `test_x25519.py`, `bench_x25519.py`, `test_p384_symbols.py`) now go - through it; mirror that pattern in any new VICE test rather than - spelling out `ViceConfig(extra_args=["-reu", "-reusize", "512"])` - by hand. The UCI path is unaffected because the U64E hardware has + pre-applies the mandatory `-reu -reusize 512` flags. **Eight** suites + go through it — `test_x509.py`, `test_ecdsa_kat_oracle.py`, + `test_x25519.py`, `bench_x25519.py`, `test_p384_symbols.py`, + `test_finished_verify.py`, `test_tls_record.py`, + `test_tls_handshake.py` — i.e. the ones that touch the P-256 path. + That is **not** "all in-tree VICE tests", as this line used to say: + fifteen other files still build `ViceConfig` directly + (`grep -ln 'ViceConfig(' tools/*.py tests/*.py`), including + `tools/run_all_tests.py`, the primary runner, which hand-spells + `extra_args=["-reu", "-reusize", "512"]` at its own lines 158-159. + Migrating those is open work. Mirror the helper in any + new VICE test rather than spelling the flags out by hand. The UCI path is unaffected because the U64E hardware has REU enabled by default; the symptom was VICE-only. The single deliberate exception is `C64_VICE_NO_REU=1`, which makes `default_vice_config()` drop the REU flags (and say so on stderr). @@ -1045,11 +1076,16 @@ Five latent bugs and three new ones were cleared to get here: runs in ~85 s median in the pre-Phase-C.4 benchmark (see `tools/uci/bench_ecdsa_u64e.py` for the protocol). The full `tls_connect` handshake — which does one ECDSA verify over the -CertificateVerify signature — now takes **81.9 s** wall-clock -end-to-end under Phase C.4's sibling `libs/nistcurves` P-256 -integration, down from ~110 s pre-integration. The remainder is -network I/O + SHA-256 + X25519 + Finished HMACs + handshake -state-machine overhead. +CertificateVerify signature — took **81.9 s** wall-clock end-to-end at +Phase C.4's sibling `libs/nistcurves` P-256 integration (commit +`cc182f1`, whose pin cannot be resolved from this tree — the commit is +unreachable after the 2026-05 upstream history rebuild; from its +neighbours it is a pre-v0.3.0 pin), down from ~110 s pre-integration. +The remainder is network I/O + SHA-256 + X25519 + Finished HMACs + +handshake state-machine overhead. Do not compare 81.9 s against the +82.1 s below as though the difference meant something: they are +different pins measured months apart, and the gap is inside the +run-to-run spread. 81.9 s still does not fit a typical 10-30 s real-world server handshake window, so this is a blocker for arbitrary internet TLS @@ -1079,6 +1115,23 @@ a speedup, and do not quote these rows as v0.9.1 numbers until someone re-runs `bench_ecdsa_u64e.py` on hardware. The v0.3.0 row is kept only as the REU-profile baseline. +**Read the pin, not the commit.** Every table below labels its rows +with the `libs/nistcurves` pin they were measured at, because that is +the variable that moves the number; a c64-https commit hash alone does +not tell you the pin, and this repo's history contains commits whose +pin is not recoverable (the upstream repo was rebuilt in 2026-05 — +see the pin-hygiene note at the end of this file). The mapping for the +hashes that appear below, resolved with `git ls-tree +libs/nistcurves` plus `git -C libs/nistcurves tag --points-at`: + + c64-https commit libs/nistcurves libs/x25519 + 2ceb5b1 v0.6.0 (00d2626) v0.6.0 (95fdd70) + f0127a0 v0.6.0 (00d2626) v0.6.0 (95fdd70) + HEAD (3a43f61) v0.9.1 (f9701e1) v0.10.0 (68ae0ef) + +So every figure in this section is a v0.6.0 figure, including the ones +labelled only by commit. None of them is a HEAD figure. + On the **C64 Ultimate** (10.53.21.158, see "C64 Ultimate notes"), measured 2026-07-19 with the INNER=217 fence and boot-at-speed flow: @@ -1128,10 +1181,11 @@ C64U, fits T(f)=D+C/f, residuals <=4.1%): C64U v0.5.0 onchip -- -- -- -- 59.9 s 47.5 s (n=3) C64U v0.6.0 onchip -- -- -- -- 51.0 s 39.7 s C64U v0.6.0 onchip+comb -- -- -- -- 38.4 s **31.0 s** - U64E REU @ 2ceb5b1 1157.7 s 196.5 s 124.0 s 108.9 s 80.8 s n/a (no enum) - U64E onchip @ 2ceb5b1 2120.7 s 264.5 s 131.8 s 103.9 s 45.5 s n/a (no enum) + U64E v0.6.0 REU 1157.7 s 196.5 s 124.0 s 108.9 s 80.8 s n/a (no enum) + U64E v0.6.0 onchip 2120.7 s 264.5 s 131.8 s 103.9 s 45.5 s n/a (no enum) - The U64E rows are a 2026-08-13 sweep at master 2ceb5b1. One clean + The U64E rows are a 2026-08-13 sweep at master 2ceb5b1, whose + `libs/nistcurves` pin is v0.6.0 (see the pin table above). One clean build per profile, reused across that profile's clocks, so clock is the only variable within a row. Every run PASSes with server-side evidence (the listener decrypted the full GET; no TLS error). Times @@ -1235,7 +1289,8 @@ Two merged changes moved these numbers in opposite directions, so the calls, cost UCI **~80 s** — see the drain note in "Design note" below. **#74** made the budget per-backend and restored it. -Measured end-to-end (handshake + GET, local listener): +Measured end-to-end (handshake + GET, local listener). All rows at the +`libs/nistcurves` **v0.6.0** pin: device profile clock pre-#71 post-#71 post-#74 baseline C64U onchip 48 MHz 51.0 s 125.4 s **44.6 s** 51.0 s @@ -1257,13 +1312,15 @@ Measured end-to-end (handshake + GET, local listener): First measurements of the ip65 backend end-to-end, from the macOS feth/pcap rig (see "VICE ip65 rig" under Smoke tests). These are the -**REU-less stock-C64 story** — no REU, no turbo, RR-Net networking: +**REU-less stock-C64 story** — no REU, no turbo, RR-Net networking. +All four rows are at the `libs/nistcurves` **v0.6.0** pin: build mode G -> CONNECTION CLOSED ip65 + onchip, no REU honest 1 MHz **2,159.7 s (36.0 min)** ip65 + onchip, no REU ~1.2x accelerated 1,813.9 s ip65 + REU profile ~1.2x accelerated 988.9 s - ip65 + onchip @ 2ceb5b1 ~1.2x accelerated 1,876.0 s (2026-08-13) + ip65 + onchip ~1.2x accelerated 1,876.0 s (2026-08-13, + master 2ceb5b1) Honest-1 MHz phase breakdown (seconds after 'G'): TCP CONNECTED 3.0 | CH 329.2 | SH 700.7 | PROC 718.9 | @@ -1292,7 +1349,9 @@ feth/pcap rig (see "VICE ip65 rig" under Smoke tests). These are the **U64E lane (2026-07-25)** — same sweep protocol on the U64E (10.43.23.81), 16/32/48 MHz only (no 64 MHz enum on the U64E), all -three v0.6.0 profiles at HEAD, n=2 medians of the RFC 6979 vector, +three profiles at the `libs/nistcurves` **v0.6.0** pin (which was +c64-https HEAD on that date; it is not HEAD now), n=2 medians of the +RFC 6979 vector, 72/72 runs correctness-PASS (fits T(f)=D+C/f; REU/onchip residuals <=0.4%, comb <=3.8%): @@ -1339,15 +1398,16 @@ Not yet measured end-to-end, and still UNMEASURABLE: the `ar65` staging failure is fixed, but the P-384 build now stops on the SHA-384 overlay table overflow (and the embed variant on a separate `build/labels.txt` ordering defect) — see "Known issues", so -`tools/uci/test_https_local_p384.py` has no P-384 PRG to run and +`tools/uci/rig_https_local_p384.py` has no P-384 PRG to run and would just boot the default P-256 image. The May-2026 hw attempts that predate the build breakage died at EncryptedExtensions decrypt (issue #45, closed 2026-07-26 as stale — the suspect commit window was buried by the W1/v0.5.0/v0.6.0 rework; restart from a fresh build + fresh repro). Once the build is fixed, run the script from a host with U64E LAN access to capture the number; it defaults to a -30 minute wall-clock budget (`SENTINEL_POLL_TIMEOUT=1800` / -`ACCEPT_TIMEOUT=1800`) — expect 4-7 minutes per handshake at 48 MHz +90 minute wall-clock budget (`SENTINEL_POLL_TIMEOUT=5400` / +`ACCEPT_TIMEOUT=5400`, both `os.environ.setdefault` at +`rig_https_local_p384.py:73-74`) — expect 4-7 minutes per handshake at 48 MHz turbo, dominated by: - one ECDSA-P384 verify (sibling `libs/nistcurves` @@ -1366,8 +1426,8 @@ turbo, dominated by: Once measured, drop the wall-clock here. Phase 4 cert-profile flag in the local listener (`HTTPS_LISTENER_CERT_PROFILE=p384` or the `cert_profile="p384"` kwarg to `start_https_listener`) is the -upstream selector; `tools/uci/test_https_local_p384.py` inlines its -own listener (matching `test_https_local.py`'s pattern) and points it +upstream selector; `tools/uci/rig_https_local_p384.py` inlines its +own listener (matching `rig_https_local.py`'s pattern) and points it at `tools/https_e2e/certs/server-p384.{pem,key}`. ### Design note — bounded timeouts must use wall-clock time @@ -1385,8 +1445,11 @@ budgets and broke DHCP at turbo for exactly this reason; the branch was abandoned. `uci_wait_idle` was the first helper to follow this pattern (issue #37). -At entry it samples CIA1 TOD ($DC08-$DC0B) — read order is HOUR -(latch) → MIN → SEC → TENTHS (unlatch) — and on each spin pass re-reads +At entry it samples CIA1 TOD — read order is HOUR ($DC0B, latch) → +TENTHS ($DC08, unlatch). MIN and SEC are deliberately skipped: only +the tenths counter is needed, and the latch/unlatch pair is what the +CIA requires. `uci_cmd.s` defines no equate for $DC09/$DC0A at all. +On each spin pass it re-reads TENTHS, bailing with C=1 + `net_last_error = UCI_ERR_WAIT_TIMEOUT` after 50 transitions (~5 s wall-clock, independent of CPU turbo). State lives in two SMC bytes inside the routine to match the file's no-ZP @@ -1409,7 +1472,7 @@ SOCKET_WRITE / POLL_DATA / SOCKET_CLOSE responses, and if firmware ever leaves DATA_AV / STAT_AV asserted post-response the old unbounded `jmp ` loops would wedge the C64 with no wall-clock escape. Same 5 s budget, same error code, same SMC-byte state -convention. All 13 call sites in `net.s` `bcs` out on C=1 to skip +convention. All 22 call sites in `net.s` `bcs` out on C=1 to skip the companion drain + ack and force the appropriate exit state. ### Design note — the post-ServerHello drain, and why its budget is per-backend @@ -1463,14 +1526,15 @@ bumped `libs/nistcurves` library without overflowing CRYPTO. UCI layout (W1 reference — `cfg/c64-https-uci.cfg`): $0801-$1FFF LOADER BASIC stub + boot + HTTP + net wrapper - $2000-$3B25 NET_CODE UCI adapter (~1.7 KB) + LOADER_OVERFLOW + $2000-$3B65 NET_CODE UCI adapter (~1.7 KB) + LOADER_OVERFLOW + TLS_CODE + CRYPTO_AUX_CODE - $3B26-$3FFF NET_BSS_TAIL BSS spill carved from NET_CODE tail + $3B66-$41FF NET_BSS_TAIL BSS spill carved from NET_CODE tail (UCI_BSS + LIB_NISTCURVES_P256_BSS land here when they don't fit in CRYPTO_HOT) - $4000-$41FF UCI_BSS_REGION Zero-size alias post-W1 (UCI_BSS moved - into NET_BSS_TAIL above) + $4000 (size 0) UCI_BSS_REGION Zero-size alias post-W1 (UCI_BSS moved + into NET_BSS_TAIL above, which is why + NET_BSS_TAIL now runs through $41FF) $4200-$5FFF CRYPTO_OVERLAY 7.5 KB swappable overlay slot (X25519 sibling / P-384 SHA-384 / P-384 curve / W3 P-256 verify embed) @@ -1571,11 +1635,16 @@ ld65 and ca65 edge cases; they are intentional and should stay: - `src/loadaddr.s` — a single `.word $0801` in the `LOADADDR` segment. ld65 needs *some* symbol in that segment for the 2-byte PRG load-address header to land at `$07FF`. - - `src/exports.s` — promotes the numeric equates `tcp_recv_buf`, - `ip65_init`, `ip65_process` to linker-visible `.export`s so they - appear in `build/labels.txt` for the Python test harness. The - `.export` has to live in exactly one translation unit; doing it - inside the `.inc` header would duplicate on every include. + - `src/exports.s` — promotes numeric equates to linker-visible + `.export`s so they appear in `build/labels.txt` for the Python test + harness. The `.export` has to live in exactly one translation unit; + doing it inside the `.inc` header would duplicate on every include. + Only **backend-agnostic** symbols live here: `tcp_recv_buf`, + `fe_src1`, `fe_src2`, `fe_dst`, `cc20_data_ptr`, `cc20_remain`, + `zp_ptr`. The ip65-specific ones (`ip65_init`, `ip65_process`) moved + to `src/net/ip65/exports.s`, which is linked only under + `BACKEND=ip65` and also carries the `cert_buf = $A000` + SCRATCH_UNION link-time assert. ## Packaging @@ -1671,7 +1740,9 @@ bad image rather than a slow one. `verify_release.py` passes both flags. The comb profile stays deliberately excluded (REU bank 2 residency + ~40 min boot precompute at 1 MHz make it wrong for a general release). -Validation record (2026-07-27, HEAD cb6eab4): +Validation record (2026-07-27, then-HEAD cb6eab4, `libs/nistcurves` +pin v0.6.0 — the wall-clock rows below are v0.6.0 figures like every +other table in this file, and have not been re-measured at v0.9.1): - onchip PRG passes the 3-vector ECDSA KAT in VICE **without** REU (and with, as control) — the no-REU claim is verified, and boot.s's unconditional reu_mul_init is harmless with no REU @@ -1720,7 +1791,13 @@ the TLS state machine. For a quick sanity check after a build: - `tools/test_finished_verify.py` — server-Finished **rejection** path (18 cases, 2 vector sets; see below) -All 7 pass as of the ca65-conversion branch (97/97 assertions). +The list is 8 entries, not the 7 this line used to claim, and the +"97/97 assertions" total predates `test_finished_verify.py` being added +to it — so do not quote either number without re-running. There is also +no single runner behind an "all N pass" claim: `tools/run_all_tests.py` +dispatches 11 suites and includes neither `test_finished_verify` nor +`test_chained_hmac`. Run the ones you care about individually, or +`tools/run_all_tests.py` and read what it actually covered. ### `pytest` is not the runner — the collection boundary @@ -1734,26 +1811,37 @@ errors**, and `tests/` contributed a silent zero because its five live-rig The boundary is now pinned rather than accidental: - - the rig scripts are `tests/rig_*.py`, outside pytest's discovery - namespace whatever the working directory (`tests/README.md` says how - to run them; they need `sudo` and a live rig) + - **both** rig directories are `rig_*.py`, outside pytest's discovery + namespace whatever the working directory: `tests/` (VICE + network + rig, needs `sudo` — `tests/README.md`) and `tools/uci/` (real + U64E/C64U hardware — `tools/uci/README.md`). #111 renamed `tests/`; + its follow-up renamed `tools/uci/`, whose six scripts had the + identical shape - `pytest.ini` pins `testpaths` to the three genuinely pure-logic modules and keeps collection out of `libs/`, `ip65/`, `tests/` and - `tools/uci/` (all of which are `test_*.py`-named and collect zero) + `tools/uci/` - root `conftest.py` prints what the run does and does not cover, in both the header and the summary — no skips, because a vague skip reads like coverage - - `tools/test_pytest_boundary.py` fails if either direction drifts - -Bare `pytest` at the repo root is now **30 passed** (exit 0), and -`pytest tests/` still exits 5, now with an explanation. `testpaths` only -applies at the rootdir, so `pytest` from a subdirectory collects that -subdirectory: from `tools/` it is 30 passed + 74 fixture errors, exit 1 — + - `tools/test_pytest_boundary.py` fails if any direction drifts: a + `test_*.py` reappearing in either rig directory, a rig directory + dropping out of `norecursedirs`, a runnable `tools/test_*.py` + missing from `testpaths`, or a listed one pytest cannot run + +The rename and the `norecursedirs` entry are both load-bearing and the +guard pins both. The rename is what holds from an arbitrary working +directory, since `testpaths` only applies at the rootdir; the config +entry is what stops a root-level run descending there at all. + +Bare `pytest` at the repo root is now **31 passed** (exit 0), and +`pytest tests/` still exits 5, now with an explanation. Because +`testpaths` is rootdir-only, `pytest` from a subdirectory collects that +subdirectory: from `tools/` it is 31 passed + 74 fixture errors, exit 1 — loud and correct, since those modules cannot run under pytest at all. ### Negative-path coverage — the server Finished -`tools/test_finished_verify.py` and `tools/uci/test_https_bad_finished.py` +`tools/test_finished_verify.py` and `tools/uci/rig_https_bad_finished.py` exist because an audit found the client's Finished-mismatch abort had **no test at all**: inverting the mismatch branch (`sec` -> `clc` in `tls_verify_finished`, `src/tls_keyschedule.s`) left the full hardware e2e @@ -1766,7 +1854,7 @@ sends a *correct* Finished, so nothing ever exercised the reject. (secret, transcript) vector sets x 9 cases each, including the two realistic attacks: a valid HMAC under the wrong secret, and one over the wrong transcript. - - `tools/uci/test_https_bad_finished.py` (U64E/C64U) is the end-to-end + - `tools/uci/rig_https_bad_finished.py` (U64E/C64U) is the end-to-end version, against `tools/https_e2e/evil_listener.py` — a hand-rolled TLS 1.3 server (real X25519, real key schedule, real ChaCha20-Poly1305 records, real P-256 CertificateVerify) that flips **one bit** of the server @@ -1787,7 +1875,7 @@ Note `evil_listener.py` is a test fixture, not a TLS stack — it has no hardening and belongs nowhere near production. The `tools/uci/` scripts cover the UCI backend on U64E hardware (see -the "UCI test scripts" subsection above). +the "UCI rig scripts" subsection above). ### Upstream pin drift — `tools/check_upstream_pins.py` diff --git a/README.md b/README.md index d6021c6..59ab79d 100644 --- a/README.md +++ b/README.md @@ -44,17 +44,17 @@ that does not name it. Both are one-time, per clone: ```bash git submodule update --init --recursive -# ip65 backend only — `make` will NOT build the blob for you +# ip65 backend only — `make` builds the blob itself, but not these make ip65-libs -make ip65-blob # any test script, VICE or hardware — separate public repo, not in requirements.txt git clone https://github.com/JC-000/c64-test-harness # sibling of this repo python3 -m pip install -e ../c64-test-harness ``` -Skipping the first gives `ip65_blob.s(22): Error: Cannot open include file -'.../ip65-c64.bin'` (reported as #89); skipping the second gives +Skipping the first gives `ld65: Error: Input file +'../ip65/ip65/ip65_tcp.lib' not found`, from the blob link step that plain +`make` runs for you (reported as #89); skipping the second gives `ModuleNotFoundError: No module named 'c64_test_harness'` (#90). Use `python3 -m pip` so the package lands in the interpreter that runs the scripts — a venv mismatch reproduces #90 exactly after an install that appeared to succeed. @@ -230,11 +230,14 @@ what makes the PRG deterministic.) ip65 is built from the submodule into a flat binary blob at $2000, using a custom ld65 linker config (`ip65-build/ip65.cfg`), and linked into the -ca65 build via `.incbin`. **A plain `make` does not produce that blob and -cannot** — `.incbin` is invisible to make's dependency graph, so there is -no rule connecting the two. Run `make ip65-libs && make ip65-blob` once -per clone, as in "Before you build or test" above. The build is -deterministic: 6,951 B, sha256 `cf1a5ff7809af4e4655e385b378b936054f41046ff2b7604828af3240c2d90dd`. +ca65 build via `.incbin`. **A plain `make` does produce that blob** — +`$(IP65_BIN)` is a real prerequisite of the PRG with a real rule, ordered +ahead of the `.incbin`. Measured 2026-08-15: deleting the blob and running +plain `make` rebuilds it byte-identically and links the usual 47,105 B PRG. +What `make` cannot do for you is build the ip65 `.lib` archives the blob +links against, so run `make ip65-libs` once per clone, as in "Before you +build or test" above; `make ip65-blob` exists only to force a rebuild. The +build is deterministic: 6,951 B, sha256 `cf1a5ff7809af4e4655e385b378b936054f41046ff2b7604828af3240c2d90dd`. `make clean` does not remove it, which is why the step is normally invisible. The UCI backend does not use the blob at all. @@ -269,7 +272,7 @@ Progress: - [x] ECDSA P-256 signature verification — supplied by the `libs/nistcurves` submodule (`ecdsa_verify_256`), always resident under both backends; `src/crypto/ecdsa_verify.s` is a thin dispatcher that packs the big-endian input struct. P-384 verify is **not** built — see Known Issues. - [x] HTTP/1.1 GET request — build GET, parse response (status + headers + body), plain HTTP end-to-end - [x] **End-to-end HTTPS GET demo (both backends)** — TLS 1.3 handshake + HTTP GET completes against a local Python TLS listener (ECDSA-P256 cert). Returns `http_status=200`, body `"HELLO FROM TLS SERVER"`. - - UCI: real Ultimate 64 Elite hardware at both 48 MHz turbo and stock 1 MHz. See `tools/uci/test_https_local.py` (supports `TURBO_MHZ` env var). + - UCI: real Ultimate 64 Elite hardware at both 48 MHz turbo and stock 1 MHz. See `tools/uci/rig_https_local.py` (supports `TURBO_MHZ` env var). - ip65: VICE + RR-Net at stock 1 MHz, no warp. The bridge-rig script is `tests/rig_phase3_https_1mhz.py`; the hardware-free macOS feth/pcap rig is `tests/rig_vice_https_macos.py`, and that is where the wall-clock below was taken. ### Known Issues @@ -321,7 +324,7 @@ python3 tools/test_chained_hmac.py # 10 cases: chained HMAC-SHA256 stability python3 tools/test_finished_verify.py # 18 cases: the server-Finished REJECTION path, driven over DMA python3 tools/test_ecdsa_kat_oracle.py # 6 vectors: ECDSA P-256 KAT, 3 valid + 3 negative CAVP python3 tools/test_package_verify.py # 31 cases: pure-logic tests for the release gate (no VICE, no build) -python3 tools/test_pytest_boundary.py # 4 checks: the pytest collection boundary below is intact +python3 tools/test_pytest_boundary.py # 5 checks: the pytest collection boundary below is intact # Benchmark python3 tools/bench_x25519.py # X25519 basepoint multiply: 12,635 jiffies / 211 s C64 time, ~14 s wall under warp @@ -346,21 +349,30 @@ positional arguments rather than fixtures, so pytest can only ever report several under `tools/` are `main()` programs with no `def test_` at all, so pytest collects zero from them and says nothing about it. +The two rig directories are named `rig_*.py` for exactly that reason — +`tests/` since #111, `tools/uci/` since its follow-up. A rename is what +holds no matter which directory pytest is invoked from; `norecursedirs` +is what keeps a root-level run out of them. Both halves are pinned by the +guard. + `pytest.ini` therefore pins `testpaths` to the three modules that really are pure-logic and pytest-runnable, and `conftest.py` prints the scope of the run in both the header and the summary. A bare `pytest` at the repo -root reports **30 passed**, and says in the same breath that this is not a -statement about the C64 suites or the rig scripts. +root reports **31 passed** (exit 0), and says in the same breath that this +is not a statement about the C64 suites or the rig scripts. `pytest tests/` +and `pytest tools/uci/` both exit 5, "no tests ran", with an explanation +naming the right README. `testpaths` applies only when pytest is invoked from the rootdir, so from a subdirectory you get that subdirectory instead — measured from `tools/`: -30 passed, 74 `fixture 'transport' not found` errors, exit 1. That is the +31 passed, 74 `fixture 'transport' not found` errors, exit 1. That is the honest signal (pytest genuinely cannot run those modules) and it is loud, which is the opposite of the problem being fixed here. -`tools/test_pytest_boundary.py` fails if the boundary drifts in either -direction — a pure-logic module missing from `testpaths`, or a `test_*.py` -reappearing in `tests/`. See issue #109. +`tools/test_pytest_boundary.py` fails if the boundary drifts in any +direction — a pure-logic module missing from `testpaths`, a listed module +pytest cannot run, a `test_*.py` reappearing in `tests/` or `tools/uci/`, +or a rig directory dropping out of `norecursedirs`. See issue #109. ### End-to-End Bridge Tests (ip65 backend) @@ -417,11 +429,17 @@ Install it into the **same interpreter you run the scripts with** — if you use python3 tools/uci/boot_check.py # UCI firmware detection python3 tools/uci/phase2_check.py # DHCP + local IP readback python3 tools/uci/phase3_tcp_echo.py # TCP connect/send/recv -python3 tools/uci/test_http_local.py # HTTP GET against local listener -python3 tools/uci/test_https_local.py # HTTPS GET (TLS 1.3 + ECDSA-P256) -python3 tools/uci/test_https_bad_finished.py # client must ABORT on a forged server Finished +python3 tools/uci/rig_http_local.py # HTTP GET against local listener +python3 tools/uci/rig_https_local.py # HTTPS GET (TLS 1.3 + ECDSA-P256) +python3 tools/uci/rig_https_bad_finished.py # client must ABORT on a forged server Finished ``` +These are `rig_*.py`, not `test_*.py`, for the same reason as `tests/`: a +hardware `main()` script named the pytest way gets walked by pytest, collects +zero, and reports nothing — which reads as coverage it does not have (issue +#109). `tools/uci/README.md` lists all of them; `tools/test_pytest_boundary.py` +fails if a `test_*.py` file reappears in either rig directory. + **Prerequisite — the REU, unless you build the on-chip profile.** The default `make BACKEND=uci` image is the *REU profile*: X25519's field multiply and the P-256 archive both fetch their multiply rows from REU banks by DMA. On a device @@ -435,24 +453,24 @@ REU, or build the profile that needs none: make clean && make BACKEND=uci USE_NISTCURVES_ONCHIP=1 ``` -Every script that exercises the crypto path (`test_https_local.py`, -`test_https_bad_finished.py`, `test_https_print_body.py`, -`test_https_local_p384.py`, `bench_ecdsa_u64e.py`) now **preflights this in one +Every script that exercises the crypto path (`rig_https_local.py`, +`rig_https_bad_finished.py`, `rig_https_print_body.py`, +`rig_https_local_p384.py`, `bench_ecdsa_u64e.py`) now **preflights this in one REST call and exits 4 in seconds** if a REU-profile build meets a device with no REU. On-chip builds skip the check entirely. The preflight never writes device config — the U64E is queue-shared and config writes persist until power cycle, so enabling the REU is yours to do. `C64_SKIP_REU_PREFLIGHT=1` bypasses it. -`test_https_bad_finished.py` is the negative path: it talks to +`rig_https_bad_finished.py` is the negative path: it talks to `tools/https_e2e/evil_listener.py`, a hand-rolled TLS 1.3 server that flips one bit of the server Finished `verify_data` before encryption (corrupting the ciphertext instead would be caught by Poly1305 and never reach the Finished comparison). Run `FINISHED_MODE=good` first as the control. `tools/test_finished_verify.py` is the VICE-only equivalent. -`test_https_local.py` is the end-to-end HTTPS demo (UCI backend only): it boots the U64E at 48 MHz turbo, connects to a local Python TLS listener using the test cert under `tools/https_e2e/certs/`, and confirms a full TLS 1.3 handshake + HTTP GET. That cert is gitignored throwaway material — the directory is empty in a fresh clone and the pair is generated on first use, with no dependency beyond the standard library (`python3 tools/https_e2e/ensure_certs.py` mints it by hand). With `DEBUG_CAPTURE=1`, each run writes a timestamped artifact directory under `$UCI_DEBUG_DIR` (default `/tmp/uci_https_debug/`) with raw 6510 bus trace, TLS state snapshot, and listener result. +`rig_https_local.py` is the end-to-end HTTPS demo (UCI backend only): it boots the U64E at 48 MHz turbo, connects to a local Python TLS listener using the test cert under `tools/https_e2e/certs/`, and confirms a full TLS 1.3 handshake + HTTP GET. That cert is gitignored throwaway material — the directory is empty in a fresh clone and the pair is generated on first use, with no dependency beyond the standard library (`python3 tools/https_e2e/ensure_certs.py` mints it by hand). With `DEBUG_CAPTURE=1`, each run writes a timestamped artifact directory under `$UCI_DEBUG_DIR` (default `/tmp/uci_https_debug/`) with raw 6510 bus trace, TLS state snapshot, and listener result. -Environment variables honored by `test_https_local.py`: +Environment variables honored by `rig_https_local.py`: - `U64_HOST` (default `192.168.1.81`) — U64E address - `TURBO_MHZ` (default `48`) — C64 CPU speed. `TURBO_MHZ=1` runs the test at stock 1 MHz with every wall-clock budget auto-scaled, and is validated end-to-end on real U64E hardware; the handshake + GET itself measured 1,157.7 s (~19 min) there, not the full budget. diff --git a/conftest.py b/conftest.py index 98b0a83..7395804 100644 --- a/conftest.py +++ b/conftest.py @@ -2,7 +2,8 @@ c64-https is an assembly project. Its real test suites drive VICE or real Ultimate 64 hardware and are launched by `python3 tools/run_all_tests.py` -and by the manual rig scripts in `tests/` — not by pytest. Only a few +and by the manual rig scripts in `tests/` and `tools/uci/` — not by +pytest. Only a few pure-logic host-side modules are pytest-runnable, and `pytest.ini` pins `testpaths` to exactly those. @@ -18,15 +19,16 @@ "c64-https: pytest runs ONLY the pure-logic host-side modules pinned in", "pytest.ini `testpaths`. It does NOT run the C64 suites (those need VICE:", "`python3 tools/run_all_tests.py`) and it does NOT run the live-rig", - "scripts in tests/ (manual, sudo + network rig: see tests/README.md).", - "A green run here says nothing about either.", + "scripts in tests/ (sudo + network rig) or tools/uci/ (real U64E/C64U", + "hardware). A green run here says nothing about any of them.", ] _EMPTY_RUN = [ "pytest collected nothing from the paths you gave it.", - "If that was `pytest tests/`: tests/ holds manual live-rig scripts", - "(tests/rig_*.py, main() programs needing sudo and a network rig), not", - "pytest tests. See tests/README.md for how to run them.", + "If that was `pytest tests/` or `pytest tools/uci/`: both hold manual", + "live-rig scripts (rig_*.py, main() programs needing sudo + a network", + "rig, or real U64E/C64U hardware), not pytest tests. See", + "tests/README.md and tools/uci/README.md for how to run them.", ] diff --git a/docs/phase_f_part1_root_cause.md b/docs/phase_f_part1_root_cause.md index c282412..a93e77a 100644 --- a/docs/phase_f_part1_root_cause.md +++ b/docs/phase_f_part1_root_cause.md @@ -12,7 +12,7 @@ context-dependent. ``` BACKEND=uci make clean && make -python3 tools/uci/test_https_local.py # ~10 min timeout +python3 tools/uci/rig_https_local.py # ~10 min timeout ``` Expected: ~110 s handshake, body = "HELLO FROM TLS SERVER". diff --git a/docs/phase_f_part1_trace_analysis.md b/docs/phase_f_part1_trace_analysis.md index 1aacedf..9f73602 100644 --- a/docs/phase_f_part1_trace_analysis.md +++ b/docs/phase_f_part1_trace_analysis.md @@ -5,7 +5,7 @@ **The captured `trace.bin` artifacts from runs 20260420_151301, _152431, _154548 and _160911 do NOT contain sufficient signal to pinpoint the exact instruction where `x25519_scalarmult` hangs.** The 6510 bus tap, -as filtered by `test_https_local.py::_keep_cycle`, is a periodic sampler +as filtered by `rig_https_local.py::_keep_cycle`, is a periodic sampler (one sample every ~1/48th of a CPU cycle at 48 MHz turbo); what surfaces in the PC histogram is a *cadence* artifact, not a hot-loop fingerprint. @@ -16,7 +16,7 @@ but the trace *data* is not what will identify the specific loop. ## What the trace actually shows 1. **All captured cycles have PHI2 = 1** (CPU-phase), by construction. - `_keep_cycle` in `tools/uci/test_https_local.py:134` drops PHI2 = 0 + `_keep_cycle` in `tools/uci/rig_https_local.py:134` drops PHI2 = 0 samples. Every window I sampled (cyc 0, 10k, 50k, 200k, 1M, …, 13M) reports `CPU: 100% VIC: 0%`. 2. **A dominant address stride of exactly $2F** (47 bytes) across the @@ -156,7 +156,7 @@ Given the trace does not distinguish them, the prior agent's leads ## No fix applied -I did not modify any source, and I did not run `test_https_local.py`. +I did not modify any source, and I did not run `rig_https_local.py`. Running one more 10-minute U64E test without a sharper hypothesis would consume the hardware time without adding a single new signal beyond what the four existing captures already show. The next step diff --git a/pytest.ini b/pytest.ini index ea2f6c9..1ea28dd 100644 --- a/pytest.ini +++ b/pytest.ini @@ -6,7 +6,8 @@ ; `test_*` functions take positional arguments, not fixtures, so pytest ; can only ever report them as "fixture 'transport' not found". The live ; rig scripts in tests/ are main() programs needing sudo and a network -; rig (see tests/README.md). +; rig (see tests/README.md), and those in tools/uci/ need a real U64E or +; C64U on the LAN (see tools/uci/README.md). ; ; Only a handful of modules are pure-logic and genuinely runnable by ; pytest. testpaths names them explicitly, so that `pytest` with no @@ -16,12 +17,15 @@ ; tests (tests/, tools/uci/, libs/). ; ; testpaths only applies when pytest runs from the rootdir. That is why -; the rig scripts had to be RENAMED to tests/rig_*.py as well: a rename -; holds from any working directory, config does not. +; the rig scripts had to be RENAMED to rig_*.py as well, in tests/ and in +; tools/uci/ alike: a rename holds from any working directory, config +; does not. ; -; The list is enforced, in both directions, by +; Both halves are enforced, in both directions, by ; tools/test_pytest_boundary.py: add a pure-logic tools/test_*.py module -; and that guard fails until you list it here. +; and that guard fails until you list it in testpaths; drop tests/ or +; tools/uci/ from norecursedirs, or let a test_*.py file reappear in +; either, and it fails too. ; ; A green run here does NOT mean the C64 test suites passed. conftest.py ; prints that on every invocation; do not remove it. diff --git a/tests/README.md b/tests/README.md index 791473b..6e9e696 100644 --- a/tests/README.md +++ b/tests/README.md @@ -57,3 +57,8 @@ when pytest is run from the repo root. `conftest.py` then prints what a pytest run does and does not cover, in the header and again in the summary. `tools/test_pytest_boundary.py` fails if a `test_*.py` file reappears here. + +`tools/uci/` is the same directory shape for real U64E/C64U hardware and +was renamed the same way in the follow-up to #111; see +`tools/uci/README.md`. The guard covers both directories, and also fails +if `pytest.ini`'s `norecursedirs` stops listing either of them. diff --git a/tools/https_e2e/certs/README b/tools/https_e2e/certs/README index 1c0aadb..0389681 100644 --- a/tools/https_e2e/certs/README +++ b/tools/https_e2e/certs/README @@ -32,11 +32,11 @@ packaged listener mint identical material: - `https_listener.py` (via `_ensure_certs(cert_profile)`), and so everything built on it -- `tests/rig_vice_https_macos.py`, - `evil_listener.py`, `tools/uci/test_https_bad_finished.py` - - `tools/uci/test_https_local.py`, which inlines its own listener + `evil_listener.py`, `tools/uci/rig_https_bad_finished.py` + - `tools/uci/rig_https_local.py`, which inlines its own listener (this one used to fail with `ERROR: cert/key not found` instead -- issue #93) - - `tools/uci/test_https_local_p384.py`, same, P-384 profile + - `tools/uci/rig_https_local_p384.py`, same, P-384 profile Generation needs nothing beyond the Python standard library: PR #96 reimplemented `gen_certs.py` in pure Python (curve arithmetic, a diff --git a/tools/https_e2e/ensure_certs.py b/tools/https_e2e/ensure_certs.py index 81df7ce..521331a 100644 --- a/tools/https_e2e/ensure_certs.py +++ b/tools/https_e2e/ensure_certs.py @@ -7,7 +7,7 @@ listener needs them. ``https_listener.py`` has always generated its own pair, but -``tools/uci/test_https_local.py`` inlines its own listener and so never +``tools/uci/rig_https_local.py`` inlines its own listener and so never crossed that path: it just printed ``ERROR: cert/key not found`` and exited 2, leaving the reader to discover that a generator existed somewhere else entirely (issue #93). This module is the single entry point both now use. diff --git a/tools/package/listener/README.md b/tools/package/listener/README.md index ce830bc..eedf39a 100644 --- a/tools/package/listener/README.md +++ b/tools/package/listener/README.md @@ -6,7 +6,7 @@ certificate. No dependency on the c64-https repo, on `c64-test-harness`, or on any third-party Python package. It is a stand-alone clone of the inline listener in the c64-https repo -(`tools/uci/test_https_local.py`). The protocol behavior is copied verbatim so +(`tools/uci/rig_https_local.py`). The protocol behavior is copied verbatim so the Commodore 64 client sees exactly what it expects. ## Dependencies: none. Requirement: a Python that can do TLS 1.3. diff --git a/tools/package/listener/listener.py b/tools/package/listener/listener.py index ffcaa46..2c0b04c 100755 --- a/tools/package/listener/listener.py +++ b/tools/package/listener/listener.py @@ -2,7 +2,7 @@ """Self-contained TLS 1.3 test listener for the c64-https client. This is a stand-alone clone of the inline HTTPS listener in the c64-https -repo's ``tools/uci/test_https_local.py``. It stands up the entire server +repo's ``tools/uci/rig_https_local.py``. It stands up the entire server side of the end-to-end test — including generating its own certificate — with no dependency on the c64-https repo or the c64-test-harness package. @@ -71,7 +71,7 @@ import traceback from pathlib import Path -# --- Canonical response bytes (cloned verbatim from test_https_local.py) --- +# --- Canonical response bytes (cloned verbatim from rig_https_local.py) --- EXPECTED_BODY = "HELLO FROM TLS SERVER" HTTP_RESPONSE = ( b"HTTP/1.0 200 OK\r\n" @@ -142,7 +142,7 @@ def _serialize_result(result: dict, path: Path) -> None: Bytes fields become {"__type__": "bytes-b64", "b64": ..., "len": ...}; tuples become lists; exceptions are captured as type/str/traceback. - Matches _serialize_server_result() in test_https_local.py. + Matches _serialize_server_result() in rig_https_local.py. """ out: dict = {} for key, val in result.items(): @@ -175,7 +175,7 @@ def _handle_one(srv: socket.socket, ctx: ssl.SSLContext, accept_timeout: float, result: dict) -> None: """Accept one connection, handshake, serve the canonical response. - Mirrors _run_https_server() in test_https_local.py: the result dict is + Mirrors _run_https_server() in rig_https_local.py: the result dict is populated with listening / client_addr / request / error as it goes. """ srv.settimeout(accept_timeout) diff --git a/tools/test_pytest_boundary.py b/tools/test_pytest_boundary.py index 51a72bd..13feea4 100644 --- a/tools/test_pytest_boundary.py +++ b/tools/test_pytest_boundary.py @@ -7,15 +7,22 @@ zero tests from disappears into a green pass count, and a pure-logic module that nobody adds to ``testpaths`` never runs at all. -Three invariants, checked in both directions: +Four invariants, checked in both directions: -1. ``tests/`` contains no ``test_*.py``. Those are manual live-rig - scripts (``tests/rig_*.py``); named the pytest way they would be - walked, collected as zero, and reported as nothing. +1. No rig directory contains ``test_*.py``. Both ``tests/`` and + ``tools/uci/`` hold manual live-rig scripts (``rig_*.py``); named the + pytest way they would be walked, collected as zero, and reported as + nothing. ``tests/`` was renamed by #111, ``tools/uci/`` by its + follow-up. -2. Every path in ``pytest.ini``'s ``testpaths`` exists. +2. ``pytest.ini``'s ``norecursedirs`` lists every rig directory. The + rename is what holds from an arbitrary working directory; the config + entry is what keeps a root-level run from descending there at all. + Both halves are load-bearing, so both are pinned. -3. ``testpaths`` is exactly the set of ``tools/test_*.py`` modules pytest +3. Every path in ``pytest.ini``'s ``testpaths`` exists. + +4. ``testpaths`` is exactly the set of ``tools/test_*.py`` modules pytest can actually run — that is, modules with at least one module-level ``test_*`` function where every such function's parameters all have defaults. A parameter without a default is a fixture request, and this @@ -35,6 +42,14 @@ REPO = Path(__file__).resolve().parent.parent PYTEST_INI = REPO / "pytest.ini" +# Directories of manual live-rig scripts. Every file in each is a `main()` +# program needing hardware, sudo, or a network rig; none of them defines a +# single `def test_`, so pytest collects zero from all of them. They are +# named `rig_*.py` precisely so pytest never walks them looking. +# tests/ — VICE / bridge rigs (issue #109, PR #111) +# tools/uci/ — U64E + C64U hardware rigs (the #111 follow-up) +RIG_DIRS = ("tests", "tools/uci") + def _testpaths(): """The `testpaths` entries from pytest.ini, as repo-relative strings.""" @@ -110,14 +125,44 @@ def _pytest_runnable_tools_modules(): return runnable -def test_tests_dir_holds_no_pytest_named_files() -> None: - """tests/ must not look collectable, because it is not.""" - stray = sorted(p.name for p in (REPO / "tests").glob("test_*.py")) +def test_rig_dirs_hold_no_pytest_named_files() -> None: + """A rig directory must not look collectable, because it is not.""" + stray = sorted( + f"{d}/{p.name}" + for d in RIG_DIRS + for p in (REPO / d).glob("test_*.py") + ) assert stray == [], ( - f"tests/ contains pytest-named files {stray}, but everything in " - "tests/ is a manual live-rig script. pytest would walk them, collect " - "zero tests, and report nothing. Rename to rig_*.py — see " - "tests/README.md and issue #109." + f"rig directories contain pytest-named files {stray}, but every file " + f"in {list(RIG_DIRS)} is a manual live-rig script. pytest would walk " + "them, collect zero tests, and report nothing. Rename to rig_*.py — " + "see tests/README.md, tools/uci/README.md and issue #109." + ) + + +def _norecursedirs(): + """The `norecursedirs` entries from pytest.ini, as a set of strings.""" + parser = configparser.ConfigParser() + parser.read(PYTEST_INI) + return set(parser.get("pytest", "norecursedirs").split()) + + +def test_norecursedirs_covers_every_rig_dir() -> None: + """The rename and the config entry are both load-bearing; pin both. + + The `rig_` prefix is what holds when pytest is invoked from an + arbitrary working directory. `norecursedirs` is what stops a + root-level run from descending into a rig directory at all — which + still matters, because a rig directory may legitimately grow a + non-rig helper, and because it documents the intent at the one place + a reader looks. + """ + missing = sorted(d for d in RIG_DIRS if d not in _norecursedirs()) + assert missing == [], ( + f"pytest.ini norecursedirs omits rig directories: {missing}. A bare " + "`pytest` from the repo root would descend into them. Add them to " + "norecursedirs — the rig_*.py naming alone is the other half of this " + "boundary, not all of it." ) diff --git a/tools/uci/README.md b/tools/uci/README.md new file mode 100644 index 0000000..50eba8e --- /dev/null +++ b/tools/uci/README.md @@ -0,0 +1,80 @@ +# tools/uci/ — manual hardware-rig scripts (UCI backend) + +Everything runnable in this directory is a **manual** script. Each is a +`main()` program behind `if __name__ == "__main__": sys.exit(main())`, each +needs a real UCI device on the LAN (an Ultimate 64 Elite or a C64 +Ultimate), and the HTTPS ones take one to twenty minutes per run. They are +not part of any automated suite: nothing in `make` or +`tools/run_all_tests.py` invokes them. + +The rig scripts are named `rig_*.py`, **not** `test_*.py`, and that is +deliberate — see "Why not pytest" below. + +| Script | What it proves | +|---|---| +| `boot_check.py` | UCI firmware detection + boot banner | +| `phase2_check.py` | DHCP acquire + local-IP readback | +| `phase3_tcp_echo.py` | TCP connect / send / recv against a local echo server | +| `rig_http_local.py` | plain HTTP GET against a local test server | +| `rig_http_live.py` | plain HTTP GET against a real internet host | +| `rig_https_local.py` | full TLS 1.3 handshake + HTTP GET (ECDSA-P256 cert) | +| `rig_https_bad_finished.py` | the negative path: the client must ABORT on a forged server Finished | +| `rig_https_print_body.py` | issue #28 — the decrypted body renders correctly on screen | +| `rig_https_local_p384.py` | the P-384 cert profile (blocked: no P-384 PRG builds today) | +| `bench_ecdsa_u64e.py` | ECDSA-P256 verify wall-clock across a clock sweep | + +Files with a leading underscore are helper modules, not entry points: +`_device_lock_helper.py`, `_memory_policy.py`, `_reu_preflight.py`, +`_ecdsa_vectors.py`, `_analyze_ecdsa_trace.py`. + +Three of the rigs delegate rather than duplicate: `rig_https_print_body.py` +and `rig_https_local_p384.py` both import `rig_https_local` and override a +narrow slice of it (the response body, and the cert/key pair +respectively), so a change to the shared flow lands in all three. + +## Running them + +```sh +U64_HOST=10.43.23.81 python3 tools/uci/boot_check.py +U64_HOST=10.43.23.81 python3 tools/uci/rig_https_local.py +``` + +`U64_HOST` selects the device (default `192.168.1.81`). Everything goes +through the `c64-test-harness` package's `DeviceLock` plus +`enable_uci`/`disable_uci` — never drive the device's REST API directly +and never `pkill` a run you did not start, because the device is shared +across the `c64-*` repos and the lock queue is the only thing keeping +concurrent sessions from clobbering each other. + +Other environment variables read by scripts here: `TURBO_MHZ`, +`TURBO_SETTLE`, `HTTPS_PORT`, `BOOT_TIMEOUT`, `ACCEPT_TIMEOUT`, +`SENTINEL_POLL_TIMEOUT`, `C64_INIT_WAIT`, `DEBUG_CAPTURE`, +`UCI_DEBUG_DIR`, `KEEP_DEBUG_ON_PASS`, `EXTERNAL_LISTENER`, +`EXTERNAL_HOST`, `EXTERNAL_PORT`, `FINISHED_MODE`, `BACKEND`, +`ECDSA_MHZ_LIST`, `ECDSA_REPEATS`, `ECDSA_POLL_S`, `ECDSA_DEBUG_CAPTURE`, +`ECDSA_DEBUG_DIR`. See each script's docstring for which ones it honors +and what the defaults are; `CLAUDE.md`'s "UCI test scripts" section has +the prose. + +Each prints `PASS` or `FAIL` and exits non-zero on failure. + +## Why not pytest + +These scripts cannot be pytest tests without inventing a hardware +fixture, and a hardware fixture that quietly skips is worse than no +fixture at all: it turns "nobody has a U64E plugged in" into a +green-looking run with a skip nobody reads. + +Until they were renamed they were called `tools/uci/test_*.py`, which is +exactly pytest's discovery convention — so pytest would walk this +directory, find no `def test_` functions, collect zero, and say nothing +about it. That is the same defect issue #109 fixed in `tests/`; PR #111 +closed the default-invocation path here with `norecursedirs` but left the +names, because the rename's blast radius through `CLAUDE.md` needed its +own change. This is that change. + +`tools/test_pytest_boundary.py` now fails if a `test_*.py` file reappears +in either rig directory, and also if `pytest.ini`'s `norecursedirs` stops +listing one of them. The rename is what holds from an arbitrary working +directory (`testpaths` only applies at the rootdir); `norecursedirs` is +what keeps a root-level run out of here entirely. Both halves are pinned. diff --git a/tools/uci/_memory_policy.py b/tools/uci/_memory_policy.py index 63f255b..bd28969 100644 --- a/tools/uci/_memory_policy.py +++ b/tools/uci/_memory_policy.py @@ -177,7 +177,7 @@ def build_policy( # portion so the unused tail is available as harness scratch. # The cfg comment on CRYPTO_OVERLAY explicitly designates the # tail as harness/overlay-test territory, and - # tools/uci/test_https_local.py has used this tail in + # tools/uci/rig_https_local.py has used this tail in # production since PR #41. # # Other memory regions (UCI_BSS_REGION, TCP_BUF, etc.) reserve diff --git a/tools/uci/bench_ecdsa_u64e.py b/tools/uci/bench_ecdsa_u64e.py index c87bbbd..b0e38b0 100644 --- a/tools/uci/bench_ecdsa_u64e.py +++ b/tools/uci/bench_ecdsa_u64e.py @@ -34,7 +34,7 @@ ECDSA_DEBUG_CAPTURE — "1" to enable U64 6510 debug-stream capture for the FIRST 48 MHz positive run; artifacts written under $ECDSA_DEBUG_DIR (default /tmp/ecdsa_debug/). - KEEP_DEBUG_ON_PASS — as in test_https_local.py. + KEEP_DEBUG_ON_PASS — as in rig_https_local.py. """ from __future__ import annotations diff --git a/tools/uci/test_http_live.py b/tools/uci/rig_http_live.py similarity index 100% rename from tools/uci/test_http_live.py rename to tools/uci/rig_http_live.py diff --git a/tools/uci/test_http_local.py b/tools/uci/rig_http_local.py similarity index 100% rename from tools/uci/test_http_local.py rename to tools/uci/rig_http_local.py diff --git a/tools/uci/test_https_bad_finished.py b/tools/uci/rig_https_bad_finished.py similarity index 98% rename from tools/uci/test_https_bad_finished.py rename to tools/uci/rig_https_bad_finished.py index cb02249..de35dc3 100755 --- a/tools/uci/test_https_bad_finished.py +++ b/tools/uci/rig_https_bad_finished.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""test_https_bad_finished.py — the client must refuse a forged server Finished. +"""rig_https_bad_finished.py — the client must refuse a forged server Finished. Audit finding F2: the client verifies the server's Finished HMAC and aborts on mismatch (``tls_verify_finished`` in ``src/tls_keyschedule.s``, ``bcs @@ -158,7 +158,7 @@ def _state_name(v: int) -> str: return f"{_STATE_NAMES.get(v, '?')} (${v:02X})" -# Arbiter-assigned; see the long note in test_https_local.py about why these +# Arbiter-assigned; see the long note in rig_https_local.py about why these # must never be hardcoded. ROUTINE_ADDR = HOST_STR_ADDR = PATH_STR_ADDR = -1 SENTINEL_ADDR = PROGRESS_ADDR = CARRY_FLAG_ADDR = -1 @@ -187,7 +187,7 @@ def _try_bind(bind_ip: str, port: int) -> socket.socket | None: def _build_http_routine(labels: dict[str, int], port: int) -> tuple[bytes, int]: """6502 stub: set up http_get's inputs, call it, latch carry, signal done. - Mirrors tools/uci/test_https_local.py's routine — same real code path + Mirrors tools/uci/rig_https_local.py's routine — same real code path (``http_get`` -> ``tls_connect``), so the only thing this test changes relative to the passing e2e is what the server puts on the wire. """ @@ -529,7 +529,7 @@ def main() -> int: # Set turbo BEFORE boot, and skip a redundant write — the config write # itself is what glitches the UCI bridge on a C64U (see the long note - # in test_https_local.py and the c64u_starlight_device memory). + # in rig_https_local.py and the c64u_starlight_device memory). try: cat = client.get_config_category(CAT_U64_SPECIFIC) inner = cat.get(CAT_U64_SPECIFIC, cat) diff --git a/tools/uci/test_https_local.py b/tools/uci/rig_https_local.py similarity index 99% rename from tools/uci/test_https_local.py rename to tools/uci/rig_https_local.py index 8869cb4..2151c75 100644 --- a/tools/uci/test_https_local.py +++ b/tools/uci/rig_https_local.py @@ -56,7 +56,7 @@ contract has no inline listener. Offline re-check of an archived run (no hardware): - ./test_https_local.py --check-artifact [--host IP] [--path P] + ./rig_https_local.py --check-artifact [--host IP] [--path P] re-runs the server-side criteria against that run's server_result.json. """ from __future__ import annotations @@ -1743,7 +1743,7 @@ def _check_artifact_main(argv: list[str]) -> int: stored run can be re-adjudicated after the criteria change. """ if not argv: - print("usage: test_https_local.py --check-artifact " + print("usage: rig_https_local.py --check-artifact " " [--host IP] [--path P]", file=sys.stderr) return 2 diff --git a/tools/uci/test_https_local_p384.py b/tools/uci/rig_https_local_p384.py similarity index 88% rename from tools/uci/test_https_local_p384.py rename to tools/uci/rig_https_local_p384.py index d208c25..73c9135 100644 --- a/tools/uci/test_https_local_p384.py +++ b/tools/uci/rig_https_local_p384.py @@ -4,7 +4,7 @@ path through the UCI backend on a real Ultimate 64 Elite, against a local listener serving an ECDSA secp384r1 certificate. -This is the P-384 sibling of tools/uci/test_https_local.py. Differences: +This is the P-384 sibling of tools/uci/rig_https_local.py. Differences: - Uses the P-384 cert/key bundle at tools/https_e2e/certs/ (server-p384.pem / server-p384.key). Equivalent to setting @@ -21,9 +21,9 @@ Usage: /Users/someone/.local/share/c64-test-harness/venv/bin/python \\ - tools/uci/test_https_local_p384.py + tools/uci/rig_https_local_p384.py -Environment variables (same as test_https_local.py): +Environment variables (same as rig_https_local.py): U64_HOST - U64E IP (default 192.168.1.81) TURBO_MHZ - C64 CPU MHz (default 48) HTTPS_PORT - listener port (default 443; falls back to 4433) @@ -42,7 +42,7 @@ KEEP_DEBUG_ON_PASS - 1 to preserve artifacts on PASS (default 0) UCI_DEBUG_DIR - artifact dir base (default /tmp/uci_https_debug) -Flow mirrors test_https_local.py exactly; see that file's docstring +Flow mirrors rig_https_local.py exactly; see that file's docstring for the per-step description. """ from __future__ import annotations @@ -76,7 +76,7 @@ # Now import the parent module — it will pick up the timeout env vars # above, and we patch CERT_PATH / KEY_PATH below before main() runs. sys.path.insert(0, str(Path(__file__).resolve().parent)) -import test_https_local # type: ignore +import rig_https_local # type: ignore # -------------------------------------------------------------------------- # Swap to the P-384 cert/key. These live in the same dir as the P-256 @@ -84,11 +84,11 @@ # load. # -------------------------------------------------------------------------- _REPO_ROOT = Path(__file__).resolve().parents[2] -test_https_local.CERT_PATH = _REPO_ROOT / "tools" / "https_e2e" / "certs" / "server-p384.pem" -test_https_local.KEY_PATH = _REPO_ROOT / "tools" / "https_e2e" / "certs" / "server-p384.key" +rig_https_local.CERT_PATH = _REPO_ROOT / "tools" / "https_e2e" / "certs" / "server-p384.pem" +rig_https_local.KEY_PATH = _REPO_ROOT / "tools" / "https_e2e" / "certs" / "server-p384.key" # Both are gitignored and generated on demand; the profile tells the parent's # _ensure_certs_or_fail() which pair to mint (issue #93). -test_https_local.CERT_PROFILE = "p384" +rig_https_local.CERT_PROFILE = "p384" # -------------------------------------------------------------------------- @@ -98,7 +98,7 @@ # fully occupied at PRG-load time (OVERLAY_BLOB_SHA384) and is the # active overlay swap slot at runtime, so the default # CRYPTO_OVERLAY-scoped arbiter window finds no free range and raises -# MemoryArbiterError. The parent test_https_local.py now defaults to +# MemoryArbiterError. The parent rig_https_local.py now defaults to # ``build_policy_and_arbiter_with_overlay_carveout`` (which carves # harness scratch from the NET_CODE zero-fill tail $3xxx-$3FFF), so # the P-384 sibling inherits the correct arbiter window automatically — @@ -117,14 +117,14 @@ def main() -> int: print("=" * 60) print("Phase 5 LOCAL HTTPS (P-384)") print("=" * 60) - print(f"P-384 cert : {test_https_local.CERT_PATH}") - print(f"P-384 key : {test_https_local.KEY_PATH}") + print(f"P-384 cert : {rig_https_local.CERT_PATH}") + print(f"P-384 key : {rig_https_local.KEY_PATH}") print() print("NOTE: ECDSA-P384 verify dominates handshake wall-clock;") print(" expect 4-7 minutes per handshake at U64E 48 MHz turbo.") print() - return test_https_local.main() + return rig_https_local.main() if __name__ == "__main__": diff --git a/tools/uci/test_https_print_body.py b/tools/uci/rig_https_print_body.py similarity index 98% rename from tools/uci/test_https_print_body.py rename to tools/uci/rig_https_print_body.py index 5a55d0d..94603f2 100644 --- a/tools/uci/test_https_print_body.py +++ b/tools/uci/rig_https_print_body.py @@ -4,7 +4,7 @@ injected 6502 stub also calls `print_resp_body` after `http_get`, so the decrypted response body actually lands on the C64 screen. -Unlike the all-uppercase body used by test_https_local.py (which could +Unlike the all-uppercase body used by rig_https_local.py (which could not detect the ASCII-vs-PETSCII rendering bug of issue #28), this harness serves a mixed-case body: @@ -21,7 +21,7 @@ e,l,o,f,... which render as hearts/spades/etc.) do NOT appear at the body's row. -Reads all environment variables the same way as test_https_local.py +Reads all environment variables the same way as rig_https_local.py (U64_HOST, TURBO_MHZ, HTTPS_PORT, ACCEPT_TIMEOUT, SENTINEL_POLL_TIMEOUT, UCI_DEBUG_DIR, etc.). @@ -39,7 +39,7 @@ # Reuse the existing harness's helpers by importing the module as-is. # We override a narrow slice: the stub-building function and the body. -from tools.uci import test_https_local as base +from tools.uci import rig_https_local as base # ----------------------------------------------------------------------------- From 9e1c8c9fd8b32a0b5861f8db1dbf48af5f2ec8cd Mon Sep 17 00:00:00 2001 From: JC-000 <3798556+JC-000@users.noreply.github.com> Date: Sat, 15 Aug 2026 08:33:22 -0500 Subject: [PATCH 2/3] fix(build): order ip65_blob.o after the blob, and retract a wrong doc correction Self-correction on top of the previous commit, caught in review before merge. ## What I got wrong That commit claimed plain `make` already rebuilt the ip65 blob in the right order and that `make ip65-blob` "was never a required step". I had measured it: delete the blob, `make clean`, `make`, watch it rebuild byte-identically. The measurement was real. The conclusion was wrong, and shipping it would have re-blocked the next fresh clone -- exactly what issue #89 was filed to prevent. The confound is worth knowing, because it will fool anyone else measuring here. **ca65 does not resolve `.incbin` only relative to the including source file**, whatever the comment in ip65_blob.s said. It also tries the path relative to the current directory, and `../../../` from a repo root escapes three levels ABOVE the checkout. A git worktree lives at `/.claude/worktrees//` -- exactly three levels down. So my worktree, with its own blob deleted, silently assembled the PARENT checkout's blob and reported success. Reproduced deliberately in a scratch tree (`x/y/z/src/net/ip65/ip65_blob.s`, no `x/y/z/ip65-build/`, blob planted at the top): ca65 exit 0. The original CLAUDE.md text was right about the observable behaviour and right about the error message. It was wrong only about the mechanism -- it blamed a phony target with "no rule connecting the two". ## What is actually broken, and the fix There IS a rule, and $(IP65_BIN) has always been a prerequisite of the PRG. What was missing is an edge from the *object* that consumes the blob: make cannot see through `.incbin`, so from a clean build/ it was free to assemble ip65_blob.o before the blob rule ran -- and did. build/net/ip65/ip65_blob.o: $(IP65_BIN) Fixed at the source rather than documented around, so the workaround step goes away instead of needing a carefully worded warning. ## Evidence Genuine `git clone` from GitHub into a scratch dir, submodule init, `make ip65-libs`, then plain `make`: before: src/net/ip65/ip65_blob.s(22): Error: Cannot open include file '../../../ip65-build/ip65-c64.bin': No such file or directory make: *** [build/net/ip65/ip65_blob.o] Error 1 after: exit 0; blob 6,951 B cf1a5ff7...; PRG 47,105 B d522e684... Output-neutral -- the ip65 PRG hashes d522e684... with and without the edge, reproduced in both the fresh clone and this worktree. Full evidence bar, since this touches the Makefile: make clean && make -> exit 0, 47,105 B, d522e684... make clean && make BACKEND=uci -> exit 0, 62,977 B, 66e37037... python3 tools/test_ecdsa_kat_oracle.py -> 6/6 (3 valid, 3 invalid CAVP) python3 tools/test_x509.py -> 11/11 ## Docs CLAUDE.md and README now describe the real mechanism, credit that the original text was right about the failure and wrong only about the why, and both warn against measuring blob behaviour in a nested worktree. ip65_blob.s's own comment asserted the false source-relative rule right above the `.incbin`; corrected, since this commit proves it wrong and changes the adjacent build rule. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 78 +++++++++++++++++++++++++++------------- Makefile | 12 +++++++ README.md | 24 ++++++++++--- src/net/ip65/ip65_blob.s | 18 ++++++++-- 4 files changed, 101 insertions(+), 31 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index dbf9099..c5877e1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,37 +20,66 @@ backend needs none of this): git submodule update --init --recursive make ip65-libs # once per clone — `make` will NOT do this for you - make # builds the ip65 blob on demand, then the PRG + make # builds the ip65 blob, then the PRG + +Verified from a genuinely fresh `git clone` on 2026-08-15: those three +commands produce a 47,105 B `build/c64-https.prg` with no extra step. `ip65-build/ip65-c64.bin` is a **gitignored local build artifact** (`.gitignore` line `ip65-build/*.bin`; `git ls-files ip65-build/` returns only `ip65.cfg` and `ip65_stub.s`), *not* a committed file. -**A plain `make` does build it — the step you actually need is -`make ip65-libs`.** `$(IP65_BIN)` is a real prerequisite of the PRG -(`Makefile:263`, `PRG_DEPS := $(ALL_OBJS) $(IP65_BIN) ...`) with a real -rule (`Makefile:468`), and make orders it ahead of the `.incbin` in -`src/net/ip65/ip65_blob.s`. Measured 2026-08-15 by deleting the blob and -running plain `make`: it was rebuilt byte-identically (6,951 B, -`cf1a5ff7...`) and the link produced the usual 47,105 B PRG. - -What a fresh clone is missing is not the blob but the ip65 `.lib` -archives, which the submodule ships sources for rather than binaries. -The blob rule depends only on `ip65_stub.s` + `ip65.cfg`, so make runs -it and it dies at the link. Measured the same day, submodules -initialised but `make ip65-libs` never run: - - cd ip65-build && ld65 -C ip65.cfg -o ip65-c64.bin ... ip65_stub.o \ - ../ip65/ip65/ip65_tcp.lib ../ip65/drivers/ip65_c64.lib c64.lib +**Plain `make` builds the blob, and since the `ip65_blob.o` dependency +edge landed it does so in the right order.** The history is worth +keeping, because both of the previous descriptions in this file were +wrong and the second one was wrong in a way that would have re-broken +fresh clones. + +`$(IP65_BIN)` has always been a real prerequisite of the PRG +(`Makefile:263`) with a real rule (`Makefile:477`). But +`src/net/ip65/ip65_blob.s` pulls the image in with a ca65 `.incbin`, +which make's dependency graph cannot see, so nothing ordered the blob +rule *before* the object that consumes it. From a clean `build/` make +assembled `ip65_blob.o` first and died: + + src/net/ip65/ip65_blob.s(22): Error: Cannot open include file + '../../../ip65-build/ip65-c64.bin': No such file or directory + make: *** [build/net/ip65/ip65_blob.o] Error 1 + +That is issue #89's fresh-clone failure. It is now fixed at the source +rather than documented around — `Makefile:341` states the missing edge: + + build/net/ip65/ip65_blob.o: $(IP65_BIN) + +**Proven from a genuinely fresh clone**, 2026-08-15: `git clone` into a +scratch dir, `git submodule update --init --recursive`, `make ip65-libs`, +then plain `make` → exit 0, blob 6,951 B (`cf1a5ff7...`), PRG 47,105 B, +no intermediate step. The same clone before the fix failed with the +error above. The fix changes no output: the ip65 PRG hashes +`d522e684…` with and without it. + +What plain `make` still cannot do for you is build the ip65 `.lib` +archives — the submodule ships sources, not binaries. Skip +`make ip65-libs` and the blob rule runs and dies at its link: + ld65: Error: Input file '../ip65/ip65/ip65_tcp.lib' not found make: *** [ip65-build/ip65-c64.bin] Error 1 -So the recovery is `make ip65-libs` once, then plain `make`; -`make ip65-blob` is a convenience for forcing a rebuild, not a required -step. (An earlier revision of this section said `ip65-blob` was phony -with "no rule connecting the two" and that a fresh clone fails at the -`.incbin` with `Cannot open include file`. That mechanism is wrong, and -it contradicted the `make ip65-blob` bullet below, which was right.) +So: `make ip65-libs` once per clone, then plain `make`. `make ip65-blob` +exists only to force a rebuild. + +**Trap — do not measure this in a nested git worktree.** ca65 does not +resolve `.incbin` relative to the including source file, whatever the +comment in `ip65_blob.s` says; it also tries the path relative to the +*current directory*, and `../../../` from a repo root escapes three +levels up. A worktree under `/.claude/worktrees//` is +exactly three levels down, so with its own blob missing it silently +assembles the **parent checkout's** `ip65-build/ip65-c64.bin` and the +build appears to succeed. Reproduced deliberately in a scratch tree: +`x/y/z/src/net/ip65/ip65_blob.s` with no `x/y/z/ip65-build/` assembles +fine against a blob planted at the top. This is how an earlier revision +of this section came to claim, with a measurement behind it, that a +fresh clone needs no blob step. Verify blob behaviour in a real clone. `make clean` only removes `build/`, so once built the blob survives and is never rebuilt; that persistence, not a committed file, is why the rebuild @@ -125,7 +154,8 @@ found`. The fix is `make ip65-libs`. (An earlier revision suggested `touch ip65-build/ip65-c64.bin` to restore a "committed blob is reused" path; there is no committed blob — `git ls-files ip65-build/` returns only `ip65.cfg` and `ip65_stub.s` — so on a real fresh clone there is -nothing to touch.) +nothing to touch. See the blob discussion above for the ordering fix +that made the `.incbin` half of this go away.) Variables: - `BACKEND=ip65|uci` — select networking backend cfg diff --git a/Makefile b/Makefile index 1e72760..cbe4896 100644 --- a/Makefile +++ b/Makefile @@ -338,6 +338,18 @@ build/%.o: src/%.s @mkdir -p $(dir $@) $(CA65) $(CA65FLAGS) -o $@ $< +# src/net/ip65/ip65_blob.s pulls the prebuilt ip65 image in with a ca65 +# `.incbin`, which make's dependency graph cannot see. Without this edge +# make is free to assemble ip65_blob.s before the $(IP65_BIN) rule has +# run, and from a clean build/ it does exactly that — failing with +# "Cannot open include file '../../../ip65-build/ip65-c64.bin'" even +# though the very same `make` invocation builds the blob a few targets +# later. That is the fresh-clone failure in issue #89. Stating the edge +# explicitly forces the correct order; it changes no output bytes. +# (Only meaningful under BACKEND=ip65 — the UCI build never assembles +# this object, and never requests $(IP65_BIN).) +build/net/ip65/ip65_blob.o: $(IP65_BIN) + # Phase C.3: c64-nist-curves sibling archive (libs/nistcurves/ submodule). # Phase 1.5 split: produces TWO archives, one per overlay half. The # script writes both with a single invocation; the second target is a diff --git a/README.md b/README.md index 59ab79d..51b6940 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,11 @@ python3 -m pip install -e ../c64-test-harness Skipping the first gives `ld65: Error: Input file '../ip65/ip65/ip65_tcp.lib' not found`, from the blob link step that plain -`make` runs for you (reported as #89); skipping the second gives +`make` runs for you. (Issue #89 originally reported a different symptom from +the same missing step — `ip65_blob.s(22): Error: Cannot open include file` — +because make could assemble that object before building the blob; a +dependency edge in the Makefile now orders it correctly, so the ld65 message +above is what you get today.) Skipping the second gives `ModuleNotFoundError: No module named 'c64_test_harness'` (#90). Use `python3 -m pip` so the package lands in the interpreter that runs the scripts — a venv mismatch reproduces #90 exactly after an install that appeared to succeed. @@ -230,10 +234,13 @@ what makes the PRG deterministic.) ip65 is built from the submodule into a flat binary blob at $2000, using a custom ld65 linker config (`ip65-build/ip65.cfg`), and linked into the -ca65 build via `.incbin`. **A plain `make` does produce that blob** — -`$(IP65_BIN)` is a real prerequisite of the PRG with a real rule, ordered -ahead of the `.incbin`. Measured 2026-08-15: deleting the blob and running -plain `make` rebuilds it byte-identically and links the usual 47,105 B PRG. +ca65 build via `.incbin`. **A plain `make` produces that blob for you** — +`$(IP65_BIN)` is a real prerequisite of the PRG, and a dependency edge on +`build/net/ip65/ip65_blob.o` forces it to be built before the object that +`.incbin`s it. Verified from a genuinely fresh `git clone` on 2026-08-15: +submodule init, `make ip65-libs`, then plain `make` yields the 47,105 B PRG +with no intermediate step. + What `make` cannot do for you is build the ip65 `.lib` archives the blob links against, so run `make ip65-libs` once per clone, as in "Before you build or test" above; `make ip65-blob` exists only to force a rebuild. The @@ -241,6 +248,13 @@ build is deterministic: 6,951 B, sha256 `cf1a5ff7809af4e4655e385b378b936054f4104 `make clean` does not remove it, which is why the step is normally invisible. The UCI backend does not use the blob at all. +One measurement trap, since it has already produced a wrong conclusion once: +ca65 also resolves `.incbin` relative to the current directory, and +`../../../` from a repo root escapes three levels up — which is exactly the +depth of a git worktree under `.claude/worktrees//`. Such a worktree +with no blob of its own silently assembles the parent checkout's blob and +appears to build fine. Check blob behaviour in a real clone, not a worktree. + ## Project Status Current status (measured 2026-08-14 with cc65 from Homebrew; diff --git a/src/net/ip65/ip65_blob.s b/src/net/ip65/ip65_blob.s index 8b9beb8..dc7dedf 100644 --- a/src/net/ip65/ip65_blob.s +++ b/src/net/ip65/ip65_blob.s @@ -17,6 +17,20 @@ .segment "NET_CODE" -; ca65 resolves .incbin paths relative to the including source file, so -; from src/net/ip65/ip65_blob.s the blob is three levels up from repo root. +; Three levels up from this file's directory (src/net/ip65/) is the repo +; root, where ip65-build/ lives. +; +; CAUTION: ca65 does NOT resolve .incbin relative to the including source +; file only — it also tries the path relative to the current directory, +; which is the repo root when make runs. From there `../../../` escapes +; three levels ABOVE the checkout. A git worktree under +; /.claude/worktrees// sits at exactly that depth, so if its +; own ip65-build/ip65-c64.bin is missing this silently picks up the parent +; checkout's blob and the build looks fine. Measure blob behaviour in a +; real clone, never in a nested worktree. +; +; The build order is guaranteed by an explicit dependency edge in the +; Makefile (build/net/ip65/ip65_blob.o: $(IP65_BIN)) — make cannot see +; through .incbin, so without it this object could be assembled before +; the blob exists (issue #89). .incbin "../../../ip65-build/ip65-c64.bin" From 08686ce4b987fbf217039d0fe3156c30fbcd3d39 Mon Sep 17 00:00:00 2001 From: JC-000 <3798556+JC-000@users.noreply.github.com> Date: Sat, 15 Aug 2026 08:46:57 -0500 Subject: [PATCH 3/3] docs: record the one re-measured wall-clock point, with its provenance and limits Closes the gap the previous commits left open. I had declined to write in a v0.9.1 figure because I could not corroborate one from the repo. It exists -- it lives in the release-prep session's logs rather than in the tree, which is why no amount of grepping here would have found it. The coordinator supplied all three points with per-row provenance. 48 MHz UCI, REU profile, U64E at 10.43.23.81, handshake + GET against the local listener, measured C64-side from run_prg, all PASS with server-side evidence: libs/nistcurves c64-side vs v0.6.0 v0.6.0 80.8 s -- v0.9.1 82.1 s +1.6% v0.10.1 82.4 s +2.0% Recorded with the two caveats that matter more than the numbers, because three fresh datapoints in a file full of v0.6.0 tables is exactly the kind of thing that gets read as a refresh: - n=1 per row. The +0.4% v0.9.1 -> v0.10.1 step is indistinguishable from run-to-run variation and is explicitly not presented as a measured effect. - This is ONE point of the sweep. Every other clock (1/8/16/20 MHz), every onchip and comb row, and every ip65 and C64U figure is still v0.6.0-era. The section header, the U64E sweep table and the packaging validation record all now say so in place. The +1.6% at v0.9.1 is consistent in sign and size with the FIPS 186-5 public-key validation gate v0.7.0 added -- an on-curve check on a point taken from an attacker-supplied certificate, so it is a regression worth paying rather than one to chase. Provenance is stated inline (bench/summary.txt, rel_e2e_uci48.log, e2e_wave.log) together with the fact that those logs are not in this repo, so a future reader knows the rows are not reproducible from a clean checkout and should re-run bench_ecdsa_u64e.py to confirm them. Independent corroboration of the v0.9.1 -> v0.10.1 "no timing change expected" claim: rebasing onto #113 moved the pins to nistcurves v0.10.1 / x25519 v0.11.0, and both PRGs hash exactly as they did at v0.9.1 -- ip65 d522e684... (47,105 B), UCI 66e37037... (62,977 B). Rebased onto merged master (a6cf205). One conflict, in README's USE_X25519_SIBLING known-issue bullet: resolved in favour of #113's version, which is measured at the new pins and belongs to that lane. Pin-vs-commit table extended with the a6cf205 row. Evidence bar re-run at the new pins, not carried over: make clean && make -> exit 0, 47,105 B, d522e684... make clean && make BACKEND=uci -> exit 0, 62,977 B, 66e37037... python3 tools/test_ecdsa_kat_oracle.py -> 6/6 (3 valid, 3 invalid CAVP) python3 tools/test_x509.py -> 11/11 Boundary guard and pytest count unchanged by the rebase: 5 checks pass, bare pytest 31 passed exit 0. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 75 +++++++++++++++++++++++++++++++++++++++++++------------ README.md | 2 +- 2 files changed, 60 insertions(+), 17 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c5877e1..3c00d06 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1133,17 +1133,53 @@ end-to-end (verified 2026-05-20 against the local listener; the prior v0.2.0 measurement was 86.7 s, and the pre-Phase-C.4 in-tree path was ~110 s). -**Every wall-clock figure below was measured at the v0.6.0 pin; the -pin is now v0.9.1 and none of them has been re-measured.** Treat them -as the v0.6.0 baseline, not as HEAD. The expected drift is small but -its sign is known: v0.7.0's public-key validation gate adds 2 `fp_cmp` -+ 3 mod-p muls + 4 mod-p add/subs to every verify, which is noise -against a multi-second scalar multiplication, and nothing else in -v0.7.0-v0.9.1 touches a hot path (the rest is manifest equates, dead -data removal and export hygiene). So expect a small *regression*, not -a speedup, and do not quote these rows as v0.9.1 numbers until someone -re-runs `bench_ecdsa_u64e.py` on hardware. The v0.3.0 row is kept only -as the REU-profile baseline. +**Almost every wall-clock figure below was measured at the v0.6.0 pin.** +Exactly one point of the sweep — 48 MHz UCI, REU profile — has been +re-measured past it; see "The one re-measured point" immediately below. +Treat everything else as the v0.6.0 baseline, not as HEAD. The expected +drift is small but its sign is known: v0.7.0's public-key validation +gate adds 2 `fp_cmp` + 3 mod-p muls + 4 mod-p add/subs to every verify, +which is noise against a multi-second scalar multiplication, and +nothing else through v0.10.1 touches a hot path (the rest is manifest +equates, dead data removal and export hygiene). So expect a small +*regression*, not a speedup, and do not quote the unrefreshed rows as +current-pin numbers until someone re-runs `bench_ecdsa_u64e.py` on +hardware. The v0.3.0 row is kept only as the REU-profile baseline. + +#### The one re-measured point (48 MHz UCI, REU profile) + +Three runs on the same rig and script, U64E at 10.43.23.81, handshake + +GET against the local listener, measured C64-side from `run_prg`. All +three PASS with server-side evidence (the listener decrypted the full +`GET / HTTP/1.1`, no TLS error): + + libs/nistcurves c64-side vs v0.6.0 + v0.6.0 80.8 s -- + v0.9.1 82.1 s +1.6% + v0.10.1 82.4 s +2.0% + + - **n = 1 per row.** These are single runs, not medians. The + v0.9.1 -> v0.10.1 step (+0.4%) is indistinguishable from + run-to-run variation and must not be presented as a measured + effect. + - **This is one point of the sweep, not a refresh of it.** Every + other clock (1 / 8 / 16 / 20 MHz), every onchip and comb row, and + every ip65 and C64U figure in this file is still v0.6.0-era. + - The v0.6.0 -> v0.9.1 step (+1.6%) is consistent in sign and rough + size with the FIPS 186-5 public-key validation gate v0.7.0 added. + That is the cost of an on-curve check on a point taken straight + from an attacker-supplied certificate, so it is a regression worth + paying rather than one to chase. + - v0.9.1 -> v0.10.1 is an export-surface change with both REU PRGs + byte-identical across it, so no timing change was expected there + and none is demonstrated. + - Provenance: measured in the release-prep session that produced + this entry, logs `bench/summary.txt` (v0.6.0, the 48 MHz row of + the clock sweep), `rel_e2e_uci48.log` (v0.9.1) and `e2e_wave.log` + (v0.10.1, against merged master). Those logs live in that + session, not in this repo, so the rows are not reproducible from + a clean checkout — re-run `bench_ecdsa_u64e.py` if you need to + confirm them. **Read the pin, not the commit.** Every table below labels its rows with the `libs/nistcurves` pin they were measured at, because that is @@ -1157,10 +1193,12 @@ libs/nistcurves` plus `git -C libs/nistcurves tag --points-at`: c64-https commit libs/nistcurves libs/x25519 2ceb5b1 v0.6.0 (00d2626) v0.6.0 (95fdd70) f0127a0 v0.6.0 (00d2626) v0.6.0 (95fdd70) - HEAD (3a43f61) v0.9.1 (f9701e1) v0.10.0 (68ae0ef) + 3a43f61 v0.9.1 (f9701e1) v0.10.0 (68ae0ef) + a6cf205 (#113) v0.10.1 (1edd634) v0.11.0 (e9af04e) -So every figure in this section is a v0.6.0 figure, including the ones -labelled only by commit. None of them is a HEAD figure. +So every figure in this section is a v0.6.0 figure — including the ones +labelled only by commit — except the three-row 48 MHz UCI REU table +above, which is the sole point carried forward to v0.9.1 and v0.10.1. On the **C64 Ultimate** (10.53.21.158, see "C64 Ultimate notes"), measured 2026-07-19 with the INNER=217 fence and boot-at-speed flow: @@ -1214,6 +1252,10 @@ C64U, fits T(f)=D+C/f, residuals <=4.1%): U64E v0.6.0 REU 1157.7 s 196.5 s 124.0 s 108.9 s 80.8 s n/a (no enum) U64E v0.6.0 onchip 2120.7 s 264.5 s 131.8 s 103.9 s 45.5 s n/a (no enum) + The 48 MHz REU cell (80.8 s) is the only one of these carried past + v0.6.0 — 82.1 s at v0.9.1, 82.4 s at v0.10.1, n=1 each. See "The one + re-measured point" above. Every other cell is v0.6.0-era. + The U64E rows are a 2026-08-13 sweep at master 2ceb5b1, whose `libs/nistcurves` pin is v0.6.0 (see the pin table above). One clean build per profile, reused across that profile's clocks, so clock is @@ -1771,8 +1813,9 @@ The comb profile stays deliberately excluded (REU bank 2 residency + ~40 min boot precompute at 1 MHz make it wrong for a general release). Validation record (2026-07-27, then-HEAD cb6eab4, `libs/nistcurves` -pin v0.6.0 — the wall-clock rows below are v0.6.0 figures like every -other table in this file, and have not been re-measured at v0.9.1): +pin v0.6.0 — the wall-clock rows below are v0.6.0 figures and have not +been re-measured at any later pin; the only point that has is the +48 MHz UCI REU one, in "The one re-measured point" above): - onchip PRG passes the 3-vector ECDSA KAT in VICE **without** REU (and with, as control) — the no-REU claim is verified, and boot.s's unconditional reu_mul_init is harmless with no REU diff --git a/README.md b/README.md index 51b6940..5dbe744 100644 --- a/README.md +++ b/README.md @@ -291,7 +291,7 @@ Progress: ### Known Issues -- **The handshake is slow, and the ECDSA P-256 verify dominates it.** Every figure here is quoted from the measurement record in `CLAUDE.md` and was taken at the **`libs/nistcurves` v0.6.0 pin**; HEAD is v0.9.1 and nobody has re-run the sweep, so treat them as a baseline rather than as HEAD. End-to-end handshake + GET against the local listener, U64E, master 2ceb5b1: **80.8 s** (REU profile, 48 MHz), **45.5 s** (onchip profile, 48 MHz), **1,157.7 s** (REU, stock 1 MHz). On the REU-less stock-C64 path (ip65 + onchip, no REU, honest 1 MHz in VICE) the whole run measured **2,159.7 s = 36.0 min**, of which the verify stretch alone was 1,416.7 s. That is fine for the local listener, which holds the connection open; it exceeds a typical 10-30 s real-world server handshake window. +- **The handshake is slow, and the ECDSA P-256 verify dominates it.** Every figure here is quoted from the measurement record in `CLAUDE.md`. Except where noted they were taken at the **`libs/nistcurves` v0.6.0 pin**, and the pin is now v0.10.1, so treat them as a baseline rather than as current. End-to-end handshake + GET against the local listener, U64E, master 2ceb5b1: **80.8 s** (REU profile, 48 MHz), **45.5 s** (onchip profile, 48 MHz), **1,157.7 s** (REU, stock 1 MHz). One point of that sweep has been carried forward: 48 MHz REU measures **82.1 s** at v0.9.1 and **82.4 s** at v0.10.1 (n=1 each, so the 0.4% step between them is noise; the 1.6% from v0.6.0 is the FIPS 186-5 public-key validation gate v0.7.0 added). No other clock or profile has been re-measured. On the REU-less stock-C64 path (ip65 + onchip, no REU, honest 1 MHz in VICE) the whole run measured **2,159.7 s = 36.0 min**, of which the verify stretch alone was 1,416.7 s. That is fine for the local listener, which holds the connection open; it exceeds a typical 10-30 s real-world server handshake window. - **P-384** ECDSA is stubbed at the TLS layer. The dispatcher advertises `ecdsa_secp384r1_sha384` (0x0503) and routes to `src/crypto/ecdsa_verify_384.s`, but no P-384 build target completes. Measured 2026-08-14: `make p384-overlay` from a clean tree stops at `No rule to make target 'build/labels.txt'`, and after a main build has produced that file it stops at `Segment 'LIB_NISTCURVES_SHA384_TABLES' overflows memory area 'OVERLAY_REGION' by 1536 bytes`. Cert chains requiring P-384 will not verify. - **`USE_X25519_SIBLING=1` now links under UCI, and still does not under ip65.** The duplicate-symbol failure this entry used to record — `ld65: Error: Duplicate external identifier: 'reu_mul_tables_init'`, on **both** backends — was closed by the `libs/nistcurves` v0.10.1 / `libs/x25519` v0.11.0 bump plus one line of archive surgery: `tools/integration/build_nistcurves_p256.sh` now also drops `reu_mul_init.o`, the SPEC §8.2 `reu_mul` provider that `src/boot.s` supplies itself. Measured at those pins: `make clean && make BACKEND=uci USE_X25519_SIBLING=1` produces a 62,977 B PRG, and ip65 stops instead at `Segment 'X25519_RODATA' overflows memory area 'CRYPTO_OVERLAY' by 3584 bytes` — a placement problem (ip65's overlay slot is 4,212 B against UCI's 7,680 B), not a symbol collision. The flag remains **off by default** and no shipped artifact contains the sibling; the in-tree X25519 in `src/crypto/{x25519,fe25519}.s` is what every release PRG is built from. Flipping the default is a separate decision that wants a hardware handshake behind it. - **Live internet HTTP GET (UCI backend)** has not been re-verified since the FPGA-fence rework; only the local multi-segment listener is exercised regularly.