TLS 1.3 / HTTPS client for the Commodore 64, assembled with ca65/ld65 and
delivered as a single PRG. Networking via ip65/RR-Net (prebuilt blob at
$2000) or the Ultimate 64 UCI bridge. All crypto is hand-written 6502 (plus
the sibling libs/nistcurves P-256) tuned to fit under the BASIC ROM shadow
at $A000.
This file is the terse "how does this hang together" reference. Keep it
terse. The full measurement record — every sweep, failed hypothesis and
post-mortem — lives verbatim in docs/engineering-notes.md under the same
section names used here; cite that file rather than re-growing this one.
Rule of thumb: a fact that changes what you type belongs here; the story of
how it was learned belongs in the notes.
Dependencies: ca65/ld65 (cc65), GNU make, VICE x64sc only for
make run / tests. The c64-test-harness Python package is a separate
repo (pip install -e ../c64-test-harness), not vendored.
Fresh clone (ip65 backend; UCI needs none of the ip65 steps):
git submodule update --init --recursive
make ip65-libs # once per clone — plain `make` will NOT do this
make # builds the ip65 blob on demand, then the PRG
ip65-build/ip65-c64.binis a gitignored artifact (6,951 B, sha256cf1a5ff7…, deterministic). Plainmakebuilds it, but it cannot build the ip65.libarchives — skipmake ip65-libsand the link dies withld65: Input file '../ip65/ip65/ip65_tcp.lib' not found.- A nested git worktree needs
git submodule update --init --recursivebefore it can link ip65 — a fresh worktree has no submodule working trees, so the link dies by name. It fails LOUDLY; that is the whole rule now. The old reason for this bullet is retired:.incbinno longer carries a CWD-relative path.src/net/ip65/ip65_blob.sis a bare.incbin "ip65-c64.bin"resolved through--bin-include-dir $(abspath $(IP65_BUILD))in the Makefile (#116), so a worktree three levels down assembles its own blob, not the parent checkout's. Re-proven 2026-08-31 in that exact geometry: flipping one byte of the worktree's blob moved the PRG hash, and restoring it moved it back. - To test blob provenance, flip a byte — never move the blob aside.
makeregenerates it deterministically, reproducing the baseline hash, which reads exactly like a live trap. That false positive has been hit. libs/nistcurvesmust be >= v0.14.0 (CONTRACT_ZP_DEFINES, knob-staleness guard, the §8.2 REU DMA settle, the #148 comb fail-closed fix, and the §6.1zp_aliases*.osplit the wrapper's bare-export guard is written against — full list in theCONTRACT_DEFINESblock oftools/integration/build_nistcurves_p256.sh). A stale checkout is caught in ~0.05 s by a source probe in that same script (#124);tools/check_upstream_pins.py --worktreereports checkout-vs-gitlink.
A flag change no longer needs make clean (#159). make tracks source
mtimes, not the command line, so this used to be a discipline you only had
to forget once. It is now an enforced invariant: build/flags.stamp holds
the fully expanded CA65FLAGS/LD65FLAGS (LD65FLAGS carries $(CFG), so
BACKEND= and the profile cfg variants ride along), is content-compared
at Makefile parse time, and on any change deletes every .o and the
PRG. Absence, not an mtime — which is what macOS GNU Make 3.81's 1-second
resolution cannot defeat. Both silent failure modes the old rule guarded
against are closed, and tools/test_build_flags_stamp.py pins each against
a make clean oracle: the mixed link (HTTPS_SNI= invalidating boot.o
but not http.o, which cost a false negative on #141) and no link at all
(a same-second backend flip leaving the other backend's PRG in place at
exit 0). Because the stamp holds the expanded command lines rather than a
list of knob names, a new flag is covered the day it is added. The suite
also pins the inverse — an unchanged flag set must still rebuild nothing.
flags.stamp and https_host.inc (below) delete .map/labels.txt/.dbg with the PRG;
a goal set made only of clean/ip65-libs/ip65-blob skips both (#220).
Parse time includes a dry run, so make -n used to delete the tree while
answering "what would this build?". -n/-q/-t are now exempt
(#174): they still run the compare and $(warning) what a real build
would delete, but they write nothing — not the stamp, not the objects.
-q and the make -npq completion idiom matter as much as -n. The
guard has to find the single-letter options in MAKEFLAGS, and
$(firstword ...) is not where they are: a long option arrives as
its own --word and pushes the letters elsewhere, so
make -n --no-print-directory reads [ --no-print-directory -n] — last,
and dash-prefixed. Worse, --no-print-directory itself contains an n
and a t, so searching the first word calls a real build a dry run and
skips an invalidation it needed. The guard therefore takes every word
that is neither ---prefixed nor a VAR=value assignment and strips one
leading dash; the option matrix behind that is in the Makefile comment
and pinned by two tests. Real builds are byte-for-byte unchanged.
The target strings HTTPS_HOST/HTTPS_PATH/HTTPS_SNI keep their own
narrower stamp, the generated build/https_host.inc (#128): it invalidates
boot.o + http.o + cert_pin.o only, so retargeting stays cheap. Only the strings are
outside flags.stamp; a non-empty HTTPS_SNI still moves CA65FLAGS (it
adds -D HTTPS_SNI_OVERRIDE=1), so the override's presence is stamped even
though the name in it is not.
Still true, and still how you prove a build: .o hashes are never evidence
— ca65 stamps build time into every object; ld65 does not propagate it, so
the PRG is deterministic and its sha256 is the check. One consequence
of the stamp: CA65FLAGS carries two $(abspath ...) .incbin roots, so
moving a checkout forces exactly one rebuild.
Also: $(PRG)'s recipe rm -fs the target first, because ld65 writes
nothing on a memory-area overflow and every rig loads the PRG by path — an
absent PRG is the honest state after a failed link. Keep prose out of recipe
bodies; make echoes recipe comments and scripts grep build output for words
like "overflows".
Targets: make (PRG + build/labels.txt + build/c64-https.dbg),
make clean (removes build/ only; the blob survives), make run,
make ip65-libs, make ip65-blob (force rebuild), make package,
make package-verify.
Variables:
-
BACKEND=ip65|uci— selectscfg/c64-https-$(BACKEND).cfg,src/net/$(BACKEND)/, and the-I src/net/$(BACKEND)include path that resolvesnet_tuning.inc. Default ip65. -
USE_NISTCURVES_ONCHIP=1— libs/nistcurves FP_ONCHIP_MUL profile: no REU row-fetch DMA, wins above ~18-22 MHz. Keeps the basecfg/c64-https-$(BACKEND).cfg— it only adds a-Dand swaps the archive.$(error)-guarded against the overlay-embed flags. -
USE_NISTCURVES_ONCHIP_COMB=1— implies ONCHIP; Lim-Lee comb + boot precompute into REU bank 2 (needs an REU; ~45 s boot at 48 MHz, ~36 min at 1 MHz — rigs needC64_INIT_WAIT). Fastest above ~5-7 MHz. This is the only flag that retargets$(CFG), tocfg/c64-https-$(BACKEND)-onchip.cfg— which exists for uci only, so there is no ip65 comb build. Confirm from theld65 -Cline, not from the profile name. -
USE_X25519_SIBLING— retired,$(error)-guarded (#245): every build links thelibs/x25519sibling; any value is refused. -
EMBED_P256_OVERLAY=1— retired,$(error)-guarded (#118): the P-256 verify image is 644 B larger than the 7,680 B slot at every pin since v0.7.0, andCRYPTO_OVERLAYnow holds ~5 KB of resident deframer/cert_buf/name-check code that the swap would DMA over. The plumbing (p256_overlay_blobs.s,crypto_swap_to_p256_verify,cfg/p256-overlay-verify.cfg,make p256-overlay) stays in tree. -
USE_OVERLAY_P384_EMBED=1— broken: from a clean tree the overlay.binrules order-only depend onbuild/labels.txt, which only the (gated-off) main link produces; past that, the SHA-384 tables overflow the slot by 1,536 B. -
ENABLE_P384_VERIFY=1— re-arms the P-384 verify arm. Unsafe on its own (overwrites live code — see Crypto ABI); exists sotools/test_p384_overlay_hazard.pycan be mutation-tested. It is also the only thing that linkssrc/crypto/ecdsa_verify_384.sandsrc/crypto/p384_force_link.sat all, so it is the one command that keepsecdsa_verify_384.shonest:make BACKEND=uci USE_NISTCURVES_ONCHIP=1 ENABLE_P384_VERIFY=1Assembling that file is worthless as coverage:
.importof a symbol nothing defines is a LINK-time error in ca65, not an assemble-time one, soca65 src/crypto/ecdsa_verify_384.ssucceeds with every one of its imports unresolved. Only the armed link finds a P-384 symbol that has gone away. The flag applies on all five profiles since the hoist out of theUSE_NISTCURVES_ONCHIPblock — it used to be silently dropped by the two REU-profile builds. -
HTTPS_PIN_SPKI_SHA256=<64 hex>(+HTTPS_PIN_WARN=1= report, don't abort) — leaf SPKI pin, #155.tools/spki_pin.py <host>computes it; the bytes ridebuild/https_host.inc. Both backends (ip65: in CRYPTO_RESIDENT, 0 B LOADER); unset = byte-identical PRG. Test:tools/test_cert_pin.py(builds its own images). The pin hashes the window the key SCANNER read, not the DER-parsed SPKI — see the header ofsrc/cert_pin.sbefore changing it. -
HTTPS_HOST/HTTPS_PATH/HTTPS_SNI/HTTPS_PORT/HTTPS_BODY_TO_REU=1— build-time target. Hosts >63 chars are a build error. The strings live in their ownHTTPS_TARGET_RODATAsegment (#126):CRYPTO_OVERLAYunder UCI,NET_CODEtail (a joint budget with everyNET_CODEtenant) under ip65. The 63/100/63-char asserts inboot.sare per-string, not a joint fit: host + path at maximum does not link on ip65 or uci-comb. Do not read ld65'sNET_BSS … EMPTYas headroom; that span is the ip65 blob's own BSS. -
TLS_STREAM_DEFRAME— streaming handshake deframer; ON under uci, OFF (compiled out) under ip65. -
VIC_BLANK=0— degradevic_blank/vic_unblanktoRTS(A/B control). -
X509_VERIFY_NAME— set automatically under uci only (see Memory layout). -
CA65,LD65,VICE— toolchain overrides.
Test harness expectations:
- Most
tools/test_*.pyrunmake clean && makethemselves;C64_SKIP_BUILD=1reuses the built PRG (list them withgrep -ln 'environ.*C64_SKIP_BUILD' tools/test_*.py tests/rig_*.py). - Launch VICE only through
c64-test-harness; never runx64scdirectly. - Any test touching the sibling P-256 path must launch VICE with
-reu -reusize 512— see "VICE harness gotcha" under Known issues.
src/crypto_abi.inc fronts the public crypto API; TLS/HTTP consume crypto
only through it, so swapping an implementation is a link-line change.
Conventions: AX = pointer lo/hi, caller-provided buffers, keys/IVs via
fixed buffers in crypto BSS.
X25519 / fe25519 sibling libs/x25519@v0.16.0, every build (#245):
x25519_base/scalarmult/clamp, fe25519_*.
Built X25519_ONCHIP_MUL=1 on all five profiles
(no REU surface) by
tools/integration/build_x25519.sh. Its lookup
tables are runtime-generated BSS
(src/crypto/x25519_tables.s); tls_ecdh.s only
calls the x25519_*_fresh entries, which run
x25519_tables_init first. A harness calling the
sibling directly must call it too.
ChaCha20-Poly1305 in-tree, permanent: chacha20_encrypt,
poly1305_init/update/final, aead_encrypt/decrypt
SHA-256 in-tree: sha256_init/update/final
ECDSA P-256 sibling libs/nistcurves@v0.14.0:
ecdsa_verify_256, ec_scalar_mul_var (plus the
ec_base_x, ec_gx256 data — that is the whole
surface we import). Dispatcher:
src/crypto/ecdsa_verify.s. Archive built by
tools/integration/build_nistcurves_p256.sh.
- The sibling validates the public key at entry from v0.7.0 (FIPS 186-5
range + on-curve check); c64-https does none of its own, and
Qcomes straight from the attacker-supplied certificate, soecdsa_verify.scarries.assert LIB_NISTCURVES_VERSION_MINOR >= 9. LIB_NISTCURVES_ABI_VERSIONis 4 from the v0.14.0 pin andsrc/lib_contract_asserts.spins it. Two hops from the 2 that stood at v0.11.2 — v0.13.0 gaveec_scalar_mula carry return, v0.14.0 fixedreu_fetch_mul_rowto take the row index in A as §8.2 always said — and NEITHER reaches our call surface: we neverjsr ec_scalar_mulinto the library (ecdsa_verify.sdefines its own shim) and we never call the fetch. The counter moving is the gate telling you to check, not a break.- v0.13.0 (upstream #148, reported from here) made the Lim-Lee comb
fail closed on an unusable anchor-table slot; before it,
ecdsa_verifyaccepted the collapsed u1·G = O case. That is a real forgery andc64-https-uci-comb.prgshipped on that path. Keep the scope exact: it catches a collapsed anchor table, not a corrupt one. Anyone who can write REU bank 2 plants a valid point, reaches a forged R with Z != 0 throughout, and no post-condition on the result sees it. - Zero page: ECDSA bignum
$22-$3C; nistcurves slotsfp_mul_i=$39,fp_mul_j=$3A,nistcurves_zp_ptr2=$3D(verify inbuild/labels.txt), defined locally (src/constants.inc,src/crypto/shared/zp_canon.inc); no.importzpanywhere. The X25519 sibling uses its ownzp_config.sdefaults —$14-$16,$1C,$1E-$2F,fe_wideat ZP_WIDE$40-$7F— all time-shared with per-call slots (table inbuild_x25519.sh); they are not inlabels.txt. - c64-lib-contract is a prose dependency, not a submodule — nothing
in
makereads it, so a contract release can never break a build. Always cite a tag (newest: v1.1.0). v1.0.0 cut the SPEC by 7/8 and retired §9, §12, §13, §14, §15, §6.3, §6.6, §6.7: those resolve only at v0.17.1, in a c64-lib-contract checkout (it is not a submodule here); survivors keep their numbers against the current SPEC. Note §6.1/§6.2/§6.4/§6.5 survive while §6.3/§6.6/§6.7 do not — §6 is split. §8.0 APP_OWNED shape is requested viaCONTRACT_DEFINES— no archive member is edited (§6.1) — and the manifest attests it, so the asserts insrc/lib_contract_asserts.sare live. §13 (network ABI) is adopted (#70, merged #142); see Networking backend ABI for what its retirement did and did not change.
P-384 is PARKED, and deliberately gated. The wire path used to be fully
live while the overlay image was a stub: a P-384 certificate made
crypto_swap_to_p384_sha384 DMA $1E00 bytes of nothing over resident
code and hung the machine (measured on v0.4.0 images). Closed by two
independent changes: the ClientHello no longer advertises 0x0503, and
ecdsa_verify's P-384 arm is a sec reject unless ENABLE_P384_VERIFY=1.
The curve comes from the certificate, so the gate is what makes the
advertisement change safe. No P-384 build target has ever completed (see
Known issues); fix the build before re-wiring.
The lane is now gated at the link line as well as in the source:
ecdsa_verify_384.o used to be in every shipped image and reachable from
none of them (ca65 emits no import record for an .import nothing
references, so the wildcard pulled it in), costing 299 B of
CRYPTO_AUX_CODE + 33 B of CRYPTO_RODATA everywhere. Both P-384-only
objects now link only under ENABLE_P384_VERIFY=1. Measured consequence:
exactly two symbols lose their only link-time consumer —
crypto_swap_to_p384_sha384 and crypto_swap_to_p384_curve. They are
still exported unconditionally by src/crypto/shared/crypto_swap.s and
still occupy bytes; nothing imports them in any of the five unarmed
builds. That is the whole surface change, and it is measured from the
Imports list of all five build/c64-https.map files, not estimated.
Sibling-library memory requirements: code + rodata in CRYPTO_HOT /
CRYPTO_RESIDENT ($6000-$9FFF), never crossing $A000 (boot zeroes
$A000-$BFFF); big BSS in CRYPTO_COLD_SHADOW; segments named per contract
§4 (LIB_NISTCURVES_P256_CODE etc.). REU Profile B baseline; comb claims
bank 2; banks 6-7 reserved for the P-384 overlay experiment.
Our own REU executes: never sta reu_command — jsr reu_execute
(src/reu_exec.s, SPEC §8.2 bit-6 confirm + settle; X/Y/C preserved;
confirm bound 8 reads on the no-REU onchip products, 65,536 on comb and
the REU default; reu_dma_timeout is its sticky expiry flag).
tools/test_reu_execute.py fails on a bare store.
Switching backend = a different cfg + different src/net/<backend>/*.o.
src/net_abi.inc is the build-enforced boundary (c64-lib-contract
SPEC §13, issue #70). §13 was retired at contract v1.0.0; every §13.x
number in this section resolves at tag v0.17.1, nowhere else. No §13
assert has a contract-derived counterparty, so no contract release can
break a build. The error codes below used to be asserted NOWHERE; they
are asserted now (#184) — see the allocation note. src/net_abi.inc is
the normative source now. boot.s, http.s, tls_record_io.s and tls13.s
.include it and import no net_* symbol directly, so a backend that
drops a symbol fails the link by name on both backends. Surface:
core net_init, net_dhcp_acquire, net_poll, net_local_ip (4 B), net_last_error (1 B) TCP net_tcp_connect (A/X = port), net_tcp_send (+ net_send_len), net_tcp_close, net_tcp_state (NET_TCP_* in src/net/net_states.inc), consumer-owned rx ring tcp_recv_{buf,head,tail,overflow} (§13.3) DNS net_dns_resolve, net_resolved_ip ($FF x4 = resolved by the device) ours net_recv_byte (the drain entry, #72), net_banner_str
-
src/net/<backend>/net_manifest.sexportsNET_BACKEND_FAMILIES(CORE|TCP|DNS on both; UCI's DNS is by deferral); ip65's also carries the §13.7 blob footprint, link-asserted against the.incbin'd size.src/net_abi_asserts.s(§13.8) asserts the families and the ring mask. TheNET_FAMILY_*bits aresrc/net/net_families.inc, copied verbatim from the contract and never exported. -
Error codes: ip65
$40-$7F(ip65_errors.inc,NET_ERR_IP65_*), UCI$80-$BF(uci_errors.inc,UCI_ERR_*). The UCI range is ONE namespace shared with c64-wireguard —$8A UCI_ERR_LONG_READis theirs and datagram-only.$FFFFis the SOCKET_READ no-data sentinel on both transports (idle polls answer it) and must be excluded before any over-claim test — both fleet adapters misfiled it independently (#140).net_pollcaps the copy at the request and never emits$8A; a header above the request that is NOT$FFFFleaves$8B UCI_ERR_BAD_READ_HDR(C=0, stream continues) — the stream-family counterpart of$8A. SPEC §13.2's allocation table moved, it did not vanish: allocate a new code inc64-wireguard/src/net_abi.inc, which declares itself canonical for both ranges, then here. It owns$8C-$8Fand$46-$49, which our two error headers used to present as free (#184). That is mechanically enforced now, in two halves (#184).src/net_err_registry_asserts.sis$(wildcard src/*.s), so it assembles into every build on both backends and emits no bytes: it holds the peer's codes asNET_ERR_PEER_*equates and.asserts (scopeerror, so ca65 settles it before ld65) that no code of ours lands on one, that each is in family range, and that no published value has been reassigned. Two of our own names on one byte also fails the build:NET_ERR_CLAIM_VALUEdefines.ident(.sprintf("NET_ERR_TAKEN_%02X", val)), so a second claim on a byte is a ca65 redefinition error naming the BYTE (Symbol 'NET_ERR_TAKEN_88' is already defined) — O(n), no list to maintain, still zero bytes. It covers only codes passed through the macros. Its blind spot — a code never registered — is closed bytools/test_net_err_registry.py, which parses the two headers instead and diffs our snapshot's values and names against the live peer file (C64_WIREGUARD_ROOT, else../c64-wireguard, else~/Documents/c64-wireguard). A missing checkout is an involuntary skip, so those four checks FAIL rather than pass quietly (tools/_skip_policy.py, #178) — a fresh clone with no peer checkout is RED by design;C64_NO_PEER_REGISTRY=1is the loud opt-out (its own variable, deliberately notC64_ALLOW_SKIP, which also gatestest_build_flags_stamp.py's toolchain check). Scope the guarantee in both directions. Under-coverage is three things, and a code reaches a blind spot only by being invisible to both halves: (1) an expression-valued code that is ALSO never registered — the suite reads onlyNAME = $hh,NAME = dddand.define NAME $hh, while ca65 evaluates an expression fine once registered, so neither alone is the gap; (2) a code named without the_ERR_infix that is ALSO never registered — a gap the over-coverage gate below CREATED rather than inherited, and the accepted cost of it; (3) a bare inlinelda #$8Cwith no equate at all, which no text guard sees (latent — no such site exists). Write literals, name them*_ERR_*, register them. Over-coverage: the suite reads whole headers that also hold ordinary constants, so it gates on that same_ERR_infix; without it a futureUCI_HOST_BUF_MAX = 64would be reported as an ip65-family error code needing allocation in c64-wireguard's registry. TheNET_FAMILY_*bits insrc/net/net_families.incare the same cross-repo copy problem and are still unguarded. -
Gone, per §13.1:
net_tcp_set_recv_cb(stub),net_recv_ready,net_dhcp(alias), andnet_print_ip— IP printing is consumer UI and is nowprint_local_ipinboot.s, one copy for both backends. -
Byte accounting on ip65 (the tight one):
print_local_iprides LOADER_OVERFLOW, i.e. ip65NET_CODE, so it spends theHTTPS_HOST/HTTPS_PATHbudget. Figures:tools/measure_margins.py. -
src/net/ip65/— ip65/RR-Net (cs8900a). Blob loaded at $2000 via.incbin;net.sis the adapter;ip65_symbols.incis the single source of jump-table equates. -
src/net/uci/— Ultimate 64 Elite / C64 Ultimate UCI bridge.
Registers $DF1B-$DF1F, ID byte $C9 (src/net/uci/uci_regs.inc).
uci_cmd.s holds the shared primitives (no zero page — absolute + SMC).
Error codes in uci_errors.inc; the load-bearing ones are
UCI_ERR_NO_SOCKET ($88, OPEN_TCP short-read: firmware never opened the
socket — issue #36) and UCI_ERR_WAIT_TIMEOUT (5 s wall-clock bound, #37).
DNS is done by firmware inside TCP_CONNECT; the adapter only copies the
hostname to uci_host_buf.
Every spin-wait is wall-clock bounded (5 s via CIA1 TOD) and every
caller bcs out — see "Design note — bounded timeouts" below. Never add an
iteration-counted wait. The bound is only real because net_init calls
uci_tod_start first: the CIA's TOD is halted out of reset and nothing
in the KERNAL starts it, so until #145 every one of these waits was
unbounded on hardware. VICE runs the TOD from reset, so no emulator test
can catch a regression here — the guard is tools/uci/boot_check.py.
The UCI FPGA needs a minimum gap between register accesses regardless of
CPU clock. uci_fence (uci_regs.inc, OUTER=5, INNER=217, ~85 us at
64 MHz) follows every access to $DF1C-$DF1F. INNER=217 covers both the
U64E (~38 us floor) and the C64 Ultimate (floor bracketed 51.6 FAIL /
62.9 PASS us at 64 MHz; the symptom below the floor is a silently lost
TCP_CONNECT push → NO_SOCKET). 48 MHz (U64E) and 64 MHz (C64U) verified.
Second device: C64 Ultimate "Starlight", U64_HOST=10.53.21.158, fw 1.1.0,
64 MHz enum, WiFi. Handled differences:
- Set turbo BEFORE reset/run_prg and settle ~3 s; a runtime speed switch (even a redundant config write) loses the next UCI command.
- Boot must issue some REU DMA or the first TCP_CONNECT is dropped —
boot.skeepsreu_mul_initunder both profiles. - Multiple interfaces:
net_dhcp_acquireprobes iface 0..3. - REU ships disabled and there is no
"REU"cartridge preset — setRAM Expansion Unit: Enableddirectly (runtime-only).
tools/uci/ (README there). Need a device (U64_HOST, default
192.168.1.81), c64-test-harness, and go through DeviceLock +
enable_uci. Named rig_*.py, not test_*.py, on purpose (#109/#111).
Scratch DMA addresses come from _memory_policy.py (parses
build/labels.txt) — never hardcode them. rig_https_local/_live/
_bad_finished allocate from page 3 (build_policy_and_low_ram_arbiter,
$0334-$03FF, 204 B), which no link moves; the other rigs still carve a
linked-region tail.
The acquire budget is C64_DEVICE_LOCK_TIMEOUT, default 1800 s, and
it is one number: every rig here and tests/rig_ip65_rrnet_hw.py takes
the lock through acquire_device_lock() in _device_lock_helper.py, and
tools/test_device_lock_timeout.py fails if one grows its own back (it
also pins the malformed-value and progress-window behaviour). The budget
is not how long you may queue behind a running rig: the harness's
acquire re-arms its deadline indefinitely behind a live, progressing
holder, so the budget only bounds waits it refuses to extend — a wedged
holder, or a handoff chain. The chain boundary is the FOURTH change
of holder identity, not the third: _MAX_HOLDER_HANDOFFS is 3, but the
harness extends while handoffs <= 3 and its own docstring says
otherwise (a harness doc bug — its WARNING text agrees with its code).
Pinned by test_the_handoff_boundary_is_four_changes_not_three; do not
re-derive it from their prose. That case is what several lanes cycling
one U64E would produce — the mechanism is lab-measured, no such
timeout has been captured from a real run here — and it is why the old
hardcoded 120 s could fail against a device that was merely busy. A
malformed value is fatal before the device is touched; a wait prints
progress to stderr every 30 s (elapsed, budget, holder PID, lockfile age
with a STALE flag past the 60 s progress window, queue depth), never to
stdout.
boot_check.py / phase2_check.py / phase3_tcp_echo.py — boot, DHCP, TCP
rig_http_local.py / rig_http_live.py — plaintext HTTP
rig_https_local.py — HTTPS vs local TLS 1.3 listener
(DMA'd http_get trampoline;
TURBO_MHZ, EXTERNAL_LISTENER=1,
DEBUG_CAPTURE, artifacts under
$UCI_DEBUG_DIR)
rig_https_print_body.py / rig_https_local_p384.py — wrappers (P-384 has no PRG)
rig_https_live.py / rig_https_wiki.py — real servers (github, wikipedia)
rig_https_banner.py — the ONLY rig that executes
do_https_get (walks the menu,
reads $0400) — every other HTTPS
rig enters via the trampoline.
Also the only completeness
oracle: tools/http_body_checks.py
(exit 78 = inconclusive, #210)
rig_https_bad_finished.py — forged server Finished must abort
(FINISHED_MODE=good control first)
bench_ecdsa_u64e.py — verify wall-clock sweeps
Traps: decode_screen() returns lowercase rows (only screen_text()
uppercases); at 48 MHz the handshake scrolls the screen, so read it
immediately. Crypto-path rigs call _device_prep.prepare_device() and then
preflight_reu() — that order is the contract (#197): prep is setup,
the preflight is the backstop behind it, and tools/test_device_prep.py
asserts the ordering at all five call sites. Prep configures the REU the
linked profile needs (device config is runtime-only, so REU-Disabled is the
factory default, not another lane's leftovers), sets turbo before the reset,
and logs the device's before/after state to stdout and to
device_state.json in the run's own artifact dir (each rig passes it;
the $UCI_DEBUG_DIR fallback is a base dir the next run overwrites, and is
normally unset) (#212). The two probes degrade in opposite directions on
purpose (#187): an unreadable REU state writes anyway (the write is the
safe action), an unreadable turbo state aborts after one retry (the write is
the $88 hazard, and skipping it runs the rig at an unknown clock). The REU
write can still fail — set_reu may PUT a third item, Cartridge, which a
C64U rejects — and that is exit 4 with the wedge ladder, never a traceback.
Overrides: C64_SKIP_DEVICE_PREP=1 (the dangerous one: no turbo write,
so the run inherits the previous lane's clock),
C64_FORCE_TURBO_WRITE=1. Both skip flags and
C64_SKIP_REU_PREFLIGHT share one parser, so =false leaves every guard
ON. rig_https_banner.py is the one crypto-path rig with no prep — see
KNOWN_UNPREPPED in the test, it is #212's own failure shape. preflight_reu() itself (#97) still writes no
config: a REU-profile build on a REU-disabled device exits 4 in ~2 s instead
of spinning ~44 min. C64_SKIP_REU_PREFLIGHT=1 bypasses.
It fails closed (#179): a REU setting that cannot be read — a raise, an
unrecognised shape, an empty value — exits 4 too, not a warning.
c64-test-harness is an editable install from a sibling working tree,
so their merges are our regressions; audit every get_config_* call
against their tree, not against memory. Pinned by
tools/test_reu_preflight.py (faked client, no hardware). Story in
engineering-notes.
- The
…KEYS ENC1 RXscreen is not diagnostic. Two causes look identical: a real device wedge (net_last_error=$86, power cycle at the wall) and a REU-profile build with no REU (net_last_error=$00— the row-fetch DMA no-ops, the X25519 secret is wrong, the first AEAD tag fails, and it spins for ~44 min). Checknet_last_error; the REU half is now excluded by the preflight above.$86is not by itself a wedge — it has been seen on PASSING runs (github.com HTTP 200, 2026-08-28) — but it is never noise either: the$DF1Cbit 3 it reports has exactly one setter in the FPGA (command_protocol.vhd, thePUSH_CMD-while-not-idle branch), so it always means our push was rejected, and a passing run that carries one recovered from a real rejected push. A normal end-of-fetch cannot set it: the firmware closes onlwip_recvmsg== 0 and reports01,CONNECTION CLOSED BY HOSTon the$DF1Fstatus channel, which touches no status bit. - Lease poisoning: resetting the C64 with a live firmware socket makes
GET_IPADDRreturn 0.0.0.0 forever ("REQUESTING DHCP" loop). Only a wall power cycle clears it. Rigs therefore let fetches finish and send 'Q'. Probe DHCP with a fast-boot (onchip) image, never comb. - writemem exhaustion wedge (fw 3.14d, GideonZ/1541ultimate#686):
each REST
writememleaks a/Tempfile; after ~15 loads REST and the UCI bridge wedge together.tools/uci/_temp_gc.pyFTP-deletes them; wired into the live/wiki rigs. - The U64E is a shared device: trust
DeviceLock, never kill other sessions' processes or force-reboot it.
Real servers work (2026-08-21/22, U64E @ 48 MHz, comb): github.com,
browserleaks.com, lwn.net all HTTP 200 (~32-39 s to Finished), and
en.wikipedia.org's C64 article streamed into REU $10:0000 via
HTTPS_BODY_TO_REU=1 and shown by src/viewer.s — handshake and GET
verified, body completeness NOT (#211). This entry used to claim
125,235 B byte-verified; that does not reproduce on either tree. http_get
returns carry=0/http_status=200 on bodies tens of KB short of their
Content-Length, intermittently and not as a function of size — measured
offline against a local listener with no chunking, and live at
117,192 / 89,526 / 73,720 B against a same-day 125,703 B anchor. It can also
hang outright. Do not treat any large-body fetch as complete until #211
closes; it went unnoticed because the only rig on that path could not go
red on a short body (#210, now fixed: rig_https_banner.py derives the
expected size from the response's own framing and gates its exit code on
it). That rig also sets the CPU clock instead of inheriting it — a
completeness verdict is clock-sensitive where the banner verdict was not,
and a device left at 1 MHz makes a slow fetch look like a truncated one.
#211 reproduces on the shipped path. Two runs of ONE artifact — PRG
44e6c0dd605db65682131d6621fc9ad7b8b03cb0d22b0b241691abd3c35ed32a,
BACKEND=uci USE_NISTCURVES_ONCHIP=1 HTTPS_HOST=en.wikipedia.org HTTPS_PATH=/wiki/Commodore_64, U64E @ 48 MHz, FETCH_TIMEOUT=900, same
host, same day: one PASS (754,413 B consumed == Content-Length, HTTP
200) and one FAIL (frozen at 299,123 B, 455,290 B short). One of each:
treat neither as the norm, and do not quote the PASS on its own. The stall is
abrupt — the counter freezes and stays frozen for hundreds of seconds,
which is what #219's fast-expiry path predicts — not a trickle. 754,413 B
is the rendered page at that path and is not the 125,703 B raw-article
figure quoted above; they are different resources. The handshake results above are unaffected. The
local-listener handshake works on both backends (UCI at 48 MHz and 1 MHz;
ip65 in VICE at honest 1 MHz, ~36 min).
Pieces that made real servers possible, each of which the local listener never exercised:
- W1 streaming deframer (
src/tls_deframe.s) for 11-14 record flights; W2 streaming Certificate consumer into a 2048 Bcert_buf(UCI). - 512-content records hit a page-dispatch bug in
src/tls_record.s(every local fixture was one byte short of the trigger). - Bodies past the 512 B
http_resp_bufterminate on a 24-bit consumed count (http_body_total), withContent-Lengthand chunked support. - The wikipedia stall was ours (commit d9cd021):
net_pollasked for 512 B per SOCKET_READ and dropped bytes past the ring's free space — a permanent hole on any flight over 4 KB. Now clamped tomin(ring_free-1, 512). Not a firmware bug. - Post-ServerHello drain (see design note) so impatient peers do not RST during the multi-minute crypto stall.
- Cloudflare is out of scope: ignores MFL and enforces a ~15 s deadline.
Handshake flow, both backends: CH → SH (X25519) → EE, Certificate,
CertificateVerify (P-256 via sibling) → server Finished verified → client
Finished → traffic keys, seq counters reset (§5.3) → GET → response.
Single suite: TLS_CHACHA20_POLY1305_SHA256, so the transcript hash is
always SHA-256. The eleven bugs fixed getting here are listed in
engineering-notes ("Summary of recent fixes").
That arrow order is enforced, not assumed (#152). Both dispatchers
expand TLS_HS_SEQ_CHECK from src/tls_hs_seq.inc — the UCI streaming arm
at @hdr_complete in src/tls_deframe.s and the ip65 arm in src/tls13.s
— so they cannot diverge; the macro derives the one legal message type from
tls_state (no parallel "expected" byte to drift), and rejects before the
transcript fold. The deframer error codes are DF_ERR_* at the top of
src/tls_deframe.s, reported in df_last_err; the sequence rejection is
DF_ERR_SEQ = $07. A CertificateRequest is the only RFC 8446-legal
message the gate turns away (this client cannot answer one, so it was
already refused a step later, as DF_ERR_TYPE = $04). Test:
tools/test_hs_sequence.py.
- P-384 build is broken, one link deeper than before: the
ar65member-name bug was ours (fixed), and the chain now stops atLIB_NISTCURVES_SHA384_TABLESoverflowingOVERLAY_REGIONby 1,536 B (cfg/p384-overlay-sha384.cfg); the embed variant dies on thebuild/labels.txtordering defect.ec_scalar_mul_384_shimis unreferenced by any shipped PRG, but it is the only provider ofec_scalar_mul_384in the P-384 curve archive (upstream'slib-p384-verifyexcludespoints384_comb.s) — retire it with the P-384 lane, not separately. Issues #32/#45 closed stale; file fresh ones. - Sibling archive member names are discovered, never hardcoded.
Upstream v0.9.0 made them per-variant; a hardcoded
zp_config.omeans the ZP-override object is silently dropped andzp_ptr2reverts to$fd(collides withzp_temp/zp_count) — runtime corruption, no link error. The wrappersod65-check the emitted object; confirm inbuild/labels.txtif in doubt. The canonical slot name isnistcurves_zp_ptr2(v0.10.0). poly_prod_lo/hiis a rendezvous, and c64-https owns it (§8.3: whoever providesct_mul_8x8owns its product scratch). If both sides define the pair the link succeeds with two disjoint pairs and every on-chip multiply row is wrong. Guarded behaviourally only: runtools/test_ecdsa_kat_oracle.pyagainst an onchip or comb PRG after any change here (REU builds pass regardless).- VICE harness gotcha: anything touching sibling P-256 primitives
must launch VICE with
-reu -reusize 512, orfp_mulreturnsa*255*band verify fails with no diagnostic. Usetools/_vice_helpers.py::default_vice_config()(eight suites do;tools/run_all_tests.pyand ~15 others still spell the flags by hand).C64_VICE_NO_REU=1is the deliberate opt-out for proving the onchip image's no-REU claim — never set it on a REU-profile build. - CRYPTO_OVERLAY vs rig scratch: new resident tenants in
$4200-$5FFFshrink what the rigs'MemoryArbitercan hand out, and under comb that tail is small (server-name validation already brokerig_https_wiki.py, which now drives the menu instead). Re-check rig scratch after any tenant lands there. The arbiter readsbuild/labels.txt; the harness write guard raisesMemoryPolicyErrorbefore the wire. CRYPTO_HOTmargin under UCI is per profile, even between the two that sharecfg/c64-https-uci.cfg: each links a different nistcurves archive, and comb's cfg also movesRODATA/CRYPTO_RODATAout toCRYPTO_OVERLAY. No backend-wide figure is right. Watch it on every pin bump, measuring all three UCI profiles.http_recv_response:Content-Length(single-SP matcher, 24-bit, with a separatehttp_cl_validflag byte) and chunked (http_state_body_chunked,HTTP_AUX_CODE; 24-bit chunk sizes) supported; body rendered viaascii_chrout(case folded, #28);http_resp_bufkeeps raw ASCII. The$FFFFContent-Length sentinel this line used to describe no longer exists — W4 widened the count to 24 bits andhttp_hdr_initreplaced the magic value withhttp_cl_valid, precisely so it could not collide with a real length. Do not read the two$FFFFs as related: the surviving one is the SOCKET_READ no-data sentinel (Networking backend ABI), and nothing inhttp.s,tls13.sortls_record*.streats any$FFFFas end-of-stream. That confusion was a live lead on #211 and cost a diagnosis round.http_recv_body's tick budget is not a completion signal (#211). Its@recv_no_datacounter used to fall through into the success exit, so a body short of itsContent-LengthreturnedC=0withhttp_status=200;http_getdiscarded the carry on top of that. Both are fixed and pinned bytools/test_body_truncation.py. The budget is still 65,536 consecutive ticks, which costs ~0.1 s against a socketnet_pollhas flaggedNET_TCP_ERROR(UCI: a non-CONNECTED socket makesnet_polla 6-cycle RTS) and ~87 minutes against a healthy but silent one — one predicate, two costs, and the reason the same defect was seen both as a fast false success and as a hang. The verdict is only visible to a caller that checks it, and no shipped in-PRG caller does:http_gethas no in-PRG caller at all andboot.signoreshttp_recv_body's carry, so the menu path still renders a truncated body as a normal result. The fix is what lets a rig go red; wiring the UI to it is separate, unfinished work. It also recovers no lost bytes — the truncation has its own cause.- New tails in
http.sgo inHTTP_AUX_CODE2, notCODEand notLOADER_OVERFLOW. Both wrong homes link cleanly on the default target:CODEis ip65's LOADER, which is nearly full, andLOADER_OVERFLOWlands in ip65NET_CODE, whose tail is a joint budget withHTTPS_TARGET_RODATA— #211's ~27 B verdict routine there brokeHTTPS_HOST=en.wikipedia.orgon both ip65 profiles.HTTP_AUX_CODE2is the documented home forhttp.s's jsr-only helpers and lands inCRYPTO_OVERLAYon both backends. Before adding to ip65NET_CODE, build the wikipedia target — the default target's bytes free will not tell you, and neither will PRG size. - Region margins are measured, never written down:
python3 tools/measure_margins.pyreads free bytes per region offbuild/c64-https.mapagainst the linked cfg's MEMORY block;--builddoes all five profiles plusrig_https_wiki.py's target on both ip65 profiles, each table stamped with its PRG sha256; it exits 1 on a failed link (ld65 still writes the map). Margins are per profile — never carry a figure from one to another. net_tcp_set_recv_cbis an RTS stub. Boot banner:rr-netunder ip65,UCI NETWORKINGunder UCI —boot_check.pyasserts both.
src/vic.s vic_blank/vic_unblank wrap the two X25519 scalar mults, the
ecdsa_verify dispatch, and (comb only) the ec_precompute_256 boot pass
— nothing else, so the CH/SH/HK1/KEYS/ENC1/RX progress markers stay visible
(they are the field diagnostic). The dispatch arms are jsr + php/plp
so the verify carry survives the unblank; the precompute needs no such
guard (no return value). The precompute is the one blank long enough to
look like a hang, so boot.s recolours the border ($D020 = $0B dark grey,
restored to the $0E constant) across it: with DEN clear the whole display
is border colour, so that — not the banner it prints first — is the
persistent "working" signal. Both writes are at the call site, not in
vic.s, so the mid-handshake blanks keep the markers untouched, and they
run under VIC_BLANK=0 too, which keeps that A/B control clean.
Measured: 6.35% (VICE, 1 MHz), 6.60% (U64E 48 MHz, REU profile), 6.78% (U64E 48 MHz, onchip, n=3), vs 6.31% from first principles (25 badlines × ~43 cyc / 17,045-cyc NTSC frame). Badline DMA taxes the bus, not the CPU: the fraction is flat across clock and profile — turbo does not shrink it and the REU DMA floor does not dilute it. The sibling repos' "20-25%" is a sprite-DMA figure and is wrong ~3.5x for text mode. Full sweep tables in engineering-notes.
Model: T(f) = D + C/f. The REU profile has a ~42-56 s floor (row-fetch
DMA anchored to the ~1 MHz bus, ~16 KB per fp_mul); onchip has none but
~1.9x the CPU work; comb halves the CPU work again but needs REU bank 2
and a boot precompute. Read the pin, not the commit — almost every
figure was taken at libs/nistcurves v0.6.0; the pin is v0.14.0 and the
only re-measured points are 48 MHz UCI REU (80.8 → 82.1 → 82.4 s, n=1; the
+1.6% is v0.7.0's public-key validation, worth paying) and comb.
verify only (RFC 6979 vector) 16 MHz 48 MHz 64 MHz crossover vs REU REU (U64E) 81.6 59.2 n/a — onchip, v0.6.0, unblanked (U64E) 87.6 30.5 n/a ~18 MHz (C64U ~22) comb, current pin, blanked (U64E) 47.0 16.4 ~12.8* ~5 MHz (C64U ~7)
- extrapolated; C64U comb @64 measured 12.4 s at v0.6.0
handshake + GET, local listener 1 MHz 48 MHz 64 MHz U64E REU 1157.7 80.8 n/a U64E onchip 2120.7 45.5 n/a C64U onchip (post-#74) — 44.6 33.7 C64U comb (v0.6.0) — 38.4 31.0
ip65 + onchip, no REU, VICE honest 1 MHz: 2,159.7 s (36.0 min),
verify stretch 1,416.7 s (+1.4% vs the model), X25519 ~326 s each —
measured on the retired in-tree X25519. The sibling that replaced it
(#245) is slower: bench_x25519.py 366 s vs 242 s in-tree onchip.
Best verify today: 16.4 s @ 48 MHz on the U64E (comb), 1.73x faster
than blanked onchip at that clock (28.4 s, n=3 — README's figure; the
table's 30.5 s is unblanked, at v0.6.0). The U64E's REU DMA is 10-13% slower than the
C64U's; the CPU path is at parity. tools/uci/bench_ecdsa_u64e.py is the
protocol; n=1 rows bracket, n>=3 rows measure; 2-point fits are
ill-conditioned. A "+12% comb gap" between devices recorded earlier closed
on re-measurement — do not attach mechanisms to deltas without checking
magnitude first.
Unmeasured and unmeasurable until the P-384 build is fixed. Every figure here is an extrapolation, not a measurement — treat it as a rig budget, not a result.
Scale the onchip P-256 verify, not the REU one: the REU profile's
42-56 s floor is row-fetch DMA anchored to the ~1 MHz bus and does not grow
with field width, so multiplying it mis-shapes the estimate. Onchip is
CPU-bound and structurally the same code path — ec_scalar_mul_384_shim
runs the variable-base ladder for u1*G exactly as the no-comb P-256
ec_scalar_mul shim does. From operation counts: 48-byte vs 32-byte limbs
make each schoolbook fp_mul ~(48/32)^2 = 2.25x, and a 384-bit scalar is
1.5x the ladder steps, so ~3-4x the onchip P-256 verify — ~1.5-2.5 min at
48 MHz against the 30.5 s onchip row above. SHA-384 is excluded as
negligible beside two variable-base scalar mults. rig_https_local_p384.py
keeps its 90-minute budget (it also has to cover 1 MHz).
Fences make per-iteration cost scale with CPU clock while the FPGA does
not, so an iteration budget ample at 1 MHz collapses at 48 MHz (branch
feat/net-drain-abi broke DHCP exactly this way). uci_wait_idle is the
template: sample CIA1 TOD (HOUR $DC0B latch → TENTHS $DC08 unlatch), bail
after 50 tenths transitions with C=1 + UCI_ERR_WAIT_TIMEOUT, state in
two SMC bytes. uci_wait_not_busy, uci_drain_resp, uci_drain_status
and uci_push_wait all follow it; all 22+ call sites bcs out.
A wall-clock bound needs a wall clock that is running: uci_tod_start
(#145). Measured on the U64E at 48 MHz once it does — TOD/wall rate 0.996
(the "5 s" budget is 5.02 s, and turbo does not shrink it, which is the
whole point), and the longest bounded wait observed across four full
github.com handshakes is one tenth. The budget is ~50x the worst real
wait, but a $89 has been seen in the field on a passing run, so it is
reachable; re-measure before tightening it.
tls13.s drains the network after ServerHello because ip65 sends no MSS
option and ACKs only when polled: without it the flight tail sits unACKed
through minutes of crypto and impatient peers (macOS: ~54 s) RST, which
surfaces minutes later as client Finished into a dead socket
(tls_state=$FF, tls_read_seq=4). Budget lives in
src/net/<backend>/net_tuning.inc (NET_SH_DRAIN_OUTER/INNER): ip65
8×250 (validated; do not shrink without the VICE e2e), UCI 1×16 — a UCI
net_poll is a full firmware round-trip costing ~40 ms regardless of
clock, and #71's unconditional 2000 polls cost UCI 80 s (#73/#74). Keep
INNER non-zero (0 means 256). Open: polling inside long crypto for
flights larger than the TCP window, and an idle-based bound.
UCI (cfg/c64-https-uci.cfg, W1 hot/cold split — the reference):
$0801-$1FFF LOADER BASIC stub + boot + HTTP + net wrapper
$2000-$3B65 NET_CODE UCI adapter + LOADER_OVERFLOW + TLS_CODE
+ CRYPTO_AUX_CODE
$3B66-$41FF NET_BSS_TAIL NET_BSS_TAIL segment (deframer/viewer BSS)
+ LIB_NISTCURVES_P256_BSS. NOT BSS_TAIL
and NOT UCI_BSS — both are elsewhere
(below, and CRYPTO_HOT). Read the map.
$4200-$5FFF CRYPTO_OVERLAY 7.5 KB. Resident tenants in every UCI
build: TLS_DEFRAME_CODE (~1.4 KB),
CERT_BUF_BSS (2,048 B), HTTPS_TARGET_RODATA,
x509_name; comb adds RODATA/LIMLEE_BSS
(small tail — the rigs' scratch). Also
the slot for the (broken) overlay-embed
flags.
$6000-$9FFF CRYPTO_HOT resident code + rodata + small BSS
$A000-$BFFF CRYPTO_COLD_SHADOW large BSS (RAM under BASIC ROM, $01=$36);
BSS_TAIL pinned at $A000 (tls_rec_buf,
548 B), overlaid by the X25519 field
buffers (X25519_SCRATCH, #245 — dead
across both scalar mults, asserted in
x25519_tables.s); X25519_TABLES (2 KB)
packs below TABLES_BSS; TABLES_BSS pinned
at $BA00 so sqtab lands at $BC00
(LIB_SHARED_SQTAB_BASE, asserted
post-link)
$C000-$DFFF OVERLAY_FILE_PAD zero-pad; runtime TCP ring at $C000
$E000-$FDFF OVERLAY_BLOB_CURVE_RAM P-384 curve blob staging (unused)
ip65 (cfg/c64-https-ip65.cfg):
$0801-$1FFF LOADER
$2000-$3FFF NET_CODE ip65 blob + LOADER_OVERFLOW +
CRYPTO_AUX_CODE2 + HTTPS_TARGET_RODATA
$4000-$4F8B NET_BSS blob's BSS (live at runtime, EMPTY to ld65)
$4F8C-$5FFF CRYPTO_OVERLAY 4,212 B: TLS_CODE + CRYPTO_AUX_CODE +
HTTP_AUX_CODE + HTTP_AUX_CODE2
$6000-$9FFF CRYPTO_RESIDENT code + rodata, never crossing $A000
$A000-$BFFF CRYPTO_COLD_SHADOW BSS; cert_buf (1,536 B) pinned at $A000
and unioned with LIB_NISTCURVES_P256_BSS
(SCRATCH_UNION, #68 — disjoint lifetimes,
capped so growth is a link error) and
with the 2 KB X25519 tables
(X25519_TABLES_UNION, #245, rebuilt per
scalar mult); tls_rec_buf pinned at
$A800 under X25519_SCRATCH as on UCI
$C000-$CFFF TCP_BUF 4 KB ring for the ip65 callback
ip65 is essentially full, and the onchip profile (the shipped product) is
the one to size against: its CRYPTO_RESIDENT is tighter than the REU
profile's. PRG size is not a headroom gauge. CRYPTO_OVERLAY and
CRYPTO_RESIDENT are ADJACENT ($4F8C-$5FFF and $6000-$9FFF), so they are one
pool, and no amount of shuffling segments between them creates space —
that is why the contiguous-region cfg restructure is only worth the
boundary fragmentation it recovers. The other free blocks are not
reachable from the pool: NET_CODE's tail is the HTTPS_HOST/HTTPS_PATH
budget, and CRYPTO_COLD_SHADOW's only gap is alignment padding below
TABLES_BSS.
http.sis not inLOADER_OVERFLOW; its tenants areboot.o,vic.oandcrypto_swap.o. W4 movedhttp.s's share out toHTTP_AUX_CODE(src/http.s, grep "W4: moved from LOADER_OVERFLOW").src/loadaddr.s(PRG load address) andsrc/exports.s(promotes equates tolabels.txt, incl.cert_buf_size— rigs must read it, not hardcode 2048/1536) are intentional stubs; ip65-only exports live insrc/net/ip65/exports.s.- Server name validation is UCI-only (
src/x509_name.s, 491 B, SAN dNSName only, leftmost-label wildcards, no-SAN rejects). It tail-calls fromx509_extract_pubkey's success exit so its carry is the result. ip65 has nowhere to put 491 B, and dropping wildcards would break wikipedia; ip65 cannot reach a real server anyway. Verified on hardware at 48 MHz on both UCI profiles.
make package → dist/ (gitignored). Three products, make clean
between each, matrix in tools/package/_common.sh:
c64-https-ip65-onchip.prg stock C64 + RR-Net, no REU, no turbo c64-https-uci-onchip.prg turbo, no REU c64-https-uci-comb.prg turbo + REU, fastest (needs bank 2 + boot precompute)
REU-profile images were retired (curation: still fastest below ~18 MHz,
one line in PACKAGE_VARIANTS to restore). That line is a
security-relevant decision, not just a curation one. Measured
LIB_NISTCURVES_REU_BANKS_USED: $0000 for both shipped onchip products,
$0004 for uci-comb, and $0003 for the two REU profiles — they do
library REU DMA, so restoring one restores an image exposed to the
SPEC §8.2 REU DMA settle fix (libs/nistcurves v0.12.0). Check the pin
covers it before adding the line back. One product per .d64. The
listener c64-https-listener.py is a single self-extracting file with no
third-party deps; it needs an ssl with TLS 1.3 (macOS /usr/bin/python3
is LibreSSL and cannot serve this client) and --selftest proves the path
with openssl s_client -ciphersuites TLS_CHACHA20_POLY1305_SHA256.
make package-verify rebuilds and compares PRG hashes, reads each PRG
back out of its .d64 with c1541, boots each image in VICE
(-trapdevice8 +drive8truedrive, or the load never finishes), and runs the
listener selftest. A failed variant yields a partial release with an
!! INCOMPLETE RELEASE !! MANIFEST block, and the gate cannot pass
vacuously (zero checks = fail; any SKIP_* = PARTIAL VERIFICATION).
tools/test_entropy.py, test_hkdf.py, test_chained_hmac.py,
test_keyschedule_steps.py, test_tls_handshake.py, test_http.py,
test_x509.py, test_http_body_checks_unit.py (pytest testpaths; the #210
body-completeness oracle plus decide_exit, the rig's whole exit-code
decision, one red case per branch; mutation-proven by
tools/mutate_http_body_checks.py — run it rather than quoting its
count here, and note it mutates src/http.s too), test_x509_name.py (its vector count is conditional —
never quote one without the condition. 11 synthetic vectors always run
(5 accept / 6 reject); +6 against live CA leaves fetched from wikipedia /
github / lwn, which X509_NAME_OFFLINE=1 or being offline drops; +6
against tools/https_e2e/certs/server.pem, which a fresh clone does not
have — it is gitignored listener output, minted on demand by
tools/https_e2e/ensure_certs.py. So 17 / 9 on a fresh clone with
network, 23 / 12 once the certs exist, 11 / 6 with neither (all three
measured 2026-08-31). The trap: both optional sets have the same
3-accept/3-reject shape, so 17 / 9 is produced two different ways —
fresh clone + network, and certs-present + X509_NAME_OFFLINE=1 — and
the printed total tells you neither which sets ran nor whether the
real-cert path was covered. Read the per-vector lines. The suite also
reports a missing cert as "openssl unavailable" even with openssl on
PATH, which is how an earlier miscount survived. On a non-uci build it
exits 2 with a CANNOT RUN message, not 0 — that is the behaviour to
copy, along with test_x509.py (missing labels counted as failures) and
test_finished_verify.py (missing label = FATAL)),
test_p384_overlay_hazard.py (fails under
ENABLE_P384_VERIFY=1 by design; needs a well-formed DER sig to reach the
swap), test_finished_verify.py (18 cases), test_ecdsa_kat_oracle.py
(6 vectors incl. 3 negative), test_tls_deframer.py, test_x25519.py.
tools/run_all_tests.py is the aggregate runner, and its suite list
lives in SUITE_ORDER in that file, not here — the sentence that used
to stand in this spot named two omissions when there were four, both
security suites among them (#169). Pinned rather than restated: every
tools/test_*.py with a module-level run_tests() is either in
SUITE_ORDER or in the same file's commented UNDISPATCHED_SUITES, and
tools/test_runner_coverage.py asserts that by AST (5 checks, no build,
no VICE). It is still not an "all pass" runner — a suite that is a
standalone main() with no run_tests() cannot be dispatched as written
and the guard does not reach it (grep -n '^def run_tests' tools/test_*.py
separates the two); run those individually. hs_sequence needs
tls_deframe_pump, so it is an accounted SKIP unless you run the runner
as BACKEND=uci python3 tools/run_all_tests.py. pytest is not the
runner: suites take
(transport, labels, seed) positionally. pytest.ini pins testpaths to
the pure-logic modules — that list is the enumeration; read it there, not
here — and tools/test_pytest_boundary.py proves it is exactly the set
pytest can run, in both directions; its section 5 also shape-checks #178
(skips and verdicts via tools/_skip_policy.py, blind spots listed there).
Both rig dirs (tests/, tools/uci/)
are rig_*.py and in norecursedirs, and each exits 5 on its own. A bare
pytest at the root is green; the total is not quotable, because it tracks
testpaths and the build state. Run it rather than citing a number, and
build BACKEND=uci first: test_uci_data_acc.py does not skip
without a UCI PRG in build/, it fails 3 cases (#178's gap, not a
regression).
Negative-path coverage exists because an audit found the Finished-mismatch
abort had no test: test_finished_verify.py (VICE, carry-latching stub)
and rig_https_bad_finished.py against tools/https_e2e/evil_listener.py
(hand-rolled TLS 1.3 server flipping one bit of verify_data before
encryption; stock ssl cannot produce this). Oracle: tls_last_state=6.
Test-suite blind spots found by mutation are listed in the notes.
tools/check_upstream_pins.py [--json|--strict|--worktree] reports pin
drift per submodule. Never read pins off git submodule status — it
ignores lightweight tags (c64-x25519's v0.6.0) and reads as "N commits past
a release".
tests/rig_vice_https_macos.py: full HTTPS over emulated RR-Net in VICE
against a host listener (E2E_PROFILE=reu|onchip, E2E_NO_WARP=1,
HTTPS_PORT). Needs an ip65 PRG, a pcap-capable VICE
(~/opt/vice-eth/bin/x64sc, patched 3.10 — stock macOS builds gate pcap on
euid 0 and segfault), and sudo bash tools/rig-up-macos.sh (feth pair,
10.0.65.1, /dev/bpf* perms — reset every reboot — dnsmasq). VICE
3.10 SDL2 warp caps at ~1.2x. macOS Local Network privacy can silently
block the listener; the preflight probes for it.
tests/rig_ip65_rrnet_hw.py: the same fetch over a REAL RR-Net cartridge
in the U64E's cartridge port, cabled straight to the Mac's en4. The PRG
is loaded over the U64's REST interface; nothing about the network path
goes through the Ultimate. Segment is 10.0.66.0/24 (sudo bash tools/rig-up-rrnet-macos.sh en4), deliberately not the feth rig's
10.0.65.0/24 so both can be up at once; dnsmasq pins
00:0e:3a:64:64:64 -> 10.0.66.200 and answers for www.foo.invalid
only. The capture is hand-started (sudo tcpdump -i en4 -n -s0 -U -w … —
both flags load-bearing). Stock 1 MHz, ~40-80 min.
- Every wire and memory verdict is a pure function in
tools/ip65_hw_checks.py;tools/test_ip65_hw_checks_unit.py(pytest testpaths, no hardware, ms) proves each alarms on a known-bad input, andtools/mutate_ip65_hw_checks.pybreaks each one to prove the suite goes red. Adding acheck_*without a red case fails that suite by introspection. The rig is not judgment-free, though: the verdicts it delegates toip65_hw_checks.pysit beside its ownRES.check()assertions (screen scrapes, config writes, the clock assertion, the listener probe, the selftests), which have no red case — count both in the file rather than quoting a number. Do not attribute a run's whole check count to the cartridge: the host-side group touches neither the cartridge nor the 6510. - Two stations on the cable and the Mac is one of them, so every wire assertion discriminates by Ethernet source address; a third MAC is a hard failure. The cleartext-absence check needs a positive control (the SNI hostname, which TLS 1.3 leaves in the clear) or it reports INCONCLUSIVE, and the rig exits 78 for that, never 0.
- Load the PRG chunked and verify it before SYS. A single
write_memoryof the 47 kB image lands in 0.22 s and comes back with one wrong byte at a random offset (measured n=2, U64E fw 3.15 fork);write_bytestakes ~33 s and is exact. Also: $0801/$0802 are zeroed by the device ~2-5 s after READY. with no write involved. Andclient.run_prgwould deselect the cartridge (harness #217). - Only $0801-$9FFF is verifiable from the host — the image's
$A000-$BFFF tail is BSS zero fill and reads back as the BASIC ROM
until
boot.sbanks it out.check_shadow_ram_readableis what tells a ROM read from a RAM read; run it before believing any $A000+ value. - This rig does NOT call
enable_uci(the only one that does not): the UCI command interface is a second consumer of the same expansion bus, and an RR-Net run does not need it. Its value is read and reported, never written. - A green run here does NOT mean the ip65 product validates server
names.
src/x509_name.sis UCI-only (see Memory layout), so the ip65 image accepts any certificate that chains-free-verifies, whatever name it carries — this rig fetches from a local listener presenting a self-signed leaf and asserts nothing about the name in it. Do not cite this run as coverage of that gap; it is the gap. - What one passing run covers: one clock, one device, one cartridge,
one local listener, and an image differing from the shipped one only
in
HTTPS_PORT. TURBO_MHZ=48runs the same image at turbo — an experiment, not a product check; the default stays 1 MHz because that is the clock the product ships at. Measured 2026-09-06: 43.1 s 'G' to close against 1,979 s at 1 MHz (46x), CS8900a fine, DHCP on the automatic attempt.tls_stateholds CONNECTED only untiltls_close, a window one 48 MHz poll can miss, socheck_tls_connecteddecides on thetls_reached_connectedlatch (#204), read inside the lock before any reset; the sampled maximum is corroboration only.- CIA timers are realtime under turbo — 1023.2 ticks/wall-s at
1 MHz vs 1022.9 at 48 MHz, ratio 1.000, both within 0.05% of NTSC
phi2/1000. So ip65's
timer_read(CIA2 timer B, 1000-cycle cascade) keeps its ~15 s DHCP budget at any clock, and no 1 MHz assist is needed for that. Do NOT re-derive this from the CIA1 TOD figure in the bounded-timeouts design note: different clock domain. Reproducer:tools/probe_cia_timer_rate.py.