Skip to content

Real-server HTTPS sprint: streaming deframer + first live github.com/browserleaks.com GETs (HTTP 200) - #123

Merged
JC-000 merged 43 commits into
masterfrom
feat/real-server-sprint-w0
Aug 22, 2026
Merged

JC-000 merged 43 commits into
masterfrom
feat/real-server-sprint-w0

Conversation

@JC-000

@JC-000 JC-000 commented Aug 22, 2026

Copy link
Copy Markdown
Owner

Milestone

First HTTPS GETs from the C64 to real public internet servers — U64E @ 48 MHz, comb profile, 2026-08-21:

target client Finished HTTP note
github.com 32.5 s 200 full PASS, homepage HTML prefix in http_resp_buf
browserleaks.com 33.4 s 200 TLS + status + body prefix good; known W4 body-drain gap (below)

Server-visible silence ~26 s against 90 s / 45 s measured tolerances.

What's in here

  • W0 — phase timing + real-server TLS probe (was already on the branch).
  • W1 — streaming handshake-message deframer (src/tls_deframe.s, gated TLS_STREAM_DEFRAME, UCI-on/ip65-off): in-place zero-copy dispatch via a ZP base pointer (tls_hs_ptr=$3E, own mechanical commit), 280 B carry buffer for small spanning messages, per-MESSAGE transcript discipline.
  • W2 — streaming Certificate consumer: leaf staged into cert_buf, intermediates discarded unbuffered, DF_ERR_CERT_TOO_BIG rejects before any copy.
  • W3 — make HTTPS_HOST=<host> (generated include; default build proven byte-identical).
  • W4 — chunked Transfer-Encoding + large-header robustness in src/http.s (new 367 B HTTP_AUX_CODE segment; CODE shrank 64 B).
  • W5 — tools/uci/rig_https_live.py + padded 3-cert-chain listener profile (github-shaped 11-record flight, no egress).
  • Record-layer fix: inner content type misread on exactly-512-content records — the MFL real-server shape, one byte past every local fixture. Found live against github, fixed with a page-2 arm, mutation-proven (9/10 buggy → 10/10 fixed) by an adversarial-by-construction gate vector.
  • Tests: independent deframer acceptance gate tools/test_tls_deframer.py (10/10 + 2/2 plumbing), Lane-A suite tools/test_tls_deframe.py (41/41), evil_listener.py RECORD_FRAME re-framing modes (selftest 6/6 vs OpenSSL), tools/test_http.py 48/48 (was 27).
  • /Temp GC (tools/uci/_temp_gc.py): userspace mirror of Add automatic cleanup of Temp folder GideonZ/1541ultimate#686 defusing the writemem exhaustion wedge (harness adoption: c64-test-harness#153).
  • CLAUDE.md: milestone record, live-run findings, new variables + rig docs.

Verification

  • VICE: run_all_tests.py 287/287; deframer gate 10/10 (mutation-proven); Lane-A suite 41/41; re-framed server-Finished checks on hardware: good controls PASS (mfl512 + pathological), forged Finished → clean abort at FINISHED.
  • Hardware controls: local single-cert e2e 30.8 s CFIN (baseline 31.1 s); padded 3-cert 11-record flight +0.7 s only.
  • ip65 stays byte-identical through the deframer commits (deframer compiled out); all five build profiles link; default-build PRG hash unchanged by W3.

Known follow-ups (deliberately not in this PR)

  • W4 gap: large non-chunked body cannot terminate by Content-Length after 512 B truncation (browserleaks holds the connection; rig sentinel timeout with correct status/body already captured).
  • cert_buf 1536 B vs wikipedia's 1636 B leaf (reroute to CRYPTO_OVERLAY under UCI, backend-conditional size).

🤖 Generated with Claude Code

JC-000 and others added 30 commits August 16, 2026 06:18
Groundwork for the "real public HTTPS server at 48 MHz" sprint. No 6502
code changes; both additions are host-side measurement tools.

tools/probe_server_tls.py (new)
  Sends a byte-faithful replica of the ClientHello src/tls_handshake.s
  emits and reports the three things that decide reachability: whether
  the server accepts our very narrow offer (one cipher suite, one group,
  ECDSA only), how large its records are against the 548 B
  TLS_REC_BUF_MAX, and how long it tolerates a silent client.

  Measured 2026-08-15: github.com 529 B / 90 s, browserleaks.com
  529 B / 45 s, en.wikipedia.org 529 B / >100 s (leaf 1636 B exceeds
  cert_buf), cloudflare.com 2714 B / 15 s. Normal OpenSSL servers DO
  honor max_fragment_length, so their records already fit; Cloudflare
  ignores it AND enforces a hard 15 s connect-to-first-request deadline,
  which is why it is out of scope.

tools/uci/rig_https_local.py
  Opt-in phase timing via PHASE_TIMING=1 (PHASE_POLL tunes the interval);
  default behaviour is unchanged. Writes phase_timing.txt to the artifact
  dir alongside a per-marker cost rollup.

  Deliberately does NOT reuse tests/rig_vice_https_macos.py's "latest
  progress needle on screen" heuristic: measured here it yielded 3
  transitions across a 42 s handshake, because the screen scrolls and the
  markers repeat once per encrypted record. Taking the last non-empty
  line is immune to both. tls_state ($A84A) is not a usable alternative
  either — it sits under BASIC ROM, so a DMA read returns ROM bytes.

