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
53 changes: 47 additions & 6 deletions CLAUDE.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -132,11 +132,23 @@ into `uci_host_buf` (256 bytes in UCI_BSS); `net_tcp_connect` passes
it to firmware. Dotted-quad IP literals work because firmware passes
them through.

### Firmware quirk — per-byte NEXT_DATA ACK
### Firmware quirk — FPGA register timing (delay-loop fence)

Per-byte `NEXT_DATA` ACK truncates multi-byte responses on the current
U64E firmware revision. The read path uses a tight-poll pattern instead
(read `$DF1E` until `DATA_AV` clears). Documented in `uci_cmd.s`.
The U64E's UCI FPGA needs **~38 us** between consecutive register
accesses regardless of CPU clock speed. At stock 1 MHz the bus cycle
time naturally satisfies this. At turbo speeds (4-48 MHz) the CPU
outruns the FPGA, causing double-latched writes and stale reads that
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
access — negligible for networking.

48 MHz turbo is fully supported and verified on real U64E hardware.

### Memory layout under UCI

Expand All@@ -160,11 +172,19 @@ Scripts under `tools/uci/` require a U64E at 192.168.1.81 and use
- `test_http_local.py` — HTTP GET against a local test server
- `test_http_live.py` — HTTP GET against a real internet host (requires
internet access from the U64E)
- `test_https_local.py` — HTTPS e2e scaffolding against a local TLS 1.3
listener (ECDSA-P256 cert from
`tools/https_e2e/certs/`). DMAs a 6502 stub
that calls `http_get`, flips the U64E to 48
MHz turbo, and captures full diagnostics on
pass or timeout. `DEBUG_CAPTURE=1` enables a
bounded 6510 bus stream for post-mortem.
Reproducibly stalls at `tls_state=0x03`
(see Known issues below) — the test exists
to capture the stall, not to fix it.

### Known issues

- Ring buffer needs explicit zeroing before `http_get_plain` calls
(stale data from auto-init polling).
- `http_status` parsing is garbled on large responses because the
poll-timeout counter in `http.s` expires before all headers are
consumed under UCI's slower `net_poll` round-trip. Body arrives
Expand All@@ -174,6 +194,27 @@ Scripts under `tools/uci/` require a U64E at 192.168.1.81 and use
- Boot banner line 03 still says "rr-net" under ip65 build even
though Phase 2 made it backend-aware — this is correct/expected
behavior. Under UCI it says "ULTIMATE 64 ELITE (UCI)".
- The delay-loop fence adds ~2.5 ms overhead per UCI register access
at 1 MHz (negligible for networking, but visible in tight loops).
- TLS 1.3 handshake stalls mid-flight on real U64E at 48 MHz turbo.
Server sends its full flight and the C64 consumes ServerHello plus
partial encrypted records (tcp_recv ring drains to ~$02B1), then
TLS recv waits indefinitely for more bytes at `tls_state=0x03`.
Reproducible under `tools/uci/test_https_local.py`. Root cause not
yet identified. DHCP and plain HTTP are unaffected at all speeds.

### Design note — bounded timeouts must use wall-clock time

Any future robustness work on the UCI adapter's spin-wait helpers
(`uci_wait_idle`, `uci_push_wait`, etc.) MUST use a wall-clock time
source — CIA timer on stock C64, TOD clock on U64E — rather than a
cycle-counted iteration budget. The fences around every UCI register
access make per-iteration cost scale with CPU speed: a budget that is
ample at 1 MHz collapses to far too short at 48 MHz because turbo
scales CPU cycles but not the FPGA's wire-level operation durations.
A prior attempt on branch `feat/net-drain-abi` split waits into
fast/long tiers with cycle-count budgets and broke DHCP at turbo for
exactly this reason; the branch was abandoned.

## Memory layout

Expand Down
29 changes: 23 additions & 6 deletions src/net/uci/net.s
Original file line numberDiff line numberDiff line change
Expand Up@@ -91,6 +91,7 @@ net_init:
jsr uci_abort

lda UCI_ID
uci_fence ; settle before comparing ID
cmp #UCI_ID_VALUE
beq @present

Expand DownExpand Up@@ -175,16 +176,23 @@ net_poll:
; SMC dst. Loop style matches uci_read_resp_bytes — tight-poll
; DATA_AV and read UCI_RESP_DATA; the firmware FIFO auto-advances
; on read (Phase 2 finding), so NO per-byte NEXT_DATA.
uci_fence ; give firmware time to stage response
ldy #$00
@hdr_loop:
lda UCI_STATUS
uci_fence ; settle before testing DATA_AV
and #UCI_STAT_DATA_AV
beq @hdr_done_short
bne @hdr_got ; branch past trampoline
jmp @hdr_done_short ; long branch: fence too wide for BEQ
@hdr_got:
lda UCI_RESP_DATA
uci_fence ; settle before storing header byte
sta uci_read_hdr,y
iny
cpy #2
bcc @hdr_loop
bcs @hdr_got2 ; branch past trampoline (inverted BCC)
jmp @hdr_loop ; long branch back: fence too wide for BCC
@hdr_got2:
jmp @hdr_done

