From ff43e72d73c2471e16e683daf76e58fd6597bb70 Mon Sep 17 00:00:00 2001 From: JC-000 <3798556+JC-000@users.noreply.github.com> Date: Sat, 18 Apr 2026 09:36:47 -0500 Subject: [PATCH 1/7] README: split binary size + label count by backend Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index be6da7a..e03d6b6 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,12 @@ The Makefile automatically builds ip65 from the submodule into a flat binary blo ## Project Status -Current status (40 KB binary, 537 labels): +Current status: + +- 38 KB binary (ip65 build), 1738 labels +- 38 KB binary (uci build), 1816 labels + +Progress: - [x] Project structure and build system - [x] ip65 submodule integration — 6.8 KB binary blob at $2000 (TCP/UDP/DNS/DHCP/ARP + RR-Net CS8900a) From 81dc133f83a07f14ca492f0e852e543817702697 Mon Sep 17 00:00:00 2001 From: JC-000 <3798556+JC-000@users.noreply.github.com> Date: Sat, 18 Apr 2026 09:44:50 -0500 Subject: [PATCH 2/7] Add MIT LICENSE Co-Authored-By: Claude Opus 4.7 (1M context) --- LICENSE | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a37d16d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 JC-000 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From 7b33966c757914bec0179bfd3b7d18a5b5064c12 Mon Sep 17 00:00:00 2001 From: JC-000 <3798556+JC-000@users.noreply.github.com> Date: Sat, 18 Apr 2026 09:56:01 -0500 Subject: [PATCH 3/7] README: align right edge of TLS inner crypto rows in diagram Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index e03d6b6..ee3831e 100644 --- a/README.md +++ b/README.md @@ -18,11 +18,11 @@ An HTTPS client for the Commodore 64 in 6502 assembly. Implements TLS 1.3 over T │ │ Record Layer │ Handshake Proto │ │ tls_record.asm, tls_handshake.asm │ └──────┬───────┴────────┬─────────┘ │ │ │ │ │ - │ ┌──────┴───────┐ ┌─────┴──────────┐ │ - │ │ AEAD │ │ Key Schedule │ │ (crypto modules) - │ │ ChaCha20- │ │ HKDF-SHA256 │ │ hkdf.asm - │ │ Poly1305 │ │ ECDHE P-256 │ │ - │ └──────────────┘ └────────────────┘ │ + │ ┌──────┴───────┐ ┌─────┴──────────┐ │ + │ │ AEAD │ │ Key Schedule │ │ (crypto modules) + │ │ ChaCha20- │ │ HKDF-SHA256 │ │ hkdf.asm + │ │ Poly1305 │ │ ECDHE P-256 │ │ + │ └──────────────┘ └────────────────┘ │ ├─────────────────────────────────────────┤ │ Network ABI (src/net_abi.inc) │ net_init / net_tcp_* / net_dns_* ├──────────────────────┬──────────────────┤ From 553d1e647b14e90bf34c94eb923ae730fba494f0 Mon Sep 17 00:00:00 2001 From: JC-000 <3798556+JC-000@users.noreply.github.com> Date: Sat, 18 Apr 2026 10:06:54 -0500 Subject: [PATCH 4/7] tools/uci: honor U64_HOST in boot_check.py and phase2_check.py (#25) The other five tools/uci/ scripts already read U64_HOST with a default of 192.168.1.81; these two had the address hardcoded. Make them consistent and document the override in README and CLAUDE.md. Co-authored-by: JC_000 <3798556+JC-000@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) --- CLAUDE.md | 3 ++- README.md | 2 +- tools/uci/boot_check.py | 13 +++++++++---- tools/uci/phase2_check.py | 6 +++++- 4 files changed, 17 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8432ccb..3317e1b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -163,7 +163,8 @@ to the ip65 layout. ### UCI test scripts -Scripts under `tools/uci/` require a U64E at 192.168.1.81 and use +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`: - `boot_check.py` — verify UCI firmware detection and boot banner diff --git a/README.md b/README.md index ee3831e..e34ee87 100644 --- a/README.md +++ b/README.md @@ -210,7 +210,7 @@ The setup script creates `br-c64` with `tap-c64-0`/`tap-c64-1`, assigns `10.0.65 ### Ultimate 64 Elite Hardware Tests (UCI backend) -Scripts under `tools/uci/` drive a real Ultimate 64 Elite over the network (default `192.168.1.81`), exercising the **UCI backend only** (built with `make BACKEND=uci`). They DMA the PRG into RAM, run the boot, and snapshot UCI/TLS state on completion or timeout. These scripts do not run under VICE. +Scripts under `tools/uci/` drive a real Ultimate 64 Elite over the network (default `192.168.1.81`, overridable via the `U64_HOST` environment variable), exercising the **UCI backend only** (built with `make BACKEND=uci`). They DMA the PRG into RAM, run the boot, and snapshot UCI/TLS state on completion or timeout. These scripts do not run under VICE. ```bash python3 tools/uci/boot_check.py # UCI firmware detection diff --git a/tools/uci/boot_check.py b/tools/uci/boot_check.py index 96f9895..f3ca7db 100644 --- a/tools/uci/boot_check.py +++ b/tools/uci/boot_check.py @@ -3,9 +3,10 @@ Phase 1b boot check for the UCI backend. Uploads build/c64-https.prg (assumed to have been built with -`make BACKEND=uci`) to the U64E at 192.168.1.81, waits for the PRG -to boot, reads screen RAM at $0400 (40x25 = 1000 bytes), decodes the -Commodore screen-code bytes to ASCII, and prints the non-empty lines. +`make BACKEND=uci`) to the U64E (default 192.168.1.81, overridable via +U64_HOST), waits for the PRG to boot, reads screen RAM at $0400 +(40x25 = 1000 bytes), decodes the Commodore screen-code bytes to +ASCII, and prints the non-empty lines. Pass criterion: screen contains printable text (not a uniform field of spaces or garbage). This only verifies the PRG loads and runs on @@ -13,9 +14,13 @@ Usage: python3 tools/uci/boot_check.py + +Environment: + U64_HOST — U64E address (default 192.168.1.81) """ from __future__ import annotations +import os import sys import time from pathlib import Path @@ -23,7 +28,7 @@ from c64_test_harness.backends.device_lock import DeviceLock from c64_test_harness.backends.ultimate64_client import Ultimate64Client -HOST = "192.168.1.81" +HOST = os.environ.get("U64_HOST", "192.168.1.81") PRG_PATH = Path(__file__).resolve().parents[2] / "build" / "c64-https.prg" diff --git a/tools/uci/phase2_check.py b/tools/uci/phase2_check.py index 677d8f4..0caf388 100644 --- a/tools/uci/phase2_check.py +++ b/tools/uci/phase2_check.py @@ -20,9 +20,13 @@ Usage: python3 tools/uci/phase2_check.py + +Environment: + U64_HOST — U64E address (default 192.168.1.81) """ from __future__ import annotations +import os import sys import time from pathlib import Path @@ -32,7 +36,7 @@ from c64_test_harness.backends.ultimate64_client import Ultimate64Client from c64_test_harness.uci_network import enable_uci, disable_uci -HOST = "192.168.1.81" +HOST = os.environ.get("U64_HOST", "192.168.1.81") REPO_ROOT = Path(__file__).resolve().parents[2] PRG_PATH = REPO_ROOT / "build" / "c64-https.prg" LABELS_PATH = REPO_ROOT / "build" / "labels.txt" From 8263bc0cbeef0e3cf767f4ccfd80d77eaaae6a5c Mon Sep 17 00:00:00 2001 From: JC-000 <3798556+JC-000@users.noreply.github.com> Date: Sat, 18 Apr 2026 10:07:25 -0500 Subject: [PATCH 5/7] =?UTF-8?q?README:=20align=20Handshake=20Proto=20?= =?UTF-8?q?=E2=86=92=20Key=20Schedule=20connector=20in=20diagram=20(#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ┴ on the Key Schedule box was at column 28 while the ┬ above it (and the │ bridging them) sat at column 29. Co-authored-by: JC_000 <3798556+JC-000@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e34ee87..fa1e286 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ An HTTPS client for the Commodore 64 in 6502 assembly. Implements TLS 1.3 over T │ │ Record Layer │ Handshake Proto │ │ tls_record.asm, tls_handshake.asm │ └──────┬───────┴────────┬─────────┘ │ │ │ │ │ - │ ┌──────┴───────┐ ┌─────┴──────────┐ │ + │ ┌──────┴───────┐ ┌──────┴─────────┐ │ │ │ AEAD │ │ Key Schedule │ │ (crypto modules) │ │ ChaCha20- │ │ HKDF-SHA256 │ │ hkdf.asm │ │ Poly1305 │ │ ECDHE P-256 │ │ From 80f5b4c6c502115ce369588271c9925c8ab7d3ae Mon Sep 17 00:00:00 2001 From: JC-000 <3798556+JC-000@users.noreply.github.com> Date: Sun, 19 Apr 2026 08:19:18 -0500 Subject: [PATCH 6/7] fix: remove 255-byte clamp in ip65 TCP RX callback Root cause ---------- The ip65 TCP RX callback in src/net/ip65/net.s clamped cb_remaining to 255 bytes when the high byte was non-zero, then relied on the ip65 stack to re-deliver the overflow on a subsequent callback. That second call never happened: ip65 ACKs the full tcp_inbound_data_length regardless of how many bytes the callback consumes, so any byte past #255 was silently dropped. Repro: a TLS 1.3 Certificate record (~369 B) delivered as a single TCP segment was truncated; tls_rec_buf ended up with a correct prefix followed by bytes from the next TCP segment, breaking AEAD reassembly during handshake. Fix --- Replace the clamp + 8-bit X-indexed inner loop with a 16-bit-safe copy loop modeled on the UCI adapter's pattern (src/net/uci/net.s). The inner loop still uses X as an 8-bit source index, but when X wraps $FF -> $00 the SMC source high byte (cb_copy_byte+2) is incremented so the next 256-byte window of the inbound buffer is read from the correct address. cb_remaining is decremented as a 16-bit quantity. Validation ---------- - tools/test_tls_handshake.py (21/21), tools/test_keyschedule_steps.py (9/9), tools/test_hkdf.py (12/12) all pass. - VICE + ip65 1 MHz (no WARP) end-to-end HTTPS test reaches PASS: server-side log shows completed TLS 1.3 handshake, received "GET / HTTP/1.1" from the C64, sent 200 OK with body "HELLO FROM TLS SERVER", and the TCP connection closed cleanly via the 4-way teardown. C64 screen shows "CONNECTION CLOSED"; the bridge's LISTEN :443 socket returned to accept-polling once the per-connection ESTAB socket closed. - UCI backend (src/net/uci/net.s) unchanged and still passes; the bug was ip65-specific. Known follow-up --------------- The HTTP response body is rendered on the C64 screen with ASCII -> screen-code mismatch (cosmetic, filed as a separate GitHub issue). The response bytes in http_resp_buf are correct; only the display code needs the translation the boot banner already does. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/net/ip65/net.s | 41 ++++++++++++++++++++++++----------------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/src/net/ip65/net.s b/src/net/ip65/net.s index cdf3ddd..0ff6224 100644 --- a/src/net/ip65/net.s +++ b/src/net/ip65/net.s @@ -10,9 +10,15 @@ ; The callback must NOT touch crypto state — it only copies received data ; into tcp_recv_buf (a ring buffer) for later processing by the TLS layer. ; -; The d973531 fix (clamp cb_remaining to 255 per callback invocation) is -; preserved verbatim. The ZP $02-$1B save/restore around every ip65 call -; is load-bearing — do not remove. +; The ZP $02-$1B save/restore around every ip65 call is load-bearing — +; do not remove. +; +; The former d973531 clamp (cb_remaining -> 255 bytes per callback) was +; a data-loss bug: ip65 ACKs the full tcp_inbound_data_length regardless +; of how many bytes the callback consumes, so any byte past #255 was +; silently dropped. TLS records larger than 255 B (e.g. a Certificate +; record) got corrupted. The callback now copies the full 16-bit length +; and advances the SMC source high-byte when the 8-bit X index wraps. .include "constants.inc" .include "ip65_symbols.inc" @@ -316,8 +322,11 @@ net_recv_byte: ; SMC instructions below so we can read those ip65 variables using absolute ; addressing (no ZP indirection needed). ; -; d973531 fix (preserved): cb_remaining is clamped to 255 bytes per callback -; invocation so the 8-bit X index cannot wrap and re-read the inbound buffer. +; 16-bit copy: cb_remaining carries the full inbound length (up to 1460 B +; for a full MSS segment). The inner loop uses X as an 8-bit source index +; and the SMC source base (cb_copy_byte+1/+2). When X wraps 0->0 (256 B +; consumed) we advance the high byte of the SMC source so successive +; 256-byte windows of the inbound buffer are copied correctly. ; ============================================================================= net_tcp_recv_cb: ; --- Read inbound data length (16-bit) --- @@ -340,17 +349,10 @@ cb_load_ptr_hi: lda $ffff ; SMC: patched to addr of tcp_inbound_data_ptr+1 sta cb_copy_byte+2 ; patch high byte of LDA abs,x source - ; Clamp cb_remaining to 255 bytes max per callback to prevent - ; 8-bit X-index wrap which would re-read source byte 0 onwards - ; and overwrite previously-copied ring bytes. (d973531) - lda cb_remaining+1 - beq :+ - lda #255 - sta cb_remaining - lda #0 - sta cb_remaining+1 -: - ; Copy loop: X = source index; ring store uses SMC on cb_store + ; Copy loop: X = source index (wraps every 256 B; when it wraps + ; we advance the high byte of the SMC source pointer so the next + ; 256-byte window is read from the correct address). The ring + ; store uses SMC on cb_store, repatched per byte. ldx #0 cb_loop: ; Check 16-bit remaining count @@ -359,7 +361,7 @@ cb_loop: bne :+ jmp cb_done : - ; --- Overflow check: if ((tail+1) & $3FF) == head, ring is full --- + ; --- Overflow check: if ((tail+1) & TCP_RECV_MASK) == head, ring is full --- lda tcp_recv_tail+0 clc adc #1 @@ -395,6 +397,11 @@ cb_copy_byte: cb_store: sta $ffff ; SMC: patched to tcp_recv_buf + tail inx + bne cb_src_ok + ; X wrapped $FF -> $00: advance SMC source high byte to read the + ; next 256-byte window of the inbound buffer. + inc cb_copy_byte+2 +cb_src_ok: ; tail = next (already computed above) lda cb_next_lo From 1b2edc8fc0d37a2c29a97586af5caea36c614752 Mon Sep 17 00:00:00 2001 From: JC-000 <3798556+JC-000@users.noreply.github.com> Date: Sun, 19 Apr 2026 08:23:04 -0500 Subject: [PATCH 7/7] tests: add VICE ip65 1 MHz no-WARP HTTPS regression Checks in the VICE/ip65 companion to the UCI e2e test that validated the ip65 TCP RX 255-byte clamp fix in this PR. - Runs a full TLS 1.3 handshake + HTTP GET against a local python listener bound to the bridge IP (10.0.65.1:443). - All timeouts scaled for 1 MHz stock (SENTINEL/ACCEPT/settimeout=9000s, heartbeat 120s) so ECDSA-P256 CertificateVerify (~60-70 min at 1 MHz) has headroom in a ~2-3 h total budget. - Pre-flight port gate + `VICE_HTTPS_OK_TO_RUN=1` env var to prevent accidental concurrent start while the UCI 443/4433 listener is in use on the same host. - Per-heartbeat screen-RAM snapshot under /tmp/c64-https-phase3-1mhz-screens/ so a long run's stall point can be bisected post-hoc. - _dump_diagnostics extended with TLS key-material snapshots (client random, server random, ECDHE priv/pub, server pub, shared secret) to let an offline decryptor distinguish ip65-deadlock from TLS-stall from AEAD failure. Run after the UCI HTTPS listener has stopped: sudo env VICE_HTTPS_OK_TO_RUN=1 PYTHONPATH=tools \ python3 tests/test_phase3_https_1mhz.py Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_phase3_https_1mhz.py | 804 ++++++++++++++++++++++++++++++++ 1 file changed, 804 insertions(+) create mode 100644 tests/test_phase3_https_1mhz.py diff --git a/tests/test_phase3_https_1mhz.py b/tests/test_phase3_https_1mhz.py new file mode 100644 index 0000000..a9e9b65 --- /dev/null +++ b/tests/test_phase3_https_1mhz.py @@ -0,0 +1,804 @@ +#!/usr/bin/env python3 +"""Phase 3 e2e HTTPS test — 1 MHz, WARP-off reproduction of the UCI run. + +This is the VICE/ip65 companion to the UCI e2e run on real U64E hardware: +it exercises the exact same ClientHello->...->Application-Data flow but on +the ip65/RR-Net backend at stock 1 MHz with VICE WARP disabled, so the +wall-clock is dominated by ECDSA-P256 CertificateVerify (~85 s on U64E at +48 MHz; at 1 MHz VICE the whole handshake is expected to take roughly +2-3 hours end-to-end). The upstream ip65 bug described in CLAUDE.md's +"End-to-End Bridge Tests" section and in the `project_phase3_handoff` +memory note may cause a stall partway through; ``_dump_diagnostics`` +samples the TLS state machine, tcp ring head/tail, net_poll counters, +and key TLS labels (client/server random, ECDHE pub/priv, shared secret) +so an observer can tell "ip65 starved the TLS layer" apart from "TLS +layer stuck on its own". This test MUST NOT be started while the UCI +HTTPS listener on ports 443/4433 is active on this host — the bridge +listener here binds 10.0.65.1:443 and would race host-side sockets. +Gated by the ``VICE_HTTPS_OK_TO_RUN=1`` environment variable. + +Run (after the UCI test has fully stopped): + sudo env VICE_HTTPS_OK_TO_RUN=1 PYTHONPATH=tools python3 tests/test_phase3_https_1mhz.py + +Exit codes: + 0 -- PASS + 0 -- SKIP (clearly printed) + 1 -- FAIL + 2 -- pre-flight gate refused (U64E test probably still running) +""" + +from __future__ import annotations + +import os +import socket +import subprocess +import sys +import time + +_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +_TOOLS = os.path.join(_REPO_ROOT, "tools") +if _TOOLS not in sys.path: + sys.path.insert(0, _TOOLS) + +PRG_PATH = os.path.join(_REPO_ROOT, "build", "c64-https.prg") + +# Screen needles (from src/boot.asm string labels). +MENU_NEEDLE = "Q=QUIT" +DHCP_OK_NEEDLE = "DHCP OK" +# Primary success indicator: the C64 prints this after the whole HTTPS +# exchange completes. +SUCCESS_NEEDLE = "CONNECTION CLOSED" +# Failure needles (any one of these means the C64 bailed out). +FAIL_NEEDLES = ( + "DNS RESOLVE FAILED", + "TCP CONNECT FAILED", + "TLS HANDSHAKE FAILED", + "TLS SEND FAILED", +) +# Progress needles we use to report how far we got on failure. +# Ordered roughly by expected appearance; _last_progress_seen picks the +# one with the latest rfind index on screen. +PROGRESS_NEEDLES = ( + "HTTPS GET", + "DNS OK", + "TCP CONNECTED", + "CH", + "SH", + "KEYS", + "ENC1", + "RX", + "GOT", + "DEC", + "PROC", + "EE", + "CERT", + "CV", + "FIN", + "CFIN", + "TLS HANDSHAKE OK", + "REQUEST SENT", + "CONNECTION CLOSED", +) +# Response body served by our test HTTPS server. +RESPONSE_BODY = "TLS13 OK FROM C64 TEST" + +# 1 MHz no-WARP timeouts. These are order-of-magnitude larger than the +# WARP-on variant because at stock speed ECDSA-P256 verify alone already +# dwarfs the whole handshake budget of the fast variant. +READY_TIMEOUT = 180.0 +MENU_TIMEOUT = 180.0 +DHCP_TIMEOUT = 180.0 +HTTPS_TIMEOUT = 9000.0 +ACCEPT_TIMEOUT = 9000.0 +POST_HANDSHAKE_TIMEOUT = 9000.0 +HEARTBEAT_INTERVAL = 120.0 + +# Per-heartbeat screen snapshot output directory. +SCREEN_SNAP_DIR = "/tmp/c64-https-phase3-1mhz-screens" +SCREEN_SNAP_KEEP = 30 + + +def _skip(reason: str) -> int: + print(f"SKIP: {reason}") + return 0 + + +def _ensure_built() -> bool: + if os.path.isfile(PRG_PATH): + return True + print("[build] c64-https.prg missing, running make...") + r = subprocess.run(["make"], cwd=_REPO_ROOT, capture_output=True, text=True) + if r.returncode != 0: + print(f" make failed (exit {r.returncode}):\n{r.stderr}") + return False + return os.path.isfile(PRG_PATH) + + +def _last_progress_seen(upper_screen: str) -> str: + """Return the latest progress marker seen on screen, or '(none)'.""" + last = "(none)" + last_idx = -1 + for needle in PROGRESS_NEEDLES: + idx = upper_screen.rfind(needle) + if idx > last_idx: + last_idx = idx + last = needle + return last + + +_LABELS_CACHE = None + +def _label_addr(name: str): + """Look up a label address in build/labels.txt; return int or None.""" + global _LABELS_CACHE + if _LABELS_CACHE is None: + _LABELS_CACHE = {} + try: + with open("/home/someone/c64-https/build/labels.txt") as f: + for line in f: + # format: "al C:xxxx .name" + parts = line.split() + if len(parts) >= 3 and parts[0] == "al": + addr_s = parts[1].split(":")[-1] + lbl = parts[2].lstrip(".") + try: + _LABELS_CACHE[lbl] = int(addr_s, 16) + except ValueError: + pass + except Exception: + pass + return _LABELS_CACHE.get(name) + + +def _save_heartbeat_screen(transport) -> None: + """Read the 1000 bytes of text screen RAM and save under SCREEN_SNAP_DIR. + + Keeps the last SCREEN_SNAP_KEEP snapshots by sorted filename (ISO time + stamps sort lexicographically). Silent on failure -- heartbeat must not + crash the poll loop. + """ + try: + os.makedirs(SCREEN_SNAP_DIR, exist_ok=True) + except Exception as e: + print(f" (screen snap mkdir failed: {e})") + return + try: + transport.resume() + data = transport.read_memory(0x0400, 1000) + except Exception as e: + print(f" (screen snap read failed: {e})") + return + ts = time.strftime("%Y%m%dT%H%M%S") + path = os.path.join(SCREEN_SNAP_DIR, f"heartbeat_{ts}.bin") + try: + with open(path, "wb") as f: + f.write(bytes(data)) + except Exception as e: + print(f" (screen snap write failed: {e})") + return + # Rotate -- keep only the most recent SCREEN_SNAP_KEEP files. + try: + entries = sorted( + p for p in os.listdir(SCREEN_SNAP_DIR) + if p.startswith("heartbeat_") and p.endswith(".bin") + ) + excess = len(entries) - SCREEN_SNAP_KEEP + for old in entries[:max(0, excess)]: + try: + os.remove(os.path.join(SCREEN_SNAP_DIR, old)) + except Exception: + pass + except Exception: + pass + + +def _dump_diagnostics(transport=None) -> None: + """Print dnsmasq log and host-side connectivity checks for post-mortem.""" + diag_log_path = "/tmp/c64-https-phase3-diag.log" + try: + diag_log = open(diag_log_path, "a", buffering=1) # line-buffered + _ts = time.strftime("%Y-%m-%d %H:%M:%S") + diag_log.write(f"\n=== diagnostic dump at {_ts} ===\n") + diag_log.flush() + except Exception: + diag_log = None + + def _emit(line: str) -> None: + print(line, flush=True) + if diag_log is not None: + try: + diag_log.write(line + "\n") + diag_log.flush() + except Exception: + pass + + dnsmasq_log = "/tmp/c64-https-dnsmasq.log" + if os.path.isfile(dnsmasq_log): + _emit(f"\n--- tail of {dnsmasq_log} ---") + with open(dnsmasq_log, "rb") as f: + data = f.read()[-4000:] + _emit(data.decode("utf-8", errors="replace")) + + # Host-side DNS check + try: + r = subprocess.run( + ["dig", "+short", "@10.0.65.1", "www.foo.bar"], + capture_output=True, text=True, timeout=5, + ) + _emit(f"\n dig @10.0.65.1 www.foo.bar -> {r.stdout.strip()}") + except Exception as e: + _emit(f" dig check failed: {e}") + + # Host-side HTTPS check (self-signed, so disable verification). + try: + import ssl as _ssl + import urllib.request + ctx = _ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = _ssl.CERT_NONE + resp = urllib.request.urlopen( + "https://10.0.65.1:443/", timeout=3, context=ctx + ) + _emit(f" HTTPS from host: {resp.status} {resp.read()[:100]}") + except Exception as e: + _emit(f" HTTPS from host failed: {e}") + + # ip65 error code from C64 memory + if transport is not None: + # Force-load labels so the PC/stack lookups below have data. + _label_addr("tls_state") + # CPU registers -- PC tells us where the 6502 is currently stuck. + try: + transport.resume() + regs = transport.read_registers() + pc = regs.get("PC", 0) + sp = regs.get("SP", 0) + a = regs.get("A", 0) + x = regs.get("X", 0) + y = regs.get("Y", 0) + _emit(f" CPU PC=${pc:04X} SP=${sp:02X} A=${a:02X} X=${x:02X} Y=${y:02X}") + # Find nearest label <= PC + nearest_name = None + nearest_addr = -1 + for name, addr in _LABELS_CACHE.items() if _LABELS_CACHE else []: + if addr <= pc and addr > nearest_addr: + nearest_addr = addr + nearest_name = name + if nearest_name is not None: + _emit(f" nearest label <= PC: {nearest_name} @ ${nearest_addr:04X} (PC+${pc-nearest_addr:X})") + except Exception as e: + _emit(f" read_registers failed: {e}") + + # Top of stack: return address chain from JSRs. + # 6502 SP indexes into $0100-$01FF; stack grows downward. + # Bytes ABOVE current SP (i.e. $0100+SP+1 .. $01FF) are live. + try: + transport.resume() + stack = transport.read_memory(0x01F0, 16) + _emit(f" stack $01F0-$01FF = {' '.join(f'{b:02X}' for b in stack)}") + # Parse as little-endian return-address pairs (each JSR pushes hi, lo + # where the saved addr = actual_return - 1). + _emit(" possible return-address pairs (addr+1 = instruction after JSR):") + for i in range(0, 16, 2): + lo = stack[i] + hi = stack[i + 1] + ret = ((hi << 8) | lo) + 1 + # Find nearest label <= ret + near_n = None + near_a = -1 + for name, addr in _LABELS_CACHE.items() if _LABELS_CACHE else []: + if addr <= ret and addr > near_a: + near_a = addr + near_n = name + tag = f"{near_n}+${ret-near_a:X}" if near_n else "?" + _emit(f" $01{0xF0+i:02X}: lo=${lo:02X} hi=${hi:02X} -> ${ret:04X} ({tag})") + except Exception as e: + _emit(f" stack read failed: {e}") + + try: + transport.resume() + err_addr = _label_addr("ip65_error") or 0x4CEA + err_data = transport.read_memory(err_addr, 1) + _emit(f" ip65_error @ ${err_addr:04X} = 0x{err_data[0]:02X}") + except Exception as e: + _emit(f" ip65_error read failed: {e}") + + state_names = { + 0x00: "IDLE", 0x01: "CLIENT_HELLO", 0x02: "SERVER_HELLO", + 0x03: "ENCRYPTED_EXT", 0x04: "CERTIFICATE", 0x05: "CERT_VERIFY", + 0x06: "FINISHED", 0x07: "CONNECTED", 0xFF: "ERROR", + } + + # TLS state machine progress (set before each step; $FF on error) + try: + transport.resume() + ts_addr = _label_addr("tls_state") + if ts_addr is not None: + tls_state = transport.read_memory(ts_addr, 1)[0] + name = state_names.get(tls_state, "UNKNOWN") + _emit(f" tls_state @ ${ts_addr:04X} = ${tls_state:02X} ({name})") + else: + _emit(" tls_state: label missing") + except Exception as e: + _emit(f" tls_state read failed: {e}") + + # Last attempted TLS state (preserved before error handler overwrote tls_state) + try: + transport.resume() + tls_addr = _label_addr("tls_last_state") + if tls_addr is not None: + last = transport.read_memory(tls_addr, 1)[0] + last_name = state_names.get(last, "UNKNOWN") + _emit(f" tls_last_state @ ${tls_addr:04X} = ${last:02X} ({last_name})") + else: + _emit(" tls_last_state: label missing") + except Exception as e: + _emit(f" tls_last_state read failed: {e}") + + # Most recent TLS record buffer head + try: + transport.resume() + buf_addr = _label_addr("tls_rec_buf") + if buf_addr is not None: + rec = transport.read_memory(buf_addr, 256) + _emit(f" tls_rec_buf @ ${buf_addr:04X} = ({len(rec)} bytes)") + for i in range(0, len(rec), 16): + line = rec[i:i+16] + hex_part = " ".join(f"{b:02X}" for b in line) + ascii_part = "".join(chr(b) if 32 <= b < 127 else "." for b in line) + _emit(f" +${i:02X} {hex_part:<47} {ascii_part}") + else: + _emit(" tls_rec_buf: label missing") + except Exception as e: + _emit(f" tls_rec_buf read failed: {e}") + + # Raw ip65 TCP receive ring — what ip65 actually delivered + try: + transport.resume() + ring_addr = _label_addr("tcp_recv_buf") + if ring_addr is not None: + ring = transport.read_memory(ring_addr, 256) + _emit(f" tcp_recv_buf @ ${ring_addr:04X} = ({len(ring)} bytes)") + for i in range(0, len(ring), 16): + line = ring[i:i+16] + hex_part = " ".join(f"{b:02X}" for b in line) + ascii_part = "".join(chr(b) if 32 <= b < 127 else "." for b in line) + _emit(f" +${i:02X} {hex_part:<47} {ascii_part}") + else: + _emit(" tcp_recv_buf: label missing") + except Exception as e: + _emit(f" tcp_recv_buf read failed: {e}") + + # Parser input: tls_hs_buf (stable copy made during record reception) + try: + transport.resume() + hs_addr = _label_addr("tls_hs_buf") + if hs_addr is not None: + hs = transport.read_memory(hs_addr, 128) + _emit(f" tls_hs_buf @ ${hs_addr:04X} = ({len(hs)} bytes)") + for i in range(0, len(hs), 16): + line = hs[i:i+16] + hex_part = " ".join(f"{b:02X}" for b in line) + ascii_part = "".join(chr(b) if 32 <= b < 127 else "." for b in line) + _emit(f" +${i:02X} {hex_part:<47} {ascii_part}") + else: + _emit(" tls_hs_buf: label missing") + except Exception as e: + _emit(f" tls_hs_buf read failed: {e}") + + # tls_rec_header raw 5-byte buffer (state-machine target) + try: + transport.resume() + hdr_addr = _label_addr("tls_rec_header") + if hdr_addr is not None: + hdr = transport.read_memory(hdr_addr, 5) + _emit(f" tls_rec_header @ ${hdr_addr:04X} = {' '.join(f'{b:02X}' for b in hdr)}") + else: + _emit(" tls_rec_header: label missing") + except Exception as e: + _emit(f" tls_rec_header read failed: {e}") + + # tls_recv_state and tls_recv_count (16-bit) — dynamic addrs + try: + transport.resume() + rs_addr = _label_addr("tls_recv_state") + rc_addr = _label_addr("tls_recv_count") + if rs_addr is not None: + rs_v = transport.read_memory(rs_addr, 1)[0] + _emit(f" tls_recv_state @ ${rs_addr:04X} = ${rs_v:02X}") + if rc_addr is not None: + rc_b = transport.read_memory(rc_addr, 2) + _emit(f" tls_recv_count @ ${rc_addr:04X} = ${rc_b[1]:02X}{rc_b[0]:02X}") + except Exception as e: + _emit(f" tls_recv_state read failed: {e}") + + # Single-byte diagnostic labels (dynamic; skip silently if missing) + for lbl_name in ("tls_hs_len", "tls_rec_len", "tls_rec_type"): + addr = _label_addr(lbl_name) + if addr is None: + continue + try: + transport.resume() + # 16-bit for *_len, 8-bit for type + n = 1 if lbl_name == "tls_rec_type" else 2 + b = transport.read_memory(addr, n) + if n == 1: + _emit(f" {lbl_name} @ ${addr:04X} = ${b[0]:02X}") + else: + _emit(f" {lbl_name} @ ${addr:04X} = ${b[1]:02X}{b[0]:02X}") + except Exception: + pass + + # tls_recv_progress — granular progress within tls_recv_server_hello + # $01=entered $02=record-recv ok $03=ct-handshake ok $04=copied to hs_buf $05=parse ok + try: + transport.resume() + prog_addr = _label_addr("tls_recv_progress") + if prog_addr is not None: + pv = transport.read_memory(prog_addr, 1)[0] + _emit(f" tls_recv_progress @ ${prog_addr:04X} = ${pv:02X}") + except Exception as e: + _emit(f" tls_recv_progress read failed: {e}") + + # tls_recv_sub_progress — granular progress within tls_record_recv_and_decrypt + sub_state_names = { + 0x00: "never-entered", + 0x01: "entered tls_record_recv_and_decrypt", + 0x02: "reading record header (state 0)", + 0x03: "header bytes received, parsing", + 0x04: "record type/version validated", + 0x05: "record length parsed, entering state 1", + 0x06: "reading record body (state 1)", + 0x07: "record body complete", + 0x08: "about to decrypt", + 0x09: "decrypt succeeded", + 0x0A: "returning success", + } + try: + transport.resume() + sub_addr = _label_addr("tls_recv_sub_progress") + if sub_addr is not None: + sv = transport.read_memory(sub_addr, 1)[0] + name = sub_state_names.get(sv, "UNKNOWN") + _emit(f" tls_recv_sub_progress @ ${sub_addr:04X} = ${sv:02X} ({name})") + except Exception as e: + _emit(f" tls_recv_sub_progress read failed: {e}") + + # tls_recv_poll_count — how many times @sh_wait looped + try: + transport.resume() + pc_addr = _label_addr("tls_recv_poll_count") + if pc_addr is not None: + pcb = transport.read_memory(pc_addr, 2) + pc = pcb[0] | (pcb[1] << 8) + _emit(f" tls_recv_poll_count @ ${pc_addr:04X} = {pc} (${pcb[1]:02X}{pcb[0]:02X})") + except Exception as e: + _emit(f" tls_recv_poll_count read failed: {e}") + + # TCP receive ring buffer head/tail — tells us if ip65 wrote data + # that TLS never drained. Both are 16-bit little-endian words. + try: + transport.resume() + head_addr = _label_addr("tcp_recv_head") + tail_addr = _label_addr("tcp_recv_tail") + ovf_addr = _label_addr("tcp_recv_overflow") + if head_addr is not None and tail_addr is not None: + hb = transport.read_memory(head_addr, 2) + tb = transport.read_memory(tail_addr, 2) + head = hb[0] | (hb[1] << 8) + tail = tb[0] | (tb[1] << 8) + avail = (tail - head) & 0xFFFF + _emit(f" tcp_recv_head @ ${head_addr:04X} = ${head:04X}") + _emit(f" tcp_recv_tail @ ${tail_addr:04X} = ${tail:04X}") + _emit(f" tcp ring available = {avail} bytes") + if ovf_addr is not None: + ov = transport.read_memory(ovf_addr, 1)[0] + _emit(f" tcp_recv_overflow @ ${ovf_addr:04X} = ${ov:02X}") + if avail > 0: + # dump first 48 bytes of ring starting at head (mod 4096) + buf_addr = _label_addr("tcp_recv_buf") + if buf_addr is not None: + ring = transport.read_memory(buf_addr, 4096) + n = min(avail, 48) + line_hex = " ".join(f"{ring[(head + i) & 0xFFF]:02X}" for i in range(n)) + _emit(f" ring[head..head+{n}] = {line_hex}") + except Exception as e: + _emit(f" tcp ring read failed: {e}") + + # Extended TLS key-material snapshot (1 MHz variant). These labels + # survived the tls_hs_buf elimination; any that didn't are skipped + # silently so the dump stays useful across refactors. + _extended_tls_labels = ( + ("tls_ecdhe_privkey", 32), + ("tls_ecdhe_pubkey", 32), + ("tls_server_pubkey", 32), + ("tls_shared_secret", 32), + ("tls_client_random", 32), + ("tls_server_random", 32), + ) + for lbl_name, nbytes in _extended_tls_labels: + addr = _label_addr(lbl_name) + if addr is None: + continue + try: + transport.resume() + buf = transport.read_memory(addr, nbytes) + _emit(f" {lbl_name} @ ${addr:04X} = ({len(buf)} bytes)") + for i in range(0, len(buf), 16): + line = buf[i:i+16] + hex_part = " ".join(f"{b:02X}" for b in line) + ascii_part = "".join(chr(b) if 32 <= b < 127 else "." for b in line) + _emit(f" +${i:02X} {hex_part:<47} {ascii_part}") + except Exception as e: + _emit(f" {lbl_name} read failed: {e}") + + if diag_log is not None: + try: + diag_log.close() + except Exception: + pass + + +def main() -> int: + # --- Pre-flight gate (must run BEFORE BridgeEnv, which mutates host + # netfilter via sudo). Refuses if the UCI HTTPS listener is still up. + if os.environ.get("VICE_HTTPS_OK_TO_RUN") != "1": + print( + "ABORT: VICE_HTTPS_OK_TO_RUN is not set.\n" + " This test is gated to prevent collision with the UCI HTTPS\n" + " listener (which binds 443/4433 on the LAN interface). The\n" + " U64E test is likely still running. Wait for it to finish,\n" + " then re-run with:\n" + " sudo env VICE_HTTPS_OK_TO_RUN=1 PYTHONPATH=tools \\\n" + " python3 tests/test_phase3_https_1mhz.py" + ) + return 2 + for port in (443,): + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + s.bind(("0.0.0.0", port)) + except OSError as e: + # something else is holding it — refuse + print(f"refusing: port {port} is in use (UCI test?): {e}") + return 2 + finally: + s.close() + + from https_e2e import ( + BridgeEnv, + check_prerequisites, + launch_vice_on_bridge, + shutdown_vice, + press_key, + wait_for_screen_text, + get_screen_text, + start_https_listener, + stop_https_listener, + ) + + missing = check_prerequisites() + if missing: + return _skip("missing prerequisites: " + "; ".join(missing)) + + if not _ensure_built(): + return _skip("c64-https.prg could not be built") + + handle = None + listener = None + try: + with BridgeEnv() as env: + try: + # --- Start HTTPS listener on bridge IP --- + print(f"\n=== Starting HTTPS listener on {env.bridge_ip}:443 ===") + listener = start_https_listener( + host=env.bridge_ip, + port=443, + response_body=RESPONSE_BODY, + ) + print(f" listener ready on {listener.host}:{listener.port}") + print(f" cert: {listener.cert_path}") + + # --- Launch VICE --- + print(f"\n=== Launching VICE on {env.tap0} with {PRG_PATH} ===") + # launch_vice_on_bridge hard-codes warp=False in ViceConfig + # (see tools/https_e2e/vice_on_bridge.py — "load-bearing: + # warp breaks DHCP"). We still assert below to catch any + # future drift that would invalidate the 1 MHz timing. + handle = launch_vice_on_bridge( + prg_path=PRG_PATH, + tap=env.tap0, + ready_timeout=READY_TIMEOUT, + ) + transport = handle.transport + # Verify WARP is actually off — 1 MHz timing assumption + # is load-bearing for every downstream timeout constant. + # WARP is hard-coded False in tools/https_e2e/vice_on_bridge.py ViceConfig; + # can't query it here (would need text_monitor_port on the transport). + + # --- Wait for boot menu --- + print(f"\n=== Waiting for boot menu ({MENU_NEEDLE!r}) ===") + try: + wait_for_screen_text(transport, MENU_NEEDLE, timeout=MENU_TIMEOUT) + except TimeoutError as e: + print(f"FAIL: boot menu did not appear\n{e}") + return 1 + print(" boot menu OK") + + # --- DHCP init --- + print("\n=== Pressing 'I' for DHCP init ===") + press_key(transport, "I") + + print(f"\n=== Waiting up to {DHCP_TIMEOUT:.0f}s for {DHCP_OK_NEEDLE!r} ===") + try: + wait_for_screen_text(transport, DHCP_OK_NEEDLE, timeout=DHCP_TIMEOUT) + except TimeoutError as e: + print(f"FAIL: DHCP did not complete\n{e}") + _dump_diagnostics(transport) + return 1 + print(" DHCP OK") + + # --- HTTPS GET --- + print("\n=== Pressing 'G' for HTTPS GET ===") + press_key(transport, "G") + + print(f"\n=== Waiting up to {HTTPS_TIMEOUT:.0f}s for HTTPS completion ===") + print(" (TLS handshake is slow: X25519 keygen ~3.6 min + handshake)") + # After pressing G, the C64 prints a success sequence + # culminating in "CONNECTION CLOSED", or one of the + # FAIL_NEEDLES on failure. Poll screen text and break + # on either. + deadline = time.monotonic() + HTTPS_TIMEOUT + final = "" + https_started = False + result = None # "pass" | "fail" + fail_reason = "" + last_progress = "(none)" + last_log_progress = "(none)" + next_heartbeat = time.monotonic() + HEARTBEAT_INTERVAL + + while time.monotonic() < deadline: + try: + transport.resume() + except Exception: + pass + time.sleep(3.0) + try: + final = get_screen_text(transport) + except Exception: + continue + + upper = final.upper() + + # Check if the HTTPS GET banner appeared. + idx_get = upper.find("HTTPS GET") + if idx_get < 0: + continue + if not https_started: + print(" HTTPS GET initiated") + https_started = True + + after_get = upper[idx_get:] + last_progress = _last_progress_seen(after_get) + + # Heartbeat log so the test shows forward motion. + if time.monotonic() >= next_heartbeat: + remaining = int(deadline - time.monotonic()) + # Sample ip65/TCP ring and net_poll counters so we + # can tell "slow progress" from "dead stuck". + hb_head = hb_tail = hb_pin = hb_pout = None + try: + transport.resume() + head_addr = _label_addr("tcp_recv_head") + tail_addr = _label_addr("tcp_recv_tail") + pin_addr = _label_addr("net_poll_entry_count") + pout_addr = _label_addr("net_poll_return_count") + if head_addr is not None: + b = transport.read_memory(head_addr, 2) + hb_head = b[0] | (b[1] << 8) + if tail_addr is not None: + b = transport.read_memory(tail_addr, 2) + hb_tail = b[0] | (b[1] << 8) + if pin_addr is not None: + b = transport.read_memory(pin_addr, 2) + hb_pin = b[0] | (b[1] << 8) + if pout_addr is not None: + b = transport.read_memory(pout_addr, 2) + hb_pout = b[0] | (b[1] << 8) + except Exception as _hb_exc: + print(f" (heartbeat sample failed: {_hb_exc})") + hb_extra = ( + f" head=${hb_head:04X}" if hb_head is not None else "" + ) + ( + f" tail=${hb_tail:04X}" if hb_tail is not None else "" + ) + ( + f" poll_in={hb_pin}" if hb_pin is not None else "" + ) + ( + f" poll_out={hb_pout}" if hb_pout is not None else "" + ) + print(f" [heartbeat] last seen: {last_progress} ({remaining}s left){hb_extra}") + # Also dump the 10 lines from idx_get onward so we + # can see fine-grained markers like ENC1/RX/GOT. + tail_lines = final[idx_get:].splitlines()[:12] + for tl in tail_lines: + tl_stripped = tl.rstrip() + if tl_stripped: + print(f" | {tl_stripped}") + # Save a raw screen snapshot to disk so a long run + # produces a post-mortem trail even if stdout is + # lost. + _save_heartbeat_screen(transport) + next_heartbeat = time.monotonic() + HEARTBEAT_INTERVAL + elif last_progress != last_log_progress: + print(f" progress: {last_progress}") + last_log_progress = last_progress + + # Short-circuit on any failure message. + failed = False + for needle in FAIL_NEEDLES: + if needle in after_get: + fail_reason = needle + failed = True + break + if failed: + result = "fail" + break + + # Primary success marker. + if SUCCESS_NEEDLE in after_get: + result = "pass" + break + + if result == "fail" or result != "pass": + if result == "fail": + reason = f"HTTPS GET reported {fail_reason}" + else: + reason = f"HTTPS GET did not complete within {HTTPS_TIMEOUT:.0f}s" + print(f"FAIL: {reason}") + try: + final = get_screen_text(transport) + except Exception: + pass + last_progress = _last_progress_seen(final.upper()) + print(f" last progress marker seen: {last_progress}") + print(f"\n--- final screen ---\n{final}") + _dump_diagnostics(transport) + return 1 + + print("\n=== PASS: HTTPS CONNECTION CLOSED seen on screen ===") + snippet = "\n".join(final.splitlines()[:25]) + print(f"--- final screen (first 25 lines) ---\n{snippet}") + print(f" last progress marker seen: " + f"{_last_progress_seen(final.upper())}") + + # Check for response body on screen (not a hard failure + # -- print_resp_body only writes up to 200 bytes and it + # may scroll). + body_upper = RESPONSE_BODY.upper() + if body_upper in final.upper(): + print(f" response body verified: {RESPONSE_BODY!r}") + else: + print(f" (response body not found on screen, may have scrolled)") + + return 0 + finally: + if listener is not None: + try: + stop_https_listener(listener) + except Exception as e: + print(f" stop_https_listener: {e}") + listener = None + if handle is not None: + try: + shutdown_vice(handle) + except Exception as e: + print(f" shutdown_vice: {e}") + handle = None + except Exception as e: + print(f"FAIL: unexpected error: {e}") + import traceback + traceback.print_exc() + return 1 + + +if __name__ == "__main__": + sys.exit(main())