W0 result (U64E @ 48 MHz, comb, local listener, PASS/200): client
Finished at 31.1 s, of which the server sees 24.7 s of silence — 3.6x
headroom against github.com, 45% against browserleaks.com. X25519 is now
co-dominant with the verify (8.0 s vs 16.3 s), not a minor term.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The HTTPS GET target (host data, SNI copy, DNS lookup) was hardcoded to
www.foo.bar in src/boot.s. Add `make HTTPS_HOST=<host>` mirroring the
HTTPS_PORT idiom. ca65's -D is numeric-only, so the string travels via a
generated build/https_host.inc (already on the -I build path), with a
content-compared FORCE rule so a changed host rebuilds boot.o without
`make clean` — on a change the rule also deletes boot.o, because Make
3.81's 1-second mtime resolution otherwise leaves the previous host's
object counting as up to date (measured). The link step keeps the
repo-wide same-second caveat; comment documents it.

Default build proven byte-identical (b574b344cdca...) with the default
string; en.wikipedia.org/github.com overrides verified to embed and to
revert cleanly; UCI comb variant links. A .assert refuses hosts longer
than the 63-char SNI copy guard at build time. http_host_foo renamed to
http_host_target (no external consumers).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The W0 phase-timing commit (13b5063) inserted an 'if phase_timing:' block
between the sentinel poll loop and its 'else:', silently rebinding the
else from the while (runs only on exhaustion) to the if. Result at 13b5063:
a successful default-mode run (PHASE_TIMING unset) reported TIMEOUT and
exited 1, and a PHASE_TIMING=1 run that genuinely timed out skipped the
TIMEOUT diagnostics entirely.

Track the break explicitly (sentinel_seen) and route the TIMEOUT path off
that flag, independent of phase timing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ustness

Real web servers (github.com, browserleaks.com, en.wikipedia.org) send
responses the local listener never did: Transfer-Encoding: chunked with
no Content-Length, and header blocks far larger than http_resp_buf that
span many TLS records. Previously chunk framing landed raw in the body
buffer.

- check_transfer_encoding: case-insensitive 'Transfer-Encoding: chunked'
  header match (same single-SP-after-colon shape as check_content_length),
  sets http_chunked.
- http_state_body_chunked: strips chunk framing byte-by-byte with
  persistent sub-state (hex size lines incl. chunk extensions, per-chunk
  CRLF, terminal 0/trailers/blank-line), copying only payload into
  http_resp_buf. Terminates C=0 on the terminal chunk exactly like the
  Content-Length match path.
- Truncate-at-capacity: past 512 B payload is consumed and discarded
  (http_resp_len caps at the captured prefix) and the parser still
  finds the terminal chunk, so oversized bodies end cleanly instead of
  falling to the ~65k-poll timeout.
- Header scanning was already incremental (persistent line-state across
  records, 31-byte line cap); now proven against >2.5 KB header blocks
  split across 14 spans by new tests.
- Memory: body-state handler moved out of CODE into new HTTP_AUX_CODE
  segment (UCI: LOADER, 588 B free pre-W4; ip65: CRYPTO_OVERLAY — ip65
  LOADER had only 8 B free). CODE shrank 64 B on both backends. New BSS:
  4 B (http_chunked, http_chunk_state, http_chunk_rem). PRG sizes
  unchanged (47,105 B ip65 / 62,977 B uci-comb).
- tools/test_http.py: span-mode drive (the TLS input path) + 5 new
  vector groups: chunked single-span, 5-span chunked w/ split header
  name + split chunk data + uppercase hex + chunk extension + split
  terminal CRLF, 700 B chunked truncation, 2.6 KB headers w/ buried
  Content-Length, chunked + large headers. 48/48 (was 27).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TLS 1.3 handshake + GET against a real public server (HTTPS_TARGET,
default github.com) through the UCI backend. No inline listener, no repo
certs; pass criteria are C64-side only: sentinel + http_get C=0 +
http_status == EXPECT_STATUS (default 200).

Follows every established hardware convention: turbo set BEFORE
reset/run_prg with the read-skip-write pattern, preflight_reu() under the
DeviceLock right after enable_uci (the comb PRG claims REU bank 2), all
DMA addresses from build_policy_and_arbiter_with_overlay_carveout(), and
a rig_ name so pytest never collects it. C64_INIT_WAIT defaults to 75 s
on a comb build (ec_precompute_256 boot pass), detected from
build/labels.txt via the banks equate + ec_precompute symbol union.

PHASE_TIMING defaults ON here (opt-in in the local rig) so live runs
produce the W0 phase table; artifacts are kept on PASS by default.
The target reaches the C64 via http_host_ptr DMA -> http_get, whose
prologue copies the hostname into tls_hostname for SNI, so the rig works
with today's PRG and does not depend on W3's HTTPS_HOST build define.
en.wikipedia.org is selectable but warned about: its 1636 B leaf exceeds
the 1536 B cert_buf.

Hardware-free coverage: --selfcheck exercises env handling, the comb
init-wait derivation, and the trampoline's opcode structure (verified
against both synthetic labels and a real comb build's labels.txt).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New cert profile 'p256-chain' (kwarg or HTTPS_LISTENER_CERT_PROFILE=,
mirroring the p384 selector): the existing P-256 test leaf, unchanged,
plus two padded throwaway intermediates minted by chain_certs.py with the
stdlib-only gen_certs toolkit. Chain total ~3.0 KB (real targets measured
2026-08-21: github 2719 B, browserleaks 3430 B, wikipedia 4114 B), which
forces the server flight into 11 records of <=529 B against the C64's
ClientHello — the real-record-count overhead becomes benchable with no
internet egress, while the C64 still verifies the same leaf.