@hdr_done_short:
Expand DownExpand Up@@ -234,13 +242,15 @@ net_poll:
bne @not_full
lda uci_next_hi
cmp tcp_recv_head+1
beq @done_data ; ring full — drop the rest
bne @not_full
jmp @done_data ; ring full — drop the rest

@not_full:
; Wait for DATA_AV — the firmware streams data in bursts; if the
; FIFO drained mid-record we bail (shouldn't happen if firmware
; honored actual_len but we defend anyway).
lda UCI_STATUS
uci_fence ; settle before testing DATA_AV
and #UCI_STAT_DATA_AV
bne @have_byte
jmp @done_data
Expand All@@ -256,6 +266,7 @@ net_poll:
sta @rb_store+2

lda UCI_RESP_DATA
uci_fence ; settle before storing data byte
@rb_store:
sta $ffff ; SMC: patched each byte

Expand DownExpand Up@@ -406,13 +417,18 @@ net_tcp_connect:
ldy #$00
@host_loop:
lda uci_host_buf,y
beq @host_done
bne @host_push ; branch past trampoline
jmp @host_done ; long branch: fence too wide for BEQ
@host_push:
sta UCI_CMD_DATA
uci_fence ; heavy fence: hostname bytes at 48 MHz
iny
bne @host_loop ; bounded by 256 B (and by null before that)
beq @host_done ; Y wrapped to 0 — stop (bounded by 256 B)
jmp @host_loop ; long branch back: fence too wide for BNE
@host_done:
lda #$00
sta UCI_CMD_DATA ; explicit null terminator
uci_fence

jsr uci_push_wait

Expand DownExpand Up@@ -531,6 +547,7 @@ net_tcp_send:
@sb_load:
lda $ffff,y ; SMC: source base patched above
sta UCI_CMD_DATA
uci_fence ; heavy fence: FIFO overruns at 48 MHz with standard fence
iny
bne @sb_nohi
inc @sb_load+2 ; advance base high byte
Expand DownExpand Up@@ -820,7 +837,7 @@ net_recv_byte:
.segment "RODATA"

net_banner_str:
.byte "ULTIMATE 64 ELITE (UCI)"
.byte "UCI NETWORKING"
.byte $0d, 0

; =============================================================================
Expand Down
102 changes: 81 additions & 21 deletions src/net/uci/uci_cmd.s
Original file line numberDiff line numberDiff line change
Expand Up@@ -53,6 +53,7 @@
uci_abort:
lda #UCI_CTRL_ABORT
sta UCI_CONTROL
uci_fence
ldx #$20
@spin:
dex
Expand All@@ -67,8 +68,11 @@ uci_abort:
; =============================================================================
uci_wait_idle:
lda UCI_STATUS
uci_fence ; settle read before testing bits
and #(UCI_STAT_STATE | UCI_STAT_CMD_BUSY) ; $31
bne uci_wait_idle
beq @idle_done
jmp uci_wait_idle ; long branch: fence too wide for BNE
@idle_done:
rts

; =============================================================================
Expand All@@ -79,8 +83,11 @@ uci_wait_idle:
; =============================================================================
uci_wait_not_busy:
lda UCI_STATUS
uci_fence ; settle read before testing bits
and #UCI_STAT_CMD_BUSY
bne uci_wait_not_busy
beq @busy_done
jmp uci_wait_not_busy ; long branch: fence too wide for BNE
@busy_done:
rts

; =============================================================================
Expand All@@ -91,6 +98,7 @@ uci_wait_not_busy:
; =============================================================================
uci_begin_cmd:
sta UCI_CMD_DATA
uci_fence
rts

; =============================================================================
Expand All@@ -100,15 +108,33 @@ uci_begin_cmd:
; =============================================================================
uci_put_byte:
sta UCI_CMD_DATA
uci_fence
rts

; =============================================================================
; uci_push_wait — commit pushed bytes as a command, then wait for CMD_BUSY=0
; Clobbers: A
;
; At turbo speeds the FPGA may not have latched PUSH_CMD by the time the
; CPU starts polling CMD_BUSY. A plain uci_fence after the write gives only
; ≈ 2 µs at 48 MHz — insufficient for the FPGA to assert CMD_BUSY. We add
; a short delay loop ($40 iterations ≈ 6 µs at 48 MHz, ≈ 300 µs at 1 MHz)
; before polling, ensuring CMD_BUSY has been asserted by the time we check.
;
; Clobbers: A, X
; =============================================================================
uci_push_wait:
lda #UCI_CTRL_PUSH_CMD
sta UCI_CONTROL
uci_fence
; Fixed settle delay — at turbo speeds the FPGA may not have
; latched PUSH_CMD and asserted CMD_BUSY by the time the CPU
; starts polling. $FF iterations × 5 cycles ≈ 27 µs at 48 MHz,
; ≈ 1.3 ms at 1 MHz — sufficient for the FPGA to latch the
; command without using inline NOP fences that bloat code size.
ldx #$FF
@pw_settle:
dex
bne @pw_settle
jmp uci_wait_not_busy

