Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 88 additions & 5 deletions CLAUDE.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -224,12 +224,63 @@ corrupt the UCI command protocol.
**Fix:** A nested delay-loop macro `uci_fence` (defined in
`src/net/uci/uci_regs.inc`) is inserted after every read/write to UCI
registers `$DF1C-$DF1F`. Parameters: `UCI_FENCE_OUTER = 5`,
`UCI_FENCE_INNER = 100`, yielding ~2525 cycles (~52 us at 48 MHz,
35% safety margin). 14 bytes per fence site, 24 fence sites total
(11 write + 13 read). At 1 MHz the same loop costs ~2.5 ms per
`UCI_FENCE_INNER = 217`, yielding ~5450 cycles (85.2 us at 64 MHz —
35% margin over the C64 Ultimate's empirically-bracketed floor;
113.5 us at 48 MHz). 14 bytes per fence site, 24 fence sites total
(11 write + 13 read). At 1 MHz the same loop costs ~5.5 ms per
access — negligible for networking.

48 MHz turbo is fully supported and verified on real U64E hardware.
The C64 Ultimate (firmware 1.1.0, core 1.49) needs MORE inter-access
time than the U64E's ~38 us, and the tighter floor only bites under
sustained CMD_DATA bursts: at 51.6 us spacing DHCP/GET_IPADDR works
but TCP_CONNECT's ~15-byte hostname push is silently lost
(UCI_ERR_NO_SOCKET, firmware never opens the socket). Floor bracketed
at 64 MHz: 51.6 us FAIL / 62.9 us PASS — see the tuning matrix in
`uci_regs.inc`. The U64E-era INNER=100 (52.6 us at 48 MHz) was never
observed failing on U64E but sits below the C64U floor; INNER=217 is
safe on both devices at every speed.

48 MHz turbo is fully supported and verified on real U64E hardware;
64 MHz is supported and verified on the C64 Ultimate (see "C64
Ultimate notes" below).

### C64 Ultimate notes

A second UCI-capable device joined the bench 2026-07-19: a **C64
Ultimate "Starlight Edition"** (product "C64 Ultimate", firmware
1.1.0, FPGA 122, core 1.49, NTSC mode, WiFi-connected) at
10.53.21.158 — `U64_HOST=10.53.21.158`. Differences from the U64E
that this codebase now handles:

- **64 MHz turbo** — the C64U's CPU Speed enum adds "64" (and drops
" 5"). c64-test-harness's `CPU_SPEED_BY_MHZ` carries the superset.
E2e verified at 64 MHz (see benchmarks below).
- **Runtime speed-switch quirk** — changing CPU speed via the REST
config API while the PRG is running can glitch the UCI bridge so
the NEXT pushed command is silently lost (reproduced 2x as
UCI_ERR_NO_SOCKET on the first TCP_CONNECT after a 1→64 switch,
even with a 100 us fence; a 1→48 switch happened to survive).
`tools/uci/test_https_local.py` now sets turbo BEFORE
reset/run_prg so the machine boots at target speed and never
switches mid-session. Mirror that pattern in new scripts
(`test_https_local_p384.py` still uses the old late-switch order —
fix when the P-384 build unblocks).
- **Wider fence floor** — see the delay-loop fence section above
(INNER=217 accommodates both devices).
- **Multiple network interfaces** — Ethernet AND WiFi. GET_IPADDR
(iface=0) returns 0.0.0.0 on a WiFi-connected box;
`net_dhcp_acquire` probes iface 0..3 and takes the first lease.
- **REU ships disabled** — fresh C64U config has `RAM Expansion
Unit: Disabled`; without it sibling-nistcurves `fp_mul` silently
computes garbage (same failure mode as the VICE `-reu` gotcha).
Enable via REST config write. NOTE: the C64U has no `"REU"`
Cartridge preset (presets list is just `[""]`), so the harness's
`set_reu()` helper — which also sets `Cartridge: "REU"` — is
incompatible as written; set `RAM Expansion Unit: Enabled`
directly. Config writes are runtime-only (revert on power cycle).
- Same UCI register map, ID byte $C9, command set, and DeviceLock /
enable_uci flow as the U64E — boot_check/phase2/phase3/e2e scripts
run unmodified.