Padding is one non-critical private-OID extension per intermediate
(CHAIN_PAD_BYTES tunes it); linkage is deliberately fake — nothing on
either side parses the intermediates, byte/record count is what is under
test. The chain file regenerates whenever the leaf does.

Host-side selftest (chain_certs.py --selftest, loopback, no hardware):
stdlib ssl client confirms TLS 1.3, all 3 certs served, leaf identical to
server.pem, HTTP 200; then the byte-faithful C64 ClientHello replica
(probe_server_tls) confirms MFL is honored — max record 529 B, 11-record
flight. PASSES on this host.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The W0 phase-timing commit (13b5063) inserted an 'if phase_timing:' block
between the sentinel poll loop and its 'else:', silently rebinding the
else from the while (runs only on exhaustion) to the if. Result at 13b5063:
a successful default-mode run (PHASE_TIMING unset) reported TIMEOUT and
exited 1, and a PHASE_TIMING=1 run that genuinely timed out skipped the
TIMEOUT diagnostics entirely.

Track the break explicitly (sentinel_seen) and route the TIMEOUT path off
that flag, independent of phase timing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The three encrypted-handshake handlers hardcoded tls_rec_buf as the
message base (lda tls_rec_buf,y / tls_rec_buf+N in tls_cert.s, the
tls_rec_buf+4 verify_data compare in tls_keyschedule.s). Introduce
tls_hs_ptr at $3E-$3F — inside ZP_CRYPTO but past the ECDSA bignum
claim ($22-$3C) and nistcurves_zp_ptr2 ($3D), so it survives across
ecdsa_verify inside tls_handle_cert_verify — and read the message
through (tls_hs_ptr),y instead.

Behavior is identical: each handler resets the pointer to tls_rec_buf
at entry via the new tls_hs_ptr_reset (CRYPTO_CODE — the NET_CODE
region hosting TLS_CODE under UCI has ~8 B of slack), which also keeps
the direct-call test drivers (test_finished_verify.py,
test_tls_p384_negotiation.py) working unchanged. tls_verify_finished
moves to CRYPTO_CODE for the same budget reason; TLS_CODE nets out
38 bytes smaller.

This is the mechanical precursor to the W1 streaming deframer, which
will point tls_hs_ptr at tls_rec_buf+offset (in-place dispatch) or at
a carry buffer.

Verified: UCI comb, ip65 default and ip65 onchip all link; VICE
regression on the ip65 build: run_all_tests 266/266,
test_finished_verify 18/18, test_tls_handshake 21/21,
test_tls_p384_negotiation PASS, test_x509 PASS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Real servers honoring max_fragment_length deliver the encrypted flight
as 11-14 records where handshake messages no longer align with records:
Certificate spans ~6 records, and CertificateVerify + Finished share
the tail record. The old tls_recv_encrypted dispatched exactly one
message per record from tls_rec_buf[0].

New src/tls_deframe.s tracks the current message type, its 24-bit
declared length and bytes consumed, and per decrypted record feeds the
plaintext through:

  - message wholly inside the record -> dispatched IN PLACE, zero copy
    (tls_hs_ptr = tls_rec_buf + offset). This covers 100% of the local
    listener's traffic, so the existing e2e is the control.
  - small message spanning records -> assembled into a 280 B carry
    buffer, dispatched via the pointer. Oversize spanning messages are
    an explicit error, never an overflow.
  - Certificate spanning records -> rejected for now; the W2 streaming
    consumer lands next.

Transcript discipline now holds per MESSAGE rather than per record:
when a header completes the running hash is snapshotted (so CertVerify
and Finished verify against the transcript excluding their own
message), then the header and every body chunk are folded as consumed.
Fold-as-you-go is what the streaming Certificate will require; for the
other paths it is equivalent to the old fold-after-dispatch.

tls_recv_encrypted still returns after ONE dispatched message, so the
tls_connect state machine and its screen markers are unchanged; a
record's leftover bytes are consumed on the next call before any new
record is fetched.

Placement: code in a new TLS_DEFRAME_CODE segment routed to
CRYPTO_OVERLAY by both UCI cfgs (CRYPTO_HOT is 446 B short of holding
it under the plain-uci REU archive); resident there, mutually
exclusive with the overlay-embed flags exactly as
LIB_NISTCURVES_MUL_CODE already is. State + carry buffer (300 B) in
NET_BSS_TAIL. Gated on TLS_STREAM_DEFRAME - default ON under
BACKEND=uci, OFF under ip65 (no code or BSS headroom there); the ip65
PRG is byte-identical across this commit (f557658a...).

New VICE coverage: tools/test_tls_deframe.py (BACKEND=uci build,
DMA-driven) - 18/18: multi-message records, splits at body/header/
header-boundary offsets, split server Finished accepted AND a
corrupted split Finished rejected (per-message transcript proof),
unknown-type and oversize-spanning error paths, transcript compared
against a Python SHA-256 of the exact fed bytes after every scenario.

All three UCI profiles (plain / onchip / comb) link with the deframer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Lane D of the real-server sprint. Independent acceptance gate for the
streaming handshake-message deframer (W1/W2), written without sight of
the implementation — expectations derive from RFC 8446 reassembly.