; =============================================================================
Expand All@@ -118,16 +144,18 @@ uci_push_wait:
; =============================================================================
uci_check_err:
lda UCI_STATUS
uci_fence ; settle before testing error bit
and #UCI_STAT_ERROR
beq @no_err
bne @has_err
clc
rts
@has_err:
; clear the latched error
lda #UCI_CTRL_CLR_ERR
sta UCI_CONTROL
uci_fence
sec
rts
@no_err:
clc
rts

; =============================================================================
; uci_ack — single NEXT_DATA pulse (advance response/status FIFO by one byte)
Expand All@@ -136,6 +164,7 @@ uci_check_err:
uci_ack:
lda #UCI_CTRL_NEXT_DATA
sta UCI_CONTROL
uci_fence
rts

; =============================================================================
Expand All@@ -158,31 +187,56 @@ uci_ack:
; =============================================================================
uci_read_resp_bytes:
; Patch the dst pointer into the STA abs,Y instruction below.
; The inner loop mirrors the SOCKET_READ read pattern in
; c64-test-harness/scripts/test_uci_tcp_echo.py (lines ~350-362):
; tight-poll DATA_AV and read $DF1E directly — the UCI response
; FIFO auto-advances on read, so no per-byte NEXT_DATA is needed
; inside the loop. NEXT_DATA acknowledgment happens once at the
; end via uci_drain_resp / uci_ack.
; At turbo speeds the firmware may not have staged response data
; by the time the CPU reaches this point (e.g. TCP_CONNECT takes
; a full network round-trip). Use a 16-bit spin-wait on DATA_AV
; so we tolerate up to ~150 ms at 48 MHz without bailing early.
lda uci_resp_dst
sta @rd_store+1
lda uci_resp_dst+1
sta @rd_store+2
ldy #$00
@rd_loop:
cpy uci_resp_max
bcs @rd_done
bcc @rd_not_max
jmp @rd_done
@rd_not_max:
; 16-bit spin-wait for DATA_AV. ~65536 iterations; at 48 MHz
; each iteration is ~110 cycles → total ≈ 150 ms, enough for
; TCP handshakes over a LAN. X is preserved across the wait.
stx @rd_save_x
lda #$00
sta @rd_ctr_hi
ldx #$00
@rd_wait:
lda UCI_STATUS
uci_fence ; settle before testing DATA_AV
and #UCI_STAT_DATA_AV
beq @rd_done
bne @rd_have
dex
beq @rd_xzero
jmp @rd_wait ; long branch: fence too wide for BNE
@rd_xzero:
dec @rd_ctr_hi
beq @rd_timeout
jmp @rd_wait ; long branch: fence too wide for BNE
@rd_timeout:
; Timeout: DATA_AV never appeared — bail with partial read.
ldx @rd_save_x
jmp @rd_done
@rd_have:
ldx @rd_save_x
lda UCI_RESP_DATA
uci_fence ; settle before storing/looping
@rd_store:
sta $FFFF,y ; SMC: dst low/high patched above
iny
jmp @rd_loop
@rd_done:
sty uci_resp_count
rts
@rd_save_x: .byte 0
@rd_ctr_hi: .byte 0

; =============================================================================
; uci_drain_resp — ACK remaining response bytes until DATA_AV is clear.
Expand All@@ -193,14 +247,17 @@ uci_read_resp_bytes:
; =============================================================================
uci_drain_resp:
lda UCI_STATUS
uci_fence ; settle before testing DATA_AV
and #UCI_STAT_DATA_AV
beq @drn_done
bne @drn_have
rts
@drn_have:
lda UCI_RESP_DATA
uci_fence ; settle before NEXT_DATA write
lda #UCI_CTRL_NEXT_DATA
sta UCI_CONTROL
uci_fence
jmp uci_drain_resp
@drn_done:
rts

; =============================================================================
; uci_drain_status — ACK remaining status string bytes until STAT_AV is clear.
Expand All@@ -209,14 +266,17 @@ uci_drain_resp:
; =============================================================================
uci_drain_status:
lda UCI_STATUS
uci_fence ; settle before testing STAT_AV
and #UCI_STAT_STAT_AV
beq @dst_done
bne @dst_have
rts
@dst_have:
lda UCI_STATUS_DATA
uci_fence ; settle before NEXT_DATA write
lda #UCI_CTRL_NEXT_DATA
sta UCI_CONTROL
uci_fence
jmp uci_drain_status
@dst_done:
rts

; =============================================================================
; Control block for uci_read_resp_bytes — lives in UCI_BSS so no ZP is needed
Expand Down
Loading