### Memory layout under UCI

Expand DownExpand Up@@ -488,7 +539,39 @@ Under the current `libs/nistcurves@v0.3.0` pin (post-PR #55,
c64-lib-contract-aligned) the U64E 48 MHz handshake measures **82.1 s**
end-to-end (verified 2026-05-20 against the local listener; the prior
v0.2.0 measurement was 86.7 s, and the pre-Phase-C.4 in-tree path was
~110 s). v0.3.0's hot-path code is essentially unchanged from v0.2.0;
~110 s).

On the **C64 Ultimate** (10.53.21.158, see "C64 Ultimate notes"),
measured 2026-07-19 with the INNER=217 fence and boot-at-speed flow:

- 48 MHz: **73.0 s** end-to-end (faster than the U64E's 82.1 s at
the same clock — different FPGA core)
- 64 MHz: **64.7 s** end-to-end — first >48 MHz datapoint. The
48→64 ratio (0.89) is well short of the ideal 0.75.

**Why turbo stops paying (measured 2026-07-19):** isolated
`ecdsa_verify_256` bench (`bench_ecdsa_u64e.py`, RFC 6979 vector, n=3
medians on the C64U) gives 53.8 s @ 48 MHz / 47.4 s @ 64 MHz. Fitting
T(f) = D + C/f to both pairs:

CPU-scaled C speed-invariant D
ECDSA verify 1.22 Gcycles 28.4 s (53% of wall @ 48)
full HTTPS e2e 1.59 Gcycles 39.8 s