tools/test_tls_deframer.py (new):
  Drives the public tls_recv_encrypted seam over genuinely AEAD-encrypted
  records primed into the TCP receive ring (net_poll patched to RTS).
  Oracles are implementation-independent: leaf pubkey extraction proves a
  Certificate was fully reassembled+parsed; the running transcript proves
  per-message folding; a cert_buf+1536 sentinel proves the oversized-leaf
  guard fires instead of overflowing.
  Current build: plumbing 2/2 PASS (rig proven), deframer 0/9 (xfail as
  expected pre-deframer). Exit 2 = gate not yet met, plumbing OK.
  Covers: two-messages-per-record, message split across records incl. at
  each of the 4 header-byte offsets, mid-body, exact boundary, many-record
  cert, and oversized-leaf clean error.

tools/https_e2e/evil_listener.py:
  Add RECORD_FRAME re-framing (onepermsg|mfl512|pathological), orthogonal
  to good/bad_finished. onepermsg kept byte-for-byte (rig_https_bad_finished
  unaffected). --selftest now covers all 6 combos: OpenSSL accepts every
  framing in good and rejects bad_finished, proving the wire bytes are valid
  TLS and framing is the only variable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A real server's Certificate message (~2.7 KB chain) spans ~6 records
and cannot be dispatched from a buffer. The deframer now parses it
incrementally as its bytes arrive: certificate_request_context (must
be 0), certificate_list length, then per CertificateEntry the 24-bit
cert_data length, the data, and the extensions. Only the FIRST entry
(the leaf) is copied — into cert_buf ($A000, 1536 B) — and the moment
it completes the public key is extracted through the new
tls_cert_stream_finish (src/tls_cert.s), which runs the same
x509_extract_pubkey the in-place path uses against the staged copy and
sets cert_buf_len. Every later chain entry is counted and discarded
without buffering. Message bytes are folded into the running
transcript in bulk per record chunk (fold-as-you-go — nothing is
retained to fold later).

A Certificate wholly inside one record still dispatches in place
through tls_handle_certificate, untouched — the local-listener e2e
path is byte-for-byte the same.

Error paths are explicit, never overflows:
  - DF_ERR_CERT_TOO_BIG ($06): leaf > 1536 B (en.wikipedia.org's leaf
    is 1636 B). Checked when the entry length completes, BEFORE any
    copy — cert_buf is not written at all (proven by test).
  - DF_ERR_CERT_FMT ($05): non-zero request context, entry >= 64 KB,
    truncated message, or a complete leaf with no usable EC key.

Coverage: tools/test_tls_deframe.py grows to 41/41 — streamed 2-entry
chain split at awkward offsets (leaf staged byte-exact in cert_buf,
pubkey == the generated cert's coordinates, transcript == Python
SHA-256 of the fed bytes), streamed chain followed by a trailing
message in the tail record, in-place single-record Certificate,
oversize/1536-boundary/bad-context/junk-leaf error paths. Lane D's
independent RFC-derived acceptance gate (tools/test_tls_deframer.py,
on the sprint branch) passes 8/9 plumbing+deframer scenarios; the one
miss is a fixture artifact — its overflow sentinel at cert_buf+1536
sits on tls_rec_buf[0] ($A600), which the record layer overwrites
legitimately on any build.

ip65 remains gated off and its PRG byte-identical (f557658a...); all
three UCI profiles link.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The overflow sentinel at cert_buf+1536 (=$A600) is tls_rec_buf[0] under
both cfgs — the record layer legitimately writes it on every flight, so no
implementation could pass that check (found by the deframer lane's first
run against the gate). Replace it with a strictly stronger, collision-free
oracle: fill cert_buf itself with the sentinel and require it untouched —
the CertificateEntry length field precedes the data, so a correct guard
rejects an oversized leaf before copying anything.

Also: build BACKEND=uci by default (the deframer is TLS_STREAM_DEFRAME,
UCI-only; ip65 xfails by design — DEFRAMER_BUILD_BACKEND=ip65 overrides),
and early-exit the pump loop once the ring is drained and all expected
messages dispatched, so surplus calls no longer spin the 65,536-poll
timeout loop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
cert_buf+1536 is $A600 = tls_rec_buf[0] under both cfgs, so the record
layer clobbers the past-the-end sentinel on any build — the gate's
oversized-leaf case failed against a correct deframer. Replace it with
a full-buffer sentinel fill: W2's guard rejects before any copy, so the
whole buffer staying intact is the stronger, collision-free oracle (it
also catches partial copies). Analysis credit: Lane A's report.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-sprint-w0

# Conflicts:
#	tools/test_tls_deframer.py
…eal-server shape)

The inner-type extraction in tls_record_decrypt indexed the decrypted
plaintext with a two-page dispatch: high byte 0 -> tls_rec_buf,y, any
other -> tls_rec_buf+256,y. A record carrying 512 content bytes puts the
inner type at index 512 (page 2), so the code returned tls_rec_buf[256]
— a mid-payload byte — and tls_recv_encrypted aborted on a bogus content
type. Exactly-512-content records are what max_fragment_length-honoring
servers send; every local fixture topped out one byte short, so this
latent (pre-sprint) bug was reachable only live. Found on the first
github.com attempt: abort in CERTIFICATE state, tls_rec_type=$08 =
plaintext[256] of the first Certificate record (verified by offline
decrypt of the captured ring against the DMA-dumped handshake key).

