Skip to content
Draft
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
44 changes: 37 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,13 +162,43 @@ bank 2; banks 6-7 reserved for the P-384 overlay experiment.

Switching backend = a different cfg + different `src/net/<backend>/*.o`.

**`src/net_abi.inc` is documentation, not an enforced interface.** Nothing
includes it; the surface TLS/HTTP/boot actually import is `net_init`,
`net_dhcp`, `net_poll`, `net_print_ip`, `net_dns_resolve`,
`net_tcp_connect/close/send`, `net_send_len`, `net_recv_byte`,
`net_banner_str`. ip65 exports only six of the header's twelve and has no
`net_last_error` (no error channel at all); UCI exports all twelve. Treat
the header as a TODO list until #70 lands.
**`src/net_abi.inc` is the build-enforced boundary** (c64-lib-contract
SPEC §13, issue #70). `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.s` exports `NET_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. The `NET_FAMILY_*` bits are `src/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_READ` is theirs
and datagram-only. **`$FFFF` is 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_poll` caps the copy at the request and never emits `$8A`; a header
above the request that is NOT `$FFFF` leaves `$8B UCI_ERR_BAD_READ_HDR`
(C=0, stream continues) — the stream-family counterpart of `$8A`. Allocate new codes
in SPEC §13.2's table first — `$8B` was the first one allocated that way.
- Gone, per §13.1: `net_tcp_set_recv_cb` (stub), `net_recv_ready`,
`net_dhcp` (alias), and `net_print_ip` — IP printing is consumer UI and
is now `print_local_ip` in `boot.s`, one copy for both backends.
- Byte accounting on ip65 (the tight one): LOADER went from 16 B free to
58 B; `print_local_ip` rides LOADER_OVERFLOW, so the NET_CODE tail that
is `HTTPS_HOST`/`HTTPS_PATH`'s ip65 budget shrank from 170 to ~60 B
beyond the default strings (wikipedia's +46 B still builds on both ip65
profiles; the theoretical 165 B host+path maximum no longer does).

- `src/net/ip65/` — ip65/RR-Net (cs8900a). Blob loaded at $2000 via
`.incbin`; `net.s` is the adapter; `ip65_symbols.inc` is the single
Expand Down
6 changes: 3 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ IP65_DIR := ip65
IP65_BUILD := ip65-build
IP65_BIN := $(IP65_BUILD)/ip65-c64.bin

CA65FLAGS := -I src -I src/inc -I src/crypto/shared -I src/net/$(BACKEND) -I build --debug-info
CA65FLAGS := -I src -I src/inc -I src/crypto/shared -I src/net -I src/net/$(BACKEND) -I build --debug-info
# Binary-include search roots for `.incbin` (issue #116).
#
# `-I` above does NOT feed `.incbin` — that is a separate search path in ca65,
Expand Down Expand Up @@ -191,8 +191,8 @@ CRYPTO_SRCS_ALL := $(wildcard src/crypto/*.s)
# overlay swap dispatcher, init orchestrator, shared sqtab stub. Always
# linked; sibling-lib integration (Phase C.3) hangs off these.
CRYPTO_SHARED_SRCS := $(wildcard src/crypto/shared/*.s)
IP65_SRCS := src/net/ip65/ip65_blob.s src/net/ip65/net.s src/net/ip65/net_banner.s src/net/ip65/exports.s
UCI_SRCS := src/net/uci/net.s src/net/uci/uci_cmd.s
IP65_SRCS := src/net/ip65/ip65_blob.s src/net/ip65/net.s src/net/ip65/net_banner.s src/net/ip65/exports.s src/net/ip65/net_manifest.s
UCI_SRCS := src/net/uci/net.s src/net/uci/uci_cmd.s src/net/uci/net_manifest.s

# Sibling-lib archive set. Phase C.3's nistcurves-p384 archive remains an
# external overlay image (see below), not linked into the main PRG.
Expand Down
18 changes: 9 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,15 +118,15 @@ The TLS, HTTP, and crypto layers are backend-agnostic: switching backend
is a link-line change (different cfg + different `src/net/<backend>/*.o`),
not a call-site change.

`src/net_abi.inc` is **documentation, not an enforced interface**. No
translation unit `.include`s it (`grep -rn net_abi src/ tools/ cfg/ Makefile`
returns only comments), so none of its twelve `.import`s is checked by
the assembler or the linker. The symbols it declares and the
symbols TLS/HTTP/boot actually import overlap in 6 of 17, and the ip65
adapter exports neither `net_dhcp_acquire`, `net_tcp_set_recv_cb`,
`net_local_ip`, `net_resolved_ip`, `net_last_error` nor `net_tcp_state`
(`net_last_error` exists only under UCI, so ip65 has no error channel).
Making the header real, or deleting it, is item P1 of issue #70.
`src/net_abi.inc` is the **build-enforced** boundary between those layers
and the backend, aligned with
[c64-lib-contract SPEC §13](https://github.com/JC-000/c64-lib-contract)
(issue #70): every consumer TU that touches the network `.include`s it and
imports nothing else, both backends export the full core + TCP + DNS
surface, each ships a `net_manifest.s` declaring its families (ip65 also
declares its blob footprint per §13.7), and `src/net_abi_asserts.s` fails
the link if a backend stops providing a family. Error codes are
namespaced per §13.2: ip65 `$40-$7F`, UCI `$80-$BF`.

### TLS 1.3 Cipher Suite

Expand Down
180 changes: 180 additions & 0 deletions docs/engineering-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -2741,3 +2741,183 @@ plumbing stays in tree; deleting it is a separate, larger cleanup. The
`build/labels.txt` order-only defect is untouched — after this guard it
affects only `USE_OVERLAY_P384_EMBED`, which cannot complete anyway, so a
fix there would be unverifiable.

## Issue #70 — the network ABI becomes real (2026-08-24)

What `src/net_abi.inc` used to be is recorded above ("Networking backend
ABI": nothing included it, 6 of 17 symbols overlapped, ip65 had no error
channel). This entry records what changed and what it cost.

### The change

- `src/net_abi.inc` is `.include`d by `boot.s`, `http.s`,
`tls_record_io.s`, `tls13.s`; their ad-hoc `.import net_*` lines are
gone. ld65 requires every import to resolve even when unused, so the
header now forces both backends to export the whole core/TCP/DNS surface.
- ip65 grew `net_dhcp_acquire` (was `net_dhcp`), `net_local_ip`
(copy of `ip65_cfg_ip` on DHCP success), `net_resolved_ip` (copy of
`ip65_dns_ip_addr`), `net_last_error` (`NET_ERR_IP65_*`, `$41-$45`,
`src/net/ip65/ip65_errors.inc`) and `net_tcp_state`. The ip65 driver
reports only a carry, so each code names the adapter entry that failed.
- UCI: `net_dhcp` alias, `net_tcp_set_recv_cb` stub and `net_print_ip`
deleted; `UCI_TCP_*` → `NET_TCP_*` (aliases kept in `uci_errors.inc`);
ring-full now sets `tcp_recv_overflow` (§13.3); `$8A UCI_ERR_LONG_READ`
reserved (allocated by c64-wireguard, not emitted here).
- `net_print_ip` was two identical routines, one per backend, differing
only in which IP variable they read. With `net_local_ip` on both it is
one consumer-side `print_local_ip` in `boot.s` (SPEC §13.1 lists
`net_print_ip` under "deliberately not in the contract").
- `net_recv_ready` retired (nobody imported it; `net_recv_byte`'s C flag
is the same test). `net_tcp_recv_cb` / `net_save_zp` / `net_restore_zp`
un-exported (§13.5); `tools/test_net.py` reaches them via
`build/labels.txt`, which carries local labels.
- New: `src/net/net_families.inc` (contract block verbatim),
`src/net/net_states.inc`, `src/net/{ip65,uci}/net_manifest.s`,
`src/net_abi_asserts.s` (§13.8; also took the §13.3 ring-mask assert
from `lib_contract_asserts.s`). ip65's manifest carries the §13.7 blob
equates and link-asserts `LIB_NET_IP65_BLOB_SIZE` against
`ip65_blob_end - ip65_blob_start`, labels added around the `.incbin`.
- `NET_BACKEND_FAMILIES` is exported `: absolute`. Without it ld65 warns
`Address size mismatch` — the byte-sized value infers zeropage — which
is contract #74 recurring in §13.0; worth a spec note upstream.

### Byte accounting (the part that constrained the design)

ip65 LOADER had 16 B free. The new ip65 logic (~95 B: four state stores,
two 4-byte copies, six error stores) fits only because `net_print_ip`
(~83 B) and `net_recv_ready` (14 B) left LOADER first:

region / segment (ip65) before after
LOADER free 16 B 58 B
LOADER_OVERFLOW $DE (222 B) $14C (332 B) + print_local_ip
NET_CODE tail free 170 B 60 B
BSS (COLD_SHADOW) +10 B (net_local_ip, net_resolved_ip,
net_last_error, net_tcp_state)

`print_local_ip` went to `LOADER_OVERFLOW` (NET_CODE tail) rather than
`HTTP_AUX_CODE2` (CRYPTO_RESIDENT, 150 B free on ip65-plain) on purpose:
CRYPTO_RESIDENT is where the next `libs/nistcurves` bump lands, and
ip65-onchip's copy of it is much tighter than ip65-plain's. The cost is
the ip65 `HTTPS_HOST`/`HTTPS_PATH` budget, which is now ~60 B beyond the
default strings. Measured: the wikipedia target (+46 B) still builds on
ip65-plain and ip65-onchip (`HTTPS_TARGET_RODATA` ends `$3FF1`, 14 B to
spare on onchip); the theoretical 63+100 B maximum no longer does. ip65
cannot reach a real server in any case (~36 min per handshake), so the
budget that shrank is the one with the least to buy.

PRG sizes are unchanged (47,105 / 62,977 B — both images are padded to
region ends); hashes changed, as they must.

### Evidence

- VICE: `tools/test_net.py` 65/65 (ring, callback, ZP save/restore, the
new empty-ring `net_recv_byte` oracle), `tools/test_http.py` 61/61,
`tools/test_tls_handshake.py` 21/21.
- Builds: ip65-plain, uci-plain, ip65-onchip + wikipedia target,
uci-comb + wikipedia target + `HTTPS_BODY_TO_REU=1`, all clean, no
ld65 warnings.
- Hardware (U64E, uci-onchip PRG `9e31c1bc…`, 48 MHz): `boot_check` PASS;
`phase2_check` PASS — DHCP through `net_dhcp_acquire`, `net_local_ip` =
10.43.23.81 read back over DMA, `net_last_error` = 0; `rig_https_live`
github.com **PASS, HTTP 200, CFIN 48.1 s** through the full ABI path.
`rig_https_local` FAILs — and fails identically on master `ab5de2b` as a
control (`tls_last_state=4`, sub-progress `$31`): it sends the listener's
IP as the hostname and the v0.4.2 SAN check rejects it. Pre-existing,
filed as #141; not evidence about this change.
- The `UCI_TCP_*` → `NET_TCP_*` promotion is byte-neutral: the onchip PRG
hashed `9e31c1bc…` before and after the rename (aliases are equal).
- Not run: the ip65 VICE ethernet rig (`tests/rig_vice_https_macos.py`)
needs `sudo` for the feth pair; the ip65 changes are covered by
`test_net.py` at the adapter level only.

### Cross-repo

- c64-lib-contract: §13.2 gains the cross-consumer error-code allocation
table (ip65 `$41-$45`, UCI `$81-$8A`) and the allocate-here-first rule;
§13.0 gains a canonical `net_families.inc`; §13.8 records c64-https as
aligned. The `$8A` case is why: c64-wireguard allocated it on
2026-08-24 and only a cross-repo diff noticed.
- c64-wireguard#48 keeps its own items (UDP-family renames, manifests);
`d9cd021`'s ring clamp is flagged there as §13.3 context for wg#46.

### #140 — the SOCKET_READ header is not a length (same day)

Filed by the c64-wireguard lane while reviewing the above: `net_poll` took
the response header straight into `uci_poll_rem` and copied; a wild
header was survived only because the copy loop re-checks ring-full and
DATA_AV per byte. They had learned it the hard way — c64-wireguard PR #62
trusted the header and copied ~18 KB through `$D000`, leaving packet
bytes in the VIC registers.

**First cut, and why it was wrong.** Ported their UDP check verbatim:
compare the header to `uci_req_len`, and on an over-claim drop the
response, emit `$8A UCI_ERR_LONG_READ`, mark the socket `NET_TCP_ERROR`.
It fired on **every** real github.com session, at the first poll, with
the ring empty (`tls_last_state=2`). Adding the pair to the rig's
post-mortem dump gave the numbers:

uci_req_len $0200
uci_read_hdr $FFFF

fw 3.14d answers a 512 B TCP `SOCKET_READ` with `$FFFF` — the same
sentinel c64-wireguard measured for a 1500 B UDP request. On TCP the
header is not a delivered count; the FIFO simply delivers what it has
(bounded by the request) and `DATA_AV` drops. "Over-claim" is therefore
routine on this path and the wrong frame for a code; a drop would abort
every handshake, which is exactly what it did.

**What landed instead**: the copy count is capped at `uci_req_len` — a
memory-safety bound, not an error. Bytes beyond the request were never
delivered (they stay in the firmware for the next poll), so the cap loses
nothing. `$8A` stays reserved with c64-wireguard's UDP meaning (datagram
length exceeded the request → datagram dropped) and this adapter never
emits it; the registry row in c64-lib-contract#139 is being reworded to
scope the meaning by transport, because two UCI-family adapters were
about to give one byte two operational meanings — the `$88` failure one
row down, caught by the cross-repo view a second time in one release
cycle.

The reverse port — our ring-free clamp into c64-wireguard — does NOT
apply either: their inbound is a flat interlocked buffer with no wrap,
and on 3.14d any request size other than 512 breaks their read path
outright (GideonZ/1541ultimate#802).

**Detector kept.** A bare cap would also have normalised a third case —
a header above the request that is *not* `$FFFF`, which the firmware has
no documented mode for — silently. The review on c64-lib-contract#139
asked for that to stay distinguishable, and it does: `$8B
UCI_ERR_BAD_READ_HDR`, a best-effort breadcrumb on the §13.1 short-write
pattern (copy still capped, C=0, stream continues, code left in
`net_last_error`). Allocated in SPEC §13.2's table before the adapter
emitted it — the first code to go through the rule the table exists for.

**Correction, same evening — `$FFFF` is the no-data sentinel.** The
c64-wireguard lane measured every *idle* UDP `SOCKET_READ` returning
header `$FFFF`, `rx_len` 0, across 11 sizes. Re-reading our own capture:
`tls_last_state=2` means the first polls after ClientHello, while
ServerHello is still in flight — idle polls. "~690 B queued" above was
an inference, not a measurement, and the mechanism note warned against
exactly that. So `$FFFF` is the firmware's no-data sentinel on both
transports; the drop-and-error cut aborted on an empty read, and
c64-wireguard's "`$8A` fires routinely during healthy sessions" runbook
line was the same misfiling, documented as a firmware quirk for four
days. The shipped code is unaffected (cap, then the `DATA_AV` check
copies zero bytes) but every comment that said "more than you asked"
was wrong and is corrected. Registry rows now pair `$8A` (datagram:
drop, terminal) with `$8B` (stream: cap, advisory) as one observation
with family-specific disposition, and require excluding `$FFFF` first.

**And the founding incident retires too.** The contract reviewer went
back to c64-wireguard PR #62 (`6fecc97`), the commit `$8A` was born
on: its own message says the copy count came straight from the header
into a loop whose SMC store bumps its own high byte, from a buffer at
`$89D4`. `$FFFF` = 65535 as a copy count from an idle poll writes
straight through `$D000` — the ~18 KB, the VIC damage, all of it, with
no firmware over-claim involved. So after excluding the sentinel there
is **no measured non-sentinel over-claim on either transport**; `$8A`
and `$8B` are reserved defensively, and the registry says so. The rule
that travels: exclude the sentinel before any length arithmetic, and
never let a device-supplied count be the only bound on a store loop —
which is what the cap in `net_poll` now guarantees regardless of what
the header says.

88 changes: 78 additions & 10 deletions src/boot.s
Original file line number Diff line number Diff line change
Expand Up @@ -119,14 +119,8 @@
.import crypto_init

; ---- imports: network (backend adapter — ip65 or uci) ----
.import net_init
.import net_dhcp
.import net_poll
.import net_print_ip
.import net_dns_resolve
.import net_tcp_connect
.import net_tcp_close
.import net_banner_str
; Networking comes ONLY through the §13 ABI header (issue #70).
.include "net_abi.inc"

; ---- imports: TLS state machine ----
.import tls_connect
Expand Down Expand Up @@ -421,7 +415,7 @@ do_net_init:
ldy #>dhcp_msg
jsr print_string

jsr net_dhcp
jsr net_dhcp_acquire
bcc @dhcp_ok

lda #<dhcp_fail_msg
Expand All @@ -433,7 +427,7 @@ do_net_init:
lda #<dhcp_ok_msg
ldy #>dhcp_ok_msg
jsr print_string
jsr net_print_ip
jsr print_local_ip

lda #1
sta net_initialized
Expand Down Expand Up @@ -1122,6 +1116,80 @@ reu_p384_overlay_init:
; =============================================================================
; Strings (read-only)
; =============================================================================
; =============================================================================
; print_local_ip - print net_local_ip in dotted decimal + CR
;
; Consumer UI, deliberately outside the network ABI (c64-lib-contract SPEC
; §13.1 lists net_print_ip under "deliberately not in the contract"). One
; copy for both backends now that net_local_ip is ABI data on ip65 too;
; it replaced two identical per-backend routines. Rides LOADER_OVERFLOW:
; the ip65 LOADER region is the tightest in the image and this was the
; one routine that could leave it.
; =============================================================================
.segment "LOADER_OVERFLOW"
print_local_ip:
lda net_local_ip+0
jsr @print_byte
lda #'.'
jsr chrout
lda net_local_ip+1
jsr @print_byte
lda #'.'
jsr chrout
lda net_local_ip+2
jsr @print_byte
lda #'.'
jsr chrout
lda net_local_ip+3
jsr @print_byte
lda #$0d
jmp chrout

; print decimal byte value (0-255), no leading zeros
@print_byte:
sta @pb_val
ldx #0
sec
@pb_100:
sbc #100
bcc @pb_100d
inx
jmp @pb_100
@pb_100d:
adc #100
cpx #0
beq @pb_tens ; skip leading zero
pha
txa
ora #$30
jsr chrout
pla
@pb_tens:
ldx #0
sec
@pb_10:
sbc #10
bcc @pb_10d
inx
jmp @pb_10
@pb_10d:
adc #10
cpx #0
bne @pb_t_out
ldy @pb_val
cpy #10
bcc @pb_ones ; value < 10, skip tens digit
@pb_t_out:
pha
txa
ora #$30
jsr chrout
pla
@pb_ones:
ora #$30
jmp chrout
@pb_val: .byte 0

.segment "RODATA"

; Banner is split in two so the per-backend `net_banner_str` (imported
Expand Down
Loading