From fe0f97d7b8317e7999893e2aa06d6e4983798164 Mon Sep 17 00:00:00 2001 From: JC-000 <3798556+JC-000@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:43:49 -0500 Subject: [PATCH 1/4] =?UTF-8?q?net:=20make=20src/net=5Fabi.inc=20the=20rea?= =?UTF-8?q?l=20backend=20boundary=20(c64-lib-contract=20=C2=A713,=20#70)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header used to be documentation: nothing included it, its declared surface overlapped the used one in 6 of 17 symbols, and the ip65 adapter had no error channel. Now every network-touching TU (boot, http, tls_record_io, tls13) `.include`s it and imports no `net_*` directly, so both backends must export the whole core/TCP/DNS surface or the link fails by name. ip65 (the tight one, LOADER had 16 B free): - net_dhcp -> net_dhcp_acquire; net_local_ip / net_resolved_ip copied from ip65_cfg_ip / ip65_dns_ip_addr on success; net_last_error with NET_ERR_IP65_* ($41-$45, ip65_errors.inc, the §13.2 ip65 range); net_tcp_state per §13.1. - net_print_ip and net_recv_ready left the adapter (below), which is what paid for the ~95 B of new logic in place. LOADER ends with 58 B free instead of 16. - net_tcp_recv_cb / net_save_zp / net_restore_zp un-exported (§13.5). - net_manifest.s: NET_BACKEND_FAMILIES = CORE|TCP|DNS plus the §13.7 blob equates, with LIB_NET_IP65_BLOB_SIZE link-asserted against ip65_blob_end - ip65_blob_start (labels added around the .incbin). UCI: - net_dhcp alias, net_tcp_set_recv_cb stub and net_print_ip deleted; UCI_TCP_* -> NET_TCP_* in code (aliases kept in uci_errors.inc); ring-full now sets tcp_recv_overflow (§13.3); $8A UCI_ERR_LONG_READ reserved with c64-wireguard's meaning, not emitted. - net_manifest.s: CORE|TCP|DNS (DNS by deferral). Consumer side: - src/net/net_families.inc (contract block verbatim, never exported), src/net/net_states.inc (NET_TCP_* values), src/net_abi_asserts.s (§13.8 family asserts + the §13.3 ring-mask assert moved out of lib_contract_asserts.s). - print_local_ip in boot.s replaces the two identical per-backend net_print_ip routines (§13.1 "deliberately not in the contract"), reading the now-ABI net_local_ip; it rides LOADER_OVERFLOW so the ip65 LOADER region gains rather than loses. Cost: the ip65 HTTPS_HOST/HTTPS_PATH tail budget is ~60 B beyond the defaults now; the wikipedia target (+46 B) still builds on both ip65 profiles. - NET_BACKEND_FAMILIES exported `: absolute` — a byte-sized equate otherwise infers zeropage and ld65 warns (contract #74 recurring). Evidence: - ip65-plain, uci-plain, ip65-onchip+wikipedia, uci-comb+wikipedia+ HTTPS_BODY_TO_REU all link clean, no ld65 warnings; PRG sizes unchanged (47,105 / 62,977 B). - VICE: test_net.py 65/65, test_http.py 61/61, test_tls_handshake.py 21/21. - U64E (uci-onchip, 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_local FAILs on this branch AND on master ab5de2b identically (name check vs the IP-as-hostname the rig sends) — filed separately; see the PR for the live-server run used instead. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 38 +++++-- Makefile | 6 +- README.md | 18 ++-- docs/engineering-notes.md | 98 ++++++++++++++++++ src/boot.s | 88 ++++++++++++++-- src/http.s | 8 +- src/lib_contract_asserts.s | 9 +- src/net/ip65/README.md | 31 +++--- src/net/ip65/ip65_blob.s | 5 + src/net/ip65/ip65_errors.inc | 23 +++++ src/net/ip65/net.s | 194 ++++++++++++++++------------------- src/net/ip65/net_manifest.s | 32 ++++++ src/net/net_families.inc | 21 ++++ src/net/net_states.inc | 16 +++ src/net/uci/net.s | 157 +++++++--------------------- src/net/uci/net_manifest.s | 14 +++ src/net/uci/uci_errors.inc | 33 ++++-- src/net_abi.inc | 117 +++++++++++++-------- src/net_abi_asserts.s | 31 ++++++ src/tls13.s | 2 +- src/tls_record_io.s | 4 +- tests/rig_phase1_dhcp.py | 2 +- tools/test_net.py | 29 +++--- 23 files changed, 629 insertions(+), 347 deletions(-) create mode 100644 src/net/ip65/ip65_errors.inc create mode 100644 src/net/ip65/net_manifest.s create mode 100644 src/net/net_families.inc create mode 100644 src/net/net_states.inc create mode 100644 src/net/uci/net_manifest.s create mode 100644 src/net_abi_asserts.s diff --git a/CLAUDE.md b/CLAUDE.md index 60211b1..580600d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -162,13 +162,37 @@ bank 2; banks 6-7 reserved for the P-384 overlay experiment. Switching backend = a different cfg + different `src/net//*.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//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 reserved here; allocate new codes in SPEC §13.2's table first. + - 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 diff --git a/Makefile b/Makefile index b2051ca..4447ba2 100644 --- a/Makefile +++ b/Makefile @@ -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, @@ -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. diff --git a/README.md b/README.md index 83baecb..4fc16b8 100644 --- a/README.md +++ b/README.md @@ -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//*.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 diff --git a/docs/engineering-notes.md b/docs/engineering-notes.md index aa4cbee..a793102 100644 --- a/docs/engineering-notes.md +++ b/docs/engineering-notes.md @@ -2741,3 +2741,101 @@ 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. diff --git a/src/boot.s b/src/boot.s index 71303d0..c84f2b0 100644 --- a/src/boot.s +++ b/src/boot.s @@ -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 @@ -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_ok_msg jsr print_string - jsr net_print_ip + jsr print_local_ip lda #1 sta net_initialized @@ -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 diff --git a/src/http.s b/src/http.s index f7be310..f6e230a 100644 --- a/src/http.s +++ b/src/http.s @@ -78,13 +78,7 @@ .import tls_recv ; ---- imports: net.asm wrappers around ip65 ---- - .import net_dns_resolve - .import net_tcp_connect - .import net_tcp_close - .import net_tcp_send - .import net_send_len - .import net_poll - .import net_recv_byte + .include "net_abi.inc" ; the §13 surface, nothing imported directly ; ============================================================================= ; http_get - perform an HTTPS GET request diff --git a/src/lib_contract_asserts.s b/src/lib_contract_asserts.s index 83b4ff7..853c39b 100644 --- a/src/lib_contract_asserts.s +++ b/src/lib_contract_asserts.s @@ -294,10 +294,7 @@ APP_OWNED = LIB_SHARED_PRIMITIVES_SQTAB | LIB_SHARED_PRIMITIVES_REU_MUL | LIB_SH ; ===================================================================== -; §13.3 — TCP rx ring shape +; §13 — network backend ABI ; ===================================================================== -; The ring mask must be 2^n - 1 or the backends' `and TCP_RECV_MASK` -; wrap arithmetic aliases addresses instead of wrapping. Assemble-time -; (`error`, not `lderror`) because TCP_RECV_MASK is a local equate from -; constants.inc, not an import. -.assert (TCP_RECV_MASK & (TCP_RECV_MASK + 1)) = 0, error, "TCP_RECV_MASK must be 2^n - 1 (c64-lib-contract SPEC §13.3)" +; Lives in src/net_abi_asserts.s (issue #70): the §13.0 family asserts +; plus the §13.3 ring-mask check that used to sit here. diff --git a/src/net/ip65/README.md b/src/net/ip65/README.md index 9af98e1..3560cc2 100644 --- a/src/net/ip65/README.md +++ b/src/net/ip65/README.md @@ -1,16 +1,23 @@ # src/net/ip65 — ip65 / RR-Net backend -The default networking backend for c64-https. Provides `net_*` entry +The default networking backend for c64-https. Provides the `net_*` entry points on top of the ip65 TCP/IP stack with the RR-Net ethernet driver. -**It does not implement all of `src/net_abi.inc`, and nothing checks -that it does.** That header is `.include`d by no translation unit, so -it is documentation rather than an enforced interface. This backend -exports six of its twelve symbols (`net_init`, `net_poll`, -`net_dns_resolve`, `net_tcp_connect`, `net_tcp_send`, `net_tcp_close`) -and omits `net_dhcp_acquire`, `net_tcp_set_recv_cb`, `net_local_ip`, -`net_resolved_ip`, `net_last_error` and `net_tcp_state` — see `net.s:28` -("deferred to Phase 7", which shipped). It exports `net_dhcp` in place -of `net_dhcp_acquire`. Alignment is tracked in issue #70; see the -"Networking backend ABI" section of `CLAUDE.md` for the measured -declared-vs-used surface. +It implements the full surface `src/net_abi.inc` imports, aligned with +c64-lib-contract SPEC §13 (issue #70): core, TCP and DNS families — +`net_manifest.s` declares `NET_BACKEND_FAMILIES = CORE|TCP|DNS` plus the +§13.7 footprint of the position-linked blob (`$2000`, `$1B27` bytes, +BSS `$4000-$4F8B`), and the blob size is link-asserted against the bytes +actually `.incbin`'d. + +Error channel: `net_last_error` carries `NET_ERR_IP65_*` codes from +`ip65_errors.inc`, allocated in the §13.2 ip65-family range `$40-$7F`. +ip65 itself reports only a carry, so each code names the adapter entry +point that failed. `net_tcp_state` follows `NET_TCP_*` +(`src/net/net_states.inc`); `net_local_ip` / `net_resolved_ip` are copies +of `ip65_cfg_ip` / `ip65_dns_ip_addr` taken on success. + +Adapter-internal and deliberately not exported (§13.5): the TCP receive +callback and the crypto-ZP save/restore around every ip65 call. The +save/restore is load-bearing — the callback fires inside `ip65_process` +while ip65's ZP `$02-$1B` is live. diff --git a/src/net/ip65/ip65_blob.s b/src/net/ip65/ip65_blob.s index 47f0e7e..244ccf6 100644 --- a/src/net/ip65/ip65_blob.s +++ b/src/net/ip65/ip65_blob.s @@ -17,6 +17,10 @@ .segment "NET_CODE" +; Span labels for the §13.7 footprint assert in net_manifest.s. +.export ip65_blob_start, ip65_blob_end +ip65_blob_start: + ; The blob is found through ca65's BINARY include path, set by the ; Makefile to an absolute $(abspath $(IP65_BUILD)). Hence the bare ; filename: there is deliberately no `../` here to resolve. @@ -55,3 +59,4 @@ ; see through .incbin, so without it this object could be assembled before ; the blob exists (issue #89). .incbin "ip65-c64.bin" +ip65_blob_end: diff --git a/src/net/ip65/ip65_errors.inc b/src/net/ip65/ip65_errors.inc new file mode 100644 index 0000000..249baab --- /dev/null +++ b/src/net/ip65/ip65_errors.inc @@ -0,0 +1,23 @@ +; src/net/ip65/ip65_errors.inc — ip65 backend error codes. +; +; Values stored in net_last_error by the ip65 adapter; $00 = OK. +; Allocated from the ip65-family range $40-$7F that c64-lib-contract SPEC +; §13.2 reserves (UCI-family codes live in $80-$BF, see +; src/net/uci/uci_errors.inc). The ip65 driver itself reports only a carry, +; so each code names the adapter entry point that failed rather than a +; driver-level cause; ip65's own `ip65_error` byte is not surfaced. +; +; The allocation is recorded in SPEC §13.2's table. Allocate a new code +; THERE first, then here — the range is shared with every ip65-family +; backend in the fleet. + +.ifndef IP65_ERRORS_INC_INCLUDED +IP65_ERRORS_INC_INCLUDED = 1 + +NET_ERR_IP65_INIT = $41 ; ip65_init failed (no RR-Net / cs8900a found) +NET_ERR_IP65_DHCP = $42 ; ip65_dhcp_init failed (no lease) +NET_ERR_IP65_DNS = $43 ; ip65_dns_resolve failed +NET_ERR_IP65_CONNECT = $44 ; ip65_tcp_connect failed +NET_ERR_IP65_SEND = $45 ; ip65_tcp_send failed + +.endif diff --git a/src/net/ip65/net.s b/src/net/ip65/net.s index ba14085..61a37e3 100644 --- a/src/net/ip65/net.s +++ b/src/net/ip65/net.s @@ -22,22 +22,30 @@ .include "constants.inc" .include "ip65_symbols.inc" +.include "ip65_errors.inc" ; NET_ERR_IP65_* ($40-$7F, SPEC §13.2) +.include "net_states.inc" ; NET_TCP_* (SPEC §13.1) -; --- Public ABI (what the rest of the firmware imports) --- -; Names match the legacy ACME entry points; net_abi.inc-style renames -; (net_dhcp_acquire, net_tcp_set_recv_cb, etc.) are deferred to Phase 7. +; --- Public ABI: exactly the surface src/net_abi.inc imports (SPEC §13) --- +; Core family .export net_init -.export net_dhcp +.export net_dhcp_acquire .export net_poll -.export net_dns_resolve +.export net_local_ip +.export net_last_error +; TCP family .export net_tcp_connect .export net_tcp_send +.export net_send_len .export net_tcp_close -.export net_print_ip -.export net_recv_ready +.export net_tcp_state +; DNS family +.export net_dns_resolve +.export net_resolved_ip +; c64-https extension (not §13) .export net_recv_byte -.export net_send_len -.export net_tcp_recv_cb +; net_tcp_recv_cb, net_save_zp, net_restore_zp are adapter-internal (§13.5) +; and deliberately NOT exported. tools/test_net.py reaches them through +; build/labels.txt, which carries local labels too. ; --- BSS imports from data.s --- .import zp_save_buf @@ -51,7 +59,8 @@ ; ============================================================================= ; net_init - initialize ip65 + ethernet (RR-Net CS8900a) -; Output: C=0 success, C=1 failure +; Output: C=0 success (net_last_error/net_tcp_state cleared), +; C=1 failure (net_last_error = NET_ERR_IP65_INIT) ; ============================================================================= net_init: jsr net_save_zp @@ -63,20 +72,41 @@ net_init: bcs @init_fail ; resolve variable table pointers for TCP callback SMC jsr net_init_cb_addrs + lda #0 + sta net_last_error + sta net_tcp_state ; NET_TCP_CLOSED clc + rts @init_fail: + lda #NET_ERR_IP65_INIT + sta net_last_error + sec rts ; ============================================================================= -; net_dhcp - obtain IP address via DHCP -; Output: C=0 success, C=1 failure +; net_dhcp_acquire - obtain IP address via DHCP (SPEC §13.1 core family) +; Output: C=0 success, net_local_ip = the lease +; C=1 failure, net_last_error = NET_ERR_IP65_DHCP ; ============================================================================= -net_dhcp: +net_dhcp_acquire: jsr net_save_zp jsr ip65_dhcp_init php jsr net_restore_zp plp + bcs @dhcp_fail + ldx #3 +@dhcp_copy: + lda ip65_cfg_ip,x + sta net_local_ip,x + dex + bpl @dhcp_copy + clc + rts +@dhcp_fail: + lda #NET_ERR_IP65_DHCP + sta net_last_error + sec rts ; ============================================================================= @@ -98,9 +128,10 @@ net_poll: rts ; ============================================================================= -; net_dns_resolve - resolve hostname to IP address +; net_dns_resolve - resolve hostname to IP address (eager, SPEC §13.1) ; Input: A/X = pointer to null-terminated hostname string -; Output: C=0 success (IP in ip65_dns_ip_addr), C=1 failure +; Output: C=0 success (IP in ip65_dns_ip_addr and net_resolved_ip), +; C=1 failure (net_last_error = NET_ERR_IP65_DNS) ; ============================================================================= net_dns_resolve: pha ; save A (hostname lo) across ZP save @@ -115,6 +146,19 @@ net_dns_resolve: php jsr net_restore_zp plp + bcs @dns_fail + ldx #3 +@dns_copy: + lda ip65_dns_ip_addr,x + sta net_resolved_ip,x + dex + bpl @dns_copy + clc + rts +@dns_fail: + lda #NET_ERR_IP65_DNS + sta net_last_error + sec rts ; ============================================================================= @@ -145,12 +189,23 @@ net_tcp_connect: php jsr net_restore_zp plp + bcs @connect_fail + lda #NET_TCP_CONNECTED + sta net_tcp_state + clc + rts +@connect_fail: + lda #NET_TCP_CONNECT_FAIL + sta net_tcp_state + lda #NET_ERR_IP65_CONNECT + sta net_last_error + sec rts ; ============================================================================= ; net_tcp_send - send data over TCP ; Input: A/X = pointer to data, net_send_len = 16-bit length -; Output: C=0 success, C=1 failure +; Output: C=0 success, C=1 failure (net_last_error = NET_ERR_IP65_SEND) ; ============================================================================= net_tcp_send: sta net_send_ptr @@ -168,104 +223,22 @@ net_tcp_send: php jsr net_restore_zp plp + bcc @send_ok + lda #NET_ERR_IP65_SEND + sta net_last_error + sec +@send_ok: rts ; ============================================================================= -; net_tcp_close - close TCP connection +; net_tcp_close - close TCP connection; always leaves NET_TCP_CLOSED (§13.1) ; ============================================================================= net_tcp_close: jsr net_save_zp jsr ip65_tcp_close jsr net_restore_zp - rts - -; ============================================================================= -; net_print_ip - display current IP address in dotted decimal -; ============================================================================= -net_print_ip: - lda ip65_cfg_ip - jsr @print_byte - lda #'.' - jsr chrout - lda ip65_cfg_ip+1 - jsr @print_byte - lda #'.' - jsr chrout - lda ip65_cfg_ip+2 - jsr @print_byte - lda #'.' - jsr chrout - lda ip65_cfg_ip+3 - jsr @print_byte - lda #$0d - jsr chrout - rts - -; print decimal byte value (0-255) -@print_byte: - sta @pb_val - ; hundreds - 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 - ; print tens (always if hundreds was printed, otherwise skip leading zero) - cpx #0 - bne @pb_t_out - ldy @pb_val - cpy #10 - bcc @pb_ones ; value < 10, skip tens -@pb_t_out: - pha - txa - ora #$30 - jsr chrout - pla -@pb_ones: - ora #$30 - jsr chrout - rts -@pb_val: .byte 0 - -; ============================================================================= -; net_recv_ready - check if data is available in receive ring buffer -; Output: C=0 if data available, C=1 if empty -; -; The ring is empty iff head == tail (16-bit compare). -; ============================================================================= -net_recv_ready: - lda tcp_recv_head+0 - cmp tcp_recv_tail+0 - bne @has - lda tcp_recv_head+1 - cmp tcp_recv_tail+1 - bne @has - sec ; empty - rts -@has: - clc + lda #NET_TCP_CLOSED + sta net_tcp_state rts ; ============================================================================= @@ -503,3 +476,12 @@ net_restore_zp: ; ============================================================================= net_send_ptr: .word 0 ; pointer for tcp_send wrapper net_send_len: .word 0 ; length for tcp_send wrapper + +; SPEC §13.1 core/TCP/DNS data. Zero at boot (BSS lives under the BASIC +; ROM shadow, which boot's zbss loop clears): net_tcp_state starts +; NET_TCP_CLOSED, net_last_error at $00, both IPs unset. +.segment "BSS" +net_local_ip: .res 4 ; lease copied from ip65_cfg_ip on DHCP success +net_resolved_ip: .res 4 ; copied from ip65_dns_ip_addr on resolve success +net_last_error: .res 1 ; NET_ERR_IP65_* (ip65_errors.inc); $00 = OK +net_tcp_state: .res 1 ; NET_TCP_* (net_states.inc) diff --git a/src/net/ip65/net_manifest.s b/src/net/ip65/net_manifest.s new file mode 100644 index 0000000..7046d96 --- /dev/null +++ b/src/net/ip65/net_manifest.s @@ -0,0 +1,32 @@ +; src/net/ip65/net_manifest.s — c64-lib-contract SPEC §13.0 / §13.7 manifest +; for the ip65/RR-Net backend. Emits no bytes: equates only. + +.include "net_families.inc" + +; --- §13.0 family declaration -------------------------------------------- +.export NET_BACKEND_FAMILIES : absolute ; :abs — a byte-sized value would otherwise infer zeropage (contract #74) +NET_BACKEND_FAMILIES = NET_FAMILY_CORE | NET_FAMILY_TCP | NET_FAMILY_DNS + +; --- §13.7 fixed-address blob footprint ---------------------------------- +; ip65 is a position-linked blob (`ip65-build/ip65-c64.bin`, .incbin'd by +; ip65_blob.s into NET_CODE), not a relocatable §4 library. Its footprint is +; declared so consumer cfgs can compose around it; relocating it is a +; relink of the blob (`make ip65-blob` against a different base), not a +; cfg edit. +; +; BLOB_SIZE is asserted against the bytes actually .incbin'd, so a blob +; rebuild that changes size fails the link here instead of silently +; drifting from the declaration. The BSS span comes from +; ip65-build/ip65-c64.map (occupancy stops at $4F8B) and is not visible to +; ld65 — it is reserved by the blob image, not by a segment — so it cannot +; be asserted the same way; refresh it by hand on a blob relink. +.export LIB_NET_IP65_BLOB_BASE, LIB_NET_IP65_BLOB_SIZE +.export LIB_NET_IP65_BLOB_BSS_BASE, LIB_NET_IP65_BLOB_BSS_SIZE +LIB_NET_IP65_BLOB_BASE = $2000 +LIB_NET_IP65_BLOB_SIZE = $1B27 ; 6,951 B — refreshed per blob rebuild +LIB_NET_IP65_BLOB_BSS_BASE = $4000 +LIB_NET_IP65_BLOB_BSS_SIZE = $0F8C + +.import ip65_blob_start, ip65_blob_end +.assert ip65_blob_end - ip65_blob_start = LIB_NET_IP65_BLOB_SIZE, lderror, "LIB_NET_IP65_BLOB_SIZE no longer matches ip65-c64.bin — refresh src/net/ip65/net_manifest.s (SPEC §13.7)" +.assert ip65_blob_start = LIB_NET_IP65_BLOB_BASE, lderror, "ip65 blob is not linked at LIB_NET_IP65_BLOB_BASE (SPEC §13.7)" diff --git a/src/net/net_families.inc b/src/net/net_families.inc new file mode 100644 index 0000000..ce4eb66 --- /dev/null +++ b/src/net/net_families.inc @@ -0,0 +1,21 @@ +; src/net/net_families.inc — c64-lib-contract SPEC §13.0 family bits. +; +; Copied VERBATIM from the contract (like the §8.x bit constants); do not +; derive or extend locally. Plain assemble-time equates, never .export'ed: +; both the backend manifest (src/net//net_manifest.s) and every +; family-asserting consumer (src/net_abi_asserts.s) include this header, +; and only exported symbols can collide at link time. +; +; Bits are append-only and never reused (§8.0 discipline). + +.ifndef NET_FAMILIES_INC_INCLUDED +NET_FAMILIES_INC_INCLUDED = 1 + +NET_FAMILY_CORE = $0001 ; net_init, net_dhcp_acquire, net_poll, + ; net_local_ip, net_last_error +NET_FAMILY_TCP = $0002 ; net_tcp_connect/send/close, net_tcp_state, + ; consumer-owned rx ring (§13.3) +NET_FAMILY_UDP = $0004 ; net_udp_listen/send, UDP rx buffer +NET_FAMILY_DNS = $0008 ; net_dns_resolve, net_resolved_ip + +.endif diff --git a/src/net/net_states.inc b/src/net/net_states.inc new file mode 100644 index 0000000..4398ed5 --- /dev/null +++ b/src/net/net_states.inc @@ -0,0 +1,16 @@ +; src/net/net_states.inc — c64-lib-contract SPEC §13.1 `net_tcp_state` values. +; +; The values are normative: they are the original UCI_TCP_* values, +; name-promoted by the contract. Included by both backends (which store +; them) and by src/net_abi.inc (so consumers can compare against them). +; Never .export'ed — same reasoning as net_families.inc. + +.ifndef NET_STATES_INC_INCLUDED +NET_STATES_INC_INCLUDED = 1 + +NET_TCP_CLOSED = $00 ; no active socket +NET_TCP_CONNECTED = $01 ; connected; reads/writes valid +NET_TCP_ERROR = $02 ; backend saw an error on the socket — stop polling +NET_TCP_CONNECT_FAIL = $03 ; net_tcp_connect did not yield a usable socket + +.endif diff --git a/src/net/uci/net.s b/src/net/uci/net.s index be8715c..2e3590e 100644 --- a/src/net/uci/net.s +++ b/src/net/uci/net.s @@ -24,28 +24,26 @@ .include "uci_errors.inc" .include "constants.inc" -; --- net_abi.inc contract --- +.include "net_states.inc" ; NET_TCP_* (SPEC §13.1) + +; --- Public ABI: exactly the surface src/net_abi.inc imports (SPEC §13) --- .export net_init .export net_poll .export net_dhcp_acquire .export net_tcp_connect .export net_tcp_send .export net_tcp_close -.export net_tcp_set_recv_cb .export net_dns_resolve .export net_local_ip .export net_resolved_ip .export net_last_error .export net_tcp_state -; --- legacy caller names (still imported by boot.s / http.s / tls_record_io.s) --- -.export net_dhcp -.export net_print_ip -.export net_recv_byte -.export net_send_len - -; --- banner label consumed by boot.s --- -.export net_banner_str +; --- c64-https extensions (not SPEC §13), imported via src/net_abi.inc --- +.export net_recv_byte ; the blessed drain entry (#72) +.export net_send_len ; §13.1 TCP-family data, listed here for the + ; UCI file layout only +.export net_banner_str ; boot banner identity line ; --- UCI-owned state exported for future phases --- .export uci_host_buf @@ -70,6 +68,7 @@ ; --- ring BSS owned by src/data.s --- .import tcp_recv_head .import tcp_recv_tail +.import tcp_recv_overflow ; §13.3: set when the ring fills ; (chrout is provided by constants.inc) @@ -119,22 +118,20 @@ net_init: ; ============================================================================= ; net_poll — pump UCI receive into the TCP ring buffer. ; -; If no socket is open (net_tcp_state != UCI_TCP_CONNECTED) we just RTS. +; If no socket is open (net_tcp_state != NET_TCP_CONNECTED) we just RTS. ; Otherwise we issue SOCKET_READ(sock, UCI_READ_CHUNK_MAX) and, for each ; data byte returned after the 2-byte actual_len header, store into ; tcp_recv_buf at tcp_recv_tail and advance the masked tail. ; -; We intentionally do NOT honor net_tcp_set_recv_cb here — the HTTP/TLS -; path drains via net_recv_byte, not via a callback. The set-cb call site -; exists only in net_abi.inc and is never actually invoked in-tree -; (Phase 3 grep: 0 `jsr net_tcp_set_recv_cb`), so the UCI backend leaves -; its set-cb entry point as an RTS stub. +; There is no receive callback: the HTTP/TLS path drains the ring via +; net_recv_byte (SPEC §13.3 drain model). The old net_tcp_set_recv_cb +; RTS stub was deleted per §13.1 (issue #70). ; ; Clobbers: A, X, Y ; ============================================================================= net_poll: lda net_tcp_state - cmp #UCI_TCP_CONNECTED + cmp #NET_TCP_CONNECTED beq @do_poll rts @do_poll: @@ -189,7 +186,7 @@ net_poll: ; FPGA wedged before we could push SOCKET_READ — net_last_error is ; already UCI_ERR_WAIT_TIMEOUT. Force tcp_state to ERROR so the ; HTTP/TLS layer stops polling on this socket. - lda #UCI_TCP_ERROR + lda #NET_TCP_ERROR sta net_tcp_state rts : @@ -215,7 +212,7 @@ net_poll: ; FPGA wedged waiting for SOCKET_READ response — net_last_error is ; already UCI_ERR_WAIT_TIMEOUT. Force tcp_state to ERROR so the ; HTTP/TLS layer stops polling on this socket. - lda #UCI_TCP_ERROR + lda #NET_TCP_ERROR sta net_tcp_state rts : @@ -225,7 +222,7 @@ net_poll: lda #UCI_ERR_READ_FAIL sta net_last_error - lda #UCI_TCP_ERROR + lda #NET_TCP_ERROR sta net_tcp_state jsr uci_drain_resp bcs @pe_drain_to ; drain wedged — tcp_state already ERROR @@ -271,7 +268,7 @@ net_poll: jsr uci_ack rts @hds_drain_to: - lda #UCI_TCP_ERROR + lda #NET_TCP_ERROR sta net_tcp_state rts @@ -290,7 +287,7 @@ net_poll: jsr uci_ack rts @hd0_drain_to: - lda #UCI_TCP_ERROR + lda #NET_TCP_ERROR sta net_tcp_state rts @@ -322,7 +319,14 @@ net_poll: lda uci_next_hi cmp tcp_recv_head+1 bne @not_full - jmp @done_data ; ring full — drop the rest + ; ring full — record it (SPEC §13.3: the backend sets the flag, + ; then drops) and stop copying this delivery. The clamp at + ; @do_poll makes this unreachable in practice: the request never + ; exceeds free-1. If it latches, the request/free-space arithmetic + ; has regressed — that is the wikipedia-stall bug's signature. + lda #1 + sta tcp_recv_overflow + jmp @done_data @not_full: ; Wait for DATA_AV — the firmware streams data in bursts; if the @@ -373,7 +377,7 @@ net_poll: jsr uci_ack rts @dd_drain_to: - lda #UCI_TCP_ERROR + lda #NET_TCP_ERROR sta net_tcp_state rts @@ -504,10 +508,6 @@ net_dhcp_acquire: clc rts -; Legacy alias — boot.s still imports `net_dhcp` directly. -net_dhcp: - jmp net_dhcp_acquire - ; ============================================================================= ; net_tcp_connect — open a TCP socket to (uci_host_buf, port). ; @@ -519,7 +519,7 @@ net_dhcp: ; port_hi, host_bytes..., 0]. Response = [socket_id]. ; ; On success: stores socket_id in uci_socket_id, sets net_tcp_state = -; UCI_TCP_CONNECTED, returns C=0. On failure: sets net_last_error = +; NET_TCP_CONNECTED, returns C=0. On failure: sets net_last_error = ; UCI_ERR_CONNECT_FAIL and returns C=1. ; ============================================================================= net_tcp_connect: @@ -530,7 +530,7 @@ net_tcp_connect: bcc :+ ; FPGA wedged before we even queued anything — surface the timeout ; (net_last_error already set) with the connect-fail tcp_state. - lda #UCI_TCP_CONNECT_FAIL + lda #NET_TCP_CONNECT_FAIL sta net_tcp_state sec rts @@ -572,7 +572,7 @@ net_tcp_connect: ; FPGA wedged waiting for TCP_CONNECT response — net_last_error is ; already UCI_ERR_WAIT_TIMEOUT. Force tcp_state to CONNECT_FAIL so ; callers don't try to use a phantom socket. - lda #UCI_TCP_CONNECT_FAIL + lda #NET_TCP_CONNECT_FAIL sta net_tcp_state sec rts @@ -615,7 +615,7 @@ net_tcp_connect: jsr uci_ack jmp @tc_validate @tc_ok_drain_to: - lda #UCI_TCP_CONNECT_FAIL + lda #NET_TCP_CONNECT_FAIL sta net_tcp_state sec rts @@ -631,7 +631,7 @@ net_tcp_connect: lda uci_socket_id beq @tc_no_socket - lda #UCI_TCP_CONNECTED + lda #NET_TCP_CONNECTED sta net_tcp_state clc rts @@ -639,7 +639,7 @@ net_tcp_connect: @tc_no_socket: lda #UCI_ERR_NO_SOCKET sta net_last_error - lda #UCI_TCP_CONNECT_FAIL + lda #NET_TCP_CONNECT_FAIL sta net_tcp_state sec rts @@ -846,14 +846,14 @@ net_tcp_send: ; ============================================================================= ; net_tcp_close — CMD_SOCKET_CLOSE on the open socket. Best-effort; the ; UCI error bit is drained but not surfaced, and net_tcp_state is always -; forced back to UCI_TCP_CLOSED. +; forced back to NET_TCP_CLOSED. ; ============================================================================= net_tcp_close: jsr uci_wait_idle bcc :+ ; FPGA wedged on close — force CLOSED state and bail. Best-effort ; semantics already match the existing close path (no return code). - lda #UCI_TCP_CLOSED + lda #NET_TCP_CLOSED sta net_tcp_state rts : @@ -871,7 +871,7 @@ net_tcp_close: bcc :+ ; FPGA wedged on close — force CLOSED state and bail. Best-effort ; semantics: skip drains (FIFO state is undefined when wedged). - lda #UCI_TCP_CLOSED + lda #NET_TCP_CLOSED sta net_tcp_state rts : @@ -883,19 +883,10 @@ net_tcp_close: jsr uci_ack @cl_drain_to: - lda #UCI_TCP_CLOSED + lda #NET_TCP_CLOSED sta net_tcp_state rts -; ============================================================================= -; net_tcp_set_recv_cb — RTS stub. -; Phase 3 grep (src/): no `jsr net_tcp_set_recv_cb` call sites exist; -; only the `.import` in net_abi.inc. Keep the entry point so the ABI -; link resolves. If a future caller appears, wire it into net_poll. -; ============================================================================= -net_tcp_set_recv_cb: - rts - ; ============================================================================= ; net_dns_resolve — stage a hostname for the next net_tcp_connect. ; @@ -939,78 +930,6 @@ net_dns_resolve: clc rts -; ============================================================================= -; net_print_ip — print net_local_ip as dotted decimal (PETSCII + CR) -; -; Shared with the ip65 backend in shape: three `.`-separated decimal octets -; plus a trailing carriage return. Implementation is local so the UCI -; backend has no ip65 dependencies. -; ============================================================================= -net_print_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 - jsr chrout - rts - -@print_byte: - sta @pb_val - ; hundreds - 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 - jsr chrout - rts -@pb_val: .byte 0 - ; ============================================================================= ; net_recv_byte — pop one byte from the TCP receive ring. ; diff --git a/src/net/uci/net_manifest.s b/src/net/uci/net_manifest.s new file mode 100644 index 0000000..6c4f7d5 --- /dev/null +++ b/src/net/uci/net_manifest.s @@ -0,0 +1,14 @@ +; src/net/uci/net_manifest.s — c64-lib-contract SPEC §13.0 manifest for the +; UCI (Ultimate 64 / C64 Ultimate) backend. Emits no bytes: equates only. +; +; DNS is implemented BY DEFERRAL (§13.0): the firmware resolves the hostname +; inside TCP_CONNECT, so net_dns_resolve only stages the name and +; net_resolved_ip reads the $FF,$FF,$FF,$FF deferral marker. The bit is +; still set — the consumer-visible behaviour ("pass a hostname, connect to +; it") is what the bit declares. No §13.7 equates: the UCI adapter is +; ordinary relocatable code in UCI_CODE, not a fixed-address blob. + +.include "net_families.inc" + +.export NET_BACKEND_FAMILIES : absolute ; :abs — a byte-sized value would otherwise infer zeropage (contract #74) +NET_BACKEND_FAMILIES = NET_FAMILY_CORE | NET_FAMILY_TCP | NET_FAMILY_DNS diff --git a/src/net/uci/uci_errors.inc b/src/net/uci/uci_errors.inc index 6525e6c..3107962 100644 --- a/src/net/uci/uci_errors.inc +++ b/src/net/uci/uci_errors.inc @@ -1,8 +1,15 @@ ; src/net/uci/uci_errors.inc — UCI backend error codes ; ; Values stored in net_last_error by the UCI adapter. 0 = OK. -; Kept deliberately small and distinct from any ip65 error values so -; that future multi-backend debugging can tell them apart at a glance. +; +; $80-$BF is the UCI-family range c64-lib-contract SPEC §13.2 grandfathers, +; and it is ONE namespace shared by every UCI-family adapter in the fleet +; (c64-https and c64-wireguard today). The allocation table in SPEC §13.2 +; is the authority: allocate a new code THERE first, then here, so the two +; adapters cannot hand out the same value with different meanings. +; ip65-family codes live in $40-$7F (src/net/ip65/ip65_errors.inc). + +.include "net_states.inc" ; NET_TCP_* (SPEC §13.1) — the primary names UCI_ERR_OK = $00 ; no error UCI_ERR_NOT_PRESENT = $81 ; $DF1D did not read back UCI_ID_VALUE ($C9) @@ -12,14 +19,22 @@ UCI_ERR_CONNECT_FAIL = $84 ; TCP_CONNECT returned an error bit UCI_ERR_SEND_FAIL = $85 ; SOCKET_WRITE returned an error bit UCI_ERR_READ_FAIL = $86 ; SOCKET_READ returned an error bit UCI_ERR_SHORT_WRITE = $87 ; SOCKET_WRITE wrote fewer bytes than requested -UCI_ERR_NO_SOCKET = $88 ; TCP_CONNECT response yielded no socket_id -UCI_ERR_WAIT_TIMEOUT = $89 ; uci_wait_idle exceeded its wall-clock budget +UCI_ERR_NO_SOCKET = $88 ; socket-open response yielded no socket_id +UCI_ERR_WAIT_TIMEOUT = $89 ; a bounded wait exceeded its wall-clock budget +UCI_ERR_LONG_READ = $8A ; SOCKET_READ claimed more bytes than we asked + ; for. Allocated by c64-wireguard (2026-08-24); + ; reserved here with the same meaning. This + ; adapter does not emit it yet — net_poll + ; stops at DATA_AV rather than checking the + ; claimed length against the request. -; TCP state values stored in net_tcp_state -UCI_TCP_CLOSED = $00 ; no active socket -UCI_TCP_CONNECTED = $01 ; connected, reads/writes valid -UCI_TCP_ERROR = $02 ; saw an error on a read — stop polling -UCI_TCP_CONNECT_FAIL = $03 ; TCP_CONNECT did not yield a usable socket +; TCP state values stored in net_tcp_state. The NET_TCP_* spellings in +; net_states.inc are the contract's (§13.1); these aliases keep the +; historical names readable in diagnostics and in c64-wireguard's copy. +UCI_TCP_CLOSED = NET_TCP_CLOSED +UCI_TCP_CONNECTED = NET_TCP_CONNECTED +UCI_TCP_ERROR = NET_TCP_ERROR +UCI_TCP_CONNECT_FAIL = NET_TCP_CONNECT_FAIL ; UCI firmware data queue max per SOCKET_WRITE push (see uci_network.py) UCI_DATA_QUEUE_MAX = 800 diff --git a/src/net_abi.inc b/src/net_abi.inc index 229e932..c311f3f 100644 --- a/src/net_abi.inc +++ b/src/net_abi.inc @@ -1,46 +1,79 @@ -; src/net_abi.inc — INTENDED public networking API for TLS/HTTP. +; src/net_abi.inc — the networking ABI consumed by TLS/HTTP/boot. ; -; !! THIS FILE IS NOT IN THE BUILD. !! +; This header IS the build-enforced boundary between the protocol layers +; and the network backend (c64-lib-contract SPEC §13, issue #70). Every +; consumer TU that touches the network `.include`s it, and every symbol +; below is `.import`ed — so a backend that stops exporting one fails the +; link by name, on both backends, in every profile. Nothing else in the +; consumer layer may `.import` a `net_*` symbol directly. ; -; No translation unit `.include`s it (verified 2026-08-14: -; `grep -rn 'net_abi' src/ tools/ cfg/ Makefile` returns only comments), -; so not one of the `.import`s below is checked by the assembler or the -; linker. It reads like a drop-in contract and is not one. Three -; measured consequences, all of which someone planning work here needs: +; Both backends export the full surface: +; src/net/ip65/net.s — ip65/RR-Net (cs8900a), position-linked blob at $2000 +; src/net/uci/net.s — Ultimate 64 / C64 Ultimate command interface ; -; 1. The ip65 backend provides only six of the twelve. It has no -; `net_dhcp_acquire` (it exports `net_dhcp`), no -; `net_tcp_set_recv_cb`, no `net_local_ip`, no `net_resolved_ip`, -; no `net_last_error` and no `net_tcp_state` — see -; src/net/ip65/net.s:28. `net_last_error` exists ONLY under UCI, so -; ip65 has no error channel at all. -; 2. Five symbols TLS/HTTP genuinely depend on are missing here: -; `net_dhcp`, `net_print_ip`, `net_send_len`, `net_recv_byte`, -; `net_banner_str`. Declared and used surfaces overlap in 6 of 17. -; 3. `net_tcp_set_recv_cb` is an RTS stub with zero callers; -; c64-lib-contract SPEC §13.1 retires it outright. +; Families implemented (src/net//net_manifest.s, asserted by +; src/net_abi_asserts.s): NET_FAMILY_CORE | NET_FAMILY_TCP | NET_FAMILY_DNS +; on both backends. UCI's DNS is by deferral (§13.0): firmware resolves the +; name inside TCP_CONNECT; `net_resolved_ip` then reads $FF,$FF,$FF,$FF. ; -; Making this real (or deleting it) is item P1 of the plan on issue #70. -; Until then: adding a symbol here changes nothing but this comment -; block's neighbours — put it in the backend adapters instead. -; -; Original intent, preserved: any backend (ip65/RR-Net, UCI/U64E) should -; export these exact symbols, so swapping backend is a link-time choice -; via a different ld65 cfg + different net//*.o files, with no -; changes to TLS or HTTP sources. - -.import net_init -.import net_dhcp_acquire -.import net_poll - -.import net_tcp_connect -.import net_tcp_send -.import net_tcp_close -.import net_tcp_set_recv_cb - -.import net_dns_resolve - -.import net_local_ip -.import net_resolved_ip -.import net_last_error -.import net_tcp_state +; Calling conventions follow SPEC §13.1 exactly; C=0 ok / C=1 fail with +; `net_last_error` set (§13.2). Error code namespaces: ip65 backend +; $40-$7F (src/net/ip65/ip65_errors.inc), UCI backend $80-$BF +; (src/net/uci/uci_errors.inc). + +.ifndef NET_ABI_INC_INCLUDED +NET_ABI_INC_INCLUDED = 1 + +.include "net_states.inc" ; NET_TCP_* values for net_tcp_state + +; --- Core family (§13.1, required) --------------------------------------- +.import net_init ; no args. C=0 ok; C=1 + net_last_error +.import net_dhcp_acquire ; no args. Populates net_local_ip. C per §13.2 +.import net_poll ; pump one unit of driver work. C=0 carries + ; no "data arrived" meaning — observe the + ; rx ring instead. Clobbers A/X/Y. +.import net_local_ip ; 4 B, zero until net_dhcp_acquire succeeds +.import net_last_error ; 1 B, $00 = no error + +; --- TCP family (§13.1) --------------------------------------------------- +.import net_tcp_connect ; A = port lo, X = port hi; host staged by + ; the prior net_dns_resolve. On failure + ; net_tcp_state = NET_TCP_CONNECT_FAIL +.import net_tcp_send ; A/X = data ptr, net_send_len = 16-bit length +.import net_send_len ; 2 B, set by the caller before net_tcp_send +.import net_tcp_close ; no args; always leaves NET_TCP_CLOSED +.import net_tcp_state ; 1 B, NET_TCP_* (net_states.inc) + +; TCP rx ring (§13.3) — CONSUMER-owned: placed by the cfg, defined in +; src/data.s / src/constants.inc, imported by the backend. Declared here so +; the shape is visible next to the drain entry point that reads it. +; `tcp_recv_buf` and `TCP_RECV_MASK` are equates from constants.inc. +.import tcp_recv_head ; 2 B, consumer read position (masked) +.import tcp_recv_tail ; 2 B, backend write position (masked) +.import tcp_recv_overflow ; 1 B, set by the backend when the ring fills + +; --- DNS family (§13.1) --------------------------------------------------- +.import net_dns_resolve ; A/X = NUL-terminated hostname (dotted-quad + ; literals pass through). May be lazy. +.import net_resolved_ip ; 4 B: zero = unresolved; $FF x4 = resolved + ; by the device (deferral marker — NOT an + ; address); anything else = the address + +; --- c64-https extensions (NOT part of SPEC §13) -------------------------- +; Both backends export these; they are this consumer's conventions, kept +; out of the contract's tables on purpose. +.import net_recv_byte ; the blessed drain entry (#72): A = byte, + ; C=0 ok, C=1 ring empty. Consumers use + ; this rather than reading the ring raw. +.import net_banner_str ; backend identity line for the boot banner + ; ("rr-net" / "UCI NETWORKING"); + ; tools/uci/boot_check.py asserts on it + +; Deliberately absent (SPEC §13.1 "deliberately not in the contract"): +; net_tcp_set_recv_cb — RTS stub with no callers; deleted from both backends +; net_print_ip — consumer UI; now `print_local_ip` in src/boot.s, +; reading net_local_ip, one copy for both backends +; net_recv_ready — retired; net_recv_byte's C flag is the same test +; net_save_zp / net_restore_zp / net_tcp_recv_cb — adapter-internal (§13.5) + +.endif diff --git a/src/net_abi_asserts.s b/src/net_abi_asserts.s new file mode 100644 index 0000000..1d7bc0e --- /dev/null +++ b/src/net_abi_asserts.s @@ -0,0 +1,31 @@ +; src/net_abi_asserts.s — c64-lib-contract SPEC §13.8 consumer intake asserts. +; +; Assembled into every build, both backends, every profile; emits no bytes. +; Companion to src/lib_contract_asserts.s (§1/§3/§5/§8), which carried the +; §13.3 ring-mask check before this TU existed — it now lives here with the +; rest of §13. +; +; NET_BACKEND_FAMILIES is exported by src/net//net_manifest.s and +; is the sole §13.0 export; the NET_FAMILY_* bits are assemble-time equates +; from the copied-verbatim net_families.inc. It is `.import`ed, so its +; value is unknown until link — hence `lderror`, as §13.0/§13.8 specify. +; +; What this consumer needs: TCP client + a way to hand the backend a +; hostname. Under UCI the DNS family is satisfied by deferral (§13.0), which +; still sets the bit — the consumer-visible behaviour is what is declared. + +.include "constants.inc" ; TCP_RECV_MASK +.include "net_families.inc" + +.import NET_BACKEND_FAMILIES + +NET_REQUIRED_FAMILIES = NET_FAMILY_CORE | NET_FAMILY_TCP | NET_FAMILY_DNS + +.assert (NET_BACKEND_FAMILIES & NET_FAMILY_CORE) = NET_FAMILY_CORE, lderror, "network backend missing the core family (c64-lib-contract SPEC §13.0)" + +.assert (NET_BACKEND_FAMILIES & NET_REQUIRED_FAMILIES) = NET_REQUIRED_FAMILIES, lderror, "network backend missing a family c64-https needs: CORE|TCP|DNS (SPEC §13.8)" + +; §13.3: the backends mask head/tail with `and #>TCP_RECV_MASK`, which is +; only a ring wrap when the mask is 2^n - 1. Assemble-time (`error`) because +; TCP_RECV_MASK is a local equate, not an import. +.assert (TCP_RECV_MASK & (TCP_RECV_MASK + 1)) = 0, error, "TCP_RECV_MASK must be 2^n - 1 (c64-lib-contract SPEC §13.3)" diff --git a/src/tls13.s b/src/tls13.s index 9efc7d1..8856127 100644 --- a/src/tls13.s +++ b/src/tls13.s @@ -100,7 +100,7 @@ .endif ; --- Networking (net.s) --- -.import net_poll +.include "net_abi.inc" ; net_poll ; --- Console output (main/util) --- .import print_string diff --git a/src/tls_record_io.s b/src/tls_record_io.s index a61653a..1b1c27b 100644 --- a/src/tls_record_io.s +++ b/src/tls_record_io.s @@ -25,9 +25,7 @@ .export tls_recv_state .export tls_recv_count -.import net_tcp_send -.import net_recv_byte -.import net_send_len +.include "net_abi.inc" ; net_tcp_send, net_recv_byte, net_send_len .import tls_record_encrypt .import tls_record_decrypt .import tls_rec_header diff --git a/tests/rig_phase1_dhcp.py b/tests/rig_phase1_dhcp.py index 8992b9f..76ef0ea 100644 --- a/tests/rig_phase1_dhcp.py +++ b/tests/rig_phase1_dhcp.py @@ -2,7 +2,7 @@ """Phase 1 e2e test: boot c64-https.prg in VICE, press I, see DHCP OK. This test runs the real c64-https binary in VICE on a Linux bridge with -RR-Net ethernet and a host-side dnsmasq. It exercises ip65's net_dhcp +RR-Net ethernet and a host-side dnsmasq. It exercises ip65's net_dhcp_acquire end-to-end. It touches NO TLS/HTTP logic -- it only asserts that the boot menu appears and that pressing 'I' produces the 'DHCP OK' banner. diff --git a/tools/test_net.py b/tools/test_net.py index 00fcbec..37e4e94 100644 --- a/tools/test_net.py +++ b/tools/test_net.py @@ -32,7 +32,8 @@ def test_build_integrity(labels): # Our code references ip65_base = $2000 in constants # Verify key labels exist required = [ - "net_init", "net_dhcp", "net_poll", "net_tcp_connect", + "net_init", "net_dhcp_acquire", "net_poll", "net_tcp_connect", + "net_local_ip", "net_last_error", "net_tcp_state", "net_resolved_ip", "net_tcp_send", "net_tcp_close", "net_save_zp", "net_restore_zp", "zp_save_buf", "tcp_recv_buf", "tcp_recv_head", "tcp_recv_tail", "net_send_ptr", "net_send_len", @@ -137,7 +138,8 @@ def test_recv_ring_buffer(transport, labels): """Test the TCP receive ring buffer read/write logic. Manually write data to tcp_recv_buf and manipulate head/tail, - then call net_recv_byte and net_recv_ready to verify behavior. + then call net_recv_byte to verify behavior. (net_recv_ready was retired + with issue #70 — net_recv_byte's C flag is the same test.) """ passed = 0 failed = 0 @@ -145,21 +147,24 @@ def test_recv_ring_buffer(transport, labels): recv_buf = labels.address("tcp_recv_buf") recv_head = labels.address("tcp_recv_head") recv_tail = labels.address("tcp_recv_tail") - recv_ready = labels.address("net_recv_ready") recv_byte = labels.address("net_recv_byte") - if None in (recv_buf, recv_head, recv_tail, recv_ready, recv_byte): + if None in (recv_buf, recv_head, recv_tail, recv_byte): print(" FAIL: ring buffer labels not found") return 0, 1 - # Test 1: Empty buffer — head == tail - write_bytes(transport, recv_head, [0]) - write_bytes(transport, recv_tail, [0]) - jsr(transport, recv_ready) - # After jsr, we can read the processor status from the stack or check carry - # Actually, let's test net_recv_byte which returns C=1 when empty - # We can check the carry flag indirectly by reading the status register - # For simplicity, test by writing known data and reading it back + # Test 1: Empty buffer — head == tail. net_recv_byte must not advance + # head on an empty ring (it returns C=1; the carry is not observable + # through jsr(), so the head is the oracle). + write_bytes(transport, recv_head, [0, 0]) + write_bytes(transport, recv_tail, [0, 0]) + jsr(transport, recv_byte) + head_val = list(read_bytes(transport, recv_head, 2)) + if head_val == [0, 0]: + passed += 1 + else: + print(f" FAIL: net_recv_byte advanced head on an empty ring: {head_val}") + failed += 1 # Test 2: Write 5 bytes to buffer, set tail=5, head=0 test_data = [0x48, 0x65, 0x6C, 0x6C, 0x6F] # "Hello" From a55e5fe4119db142840e54537ebd5577858a7639 Mon Sep 17 00:00:00 2001 From: JC-000 <3798556+JC-000@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:05:24 -0500 Subject: [PATCH 2/4] uci: bound net_poll's copy by the request; $8A is UDP-only, $8B breadcrumb (#140) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit net_poll took the SOCKET_READ response header straight into uci_poll_rem and copied, surviving a wild header only because the copy loop re-checks ring-full and DATA_AV per byte. Measured on a U64E (fw 3.14d, real github.com session): a 512 B request with ~690 B queued is answered with header $FFFF; a 22 B request with 22 B queued with $0016. On TCP the header is a count only when the queue fits the request, and the $FFFF sentinel otherwise. So c64-wireguard's drop-on-over-claim check does not port: a first cut of it aborted every real handshake at the first read (two runs). What lands instead is a bound — the copy count is capped at uci_req_len, the request this poll actually made; bytes beyond it were never delivered and stay queued, so nothing is lost. $8A UCI_ERR_LONG_READ keeps c64-wireguard's datagram disposition and is never emitted here. A header above the request that is NOT $FFFF — a value the firmware has no documented mode for — leaves $8B UCI_ERR_BAD_READ_HDR as a best- effort breadcrumb (C=0, copy still capped, §13.1 short-write pattern), allocated in c64-lib-contract SPEC §13.2's table (#139) before use. Evidence: cap-only build PASS on the U64E at 48 MHz, github.com HTTP 200, CFIN 48.5 s, PRG eccb7ec7. The cap+$8B build (2396d97d) hit $88 NO_SOCKET at TCP_CONNECT 2/2 — the device's after-N-loads connect degradation, before net_poll runs — so its twelve added bytes are hardware-unverified today; stated on the PR rather than claimed. rig_https_local/_live now dump uci_req_len/uci_read_hdr post-mortem. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 7 ++++- docs/engineering-notes.md | 52 ++++++++++++++++++++++++++++++++++ src/net/uci/net.s | 55 ++++++++++++++++++++++++++++++------ src/net/uci/uci_errors.inc | 22 +++++++++++---- tools/uci/rig_https_live.py | 1 + tools/uci/rig_https_local.py | 2 +- 6 files changed, 122 insertions(+), 17 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 580600d..c0511c7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -184,7 +184,12 @@ drops a symbol fails the link by name on both backends. Surface: - 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 reserved here; allocate new codes in SPEC §13.2's table first. + and UDP-only: on TCP the SOCKET_READ header is not a delivered count + (fw 3.14d answers a 512 B request with `$FFFF` on every session), so + `net_poll` caps the copy at the request and never emits `$8A` (#140); + a header above the request that is NOT `$FFFF` leaves the breadcrumb + `$8B UCI_ERR_BAD_READ_HDR` (C=0, stream continues). 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. diff --git a/docs/engineering-notes.md b/docs/engineering-notes.md index a793102..7da6e7e 100644 --- a/docs/engineering-notes.md +++ b/docs/engineering-notes.md @@ -2839,3 +2839,55 @@ region ends); hashes changed, as they must. 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. + diff --git a/src/net/uci/net.s b/src/net/uci/net.s index 2e3590e..433da49 100644 --- a/src/net/uci/net.s +++ b/src/net/uci/net.s @@ -10,15 +10,13 @@ ; GET_IPADDR command. Does NOT run DHCP ourselves — ; the firmware already did that before the PRG started. ; -; Exports two symbol families: -; -; (a) net_abi.inc contract — the long-term public names. -; (b) legacy caller names currently imported by boot.s / http.s / -; tls_record_io.s — kept as thin aliases until those callers are -; migrated onto net_abi.inc in a later phase. -; -; Also publishes `net_banner_str`, the backend-specific banner line -; consumed by boot.s's startup print. +; Exports exactly the surface src/net_abi.inc imports (c64-lib-contract +; SPEC §13 core/TCP/DNS families, issue #70) plus the two c64-https +; extensions declared there: net_recv_byte (the drain entry) and +; net_banner_str (the backend-specific banner line boot.s prints). +; Error codes live in uci_errors.inc, in the §13.2 UCI range $80-$BF, +; which is ONE namespace shared with c64-wireguard — allocate in SPEC +; §13.2's table first. .include "uci_regs.inc" .include "uci_errors.inc" @@ -292,6 +290,45 @@ net_poll: rts @have_data: + ; --- Bound the copy by the request (#140, SPEC §13.3) -------------- + ; The response header is NOT a delivered-byte count on the TCP + ; SOCKET_READ path. Measured on fw 3.14d (U64E, 2026-08-24, real + ; github.com session, first poll, ring empty): request $0200, + ; header $FFFF — the same sentinel c64-wireguard saw for a 1500 B + ; UDP request. The copy below used to survive that only because it + ; re-checks ring-full and DATA_AV per byte; a refactor of the loop + ; would have turned the header into a runaway copy. So the count is + ; capped at uci_req_len — what this poll actually asked for, which + ; the ring clamp above may have made smaller than UCI_READ_CHUNK_MAX. + ; Bytes beyond the request were never delivered (they stay in the + ; firmware for the next poll), so capping loses nothing and is not + ; a trim of real data. A drop-and-error here (the first cut of this + ; check, emitting UCI_ERR_LONG_READ) aborted every real handshake at + ; ServerHello: "header > request" is routine on TCP, so $8A is a + ; UDP-path code and this adapter never emits it. + lda uci_req_len+0 + cmp uci_poll_rem+0 + lda uci_req_len+1 + sbc uci_poll_rem+1 ; C=1 iff req >= header (16-bit) + bcs @len_bounded + ; header > request. $FFFF is the firmware's routine "more than you + ; asked" sentinel; anything else above the request is a header the + ; firmware has no documented mode for. Leave a breadcrumb for that + ; case ($8B, best-effort: C=0, stream continues — the cap below + ; makes it safe either way) so a post-mortem can tell the two + ; apart. Allocated in c64-lib-contract SPEC §13.2 before use. + lda uci_poll_rem+0 + and uci_poll_rem+1 + cmp #$FF + beq @len_cap ; $FFFF sentinel — routine, no breadcrumb + lda #UCI_ERR_BAD_READ_HDR + sta net_last_error +@len_cap: + lda uci_req_len+0 + sta uci_poll_rem+0 + lda uci_req_len+1 + sta uci_poll_rem+1 +@len_bounded: ; Copy exactly (uci_poll_rem) bytes from UCI_RESP_DATA into the ; ring at tcp_recv_buf + tcp_recv_tail, advancing the masked tail. ; The store uses SMC on @rb_store so we can hit the full 4 KB diff --git a/src/net/uci/uci_errors.inc b/src/net/uci/uci_errors.inc index 3107962..f2c1fd7 100644 --- a/src/net/uci/uci_errors.inc +++ b/src/net/uci/uci_errors.inc @@ -21,12 +21,22 @@ UCI_ERR_READ_FAIL = $86 ; SOCKET_READ returned an error bit UCI_ERR_SHORT_WRITE = $87 ; SOCKET_WRITE wrote fewer bytes than requested UCI_ERR_NO_SOCKET = $88 ; socket-open response yielded no socket_id UCI_ERR_WAIT_TIMEOUT = $89 ; a bounded wait exceeded its wall-clock budget -UCI_ERR_LONG_READ = $8A ; SOCKET_READ claimed more bytes than we asked - ; for. Allocated by c64-wireguard (2026-08-24); - ; reserved here with the same meaning. This - ; adapter does not emit it yet — net_poll - ; stops at DATA_AV rather than checking the - ; claimed length against the request. +UCI_ERR_LONG_READ = $8A ; UDP path (c64-wireguard, 2026-08-24): + ; SOCKET_READ reported a datagram length + ; exceeding the request; the datagram is + ; dropped. Reserved here, NEVER emitted: on + ; the TCP path the header is not a delivered + ; count (fw 3.14d answers a 512 B request with + ; $FFFF on every session), so net_poll caps the + ; copy at the request instead (#140). +UCI_ERR_BAD_READ_HDR = $8B ; SOCKET_READ header exceeded the request and + ; was NOT the $FFFF sentinel — a value the + ; firmware has no documented mode for. Best- + ; effort breadcrumb (§13.1 short-write pattern): + ; the copy is still capped at the request and + ; net_poll returns normally; the code stays in + ; net_last_error for post-mortems. Allocated in + ; SPEC §13.2's table first (c64-lib-contract#139). ; TCP state values stored in net_tcp_state. The NET_TCP_* spellings in ; net_states.inc are the contract's (§13.1); these aliases keep the diff --git a/tools/uci/rig_https_live.py b/tools/uci/rig_https_live.py index dca7a39..3df5a85 100644 --- a/tools/uci/rig_https_live.py +++ b/tools/uci/rig_https_live.py @@ -338,6 +338,7 @@ def main() -> int: "net_init", "net_initialized", "uci_socket_id", "net_last_error", "net_tcp_state", "tcp_recv_head", "tcp_recv_tail", + "uci_req_len", "uci_read_hdr", # #140: SOCKET_READ request vs claim "http_resp_buf", "http_resp_len", "http_status", "tls_state", "tls_last_state", ] diff --git a/tools/uci/rig_https_local.py b/tools/uci/rig_https_local.py index 8ce272f..bd74b3f 100644 --- a/tools/uci/rig_https_local.py +++ b/tools/uci/rig_https_local.py @@ -715,7 +715,7 @@ def r16(name: str) -> int: "tls_recv_progress", "tls_recv_sub_progress"): if name in labels: print(f" {name:22s} : 0x{r8(name):02X}") - for name in ("tcp_recv_head", "tcp_recv_tail", + for name in ("tcp_recv_head", "tcp_recv_tail", "uci_req_len", "uci_read_hdr", "http_status", "http_resp_len"): if name in labels: print(f" {name:22s} : ${r16(name):04X}") From cfebf5f0a17f2d3f52813e37fc72adb98684a058 Mon Sep 17 00:00:00 2001 From: JC-000 <3798556+JC-000@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:09:52 -0500 Subject: [PATCH 3/4] =?UTF-8?q?uci:=20$FFFF=20is=20the=20SOCKET=5FREAD=20n?= =?UTF-8?q?o-data=20sentinel=20on=20both=20transports=20=E2=80=94=20commen?= =?UTF-8?q?ts=20and=20docs=20corrected=20(#140)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code unchanged (byte-identical PRG): the cap plus the DATA_AV check already copy zero bytes on the sentinel. What was wrong was every comment saying $FFFF meant 'more than you asked': the first-poll $FFFF was an idle poll waiting for ServerHello, as c64-wireguard's idle-UDP measurement (11/11 sizes) makes clear. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 11 +++++----- docs/engineering-notes.md | 16 ++++++++++++++ src/net/uci/net.s | 43 +++++++++++++++++++------------------- src/net/uci/uci_errors.inc | 25 ++++++++++++---------- 4 files changed, 58 insertions(+), 37 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c0511c7..9d1fb4f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -184,11 +184,12 @@ drops a symbol fails the link by name on both backends. Surface: - 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 UDP-only: on TCP the SOCKET_READ header is not a delivered count - (fw 3.14d answers a 512 B request with `$FFFF` on every session), so - `net_poll` caps the copy at the request and never emits `$8A` (#140); - a header above the request that is NOT `$FFFF` leaves the breadcrumb - `$8B UCI_ERR_BAD_READ_HDR` (C=0, stream continues). Allocate new codes + 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 diff --git a/docs/engineering-notes.md b/docs/engineering-notes.md index 7da6e7e..a6776f7 100644 --- a/docs/engineering-notes.md +++ b/docs/engineering-notes.md @@ -2891,3 +2891,19 @@ 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. + diff --git a/src/net/uci/net.s b/src/net/uci/net.s index 433da49..00f4e72 100644 --- a/src/net/uci/net.s +++ b/src/net/uci/net.s @@ -291,32 +291,33 @@ net_poll: @have_data: ; --- Bound the copy by the request (#140, SPEC §13.3) -------------- - ; The response header is NOT a delivered-byte count on the TCP - ; SOCKET_READ path. Measured on fw 3.14d (U64E, 2026-08-24, real - ; github.com session, first poll, ring empty): request $0200, - ; header $FFFF — the same sentinel c64-wireguard saw for a 1500 B - ; UDP request. The copy below used to survive that only because it - ; re-checks ring-full and DATA_AV per byte; a refactor of the loop - ; would have turned the header into a runaway copy. So the count is - ; capped at uci_req_len — what this poll actually asked for, which - ; the ring clamp above may have made smaller than UCI_READ_CHUNK_MAX. - ; Bytes beyond the request were never delivered (they stay in the - ; firmware for the next poll), so capping loses nothing and is not - ; a trim of real data. A drop-and-error here (the first cut of this - ; check, emitting UCI_ERR_LONG_READ) aborted every real handshake at - ; ServerHello: "header > request" is routine on TCP, so $8A is a - ; UDP-path code and this adapter never emits it. + ; The response header is not a delivered-byte count. On fw 3.14d + ; $FFFF is the NO-DATA sentinel on both transports (c64-wireguard: + ; every idle UDP poll; here: the idle polls after ClientHello while + ; ServerHello is still in flight answer $FFFF to a 512 B request). + ; The copy below survived it only because it re-checks ring-full + ; and DATA_AV per byte and so copied zero bytes; a refactor of the + ; loop would have turned the header into a runaway copy. So the + ; count is capped at uci_req_len — what this poll actually asked + ; for, which the ring clamp above may have made smaller than + ; UCI_READ_CHUNK_MAX. Bytes beyond the request were never delivered + ; (they stay queued for the next poll), so the cap loses nothing. + ; A drop-and-error here (the first cut, emitting UCI_ERR_LONG_READ) + ; aborted every real handshake on its first idle poll — the same + ; misfiling c64-wireguard carried for four days as a "firmware + ; quirk". $FFFF MUST be excluded before any over-claim test. lda uci_req_len+0 cmp uci_poll_rem+0 lda uci_req_len+1 sbc uci_poll_rem+1 ; C=1 iff req >= header (16-bit) bcs @len_bounded - ; header > request. $FFFF is the firmware's routine "more than you - ; asked" sentinel; anything else above the request is a header the - ; firmware has no documented mode for. Leave a breadcrumb for that - ; case ($8B, best-effort: C=0, stream continues — the cap below - ; makes it safe either way) so a post-mortem can tell the two - ; apart. Allocated in c64-lib-contract SPEC §13.2 before use. + ; header > request. $FFFF is the no-data sentinel (routine, copied + ; as zero bytes by the DATA_AV check); anything else above the + ; request is a header the firmware has no documented mode for. + ; Leave a breadcrumb for that case ($8B, best-effort: C=0, stream + ; continues — the cap below makes it safe either way) so a + ; post-mortem can tell the two apart. Allocated in c64-lib-contract + ; SPEC §13.2 before use; the datagram-family counterpart is $8A. lda uci_poll_rem+0 and uci_poll_rem+1 cmp #$FF diff --git a/src/net/uci/uci_errors.inc b/src/net/uci/uci_errors.inc index f2c1fd7..98e82ae 100644 --- a/src/net/uci/uci_errors.inc +++ b/src/net/uci/uci_errors.inc @@ -21,17 +21,20 @@ UCI_ERR_READ_FAIL = $86 ; SOCKET_READ returned an error bit UCI_ERR_SHORT_WRITE = $87 ; SOCKET_WRITE wrote fewer bytes than requested UCI_ERR_NO_SOCKET = $88 ; socket-open response yielded no socket_id UCI_ERR_WAIT_TIMEOUT = $89 ; a bounded wait exceeded its wall-clock budget -UCI_ERR_LONG_READ = $8A ; UDP path (c64-wireguard, 2026-08-24): - ; SOCKET_READ reported a datagram length - ; exceeding the request; the datagram is - ; dropped. Reserved here, NEVER emitted: on - ; the TCP path the header is not a delivered - ; count (fw 3.14d answers a 512 B request with - ; $FFFF on every session), so net_poll caps the - ; copy at the request instead (#140). -UCI_ERR_BAD_READ_HDR = $8B ; SOCKET_READ header exceeded the request and - ; was NOT the $FFFF sentinel — a value the - ; firmware has no documented mode for. Best- +UCI_ERR_LONG_READ = $8A ; Datagram family (c64-wireguard): the + ; SOCKET_READ header claimed more than the + ; request and was NOT the $FFFF no-data + ; sentinel; the datagram is dropped. Reserved + ; here, NEVER emitted: on a stream the same + ; observation is $8B below. $FFFF is the + ; firmware's no-data sentinel on BOTH + ; transports and must be excluded before any + ; over-claim test (#140; both adapters got + ; this wrong independently). +UCI_ERR_BAD_READ_HDR = $8B ; Stream family: SOCKET_READ header exceeded + ; the request and was NOT the $FFFF no-data + ; sentinel — a value the firmware has no + ; documented mode for. Best- ; effort breadcrumb (§13.1 short-write pattern): ; the copy is still capped at the request and ; net_poll returns normally; the code stays in From 9f26c8cb9ca9bf84d3c4e8cae680bef4061ace68 Mon Sep 17 00:00:00 2001 From: JC-000 <3798556+JC-000@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:12:58 -0500 Subject: [PATCH 4/4] =?UTF-8?q?uci:=20$8A/$8B=20are=20reserved=20defensive?= =?UTF-8?q?ly=20=E2=80=94=20no=20non-sentinel=20over-claim=20has=20been=20?= =?UTF-8?q?observed=20(#140)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- docs/engineering-notes.md | 14 ++++++++++++++ src/net/uci/uci_errors.inc | 7 +++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/docs/engineering-notes.md b/docs/engineering-notes.md index a6776f7..047fff6 100644 --- a/docs/engineering-notes.md +++ b/docs/engineering-notes.md @@ -2907,3 +2907,17 @@ 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. + diff --git a/src/net/uci/uci_errors.inc b/src/net/uci/uci_errors.inc index 98e82ae..2cbe650 100644 --- a/src/net/uci/uci_errors.inc +++ b/src/net/uci/uci_errors.inc @@ -29,8 +29,11 @@ UCI_ERR_LONG_READ = $8A ; Datagram family (c64-wireguard): the ; observation is $8B below. $FFFF is the ; firmware's no-data sentinel on BOTH ; transports and must be excluded before any - ; over-claim test (#140; both adapters got - ; this wrong independently). + ; length arithmetic (#140; both adapters got + ; this wrong independently). No non-sentinel + ; over-claim has been observed on either + ; transport; both codes are reserved + ; defensively. UCI_ERR_BAD_READ_HDR = $8B ; Stream family: SOCKET_READ header exceeded ; the request and was NOT the $FFFF no-data ; sentinel — a value the firmware has no