Add the missing page-2 arm (legal indices reach $0213) and a gate
vector shaped like the real flight — a 512-byte zero-padded EE message
so the misread byte is $00 by construction, never $16 by luck (the
first draft of the vector passed on the buggy build exactly that way).
Mutation-proven in VICE: buggy build 9/10 failing only this scenario,
fixed build 10/10.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…) + live-run findings

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fw <= 3.14d leaks one /Temp attachment per REST body (run_prg's ~63 KB
PRG + a 3 B trigger POST per rig run, verified by FTP listing) and never
collects them; ~15 runs fill /Temp and wedge REST + the UCI bridge
together, recoverable only by power cycle. Upstream fixed it in
GideonZ/1541ultimate#686 (auto-cleanup, keep youngest 10) but no
released firmware carries it yet.

_temp_gc.py mirrors that policy host-side over FTP: delete managed
temp<digits> files oldest-first, keep the youngest 2, touch nothing
else. Wired into rig_https_live.py right after enable_uci (under the
DeviceLock); best-effort, C64_SKIP_TEMP_GC=1 bypasses. Verified live on
the U64E.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… reference sections

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Same generated-include mechanism; default '/' behaves identically.
Needed for the Wikipedia-article stretch goal (action=raw endpoint).
Build-time assert caps the path at 100 chars for the request builder.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Host-side rig for the ~122 KB Commodore_64 wikitext fetch into the REU
at $10:0000 with the on-screen viewer left live for the human. Based on
rig_https_live.py; written against the 6502 lanes' contract exports
(http_body_total 3B LE / http_body_sink / http_resp_buf 512 B prefix /
viewer_enter) before those lanes land — main() refuses a PRG missing
them and names the build line.

Differences from the live rig:
  - REU forced to (Enabled, 16 MB) under the DeviceLock before reset,
    via the harness's device-generation-aware set_reu(); read first,
    write only on mismatch, settle after a real write, revert-on-power-
    cycle printed. preflight_reu() kept (presence, not size).
  - 600 s sentinel budget (WIKI_TIMEOUT), http_body_total polled at
    1 Hz as the progress signal, printed at most every 10 s.
  - Verification: http_status==200 AND a host-side stdlib-ssl reference
    fetch of the same URL with C64-faithful headers + a User-Agent;
    exact size match + byte-exact 512 B prefix = PASS, size drift with
    matching prefix = WARN (both sizes printed), prefix mismatch or an
    unavailable reference = FAIL.
  - On PASS the machine is NOT reset and UCI stays enabled; the lock-
    release caveat (next queued job resets the device) is printed.
  - Trampoline JSRs viewer_enter carry-gated, after the sentinel write.

Recon baked into the header (2026-08-21): wikipedia still MFL-honoring
(529 B max record, 14-rec/4,659 B flight, ~120 s tolerance), ECDSA leaf
still 1636 B DER; action=raw is Transfer-Encoding: chunked (NO
Content-Length), 4 chunks <=32 KB, body 125,235 B, byte-stable; and a
UA-less request — what src/http.s sends today — gets HTTP 403.

Selfcheck: 26/26 offline+live; tools/test_pytest_boundary.py green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t-Length

The W4 body-termination gap found live against browserleaks.com: once
the stored copy truncated at the 512 B http_resp_buf cap, http_resp_len
froze, the Content-Length match could never fire, and only the
connection-close / poll-timeout fallback ended the read — a server that
holds the connection open left http_get never returning.

- New 24-bit consumed-byte counter http_body_total (exported, LE),
  incremented in the new shared http_body_append for every body byte,
  stored or not; bytes past the 512 B cap are drained and discarded
  instead of ending the parse.
- http_content_length widened to 24 bits (real bodies exceed 64 KB);
  the $FFFF magic 'absent' sentinel is replaced by a separate
  http_cl_valid flag byte that cannot collide with a real length.
- Termination compares consumed count against Content-Length; with the
  header absent the legacy 512 B cap behaviour is preserved
  (chunked/streaming fallback).
- http_resp_len keeps its contract: min(512, total), first 512 bytes.
- The body machinery moved to a new HTTP_AUX_CODE segment: LOADER slack
  under UCI, CRYPTO_OVERLAY under ip65 (LOADER is 8 B from full there;
  the grown 24-bit parser no longer fits the NET_CODE tail on either
  backend). All five build profiles link.
- tools/test_http.py: three new span-driven vectors (700 B body with
  CL — the regression; 24-bit CL parse; CL-absent legacy path);
  38/38 PASS in VICE.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Lane H's live recon (2026-08-21): en.wikipedia.org returns HTTP 403 to
the exact request c64-https sends today because it carries no
User-Agent; with one it returns 200. Emit a short honest UA line,
'User-Agent: c64-https/0.1', between Host and Connection. Request is
now 87 B for the test vector (256 B http_req_buf; the 42-char article
path leaves ample headroom). String rides RODATA; only the ~20 B copy
loop lands in LOADER CODE.

tools/test_http.py asserts the exact UA line and the updated full
request bytes; 39/39 PASS in VICE.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tch goal

New src/viewer.s (UCI-only, .ifdef BACKEND_UCI — the ip65 PRGs are
byte-identical with and without this change, verified by hash on both
ip65 profiles): renders a large ASCII document stored in the REU at
HTTP_REU_BODY_BASE (default $10:0000, -D-overridable; length in the
24-bit http_body_total) on the 40x25 screen, writing screen RAM
directly with the ascii_chrout strategy-B character mapping.

- 24 content rows + status row 0 (hex offset/total + Q=QUIT)
- hard wrap at 40 cols, LF newline (one LF after a wrap is consumed),
  CR ignored, TAB -> space; clamped at both ends; EMPTY screen for
  total = 0