D is self-consistent to 0.1 s from either endpoint. The 28.4 s
verify-side D matches the sibling fp_mul's REU row-fetch traffic:
each 256-bit multiply DMAs up to 32 rows x 512 B = 16 KB from REU
banks 0/1, and REU DMA runs at the stock ~1 MB/s bus rate
regardless of CPU turbo (independently evidenced by the P-384
overlay swap: 2x7.5 KB in ~16 ms at 48 MHz = ~1.04 us/B). ~28 s
= ~27 MB of row DMA per verify at that rate. The remaining
~11.4 s of e2e D is UCI firmware/network latency. Above ~48 MHz
the verify is majority-DMA-bound; the projected ceiling with this
fp_mul is T(inf) ~= D = 28 s no matter the clock. Getting
meaningfully faster requires cutting REU traffic in the sibling
library (fetch-free on-chip square-table mul a la c64-x25519 —
breakeven vs row DMA is ~2.5 MHz — or narrower row transfers),
tracked as a c64-nist-curves issue candidate. v0.3.0's hot-path code is essentially unchanged from v0.2.0;
the small wall-clock improvement is within measurement noise across
runs. It is fine for the local listener used by the e2e harness (600 s
budget, ample headroom). Further speedups live in the sibling
Expand Down
7 changes: 5 additions & 2 deletions cfg/c64-https-uci.cfg
Original file line numberDiff line numberDiff line change
Expand Up@@ -76,13 +76,16 @@ MEMORY {
# but only used ~$125 — folding it into NET_BSS_TAIL frees ~370 B
# for the libs/nistcurves spill-over). ip65 has had an equivalent
# NET_BSS_TAIL since Phase C.4 — UCI now adopts the same pattern.
NET_CODE: start = $2000, size = $1B26, file = %O, define = yes, fill = yes, fillval = $00;
# Grown $40 (iface-fallback loop in net_dhcp_acquire, C64U WiFi
# support) — donated from NET_BSS_TAIL, whose occupancy tops out at
# $625 (LIB_NISTCURVES_P256_BSS) leaving $B5 slack pre-donation.
NET_CODE: start = $2000, size = $1B66, file = %O, define = yes, fill = yes, fillval = $00;
# NET_BSS_TAIL spans NET_CODE end through the start of CRYPTO_OVERLAY,
# subsuming the historical UCI_BSS_REGION at $4000-$41FF (UCI_BSS now
# rides on the head of this combined region). Holds 1.7 KB total —
# ld65 distributes UCI_BSS (~293 B) + LIB_NISTCURVES_P256_BSS (~1.5
# KB) + a stub UCI_BSS_REGION definition.
NET_BSS_TAIL: start = $3B26, size = $06DA, file = %O, define = yes, fill = yes, fillval = $00;
NET_BSS_TAIL: start = $3B66, size = $069A, file = %O, define = yes, fill = yes, fillval = $00;
# UCI_BSS_REGION kept as a zero-size alias so any external tooling
# that resolves the symbol via labels.txt still finds it (the cfg's
# `define = yes` emits __UCI_BSS_REGION_START__ etc.).
Expand Down
58 changes: 48 additions & 10 deletions src/net/uci/net.s
Original file line numberDiff line numberDiff line change
Expand Up@@ -333,22 +333,36 @@ net_poll:
; =============================================================================
; net_dhcp_acquire — read the firmware-assigned IP via UCI GET_IPADDR
;
; The U64E firmware runs DHCP autonomously before the PRG is launched, so
; our job is to READ the result, not to perform DHCP ourselves. Sequence:
; The Ultimate firmware runs DHCP autonomously before the PRG is launched,
; so our job is to READ the result, not to perform DHCP ourselves. Sequence
; (per interface):
;
; wait_idle -> begin_cmd(NETWORK) -> put(CMD_GET_IPADDR) -> put(iface=0)
; wait_idle -> begin_cmd(NETWORK) -> put(CMD_GET_IPADDR) -> put(iface)
; -> push_wait -> check_err -> read 12 bytes -> drain resp
; -> drain status -> ack
;
; Interface fallback: the U64E has a single interface (index 0), but the
; C64 Ultimate has Ethernet AND WiFi — a box on WiFi returns 0.0.0.0 for
; index 0. We probe indices 0..NET_DHCP_MAX_IFACE-1 and take the first
; one with a non-zero lease. A CMD_FAILED on an out-of-range index is
; cleaned up (drain + ack) and treated like "no lease on this interface".
;
; The 12-byte response layout is IP(4) + Netmask(4) + Gateway(4). We copy
; the first 4 bytes into net_local_ip. If all four are zero we treat the
; call as having failed (no DHCP lease) and return C=1.
; the first 4 bytes into net_local_ip. If all probed interfaces yield a
; zero IP we return C=1 with net_last_error = UCI_ERR_NO_IP (or
; UCI_ERR_CMD_FAILED if the last probe failed at the command layer).
;
; Clobbers: A, X, Y
; Output: C=0 on success (net_local_ip populated), C=1 on failure
; (net_last_error contains the specific failure code).
; =============================================================================
NET_DHCP_MAX_IFACE = 4 ; probe interface indices 0..3

net_dhcp_acquire:
lda #$00
sta @iface_idx ; SMC-style local, no-ZP file convention

@next_iface:
jsr uci_wait_idle
bcs @dhcp_wait_to ; FPGA wedged — bail with C=1

Expand All@@ -358,9 +372,9 @@ net_dhcp_acquire:
lda #UCI_CMD_GET_IPADDR
jsr uci_put_byte

; Interface index 0 — matches the build_get_ip helper in
; c64-test-harness/src/c64_test_harness/uci_network.py.
lda #$00
; Interface index — 0 first (only iface on U64E; Ethernet on the
; C64 Ultimate), then 1.. (C64U WiFi) until one has a lease.
lda @iface_idx
jsr uci_put_byte

jsr uci_push_wait
Expand All@@ -370,12 +384,24 @@ net_dhcp_acquire:
jsr uci_check_err
bcc @no_err

; Command failed for this interface (e.g. index out of range on
; single-interface firmware). Clean up response/status state so
; the next probe starts from idle, then advance.
lda #UCI_ERR_CMD_FAILED
sta net_last_error
jsr uci_drain_resp
bcs @dhcp_wait_to
jsr uci_drain_status
bcs @dhcp_wait_to
jsr uci_ack
jmp @advance

@dhcp_wait_to:
sec
rts

@iface_idx: .byte 0

@no_err:
; Read the 12-byte response into uci_ipaddr_resp.
lda #<uci_ipaddr_resp
Expand DownExpand Up@@ -403,7 +429,8 @@ net_dhcp_acquire:
dex
bpl @copy_ip

; If all four bytes are zero the firmware has no lease yet.
; If all four bytes are zero this interface has no lease —
; fall through to probe the next one.
lda net_local_ip+0
ora net_local_ip+1
ora net_local_ip+2
Expand All@@ -412,10 +439,21 @@ net_dhcp_acquire:

lda #UCI_ERR_NO_IP
sta net_last_error
sec

@advance:
inc @iface_idx
lda @iface_idx
cmp #NET_DHCP_MAX_IFACE
bcc @next_iface
sec ; every interface probed, none had a lease
rts

@have_ip:
; Clear any residue from earlier no-lease probes (e.g. iface 0's
; UCI_ERR_NO_IP on a WiFi-connected C64U) so diagnostics don't
; read a stale error next to a successful acquire.
lda #$00
sta net_last_error
clc
rts

Expand Down
20 changes: 16 additions & 4 deletions src/net/uci/uci_regs.inc
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,17 +57,29 @@ UCI_TARGET_NETWORK = $03 ; network stack
; the FPGA needs). Instead we use a nested delay loop:
; total cycles ≈ OUTER * (INNER * 5 + 5)
;
; Tuned empirically via binary search at 48 MHz:
; Tuned empirically via binary search at 48 MHz on U64E:
; OUTER=3 INNER=121 (~1830 cycles, ~38 µs at 48 MHz) = FAIL
; OUTER=3 INNER=122 (~1845 cycles, ~38.4 µs at 48 MHz) = PASS (minimum)
; OUTER=5 INNER=100 (~2525 cycles, ~52 µs at 48 MHz) = chosen (35% margin)
; OUTER=5 INNER=100 (~2525 cycles, ~52 µs at 48 MHz) = U64E-era choice
;
; At 1 MHz the overhead is ~2.5 ms per access — acceptable for networking.
; Re-tuned at 64 MHz on the C64 Ultimate (firmware 1.1.0, core 1.49),
; whose floor is HIGHER than the U64E's ~38 µs — and only bites under
; sustained CMD_DATA bursts (TCP_CONNECT hostname push; short commands
; like GET_IPADDR survive spacings that lose the connect command):
; OUTER=5 INNER=131 (3300 cycles, 51.6 µs at 64) = FAIL (NO_SOCKET —
; DHCP fine, TCP_CONNECT never latched)
; OUTER=5 INNER=160 (4025 cycles, 62.9 µs at 64) = PASS
; OUTER=5 INNER=255 (6400 cycles, 100.0 µs at 64) = PASS
; OUTER=5 INNER=217 (5450 cycles, 85.2 µs at 64) = chosen — 35%
; margin over the worst-case floor (62.9 µs);
; 113.5 µs at 48 MHz, ~5.5 ms at 1 MHz
;
; At 1 MHz the overhead is ~5.5 ms per access — acceptable for networking.
; The macro preserves A and X via the stack, costing ~14 bytes per call
; site (vs 256 for the NOP sled that still wasn't enough).
; =============================================================================
UCI_FENCE_OUTER = 5 ; outer loop iterations
UCI_FENCE_INNER = 100 ; inner loop iterations
UCI_FENCE_INNER = 217 ; inner loop iterations

.macro uci_fence
pha ; save A
Expand Down
14 changes: 9 additions & 5 deletions tools/uci/bench_ecdsa_u64e.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -180,16 +180,20 @@ def _prepare_vector_buffers(transport: Ultimate64Transport,
"""DMA-write all ecdsa_* input buffers from a JSON vector dict."""
# Zero-fill first (buffers are 48 bytes each to handle P-384 hypothetical);
# verify_256 reads the first 32 only but pre-zeroing is paranoid-safe.
# These buffers legitimately live inside CRYPTO_COLD_SHADOW
# ($A000-$BFFF) post-W1, which the MemoryPolicy reserves — override
# with a reason rather than widening the policy for everyone.
_OVR = "ecdsa vector input buffers (CRYPTO_BSS in CRYPTO_COLD_SHADOW)"
zero48 = bytes(48)
for name in ("ecdsa_hash", "ecdsa_sig_r", "ecdsa_sig_s",
"ecdsa_pubkey_x", "ecdsa_pubkey_y"):
transport.write_memory(labels[name], zero48)
transport.write_memory(labels[name], zero48, override=_OVR)

# Write the 32-byte big-endian values verbatim.
def _write_be32(name: str, hex_str: str) -> None:
raw = bytes.fromhex(hex_str)
assert len(raw) == 32, f"{name}: expected 32 bytes, got {len(raw)}"
transport.write_memory(labels[name], raw)
transport.write_memory(labels[name], raw, override=_OVR)

_write_be32("ecdsa_hash", vec["hash"])
_write_be32("ecdsa_sig_r", vec["sig_r"])
Expand All@@ -198,9 +202,9 @@ def _write_be32(name: str, hex_str: str) -> None:
_write_be32("ecdsa_pubkey_y", vec["pubkey_y"])

# Control bytes.
transport.write_memory(labels["ecdsa_curve_id"], bytes([0]))
transport.write_memory(labels["ecdsa_hash_len"], bytes([32]))
transport.write_memory(labels["ecdsa_sig_len"], bytes([32]))
transport.write_memory(labels["ecdsa_curve_id"], bytes([0]), override=_OVR)
transport.write_memory(labels["ecdsa_hash_len"], bytes([32]), override=_OVR)
transport.write_memory(labels["ecdsa_sig_len"], bytes([32]), override=_OVR)


def _run_single(transport: Ultimate64Transport,
Expand Down
20 changes: 15 additions & 5 deletions tools/uci/test_https_local.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -1185,6 +1185,19 @@ def main() -> int:
)
return 3

# --- Set turbo BEFORE booting the PRG ---
# C64 Ultimate quirk (firmware 1.1.0, core 1.49): a runtime CPU
# speed change via the REST config API can glitch the UCI bridge
# so that the next pushed command is silently lost — observed as
# UCI_ERR_NO_SOCKET on the first TCP_CONNECT after a 1->64 MHz
# switch (2x reproduced; 1->48 happened to survive). Booting at
# the target speed avoids the mid-session switch entirely and
# also makes boot speed deterministic (it used to be whatever
# the previous run left in the device config).
print(f"Setting turbo to {TURBO_MHZ} MHz...")
set_turbo_mhz(client, TURBO_MHZ)
time.sleep(0.5)

print("Resetting machine...")
client.reset()
time.sleep(2.5)
Expand All@@ -1200,12 +1213,9 @@ def main() -> int:
if init_flag == 0:
print("WARNING: net_initialized is 0 — auto-init may have failed")

# --- Flip to 48 MHz turbo BEFORE we trigger the stub ---
print(f"Setting turbo to {TURBO_MHZ} MHz...")
set_turbo_mhz(client, TURBO_MHZ)
time.sleep(0.5)
# (Turbo was set before boot — see the C64U quirk note above.)

# --- Start 6510 debug-stream capture (after turbo, before trigger) ---
# --- Start 6510 debug-stream capture (before trigger) ---
if DEBUG_CAPTURE_ENABLED:
try:
set_debug_stream_mode(client, DEBUG_MODE_6510)
Expand Down