- keys: CRSR-DOWN/UP one row, SPACE/F1 page, HOME top, Q quit
- window model: 3,584 B REU->C64 DMA into the dead tcp_recv_buf ring
  (1 KB back-coverage); row-start table + REU shadow-register save
  slots live in the ring tail, so viewer BSS is 20 B in NET_BSS_TAIL
- scroll-up derives the previous display-row start from a backward LF
  scan (P + 40*floor((n-1)/40)), matching forward rendering exactly
- REU shadow registers ($DF02-$DF0A) are saved/restored around every
  DMA-using entry so reu_fetch_mul_row's latched state survives

VIEWER_CODE segment: CRYPTO_HOT in the comb cfg (265 B still free),
CRYPTO_OVERLAY in the plain UCI cfg (CRYPTO_HOT there has only ~69 B
of slack at the v0.11.2 pin). Code+rodata 1,189 B.

boot.s hook (BACKEND_UCI only): after http_recv_body, a non-zero
http_body_sink routes to viewer_enter instead of print_resp_body.
viewer.s carries .ifndef-guarded provisional definitions of
http_body_sink / http_body_total until the body-sink lane's land
(-D VIEWER_EXTERNAL_SINK=1 flips them to imports).

tools/test_viewer.py (VICE, default_vice_config): 7/7 PASS — exact
25x40 screen-code assertions for LF handling, wrap + wrap-LF consume,
lowercase folding, TAB/CR, odd bytes, mid-document and arbitrary
offsets, EOF clamp, offset==total, EMPTY, plus 9 scroll-up vectors
against a forward-rendered row-start list. Test image builds with
make BACKEND=uci HTTP_REU_BODY_BASE=196608 VIEWER_TEST_HELPERS=1.
The interactive key loop is a thin dispatch over these tested
primitives and is deliberately not driven from VICE.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…mination

Wikipedia — the stretch-goal target — serves the article CHUNKED with
NO Content-Length (lane H live recon 2026-08-21: 4 data chunks of
26,931-32,768 B, de-chunked body 125,235 B), so on that path the
terminal 0-chunk, not a Content-Length match, is what must end the
body read.

- 'Transfer-Encoding: chunked' matched case-insensitively per header
  line (check_header_line now runs both matchers); http_te_chunked
  routes the end-of-headers transition to the new chunk machine.
- parse_state 3..6: chunk-size line (hex, case-insensitive, chunk
  extensions skipped), chunk payload, closing CRLF, trailer section;
  the empty trailer line after the 0-chunk completes the body (C=0).
- Only payload bytes reach http_body_append, so http_body_total counts
  de-chunked payload — never framing — and the first-512/http_resp_len
  contracts hold unchanged on the chunked path.
- http_chunk_rem is 16-bit; chunks >= 64 KB would mis-parse (largest
  observed anywhere is Wikipedia's 32,768 B) — documented at the
  declaration.
- New HTTP_CHUNK_CODE segment: CRYPTO_RESIDENT tail under ip65 (the
  onchip profile now has 2 B free there — the region is at capacity),
  CRYPTO_OVERLAY under UCI. chunk_hex_digit rides HTTP_AUX_CODE for
  exactly that fit. All five profiles link.
- tools/test_http.py: two chunked vectors (700 B across 3 chunks with
  an extension + uppercase hex + trailer; small 2-chunk body with
  case-folded TE header); 47/47 PASS in VICE.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…af fits

CERT_BUF_SIZE is now a per-backend equate in net_tuning.inc (2048 UCI /
1536 ip65); der_decode.s, the deframer's leaf cap (DF_CERT_BUF_SIZE) and
the tests all derive from it instead of a literal. src/exports.s exports
it as the absolute symbol `cert_buf_size` so tests/rigs read the real
capacity from build/labels.txt.

Under UCI, CERT_BUF_BSS moves from CRYPTO_COLD_SHADOW into CRYPTO_OVERLAY
(after TLS_DEFRAME_CODE; plain/onchip end at $4F62 with 4,253 B free,
comb through ~$5BD2 with ~1.1 KB free — still room for the rigs' DMA
scratch, proven via the _memory_policy arbiter + rig_https_live
--selfcheck). Moving it removed the packing that landed the shared sqtab
at its baked $BC00 anchor, so both UCI cfgs now pin TABLES_BSS with
start=$BA00; the Makefile's post-link check passes on all UCI profiles.
Under ip65 nothing moves: cert_buf stays 1536 B at $A000 in the #68
SCRATCH_UNION, and both ip65 PRGs are byte-identical across this change.

Casualty, accepted: USE_X25519_SIBLING=1 no longer links under UCI
(X25519_BSS overflows CRYPTO_OVERLAY by 1,280 B; it linked pre-growth).
EMBED_P256_OVERLAY was already broken before this change (labels.txt
ordering defect) and is unchanged. Documented in CLAUDE.md.

Tests: tools/test_tls_deframe.py gains cap-relative boundary vectors
(cap accepted, cap+64 rejected, 1636 B accepted on UCI) — 42/42;
tools/test_tls_deframer.py gains a wikipedia_sized_leaf_accepted gate
(real minted 1636 B P-256 cert, pubkey extraction as oracle) — 13/13.
The acceptance rig now calls tls_deframe_init per scenario, mirroring
tls_connect's per-connection reset (the oversize-abort scenario used to
leak DF_MODE_STREAM state into whatever ran after it).
ip65 regression: run_all_tests 287/287, test_x509 11/11,
test_tls_handshake 21/21.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
JC-000 and others added 12 commits August 21, 2026 20:49
…raint

With CERT_BUF_BSS resident in CRYPTO_OVERLAY, a runtime overlay swap-in
would clobber the staged leaf certificate mid-handshake — live data, not
just deframer code. Note it in both UCI cfgs (raised by the deframer
lane's review).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
With http_body_sink=1 the 512 B http_resp_buf becomes a bounce buffer:
http_body_append fills it, and each fill is REU-DMA-STASHed
(HTTP_SINK_CODE, register programming mirrors boot.s::reu_mul_init) to
http_reu_body_base + a 24-bit http_reu_cursor. At completion
http_body_finish (idempotent via a latch, also invoked from the
poll-timeout fallbacks) blits the final partial bounce, then FETCHes
the FIRST min(512, total) body bytes back into http_resp_buf — the
host-side verification contract: first-512 prefix + http_body_total +
REU contents. Works under both identity and chunked bodies (the sink
sits under the shared http_body_append).

- Base: bank 16 ($10:0000, 16 MB REU — the stretch-goal config), clear
  of mul-table banks 0-1, comb's Lim-Lee bank 2, reserved banks 6-7.
  HTTP_REU_BODY_BASE equate in constants.inc, overridable with
  `make HTTP_REU_BODY_BASE=<decimal>`; the runtime copy
  http_reu_body_base (3 B LE, RODATA = RAM) can also be retargeted by
  DMA — VICE's 512 KB REU cannot hold bank 16, so the harness pokes
  bank 3 ($03:0000) at runtime.
- New knob HTTPS_BODY_TO_REU=1: boot.s's do_https_get sets
  http_body_sink=1 before http_recv_body (the only boot.s change).
- UCI-ONLY: every ip65 code pool is within ~30 B of full after the W4
  termination + chunked work, so ip65 builds get 3-byte inert stubs
  (sanctioned by the sprint brief; the knob is UCI-only anyway) and
  http_body_sink is a dead flag there. All five profiles link;
  the knob+base-override build links too.
- http_body_finish re-latches the reu_fetch_mul_row register state
  (mul_dma_lo target, 512 B length) that the sink clobbers, so a
  second handshake on a REU-profile build still fetches multiply rows
  correctly (guard mirrors boot.s's).
- tools/test_http.py: sink vectors (chunked 700 B wiki-shape +
  identity/CL 600 B) verify body_total, the first-512 restore, and the
  REU contents via a C64-side DMA FETCH stub; the suite now uses
  default_vice_config (mandatory -reu). On an ip65 build the sink
  vectors report loudly that they did NOT run (C64_EXPECT_SINK=1
  makes absence a failure). 47/47 PASS on ip65, 52/52 on uci.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Lane E's landed contract sets http_body_sink=1 inside do_https_get (the
menu 'G' path), but the rig's trampoline JSRs http_get directly and
never crosses it — without the poke the body would not be sunk. Poking
the exported flag by DMA before http_get is the supported alternative
per lane E.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…print tip

My four W4 commits were built from a stale base (pre-2158037). This
merge keeps Lane B's github-validated chunked decoder as the base and
ports the Lane E deltas onto it:

- ONE chunked decoder survives: Lane B's http_state_body_chunked (with
  its public names http_chunked / http_chunk_state / http_chunk_rem and
  its large-header robustness).  My parallel state-3..6 machine,
  HTTP_CHUNK_CODE segment, and duplicate check_transfer_encoding are
  gone.
- Lane E deltas ported onto it: 24-bit Content-Length + http_cl_valid
  ($FFFF sentinel retired in http_hdr_init), 24-bit consumed-count
  termination (http_body_total, maintained in the shared body_append —
  de-chunked payload only), truncate-at-capacity now counts past the
  cap on the identity path too (the browserleaks fix), User-Agent
  header, and the BACKEND=uci REU body sink (bounce blits +
  http_body_finish first-512 restore + reu_fetch_mul_row latch
  restore) hooked into both body paths via sink_finish_done.
- viewer.s: Lane G's provisional .ifndef VIEWER_EXTERNAL_SINK block
  deleted as its header prescribed; http_body_total / http_body_sink
  are canonically defined + exported in src/data.s.
- Makefile: HTTP_REU_BODY_BASE pass-through deduped (Lane G's block
  kept, comment widened); HTTPS_BODY_TO_REU added, honored only under
  BACKEND=uci.
- cfgs: tip's CERT_BUF_BSS / VIEWER_CODE / deframer lines kept.  New
  segments: HTTP_SINK_CODE (UCI: CRYPTO_OVERLAY; ip65: 3-byte stub in
  NET_CODE) and HTTP_AUX_CODE2 (jsr-only helpers + patterns split out
  of HTTP_AUX_CODE: ip65 CRYPTO_RESIDENT / UCI CRYPTO_OVERLAY —
  ip65's CRYPTO_OVERLAY and UCI's CRYPTO_HOT could not absorb the
  growth whole).  Comb overlay keeps ~700 B tail free for the rig
  MemoryArbiter (rig_https_live.py --selfcheck PASS).
- tools/test_http.py: UNION suite — Lane B's 48-vector set (incl. the
  5-span github-shape groups) + Lane E's vectors (identity>512/CL
  regression, 24-bit CL, CL-absent legacy, REU sink with DMA
  readback, UA line, body_total assert in the chunked-truncation
  group).  61/61 on ip65, 66/66 on BACKEND=uci in VICE.

All five build profiles link.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…O_COLD_SHADOW)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
temp0009 is followed by temp000A (observed live on the U64E); the \d+
pattern silently skipped lettered names forever. Match and sort hex.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ckets on rig abort

Two findings from the U64E wikipedia attempts (2026-08-21):

1. UCI firmware ACK autonomy ends at its ~4.3 KB socket buffer — bytes
   beyond are silently DROPPED, not flow-controlled. wikipedia's
   4,659 B flight is the first to exceed it: with 16 drain polls the
   ~8 s X25519 stall let the buffer overflow, leaving a permanent hole
   at record 9 (tls_read_seq=8, mid-header — measured twice). github's
   3.2 KB flight squeaked under. Raise the post-SH drain budget to
   8x16=128 polls (~5.2 s), which moves ~4 KB firmware->ring before
   the stall. Client silence grows ~30 s vs 45/90/120 s tolerances.

2. Abandoning an ACTIVE firmware socket (rig timeout with the C64
   parked in http_get) is the leading suspect for the recurring
   DHCP-0.0.0.0 poisoning that survives machine:reboot. The wiki rig's
   timeout path now saves the socket id, resets the C64 (PRG stays in
   RAM), and SYS-trampolines net_tcp_close so the firmware sees a real
   SOCKET_CLOSE.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nnect host

Lets the C64 dial a local TCP relay while the ClientHello names the
real origin (wikipedia rejects absent/mismatched SNI — verified).
Built for the wikipedia-stall forensics: the instrumented relay proved
the full 4,658 B flight is delivered into the firmware connection with
zero backpressure and the client stalls anyway, killing the delivery/
window theories. Off unless HTTPS_SNI= is set; ip65 unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
net_poll always requested a fixed 512 B from firmware, but the fill
loop drops any byte past the ring's current free space and the response
drain discards the remainder — bytes the firmware counts as delivered.
Once the ~4 KB ring wraps (any TLS flight over ring capacity, i.e.
en.wikipedia.org's 4.7 KB cert flight), the stream gets a permanent
hole and the client parks forever at that record.

Found by decrypting the stalled run's captured ring (wrap-aware, per-
record seq brute-force): the hole sits exactly one ring-capacity past
the wrap origin. The relay experiment had already excluded delivery/
window causes (full flight handed to the firmware, zero backpressure,
client stalled anyway) — this is the read side.

Fix: request min(ring_free - 1, 512) and skip the read entirely when
nothing fits; unread bytes wait in the firmware for the next poll.

Adds a wikipedia-shaped gate scenario (multi-cert chain, real 1636 B
leaf) that proves the CONSUMER handles the shape — but note in-code:
the gate primes the ring directly with net_poll stubbed, so it CANNOT
exercise this adapter fill path. The fix is validated on hardware, not
in VICE. Gate 12/12, viewer 7/7, http 61/61.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@JC-000

JC-000 commented Aug 22, 2026

Copy link
Copy Markdown
Owner Author

⭐ Stretch goal achieved — Commodore 64 reads its own Wikipedia article over real-internet TLS 1.3

Confirmed on U64E hardware @ 48 MHz (commit d9cd021):

  • http_status = 200 from en.wikipedia.org
  • 125,235 bytes (the full article) streamed through the C64 into REU bank 16 ($10:0000), verified byte-exact against a host-side reference fetch
  • prefix: {{Short description|8-bit home computer introduced in 1982}}\n{{Redirect|C64}}
  • machine drops into the scroll viewer showing the article
  • ~2.35 KB/s steady, ~76 s to full body, clean exit (REST healthy after — no socket orphaning)

The bug that blocked it (d9cd021)

net_poll requested a fixed 512 B from firmware every poll, but its ring-fill loop silently drops bytes past the ring's current free space and the response drain discards them — bytes the firmware counts as delivered. Once the 4 KB ring wraps (any TLS flight over ~4 KB — wikipedia's 4.7 KB cert flight is the first target to cross it), the stream gets a permanent hole and the client parks forever. github/browserleaks/lwn (3.2–4.0 KB flights) squeaked under the wrap, which is why they passed first.

Fix: request min(ring_free − 1, 512) and skip the read when nothing fits — unread bytes wait in firmware for the next poll. Not a firmware bug; nothing filed upstream.

Found by decrypting the stalled run's captured ring (wrap-aware) after a TCP-relay experiment excluded delivery/window causes (full flight handed to firmware, zero backpressure, client stalled anyway).

Three real public sites now verified end-to-end: github.com, browserleaks.com, lwn.net (all HTTP 200), plus the full 122 KB wikipedia article into REU with an on-C64 scroll viewer.

…DE.md

README: milestone callout in the intro, two Project Status entries (real
public-internet HTTPS on UCI/comb; wikipedia article into REU + viewer),
and refreshed the two stalest Known Issues (real-server reach/limits; the
now-fixed wikipedia ring-drop).

CLAUDE.md: rewrote the End-to-end HTTPS status milestone block for the
final state — 4 live sites at HTTP 200 incl. wikipedia's 125,235 B
article to REU + viewer; the three live-only bugs fixed (512-content
inner-type, large-body consumed-count termination, the net_poll
ring-drop stall d9cd021); and the two distinct U64E device gotchas
(lease-poisoning via reset-with-live-socket vs the writemem/temp wedge).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant