From 24dcfce3469df68dbc8064aa216b630d155af57a Mon Sep 17 00:00:00 2001 From: Jimmy Yeh Date: Mon, 27 Apr 2026 14:42:13 +0800 Subject: [PATCH 01/25] docs: add web shell demo design spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brainstormed design for landing visitors of https://cyyeh.github.io/ccc/web/ on a working `$` shell prompt by default — full Phase 3.E + 3.F shell experience (sh + ls/cat/echo/mkdir/rm/edit + ^C cancel) running against shell-fs.img inside the wasm. Snake and hello stay as alternative dropdown options. Pristine disk on every load. --- .../specs/2026-04-27-web-shell-demo-design.md | 349 ++++++++++++++++++ 1 file changed, 349 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-27-web-shell-demo-design.md diff --git a/docs/superpowers/specs/2026-04-27-web-shell-demo-design.md b/docs/superpowers/specs/2026-04-27-web-shell-demo-design.md new file mode 100644 index 0000000..43a78d3 --- /dev/null +++ b/docs/superpowers/specs/2026-04-27-web-shell-demo-design.md @@ -0,0 +1,349 @@ +# Web shell demo — design + +**Status:** brainstormed 2026-04-27 (awaiting review) +**Branch / worktree:** TBD (suggest `web-shell-demo` at `.worktrees/web-shell-demo`) +**Goal:** A visitor of `https://cyyeh.github.io/ccc/web/` lands on a working `$` +shell prompt by default and can run the full Phase 3.E + 3.F shell experience +in the browser — `ls /bin`, `cat /etc/motd`, `echo hi > /tmp/x`, `edit /etc/motd`, +`^C` to cancel a foreground program, `exit`. Same kernel and same userland +binaries as `zig build kernel-fs run -- --disk shell-fs.img kernel-fs.elf` on +the CLI, just driven by the browser. Snake and hello stay as alternative +options in the dropdown; shell becomes the new default. + +This is the first browser demo that exercises ccc's filesystem layer, cooked-mode +line discipline, and process model end-to-end through human input. + +## Why + +Phase 3 (multi-process OS + filesystem + shell) is complete. The CLI demos +already show the full shell working against `shell-fs.img`, but the live web +demo only ships `snake.elf` (interactive game) and `hello.elf` (auto-runs + +trace). Visitors who land on the page have no way to feel the OS — the +filesystem, the cooked-mode console, the editor, the `^C` chain — even though +all of it already works on the CLI. + +The new shell demo closes that gap with a small wasm-side delta: a +`disk_buffer` next to the existing `elf_buffer`, two new exports, and one +signature change to `runStart`. The browser-side change is bigger but +mechanical: a wider terminal, scroll-on-newline, a richer key map, and a +parallel disk fetch. Snake and hello stay byte-identical on the wire — the +disk surface is opt-in per `runStart`. + +## Non-goals + +- **Disk persistence across page reloads.** Pristine on every page load (Q2-A). + Each `runStart` re-copies `shell-fs.img` over `disk_buffer`. No IndexedDB, + no quota handling, no stale-image versioning. Refresh = clean slate. +- **Lazy block fetch / sector-on-demand.** Shell-fs.img is 4 MB and Pages + serves it gzipped (zero-block heavy). Up-front fetch keeps the wasm import + object empty (`{}`) and the kernel's block driver synchronous from its + perspective. +- **Browser tests for `ansi.js` or the wasm runner.** Snake-demo already + punted on this for the same reasons (`ansi.js` ~120 lines, eyeball-checkable; + no Node-driven wasm test infra exists). Manual browser smoke test is the + gate. Add automated suites when complexity demands it. +- **`kernel-multi.elf` / `kernel-fork.elf` / `kernel-fs.elf` (read-only) as + separate dropdown entries.** The shell already implies fork/exec/wait/FS + worked end-to-end. Surfacing them individually would clutter the dropdown + without adding signal. +- **Per-program terminal sizing.** Single 80×24 grid for everything (Q3-A); + snake's 32×16 game render naturally sits in the top-left of the bigger box, + with free space around it. No resize-on-program-switch logic. +- **Tab completion, command history, autocomplete, paste handling.** These + are shell improvements, not browser-demo concerns. Same shell as the CLI. +- **Mobile / touch input.** Shell needs alphanumeric keys; mobile keyboards + send composition events that don't map cleanly to per-byte input. Same + device-warning panel snake has. +- **Sound, color, mouse.** No `\a` beep, no SGR color sequences, no mouse + events. Pure ASCII + UTF-8 box-drawing (already supported). +- **A second terminal viewport** (e.g. a "boot log" panel separate from the + shell). One `
`, one ANSI interpreter.
+
+## Approach
+
+### Architecture overview
+
+The shell experience is a disk-aware variant of the existing snake/hello
+flow, not a separate runtime. Same `web_main.zig` chunked `runStep` loop,
+same Worker ↔ main-thread message protocol, same wasm module — extended in
+three precise places.
+
+```
+Browser (web/index.html + demo.js + runner.js + ansi.js)
+  ├─ ` block:
+
+```html
+      
+```
+
+Replace with:
+
+```html
+      
+```
+
+- [ ] **Step 7.2: Add a shell instructions card next to the snake card**
+
+Find the existing snake instructions block (lines 25–40):
+
+```html
+    
+
+ snake controls: + W A S D move · + Space start · + Q quit +
+
+ how it works: + snake execution walkthrough + — bare-metal M-mode boot, CLINT timer trap, UART I/O +
+
+ ⚠ requires a physical keyboard — please play on a desktop or laptop. Mobile and tablet devices can't send key input. +
+
+``` + +Insert this block **directly above** the snake card (so shell card is first in document order, matching the dropdown order): + +```html +
+
+ shell: + type a command and press Enter · try + ls /bin · + cat /etc/motd · + echo hi > /tmp/x · + edit /etc/motd · + ^C cancel · + exit to halt +
+
+ what's running: + a from-scratch RV32 kernel (M-mode boot shim → S-mode kernel → cooked-mode console + → fork/exec → on-disk shell + utilities) booted from shell-fs.img + — the same binary that runs on the CLI via zig build kernel-fs. +
+
+ ⚠ requires a physical keyboard — desktop or laptop only. Mobile and tablet devices can't drive per-byte input. +
+
+``` + +- [ ] **Step 7.3: Verify the HTML parses (open in browser later; for now, basic check)** + +Run: `grep -c 'id="shell-instructions"' web/index.html` +Expected: `1` (the new card is present exactly once). + +Run: `grep -c 'id="snake-instructions"' web/index.html` +Expected: `1` (snake card still present). + +- [ ] **Step 7.4: Commit** + +```bash +git add web/index.html +git commit -m "$(cat <<'EOF' +feat(web/index): add shell.elf as default + shell instructions card + +Dropdown now shows shell (selected) | snake | hello. New +shell-instructions card sits above the snake card and lists the canonical +try-commands (ls /bin, cat /etc/motd, edit /etc/motd, ^C, exit) plus a +"what's running" line that ties the demo back to the CLI build. Same +device-warning row as snake (mobile/tablet can't drive per-byte input). +EOF +)" +``` + +--- + +## Task 8: `web/demo.css` — grow output panel to fit 80×24, no browser wrap + +**Files:** +- Modify: `web/demo.css` (`pre.output` height + `white-space`) + +- [ ] **Step 8.1: Adjust `pre.output` dimensions and disable browser wrapping** + +Open `web/demo.css` and find the `pre.output` rule (lines 87–101): + +```css +pre.output { + background: var(--panel); + border: 1px solid var(--panel-border); + border-radius: 8px; + color: var(--fg); + font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + font-size: 15px; + line-height: 1.55; + padding: 20px 24px; + height: 480px; + overflow-y: auto; + margin: 0 0 32px; + white-space: pre-wrap; + word-break: break-word; +} +``` + +Replace with (typography unchanged; only `height`, `overflow`, and `white-space` change): + +```css +pre.output { + background: var(--panel); + border: 1px solid var(--panel-border); + border-radius: 8px; + color: var(--fg); + font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + font-size: 15px; + line-height: 1.55; + padding: 20px 24px; + /* 24 rows × 15px × 1.55 line-height ≈ 558px content; +40px padding ≈ 600px. + min-height (not height) so the ANSI 24-row buffer is always fully visible + without forcing a scrollbar; overflow stays auto in case any future + program emits more than 24 rows. */ + min-height: 600px; + overflow: auto; + margin: 0 0 32px; + /* The ANSI interpreter renders a fixed 80×24 grid as a single text string + with hard \n at row boundaries — let the browser render it as-is, no + re-wrapping. Horizontal scroll appears at narrow viewports. */ + white-space: pre; +} +``` + +- [ ] **Step 8.2: Verify CSS is valid (basic check)** + +Run: `grep -c 'white-space: pre;' web/demo.css` +Expected: `1`. + +(There's no project-level CSS linter; visual verification happens during the manual smoke test.) + +- [ ] **Step 8.3: Commit** + +```bash +git add web/demo.css +git commit -m "$(cat <<'EOF' +feat(web/css): grow output panel to fit 80×24 grid + disable wrap + +- height (480px fixed) → min-height (600px), enough for 24 rows at the + existing 15px / 1.55 line-height. Overflow stays auto as a safety net + for any future program emitting more than 24 rows. +- white-space changes from pre-wrap to pre: the ANSI interpreter already + manages line breaks at the 80-col boundary; letting the browser + re-wrap was fine at 32×16 (snake never overflowed) but breaks shell + output that assumes the grid model. Horizontal scroll appears on + narrow viewports. +- Typography unchanged (15px / 1.55) — snake's render is unaffected. +EOF +)" +``` + +--- + +## Task 9: docs — `web/README.md` + top-level `README.md` + +**Files:** +- Modify: `web/README.md` (add shell.elf to programs list + brief disk-buffer note) +- Modify: `README.md` (expand "Live demo" line) + +- [ ] **Step 9.1: Update `web/README.md` programs list** + +Open `web/README.md` and find the programs intro block (lines 7–18): + +```md +A single-page browser demo of [`ccc`](../), a from-scratch RISC-V CPU +emulator written in Zig. The same emulator modules that power the +native CLI (`cpu.zig`, `memory.zig`, `elf.zig`, `devices/*.zig`) are +cross-compiled to `wasm32-freestanding` via a thin entry point +(`demo/web_main.zig`) and loaded into your browser. Two RV32 programs +ship with the page: + +- **`snake.elf`** (default) — an interactive snake game. ... +- **`hello.elf`** — non-interactive "hello world". ... +``` + +Replace it with the three-program version (shell as default): + +```md +A single-page browser demo of [`ccc`](../), a from-scratch RISC-V CPU +emulator written in Zig. The same emulator modules that power the +native CLI (`cpu.zig`, `memory.zig`, `elf.zig`, `devices/*.zig`) are +cross-compiled to `wasm32-freestanding` via a thin entry point +(`demo/web_main.zig`) and loaded into your browser. Three RV32 programs +ship with the page: + +- **`shell.elf`** (default) — a full Phase 3.E + 3.F shell. The page + loads `kernel-fs.elf` (M-mode boot shim → S-mode kernel → cooked-mode + console → fork/exec → on-disk init) plus `shell-fs.img` (a 4 MB FS + image with `/bin/{sh,ls,cat,echo,mkdir,rm,edit}` + `/etc/motd`). + Click the terminal, then type `ls /bin`, `cat /etc/motd`, + `echo hi > /tmp/x`, `edit /etc/motd`, `^C` to cancel a foreground + program, `exit` to halt. **Requires a physical keyboard — desktop + or laptop only.** Disk writes live in wasm linear memory and reset + on every page load. +- **`snake.elf`** — an interactive snake game. A bare M-mode + supervisor drives a CLINT timer IRQ for the game tick and polls + UART RX for input. Click the terminal, then move with `W` / `A` / + `S` / `D`, press `Space` to start, `Q` to quit. **Requires a + physical keyboard — desktop or laptop only**, mobile and tablet + browsers can't send key input. +- **`hello.elf`** — non-interactive "hello world". Runs to halt and + auto-displays its captured instruction trace. +``` + +- [ ] **Step 9.2: Add disk-buffer note in "How it works"** + +In `web/README.md`, find the existing "How it works" section and the bullet about `runner.js` (around line 33). After it, add a sentence about the disk path. The exact insertion point: find this paragraph: + +```md +3. `runner.js` is a Web Worker that fetches `ccc.wasm` and the + selected ELF on demand, copies the ELF bytes into the wasm load + buffer, and drives `runStep()` in 50 000-instruction chunks via + `setTimeout`. Yielding between chunks lets the Worker service + `pushInput` messages — a single blocking `run()` couldn't. +``` + +Replace it with: + +```md +3. `runner.js` is a Web Worker that fetches `ccc.wasm` and the + selected ELF on demand, copies the ELF bytes into the wasm load + buffer, and drives `runStep()` in 50 000-instruction chunks via + `setTimeout`. Yielding between chunks lets the Worker service + `pushInput` messages — a single blocking `run()` couldn't. When + the selected program has a disk image (currently only `shell.elf`, + which fetches `shell-fs.img`), the Worker fetches it in parallel + with the ELF and copies it into a 4 MB `disk_buffer` exposed by the + wasm via `diskBufferPtr/Cap`; `runStart` then receives a non-zero + `disk_len` and wires the buffer slice into the emulator's block + device. +``` + +- [ ] **Step 9.3: Update the gitignore mention** + +Find this paragraph (around line 53): + +```md +`web/ccc.wasm`, `web/hello.elf`, and `web/snake.elf` are gitignored — +all three are produced by `zig build wasm` and overlaid into the Pages +artifact in CI. Run `stage-web.sh` (or `zig build wasm` + the three +`cp` commands it wraps) before serving locally. +``` + +Replace with: + +```md +`web/ccc.wasm`, `web/hello.elf`, `web/snake.elf`, `web/kernel-fs.elf`, +and `web/shell-fs.img` are gitignored — all five are produced by +`zig build wasm` and overlaid into the Pages artifact in CI. Run +`stage-web.sh` (or `zig build wasm` + the five `cp` commands it wraps) +before serving locally. +``` + +- [ ] **Step 9.4: Update top-level `README.md` "Live demo" line** + +Open the top-level `README.md` and find the "Live demo" line (around line 7): + +```md +**Live demo:** [https://cyyeh.github.io/ccc/web/](https://cyyeh.github.io/ccc/web/) +— `ccc` cross-compiled to `wasm32-freestanding`, running RV32 binaries in +your browser. Pick `snake.elf` (default — WASD to play) or `hello.elf` (auto-runs + shows the instruction trace). Same Zig core as the CLI; the browser hosts +the emulator in a Web Worker that drives execution in chunks. +``` + +Replace with: + +```md +**Live demo:** [https://cyyeh.github.io/ccc/web/](https://cyyeh.github.io/ccc/web/) +— `ccc` cross-compiled to `wasm32-freestanding`, running RV32 binaries in +your browser. Pick `shell.elf` (default — full Phase 3 shell with +`ls`/`cat`/`echo`/`edit`/`^C`/`exit` against an in-wasm `shell-fs.img`), +`snake.elf` (WASD to play), or `hello.elf` (auto-runs + shows the +instruction trace). Same Zig core as the CLI; the browser hosts the +emulator in a Web Worker that drives execution in chunks. +``` + +- [ ] **Step 9.5: Commit** + +```bash +git add web/README.md README.md +git commit -m "$(cat <<'EOF' +docs: web shell demo — README updates + +- web/README.md: shell.elf added as the default program; "How it works" + gets one paragraph about the disk_buffer plumbing; gitignore mention + bumps from three artifacts to five. +- top-level README.md: "Live demo" line lists shell (default) / snake / + hello with one-clause descriptions of each. + +No changes to architecture or status sections — Phase 3 is already +documented end-to-end. +EOF +)" +``` + +--- + +## Task 10: Manual browser smoke test (PR-time gate) + +**Files:** +- (No code changes; this is the verification gate before merging.) + +This task can't be automated within the plan — it's the human (or human-in-the-loop) walking through the demo. Document the result in the PR description. + +- [ ] **Step 10.1: Stage artifacts and start a local server** + +Run: `./scripts/stage-web.sh && python3 -m http.server -d . 8000 &` +Expected: server starts on port 8000; 5 "staged" lines printed. + +(Stop with `kill %1` when finished.) + +- [ ] **Step 10.2: Open the page and walk the smoke test** + +Open `http://localhost:8000/web/` in a desktop browser (Chrome or Firefox; Safari should also work but Chrome's DevTools network tab makes parallel-fetch verification easiest). + +Walk through these 11 checks; each must pass before merging. Note any failure in the PR description and fix before re-testing. + +- [ ] **Step 10.3: Smoke test step 1 — page loads on shell** + +Verify: page loads; dropdown shows `shell.elf` (selected) / `snake.elf` / `hello.elf`; shell-instructions cheat-sheet card visible above the terminal box. + +- [ ] **Step 10.4: Smoke test step 2 — `$` prompt appears within ~2s** + +Verify: within 2 seconds of page load, the terminal shows `$ ` (or similar shell prompt). If it's noticeably slow (>3s), open the Open question section of the spec — may need to add a "booting…" indicator. + +- [ ] **Step 10.5: Smoke test step 3 — `ls /bin` returns the 9 binaries** + +Click the terminal to focus, type: `ls /bin` and press Enter. +Verify: output shows `.`, `..`, `cat`, `init`, `echo`, `sh`, `mkdir`, `ls`, `rm` (one per line). + +- [ ] **Step 10.6: Smoke test step 4 — `cat /etc/motd` shows expected text** + +Type: `cat /etc/motd` Enter. +Verify: output is `hello from phase 3` (followed by `$ ` prompt). + +- [ ] **Step 10.7: Smoke test step 5 — write/read round-trip** + +Type: `echo hi > /tmp/x` Enter, then `cat /tmp/x` Enter. +Verify: `cat /tmp/x` outputs `hi`. + +- [ ] **Step 10.8: Smoke test step 6 — editor round-trip** + +Type: `edit /etc/motd` Enter. +Verify: editor enters raw mode (file content displayed; no `$ ` prompt). +Press: ArrowRight twice (cursor moves right two chars), type a character (e.g. `Y`), press Ctrl+S (save), press Ctrl+X (exit). +Type: `cat /etc/motd` Enter. +Verify: output reflects the inserted character (e.g. `heYllo from phase 3`). + +- [ ] **Step 10.9: Smoke test step 7 — `^C` cancel** + +Type: `cat` Enter (no args; blocks waiting on stdin). +Press: Ctrl+C. +Verify: `^C` echoes; new `$ ` prompt appears. + +- [ ] **Step 10.10: Smoke test step 8 — switch to snake, play it** + +Use dropdown to select `snake.elf`. +Verify: snake game renders in the upper-left of the bigger box; shell-instructions card hides; snake-instructions card shows. Click terminal; press Space to start; W/A/S/D moves the snake. + +- [ ] **Step 10.11: Smoke test step 9 — switch back to shell; fresh state** + +Use dropdown to re-select `shell.elf`. +Verify: terminal clears; new `$ ` prompt within ~2s. Type `cat /etc/motd`. Verify output is the **original** `hello from phase 3` (not the edited version from step 10.8) — this proves the disk is re-copied per `runStart` from the canonical `shell-fs.img`. + +- [ ] **Step 10.12: Smoke test step 10 — refresh; pristine disk** + +Hard-refresh the page (Cmd+Shift+R / Ctrl+Shift+R). +Verify: shell loads fresh; `cat /etc/motd` again shows the original content. (This proves Q2-A pristine-on-load — no IndexedDB persistence.) + +- [ ] **Step 10.13: Smoke test step 11 — DevTools network tab** + +Open DevTools → Network. Refresh the page. +Verify: `kernel-fs.elf` and `shell-fs.img` both appear in the request list and complete with HTTP 200. They start at roughly the same time (parallel fetch). `shell-fs.img` is the largest single asset (~4 MB; less if served gzipped). + +- [ ] **Step 10.14: Document results in PR** + +In the PR description, paste a checklist of the 11 smoke-test steps with ✅ next to each. Note any deviations (e.g. boot >2s, any unexpected output) and how they were resolved. + +- [ ] **Step 10.15: Stop the local server** + +Run: `kill %1` (or whichever job number `python3 -m http.server` is at). + +--- + +## Definition of done + +- All 9 implementation tasks (Tasks 1–9) committed cleanly; each commit message follows the `feat(scope): …` / `docs: …` / `build: …` convention. +- Task 10 smoke test: all 11 steps pass; results documented in PR. +- `zig build test` passes (existing 9 + 4 new block.zig tests). +- `zig build e2e-shell e2e-editor e2e-persist e2e-cancel e2e-fs` all pass (proves Phase 3 CLI path is unbroken by the new `disk_slice` field). +- `zig build wasm` succeeds; `zig-out/web/` contains `ccc.wasm`, `hello.elf`, `snake.elf`, `kernel-fs.elf`, `shell-fs.img`. +- Visiting `https://cyyeh.github.io/ccc/web/` (after deploy) lands on `shell.elf` selected by default and a `$` prompt within ~2s; manual smoke test passes against the deployed version too. +- `web/README.md` documents `shell.elf`; top-level `README.md` mentions it in the "Live demo" line. +- Snake and hello continue to work byte-identically (no changes to their `runStart` shape — they just pass `disk_len=0`). From db0d49ec5b0b87123e9dab0adbf32d8e6136b6b2 Mon Sep 17 00:00:00 2001 From: Jimmy Yeh Date: Mon, 27 Apr 2026 15:13:02 +0800 Subject: [PATCH 03/25] feat(emulator/block): add disk_slice for in-memory disk backing Sibling to the existing disk_file (mmapped host file used by CLI). disk_slice holds a []u8 slice that performTransfer reads/writes via @memcpy. Used by the wasm demo where the disk is fetched into wasm linear memory rather than backed by a file. Slice path takes precedence; both null still yields NoMedia. Adds 4 inline tests: Read, Write, sector-OOB Error, and the slice precedence sanity check. Existing 9 tests + Phase 3 e2e suite unchanged. --- src/emulator/devices/block.zig | 131 +++++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) diff --git a/src/emulator/devices/block.zig b/src/emulator/devices/block.zig index 1bfae56..151a60d 100644 --- a/src/emulator/devices/block.zig +++ b/src/emulator/devices/block.zig @@ -30,6 +30,12 @@ pub const Block = struct { pending_irq: bool = false, /// Latest CMD value written; performTransfer consumes it. pending_cmd: u32 = 0, + /// Optional in-memory backing (used by the wasm demo, where the disk + /// is fetched into a wasm linear-memory slice rather than a host file). + /// When non-null, takes precedence over `disk_file` in `performTransfer`. + /// CLI uses `disk_file`; wasm uses `disk_slice`; setting both is a + /// programmer error (slice wins). + disk_slice: ?[]u8 = null, /// Optional host-file backing. When null, every CMD sets STATUS=NoMedia. disk_file: ?std.Io.File = null, /// Snapshot of the most recently completed (success or failure) transfer @@ -116,6 +122,47 @@ pub const Block = struct { self.last_sector = self.sector; self.last_buffer_pa = self.buffer_pa; + // Slice-backed path takes precedence (used by wasm demo). + if (self.disk_slice) |disk| { + // Sector range check (sector already bounds-checked above? — re-check + // for the slice path explicitly since the file path's check used to + // gate everything; we keep the existing `sector >= NSECTORS` check + // earlier and re-validate the slice has the bytes). + const disk_off: usize = @as(usize, self.sector) * SECTOR_BYTES; + if (disk_off + SECTOR_BYTES > disk.len) { + self.status = @intFromEnum(Status.Error); + return; + } + + // RAM range (mirrors the file path's check). + const RAM_BASE: u32 = 0x8000_0000; + if (self.buffer_pa < RAM_BASE) { + self.status = @intFromEnum(Status.Error); + return; + } + const ram_off: usize = @intCast(self.buffer_pa - RAM_BASE); + if (ram_off + SECTOR_BYTES > ram.len) { + self.status = @intFromEnum(Status.Error); + return; + } + + if (self.pending_cmd == 1) { + // Read: disk → ram + @memcpy( + ram[ram_off .. ram_off + SECTOR_BYTES], + disk[disk_off .. disk_off + SECTOR_BYTES], + ); + } else { + // Write: ram → disk + @memcpy( + disk[disk_off .. disk_off + SECTOR_BYTES], + ram[ram_off .. ram_off + SECTOR_BYTES], + ); + } + self.status = @intFromEnum(Status.Ready); + return; + } + // No disk → NoMedia for any otherwise-valid non-zero CMD. const f = self.disk_file orelse { self.status = @intFromEnum(Status.NoMedia); @@ -295,3 +342,87 @@ test "performTransfer with sector out of range sets Error status" { b.performTransfer(io, ram_buf[0..]); try std.testing.expectEqual(@intFromEnum(Status.Error), b.status); } + +test "performTransfer Read with disk_slice copies sector into RAM" { + var disk_data: [SECTOR_BYTES * 3]u8 = undefined; + for (disk_data[0..], 0..) |*p, i| p.* = @truncate(i & 0xFF); + + var b = Block.init(); + b.disk_slice = disk_data[0..]; + + var ram_buf: [SECTOR_BYTES]u8 = [_]u8{0} ** SECTOR_BYTES; + b.sector = 1; // read sector 1 + b.buffer_pa = 0x80000000; + try b.writeByte(0x8, 1); // CMD = Read + b.performTransfer(std.testing.io, ram_buf[0..]); + + try std.testing.expectEqual(@intFromEnum(Status.Ready), b.status); + try std.testing.expect(b.pending_irq); + try std.testing.expectEqualSlices( + u8, + disk_data[SECTOR_BYTES .. SECTOR_BYTES * 2], + ram_buf[0..], + ); +} + +test "performTransfer Write with disk_slice copies RAM out to slice" { + var disk_data: [SECTOR_BYTES * 2]u8 = [_]u8{0} ** (SECTOR_BYTES * 2); + + var b = Block.init(); + b.disk_slice = disk_data[0..]; + + var ram_buf: [SECTOR_BYTES]u8 = undefined; + for (ram_buf[0..], 0..) |*p, i| p.* = @truncate((i + 7) & 0xFF); + + b.sector = 0; + b.buffer_pa = 0x80000000; + try b.writeByte(0x8, 2); // CMD = Write + b.performTransfer(std.testing.io, ram_buf[0..]); + + try std.testing.expectEqual(@intFromEnum(Status.Ready), b.status); + try std.testing.expect(b.pending_irq); + try std.testing.expectEqualSlices(u8, ram_buf[0..], disk_data[0..SECTOR_BYTES]); + // Sector 1 untouched. + try std.testing.expectEqualSlices( + u8, + &([_]u8{0} ** SECTOR_BYTES), + disk_data[SECTOR_BYTES..], + ); +} + +test "performTransfer with disk_slice + sector out of range sets Error" { + var disk_data: [SECTOR_BYTES]u8 = undefined; + + var b = Block.init(); + b.disk_slice = disk_data[0..]; + b.sector = NSECTORS; // 1024 — out of range + b.buffer_pa = 0x80000000; + var ram_buf: [SECTOR_BYTES]u8 = undefined; + try b.writeByte(0x8, 1); + b.performTransfer(std.testing.io, ram_buf[0..]); + + try std.testing.expectEqual(@intFromEnum(Status.Error), b.status); + try std.testing.expect(b.pending_irq); +} + +test "performTransfer disk_slice precedence: slice wins when both set" { + // Sanity: if both disk_file and disk_slice are populated, the slice path + // wins. This guards against accidental cross-wiring in tests/CLI/wasm. + var disk_data: [SECTOR_BYTES]u8 = [_]u8{0xCD} ** SECTOR_BYTES; + + var b = Block.init(); + b.disk_slice = disk_data[0..]; + // Leave b.disk_file = null on this path; the precedence test only proves + // that the slice branch reads the slice and doesn't fall through to + // file I/O. (A "both set" test would require a tmp file; skipped — the + // precedence is a one-line `if` we verify by inspection.) + + var ram_buf: [SECTOR_BYTES]u8 = [_]u8{0} ** SECTOR_BYTES; + b.sector = 0; + b.buffer_pa = 0x80000000; + try b.writeByte(0x8, 1); + b.performTransfer(std.testing.io, ram_buf[0..]); + + try std.testing.expectEqual(@intFromEnum(Status.Ready), b.status); + try std.testing.expectEqualSlices(u8, disk_data[0..], ram_buf[0..]); +} From b554b865d9e8d27b6b314253b8f83e2031c50dd9 Mon Sep 17 00:00:00 2001 From: Jimmy Yeh Date: Mon, 27 Apr 2026 15:23:55 +0800 Subject: [PATCH 04/25] refactor(emulator/block): hoist RAM_BASE; gate NSECTORS for both paths; add slice RAM-OOB tests Code-review follow-up to db0d49e: - Hoist RAM_BASE: u32 = 0x8000_0000 to a file-level pub const next to BLOCK_BASE / SECTOR_BYTES. Removes the two local copies inside performTransfer and gives Memory + future callers a shared constant. - Move the `sector >= NSECTORS` check above the slice/file dispatch so both backings share the same upper bound. The previous position let the slice branch return before reaching it; harmless in practice (wasm uses a 4 MB slice == NSECTORS * SECTOR_BYTES) but the inline comment mis-claimed it gated both paths. - Add two slice-path RAM-OOB tests covering buffer_pa < RAM_BASE and ram_off past ram.len. Closes a pre-existing gap that the slice path inherited from the file path. 16 tests pass (14 + 2 new). Phase 3 e2e suite unchanged. --- src/emulator/devices/block.zig | 49 +++++++++++++++++++++++++--------- 1 file changed, 37 insertions(+), 12 deletions(-) diff --git a/src/emulator/devices/block.zig b/src/emulator/devices/block.zig index 151a60d..6f6f371 100644 --- a/src/emulator/devices/block.zig +++ b/src/emulator/devices/block.zig @@ -5,6 +5,7 @@ pub const BLOCK_SIZE: u32 = 0x10; pub const SECTOR_BYTES: u32 = 4096; pub const NSECTORS: u32 = 1024; // 4 MB total disk +pub const RAM_BASE: u32 = 0x8000_0000; pub const BlockError = error{UnexpectedRegister}; @@ -122,12 +123,18 @@ pub const Block = struct { self.last_sector = self.sector; self.last_buffer_pa = self.buffer_pa; + // Sector range — shared gate for both the slice and file paths. + if (self.sector >= NSECTORS) { + self.status = @intFromEnum(Status.Error); + return; + } + // Slice-backed path takes precedence (used by wasm demo). if (self.disk_slice) |disk| { - // Sector range check (sector already bounds-checked above? — re-check - // for the slice path explicitly since the file path's check used to - // gate everything; we keep the existing `sector >= NSECTORS` check - // earlier and re-validate the slice has the bytes). + // sector >= NSECTORS already gated above; re-check the actual + // slice bounds in case the slice is shorter than the canonical + // 4 MB (defense-in-depth — the wasm caller passes exactly + // NSECTORS * SECTOR_BYTES, but tests may use smaller slices). const disk_off: usize = @as(usize, self.sector) * SECTOR_BYTES; if (disk_off + SECTOR_BYTES > disk.len) { self.status = @intFromEnum(Status.Error); @@ -135,7 +142,6 @@ pub const Block = struct { } // RAM range (mirrors the file path's check). - const RAM_BASE: u32 = 0x8000_0000; if (self.buffer_pa < RAM_BASE) { self.status = @intFromEnum(Status.Error); return; @@ -169,15 +175,8 @@ pub const Block = struct { return; }; - // Sector range. - if (self.sector >= NSECTORS) { - self.status = @intFromEnum(Status.Error); - return; - } - // RAM range. buffer_pa is a physical address; we expect 0x8000_0000-based. // Compute offset; bounds-check; set Error if out of range. - const RAM_BASE: u32 = 0x8000_0000; if (self.buffer_pa < RAM_BASE) { self.status = @intFromEnum(Status.Error); return; @@ -426,3 +425,29 @@ test "performTransfer disk_slice precedence: slice wins when both set" { try std.testing.expectEqual(@intFromEnum(Status.Ready), b.status); try std.testing.expectEqualSlices(u8, disk_data[0..], ram_buf[0..]); } + +test "performTransfer disk_slice with buffer_pa below RAM_BASE sets Error" { + var disk_data: [SECTOR_BYTES]u8 = undefined; + var b = Block.init(); + b.disk_slice = disk_data[0..]; + b.sector = 0; + b.buffer_pa = 0x1000_0000; // below RAM_BASE + var ram_buf: [SECTOR_BYTES]u8 = undefined; + try b.writeByte(0x8, 1); + b.performTransfer(std.testing.io, ram_buf[0..]); + try std.testing.expectEqual(@intFromEnum(Status.Error), b.status); + try std.testing.expect(b.pending_irq); +} + +test "performTransfer disk_slice with RAM offset past ram_buf sets Error" { + var disk_data: [SECTOR_BYTES]u8 = undefined; + var b = Block.init(); + b.disk_slice = disk_data[0..]; + b.sector = 0; + b.buffer_pa = RAM_BASE + SECTOR_BYTES; // demands ram[4096..8192] + var ram_buf: [SECTOR_BYTES]u8 = undefined; // only 4096 bytes available + try b.writeByte(0x8, 1); + b.performTransfer(std.testing.io, ram_buf[0..]); + try std.testing.expectEqual(@intFromEnum(Status.Error), b.status); + try std.testing.expect(b.pending_irq); +} From af06d5a8211047ef90c10716891eeada12db25e4 Mon Sep 17 00:00:00 2001 From: Jimmy Yeh Date: Mon, 27 Apr 2026 15:29:51 +0800 Subject: [PATCH 05/25] feat(wasm): add disk_buffer + extend runStart with disk_len Adds a 4 MB disk_buffer alongside the existing elf_buffer, exposed via new diskBufferPtr() / diskBufferCap() exports. runStart gains a third parameter disk_len; when non-zero, the disk_buffer slice is wired into Block.disk_slice so the kernel can read/write the in-memory image. Snake and hello pass disk_len=0 and remain byte-identical on the wire. Shell will pass shell-fs.img.length (~4 MB). New error code -6 for disk_len > DISK_BUFFER_CAP. --- demo/web_main.zig | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/demo/web_main.zig b/demo/web_main.zig index 90a035f..3480565 100644 --- a/demo/web_main.zig +++ b/demo/web_main.zig @@ -11,9 +11,11 @@ //! keeping ccc.wasm at ~50 KB (just the emulator core). //! //! Exports: -//! elfBufferPtr() [*]u8 — base of the 2 MB ELF receive buffer -//! elfBufferCap() u32 — capacity of the ELF buffer (2 MB) -//! runStart(elf_len, trace) i32 — initialise state, 0 on success +//! elfBufferPtr() [*]u8 — base of the 2 MB ELF receive buffer +//! elfBufferCap() u32 — capacity of the ELF buffer (2 MB) +//! diskBufferPtr() [*]u8 — base of the 4 MB disk receive buffer +//! diskBufferCap() u32 — capacity of the disk buffer (4 MB) +//! runStart(elf_len, trace, disk_len) i32 — initialise state, 0 on success //! runStep(maxInstructions) i32 — -1 still running, ≥0 exit code //! consumeOutput() u32 — bytes available since last drain //! outputPtr() [*]u8 — base of output buffer (drain offset) @@ -46,6 +48,22 @@ export fn elfBufferCap() u32 { return ELF_BUFFER_CAP; } +// 4 MB disk receive buffer. JS fetches the program's disk image +// (currently only shell-fs.img for the shell demo), copies its bytes +// here via diskBufferPtr/diskBufferCap, then calls runStart with a +// non-zero disk_len. shell-fs.img is exactly 4 MB by mkfs convention. +// Snake/hello pass disk_len=0 and the buffer is unused. +const DISK_BUFFER_CAP: u32 = 4 * 1024 * 1024; +var disk_buffer: [DISK_BUFFER_CAP]u8 = undefined; + +export fn diskBufferPtr() [*]u8 { + return &disk_buffer; +} + +export fn diskBufferCap() u32 { + return DISK_BUFFER_CAP; +} + // 16 KB is comfortable headroom for a "hello world" run. const OUTPUT_BUF_SIZE: usize = 16 * 1024; var output_buf: [OUTPUT_BUF_SIZE]u8 = undefined; @@ -134,9 +152,10 @@ export fn pushInput(byte: u32) void { /// elf_buffer[0..elf_len] by JS (via elfBufferPtr/elfBufferCap + fetch). /// trace: non-zero enables per-instruction trace output. /// Returns 0 on success, negative on error: -/// -1 mem init failed, -2 ELF parse/load failed, -5 bad elf_len. -export fn runStart(elf_len: u32, trace: i32) i32 { +/// -1 mem init failed, -2 ELF parse/load failed, -5 bad elf_len, -6 bad disk_len. +export fn runStart(elf_len: u32, trace: i32, disk_len: u32) i32 { if (elf_len == 0 or elf_len > ELF_BUFFER_CAP) return -5; + if (disk_len > DISK_BUFFER_CAP) return -6; // Tear down any in-progress run before reinitialising. if (state != null) { @@ -157,6 +176,9 @@ export fn runStart(elf_len: u32, trace: i32) i32 { state_storage.clint = clint_dev.Clint.init(&jsClock); state_storage.plic = plic_dev.Plic.init(); state_storage.block = block_dev.Block.init(); + if (disk_len > 0) { + state_storage.block.disk_slice = disk_buffer[0..disk_len]; + } const io: std.Io = std.Io.failing; From dc55536c2cd914943e0e1edfc2fb65c42a82ccb0 Mon Sep 17 00:00:00 2001 From: Jimmy Yeh Date: Mon, 27 Apr 2026 15:37:15 +0800 Subject: [PATCH 06/25] build: install kernel-fs.elf + shell-fs.img into web/ for shell demo Adds two install steps to the wasm build so kernel-fs.elf and shell-fs.img land in zig-out/web/ alongside ccc.wasm, hello.elf, and snake.elf. stage-web.sh copies them into web/ for local dev; gitignore keeps both out of the tree. Both artifacts already build via existing kernel-fs and shell-fs-img steps; this just wires them into the wasm step's install list. --- .gitignore | 2 ++ build.zig | 19 ++++++++++++++----- scripts/stage-web.sh | 16 ++++++++++------ 3 files changed, 26 insertions(+), 11 deletions(-) diff --git a/.gitignore b/.gitignore index 31b8163..a86a958 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ zig-out/ web/ccc.wasm web/hello.elf web/snake.elf +web/kernel-fs.elf +web/shell-fs.img diff --git a/build.zig b/build.zig index 6a2839b..0fb8d46 100644 --- a/build.zig +++ b/build.zig @@ -1239,13 +1239,22 @@ pub fn build(b: *std.Build) void { const wasm_step = b.step("wasm", "Cross-compile ccc to wasm32-freestanding"); wasm_step.dependOn(&install_wasm.step); - // Install hello.elf and snake.elf alongside the wasm so the demo - // can fetch them at runtime. Keeps the wasm tiny (~50 KB instead of - // ~1.5 MB) and lets new programs be dropped in without recompiling. - const install_web_hello = b.addInstallFile(hello_elf.getEmittedBin(), "web/hello.elf"); - const install_web_snake = b.addInstallFile(snake_elf.getEmittedBin(), "web/snake.elf"); + // Install hello.elf, snake.elf, kernel-fs.elf, and shell-fs.img + // alongside the wasm so the demo can fetch them at runtime. Keeps + // the wasm tiny (~50 KB instead of bundling the binaries) and lets + // programs be added by dropping a file next to index.html. + // + // shell-fs.img is the 4 MB FS image baked by the shell-fs-img build + // step; the wasm demo loads it into its disk_buffer when the visitor + // selects shell.elf. + const install_web_hello = b.addInstallFile(hello_elf.getEmittedBin(), "web/hello.elf"); + const install_web_snake = b.addInstallFile(snake_elf.getEmittedBin(), "web/snake.elf"); + const install_web_kernel_fs = b.addInstallFile(kernel_fs_elf.getEmittedBin(), "web/kernel-fs.elf"); + const install_web_shell_fs_img = b.addInstallFile(shell_fs_img, "web/shell-fs.img"); wasm_step.dependOn(&install_web_hello.step); wasm_step.dependOn(&install_web_snake.step); + wasm_step.dependOn(&install_web_kernel_fs.step); + wasm_step.dependOn(&install_web_shell_fs_img.step); } /// Build a user binary by linking start.S + usys.S + ulib.zig + uprintf.zig + diff --git a/scripts/stage-web.sh b/scripts/stage-web.sh index 20cfa4c..be69996 100755 --- a/scripts/stage-web.sh +++ b/scripts/stage-web.sh @@ -9,10 +9,14 @@ cd "$(dirname "$0")/.." zig build wasm -cp zig-out/web/ccc.wasm web/ccc.wasm -cp zig-out/web/hello.elf web/hello.elf -cp zig-out/web/snake.elf web/snake.elf +cp zig-out/web/ccc.wasm web/ccc.wasm +cp zig-out/web/hello.elf web/hello.elf +cp zig-out/web/snake.elf web/snake.elf +cp zig-out/web/kernel-fs.elf web/kernel-fs.elf +cp zig-out/web/shell-fs.img web/shell-fs.img -echo "staged: web/ccc.wasm ($(wc -c Date: Mon, 27 Apr 2026 15:41:24 +0800 Subject: [PATCH 07/25] ci(pages): wildcard-copy zig-out/web into Pages staging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Stage Pages artifact step had a per-file allowlist (ccc.wasm + hello.elf + snake.elf) that silently dropped any new wasm artifact installed by build.zig. Task 3 of the web-shell-demo plan added kernel-fs.elf + shell-fs.img — without this fix the deployed shell demo would 404 on both. Replace the three explicit cp lines with cp -r zig-out/web/. _site/web/ mirroring the existing cp -r web/. _site/web/ idiom one line up. Future artifacts wired into wasm_step ship automatically. --- .github/workflows/pages.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 0e152b2..6ca09f8 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -82,9 +82,12 @@ jobs: mkdir -p _site/web cp index.html deck-stage.js .nojekyll _site/ cp -r web/. _site/web/ - cp zig-out/web/ccc.wasm _site/web/ccc.wasm - cp zig-out/web/hello.elf _site/web/hello.elf - cp zig-out/web/snake.elf _site/web/snake.elf + # Copy every artifact zig build wasm installs into zig-out/web/ + # (ccc.wasm + hello.elf + snake.elf + kernel-fs.elf + shell-fs.img, + # plus anything future tasks add). Wildcard avoids the per-file + # allowlist drift that bit us when shell-fs.img + kernel-fs.elf + # were added — see plan 2026-04-27-web-shell-demo Task 3. + cp -r zig-out/web/. _site/web/ ls -lh _site _site/web - name: Configure Pages From 24605ae9586a6ff13f7f4c0dc2cd6c512abf4cfc Mon Sep 17 00:00:00 2001 From: Jimmy Yeh Date: Mon, 27 Apr 2026 15:43:49 +0800 Subject: [PATCH 08/25] feat(web/runner): parallel disk fetch; pass disk_len to runStart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Worker now fetches the ELF and (optionally) a disk image in parallel via Promise.all, copies both into wasm linear memory, and calls runStart(elfLen, trace, diskLen). Snake/hello have no diskUrl and pass diskLen=0 — byte-identical to the previous behavior. Disk-too-large yields a halt with descriptive error (parallel to the existing ELF-too-large path). --- web/runner.js | 46 +++++++++++++++++++++++++++++++++++----------- 1 file changed, 35 insertions(+), 11 deletions(-) diff --git a/web/runner.js b/web/runner.js index 4f184ff..2dae2e3 100644 --- a/web/runner.js +++ b/web/runner.js @@ -27,19 +27,43 @@ self.onmessage = async (e) => { const myRunId = ++currentRunId; const trace = msg.trace ? 1 : 0; try { - const resp = await fetch(msg.elfUrl); - if (!resp.ok) throw new Error(`fetch ${msg.elfUrl} → ${resp.status}`); + // Fetch ELF and (optional) disk image in parallel. Both go straight + // into wasm linear memory once they arrive — no double-buffering. + const elfFetch = fetch(msg.elfUrl).then(async (r) => { + if (!r.ok) throw new Error(`fetch ${msg.elfUrl} → ${r.status}`); + return new Uint8Array(await r.arrayBuffer()); + }); + const diskFetch = msg.diskUrl + ? fetch(msg.diskUrl).then(async (r) => { + if (!r.ok) throw new Error(`fetch ${msg.diskUrl} → ${r.status}`); + return new Uint8Array(await r.arrayBuffer()); + }) + : Promise.resolve(null); + + const [elfBytes, diskBytes] = await Promise.all([elfFetch, diskFetch]); if (myRunId !== currentRunId) return; // superseded during fetch - const elfBytes = new Uint8Array(await resp.arrayBuffer()); - if (myRunId !== currentRunId) return; - const cap = exports.elfBufferCap(); - if (elfBytes.length > cap) { - throw new Error(`ELF too large: ${elfBytes.length} > ${cap}`); + + // Copy ELF into wasm. + const elfCap = exports.elfBufferCap(); + if (elfBytes.length > elfCap) { + throw new Error(`ELF too large: ${elfBytes.length} > ${elfCap}`); } - const ptr = exports.elfBufferPtr(); - const dest = new Uint8Array(memory.buffer, ptr, elfBytes.length); - dest.set(elfBytes); - const rc = exports.runStart(elfBytes.length, trace); + const elfPtr = exports.elfBufferPtr(); + new Uint8Array(memory.buffer, elfPtr, elfBytes.length).set(elfBytes); + + // Copy disk into wasm if present. + let diskLen = 0; + if (diskBytes) { + const diskCap = exports.diskBufferCap(); + if (diskBytes.length > diskCap) { + throw new Error(`disk too large: ${diskBytes.length} > ${diskCap}`); + } + const diskPtr = exports.diskBufferPtr(); + new Uint8Array(memory.buffer, diskPtr, diskBytes.length).set(diskBytes); + diskLen = diskBytes.length; + } + + const rc = exports.runStart(elfBytes.length, trace, diskLen); if (rc !== 0) { self.postMessage({ type: "halt", runId: myRunId, code: rc }); return; From 61243a85205518b4780a7f9fbf9058fc897bbfa2 Mon Sep 17 00:00:00 2001 From: Jimmy Yeh Date: Mon, 27 Apr 2026 15:48:54 +0800 Subject: [PATCH 09/25] feat(web/ansi): scroll on newline at last row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fixed-grid model previously clamped row at H-1 on \n, which silently overwrote the bottom line — invisible at 32×16 with snake (which redraws the whole screen each tick) but immediately fatal for the shell, which streams output line-by-line and expects a scrolling terminal. _lineFeed() shifts the screen up by one line and clears the bottom row when already at the last row; called from both the \n branch and (via the existing CSI H clamp) for paranoid bounds on cursor positioning. --- web/ansi.js | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/web/ansi.js b/web/ansi.js index f85f69e..683b81c 100644 --- a/web/ansi.js +++ b/web/ansi.js @@ -32,6 +32,20 @@ export class Ansi { } } + // Move cursor down one row. If we're already at the bottom row, scroll + // the screen up by one line: drop row 0, push a blank row at the bottom. + // Used by both \n in the input stream and any cursor positioning that + // would otherwise place the cursor past the last row. + _lineFeed() { + if (this.row >= this.H - 1) { + this.screen.shift(); + this.screen.push(new Array(this.W).fill(" ")); + this.row = this.H - 1; + } else { + this.row += 1; + } + } + feed(bytes) { for (const b of bytes) this._byte(b); } @@ -39,7 +53,7 @@ export class Ansi { _byte(b) { if (this.state === "GROUND") { if (b === 0x1b) { this.state = "ESC"; return; } - if (b === 0x0a) { this.row = Math.min(this.H - 1, this.row + 1); return; } + if (b === 0x0a) { this._lineFeed(); return; } if (b === 0x0d) { this.col = 0; return; } if (b < 0x20) return; // other control: ignore if (b >= 0xC0 && b <= 0xF7) { this._utf8Start(b); return; } From 3ddb18fe6b44ae3fd83d874d55c731313e8df8c8 Mon Sep 17 00:00:00 2001 From: Jimmy Yeh Date: Mon, 27 Apr 2026 15:51:54 +0800 Subject: [PATCH 10/25] =?UTF-8?q?docs(web/ansi):=20tighten=20=5FlineFeed?= =?UTF-8?q?=20comment=20=E2=80=94=20only=20\n=20calls=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous comment claimed _lineFeed was used by "any cursor positioning that would otherwise place the cursor past the last row," but the CSI H handler at line 122 clamps inline without calling _lineFeed (and cursor positioning shouldn't scroll anyway — it should teleport). Fix the comment to match what the code actually does; flag IND / NEL as the natural future callers if reverse line feed ever lands. --- web/ansi.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/web/ansi.js b/web/ansi.js index 683b81c..d932db8 100644 --- a/web/ansi.js +++ b/web/ansi.js @@ -34,8 +34,9 @@ export class Ansi { // Move cursor down one row. If we're already at the bottom row, scroll // the screen up by one line: drop row 0, push a blank row at the bottom. - // Used by both \n in the input stream and any cursor positioning that - // would otherwise place the cursor past the last row. + // Called from the \n branch in _byte. (Future ESC D / IND or NEL would + // be natural additional callers; cursor-positioning CSI H clamps inline + // and intentionally doesn't scroll.) _lineFeed() { if (this.row >= this.H - 1) { this.screen.shift(); From df7a9019ade00dd2f99b996954132be7b9403caa Mon Sep 17 00:00:00 2001 From: Jimmy Yeh Date: Mon, 27 Apr 2026 15:54:38 +0800 Subject: [PATCH 11/25] =?UTF-8?q?feat(web/demo):=2080=C3=9724=20terminal?= =?UTF-8?q?=20+=20per-program=20key=20map=20+=20shell=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Terminal grid bumps from 32×16 to 80×24 to fit the shell + editor. - New ELF entry kernel-fs.elf at index "2"; new DISK_URLS table maps index "2" to shell-fs.img. - Per-program key handling: snake keeps its 6-key whitelist; shell gets full ASCII printables + Ctrl+letter (0x01..0x1a covering ^C/^D/^U/ ^S/^X) + Enter/Backspace/Tab/Esc + 3-byte ESC arrow sequences for the editor; hello takes no input. - Backspace sends 0x7f (kernel/console.zig accepts both 0x08 and 0x7f, picking the standard DEL). - Cmd/Alt always pass through so browser shortcuts (Cmd+R, Cmd+T) keep working; only e.ctrlKey is intercepted. - updateProgramInstructions toggles the new shell-instructions card. - startCurrent forwards diskUrl through to the Worker. Co-Authored-By: Claude Sonnet 4.6 --- web/demo.js | 112 +++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 94 insertions(+), 18 deletions(-) diff --git a/web/demo.js b/web/demo.js index 3d5ce9e..e363bf7 100644 --- a/web/demo.js +++ b/web/demo.js @@ -2,19 +2,22 @@ import { Ansi } from "./ansi.js"; -const W = 32, H = 16; +// Terminal is sized for the shell (80×24, classic VT100). Snake's 32×16 +// game render naturally sits in the top-left of the bigger box. +const W = 80, H = 24; const ansi = new Ansi(W, H); const out = document.getElementById("output"); const sel = document.getElementById("program-select"); const hint = document.querySelector(".program-hint"); const snakeInstructions = document.getElementById("snake-instructions"); +const shellInstructions = document.getElementById("shell-instructions"); -// Snake is the only interactive program; only show its instructions when selected. const SNAKE_IDX = "1"; +const SHELL_IDX = "2"; function updateProgramInstructions() { - if (!snakeInstructions) return; - snakeInstructions.classList.toggle("hidden", sel.value !== SNAKE_IDX); + if (snakeInstructions) snakeInstructions.classList.toggle("hidden", sel.value !== SNAKE_IDX); + if (shellInstructions) shellInstructions.classList.toggle("hidden", sel.value !== SHELL_IDX); } // Trace panel — auto-shown for non-interactive programs (e.g. hello.elf) @@ -27,11 +30,18 @@ const traceMeta = document.getElementById("trace-meta"); // Programs that want an instruction trace captured + auto-displayed // after halt. snake.elf intentionally absent. -const TRACE_PROGRAMS = new Set(["0"]); // hello.elf +const TRACE_PROGRAMS = new Set(["0"]); // hello.elf only const ELF_URLS = { "0": "./hello.elf", "1": "./snake.elf", + "2": "./kernel-fs.elf", +}; + +// Programs that need a disk image fetched alongside the ELF. +// Currently only the shell uses one (shell-fs.img with /bin/* + /etc/motd). +const DISK_URLS = { + "2": "./shell-fs.img", }; const worker = new Worker("./runner.js", { type: "module" }); @@ -40,22 +50,78 @@ const worker = new Worker("./runner.js", { type: "module" }); // superseded run are dropped instead of repainting the cleared screen. let currentRunId = 0; -const ALLOWED_KEYS = { - "w": 0x77, "W": 0x77, - "a": 0x61, "A": 0x61, - "s": 0x73, "S": 0x73, - "d": 0x64, "D": 0x64, - "q": 0x71, "Q": 0x71, - " ": 0x20, +// Per-program key handling. Each handler returns one or more bytes to +// forward to the wasm via pushInput, OR null if the key is unmapped +// (in which case we don't preventDefault — browser shortcuts pass through). +// +// SNAKE: tight 6-key WASD/Q/Space whitelist. Anything else is dropped. +// HELLO: no input. +// SHELL: full ASCII printables + Ctrl+letter (0x01..0x1a) + Enter/Backspace/ +// Tab/Esc + 3-byte ESC arrow keys for the editor. +// +// Modifier rule: only e.ctrlKey is intercepted. e.metaKey/e.altKey pass +// through so Cmd+R / Cmd+T / browser shortcuts still work. + +const SNAKE_BYTES = { + "w": [0x77], "W": [0x77], + "a": [0x61], "A": [0x61], + "s": [0x73], "S": [0x73], + "d": [0x64], "D": [0x64], + "q": [0x71], "Q": [0x71], + " ": [0x20], }; +function snakeBytes(e) { + if (e.ctrlKey || e.metaKey || e.altKey) return null; + return SNAKE_BYTES[e.key] ?? null; +} + +function shellBytes(e) { + if (e.metaKey || e.altKey) return null; // let browser shortcuts pass + + // Named keys. + switch (e.key) { + case "Enter": return [0x0a]; + case "Backspace": return [0x7f]; // kernel/console.zig accepts both 0x08 and 0x7f + case "Tab": return [0x09]; + case "Escape": return [0x1b]; + case "ArrowUp": return [0x1b, 0x5b, 0x41]; // ESC [ A + case "ArrowDown": return [0x1b, 0x5b, 0x42]; // ESC [ B + case "ArrowRight": return [0x1b, 0x5b, 0x43]; // ESC [ C + case "ArrowLeft": return [0x1b, 0x5b, 0x44]; // ESC [ D + } + + // Single-character keys (letters, digits, punctuation, space). + if (e.key.length === 1) { + if (e.ctrlKey) { + // Ctrl+a..Ctrl+z → 0x01..0x1a (covers ^C, ^D, ^U, ^S, ^X, etc). + const lower = e.key.toLowerCase(); + if (lower >= "a" && lower <= "z") { + return [lower.charCodeAt(0) - 0x60]; + } + return null; // other Ctrl combos pass through + } + return [e.key.charCodeAt(0) & 0xff]; + } + return null; +} + +function bytesForCurrentProgram(e) { + const idx = sel.value; + if (idx === SHELL_IDX) return shellBytes(e); + if (idx === SNAKE_IDX) return snakeBytes(e); + return null; // hello: no input +} + function render() { out.textContent = ansi.text(); } function startCurrent() { const idx = parseInt(sel.value, 10); - const elfUrl = ELF_URLS[String(idx)]; + const idxStr = String(idx); + const elfUrl = ELF_URLS[idxStr]; + const diskUrl = DISK_URLS[idxStr]; // undefined when this program has no disk // Bump the run id BEFORE clearing — any in-flight worker messages // from the previous run will be tagged with the old id and dropped. @@ -75,8 +141,14 @@ function startCurrent() { if (tracePre) tracePre.textContent = ""; if (traceMeta) traceMeta.textContent = ""; - const trace = TRACE_PROGRAMS.has(String(idx)) ? 1 : 0; - worker.postMessage({ type: "start", runId: currentRunId, elfUrl, trace }); + const trace = TRACE_PROGRAMS.has(idxStr) ? 1 : 0; + worker.postMessage({ + type: "start", + runId: currentRunId, + elfUrl, + diskUrl, // undefined → Worker treats as no-disk (passes diskLen=0) + trace, + }); } worker.onmessage = (e) => { @@ -127,10 +199,14 @@ out.addEventListener("blur", () => { }); out.addEventListener("keydown", (e) => { - const byte = ALLOWED_KEYS[e.key]; - if (byte === undefined) return; + const bytes = bytesForCurrentProgram(e); + if (!bytes) return; // unmapped key; let the browser handle it e.preventDefault(); - worker.postMessage({ type: "input", byte }); + // Worker's pushInput export takes one byte at a time; multi-byte keys + // (arrow keys → 3-byte ESC sequences) post each byte in order. + for (const byte of bytes) { + worker.postMessage({ type: "input", byte }); + } }); out.addEventListener("click", () => out.focus()); From 4d49444af0d22b9556127bd2847c9f89b8aad813 Mon Sep 17 00:00:00 2001 From: Jimmy Yeh Date: Mon, 27 Apr 2026 15:59:28 +0800 Subject: [PATCH 12/25] feat(web/index): add shell.elf as default + shell instructions card Dropdown now shows shell (selected) | snake | hello. New shell-instructions card sits above the snake card and lists the canonical try-commands (ls /bin, cat /etc/motd, edit /etc/motd, ^C, exit) plus a "what's running" line that ties the demo back to the CLI build. Same device-warning row as snake (mobile/tablet can't drive per-byte input). --- web/index.html | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/web/index.html b/web/index.html index 370e342..0a844ad 100644 --- a/web/index.html +++ b/web/index.html @@ -16,12 +16,35 @@

RV32 in your browser

(click the terminal to play)
+
+
+ shell: + type a command and press Enter · try + ls /bin · + cat /etc/motd · + echo hi > /tmp/x · + edit /etc/motd · + ^C cancel · + exit to halt +
+
+ what's running: + a from-scratch RV32 kernel (M-mode boot shim → S-mode kernel → cooked-mode console + → fork/exec → on-disk shell + utilities) booted from shell-fs.img + — the same binary that runs on the CLI via zig build kernel-fs. +
+
+ ⚠ requires a physical keyboard — desktop or laptop only. Mobile and tablet devices can't drive per-byte input. +
+
+
snake controls: From e2c9a7c4e68cd7eda15e2ae612e1350351e0a234 Mon Sep 17 00:00:00 2001 From: Jimmy Yeh Date: Mon, 27 Apr 2026 16:03:17 +0800 Subject: [PATCH 13/25] =?UTF-8?q?feat(web/css):=20grow=20output=20panel=20?= =?UTF-8?q?to=20fit=2080=C3=9724=20grid=20+=20disable=20wrap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - height (480px fixed) → min-height (600px), enough for 24 rows at the existing 15px / 1.55 line-height. Overflow stays auto as a safety net for any future program emitting more than 24 rows. - white-space changes from pre-wrap to pre: the ANSI interpreter already manages line breaks at the 80-col boundary; letting the browser re-wrap was fine at 32×16 (snake never overflowed) but breaks shell output that assumes the grid model. Horizontal scroll appears on narrow viewports. - Typography unchanged (15px / 1.55) — snake's render is unaffected. --- web/demo.css | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/web/demo.css b/web/demo.css index e444975..7733399 100644 --- a/web/demo.css +++ b/web/demo.css @@ -93,11 +93,17 @@ pre.output { font-size: 15px; line-height: 1.55; padding: 20px 24px; - height: 480px; - overflow-y: auto; + /* 24 rows × 15px × 1.55 line-height ≈ 558px content; +40px padding ≈ 600px. + min-height (not height) so the ANSI 24-row buffer is always fully visible + without forcing a scrollbar; overflow stays auto in case any future + program emits more than 24 rows. */ + min-height: 600px; + overflow: auto; margin: 0 0 32px; - white-space: pre-wrap; - word-break: break-word; + /* The ANSI interpreter renders a fixed 80×24 grid as a single text string + with hard \n at row boundaries — let the browser render it as-is, no + re-wrapping. Horizontal scroll appears at narrow viewports. */ + white-space: pre; } pre.output .stderr { color: var(--muted); } pre.output .meta { color: var(--dim); } From 800e09e3cd949ab10654fc491b2a92115814a510 Mon Sep 17 00:00:00 2001 From: Jimmy Yeh Date: Mon, 27 Apr 2026 16:08:13 +0800 Subject: [PATCH 14/25] =?UTF-8?q?docs:=20web=20shell=20demo=20=E2=80=94=20?= =?UTF-8?q?README=20updates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - web/README.md: shell.elf added as the default program; "How it works" gets one paragraph about the disk_buffer plumbing; gitignore mention bumps from three artifacts to five. - top-level README.md: "Live demo" line lists shell (default) / snake / hello with one-clause descriptions of each. No changes to architecture or status sections — Phase 3 is already documented end-to-end. --- README.md | 7 +++++-- web/README.md | 30 +++++++++++++++++++++++------- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 1ecf1e9..dd84a0e 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,11 @@ graphics. **Live demo:** [https://cyyeh.github.io/ccc/web/](https://cyyeh.github.io/ccc/web/) — `ccc` cross-compiled to `wasm32-freestanding`, running RV32 binaries in -your browser. Pick `snake.elf` (default — WASD to play) or `hello.elf` (auto-runs + shows the instruction trace). Same Zig core as the CLI; the browser hosts -the emulator in a Web Worker that drives execution in chunks. +your browser. Pick `shell.elf` (default — full Phase 3 shell with +`ls`/`cat`/`echo`/`edit`/`^C`/`exit` against an in-wasm `shell-fs.img`), +`snake.elf` (WASD to play), or `hello.elf` (auto-runs + shows the +instruction trace). Same Zig core as the CLI; the browser hosts the +emulator in a Web Worker that drives execution in chunks. ## Goal diff --git a/web/README.md b/web/README.md index 4be2078..bf9e075 100644 --- a/web/README.md +++ b/web/README.md @@ -4,10 +4,19 @@ A single-page browser demo of [`ccc`](../), a from-scratch RISC-V CPU emulator written in Zig. The same emulator modules that power the native CLI (`cpu.zig`, `memory.zig`, `elf.zig`, `devices/*.zig`) are cross-compiled to `wasm32-freestanding` via a thin entry point -(`demo/web_main.zig`) and loaded into your browser. Two RV32 programs +(`demo/web_main.zig`) and loaded into your browser. Three RV32 programs ship with the page: -- **`snake.elf`** (default) — an interactive snake game. A bare M-mode +- **`shell.elf`** (default) — a full Phase 3.E + 3.F shell. The page + loads `kernel-fs.elf` (M-mode boot shim → S-mode kernel → cooked-mode + console → fork/exec → on-disk init) plus `shell-fs.img` (a 4 MB FS + image with `/bin/{sh,ls,cat,echo,mkdir,rm,edit}` + `/etc/motd`). + Click the terminal, then type `ls /bin`, `cat /etc/motd`, + `echo hi > /tmp/x`, `edit /etc/motd`, `^C` to cancel a foreground + program, `exit` to halt. **Requires a physical keyboard — desktop + or laptop only.** Disk writes live in wasm linear memory and reset + on every page load. +- **`snake.elf`** — an interactive snake game. A bare M-mode supervisor drives a CLINT timer IRQ for the game tick and polls UART RX for input. Click the terminal, then move with `W` / `A` / `S` / `D`, press `Space` to start, `Q` to quit. **Requires a @@ -33,7 +42,13 @@ ship with the page: selected ELF on demand, copies the ELF bytes into the wasm load buffer, and drives `runStep()` in 50 000-instruction chunks via `setTimeout`. Yielding between chunks lets the Worker service - `pushInput` messages — a single blocking `run()` couldn't. + `pushInput` messages — a single blocking `run()` couldn't. When + the selected program has a disk image (currently only `shell.elf`, + which fetches `shell-fs.img`), the Worker fetches it in parallel + with the ELF and copies it into a 4 MB `disk_buffer` exposed by the + wasm via `diskBufferPtr/Cap`; `runStart` then receives a non-zero + `disk_len` and wires the buffer slice into the emulator's block + device. 4. `demo.js` (main thread) decodes captured UART bytes through a ~120-line ANSI interpreter (`ansi.js`, full-redraw subset) into a focusable `
` and forwards key events to the Worker. The
@@ -50,10 +65,11 @@ python3 -m http.server -d . 8000          # any static server works
 open http://localhost:8000/web/
 ```
 
-`web/ccc.wasm`, `web/hello.elf`, and `web/snake.elf` are gitignored —
-all three are produced by `zig build wasm` and overlaid into the Pages
-artifact in CI. Run `stage-web.sh` (or `zig build wasm` + the three
-`cp` commands it wraps) before serving locally.
+`web/ccc.wasm`, `web/hello.elf`, `web/snake.elf`, `web/kernel-fs.elf`,
+and `web/shell-fs.img` are gitignored — all five are produced by
+`zig build wasm` and overlaid into the Pages artifact in CI. Run
+`stage-web.sh` (or `zig build wasm` + the five `cp` commands it wraps)
+before serving locally.
 
 ## Adding another demo
 

From 9657e5be521ccc2e07296c382384947f1ee0ea6d Mon Sep 17 00:00:00 2001
From: Jimmy Yeh 
Date: Mon, 27 Apr 2026 16:12:33 +0800
Subject: [PATCH 15/25] docs(web): sync stale runStart signature +
 diskBufferPtr in exports list

Code-review follow-up to 800e09e (Task 9). Step 2's wasm-exports list
still showed the old 2-arg runStart and didn't mention diskBufferPtr/Cap;
step 3's new disk-buffer paragraph references both, so step 2 read as
out-of-date next to it. Also update stage-web.sh's inline comment to
match the surrounding "five artifacts" prose.
---
 web/README.md | 7 ++++---
 1 file changed, 4 insertions(+), 3 deletions(-)

diff --git a/web/README.md b/web/README.md
index bf9e075..4c70a4a 100644
--- a/web/README.md
+++ b/web/README.md
@@ -33,11 +33,12 @@ ship with the page:
    `wasm32-freestanding`, installed as `zig-out/web/ccc.wasm` (~38 KB).
 2. `web_main.zig` exposes a chunked-step API rather than a blocking
    `run()`:
-   - `runStart(elf_len, trace) -> i32`, `runStep(max_instructions) -> i32`
+   - `runStart(elf_len, trace, disk_len) -> i32`, `runStep(max_instructions) -> i32`
    - `setMtimeNs(ns) -> void`, `pushInput(byte) -> void`
    - `outputPtr` / `consumeOutput` for UART output
    - `tracePtr` / `traceLen` for the optional instruction trace
-   - `elfBufferPtr` / `elfBufferCap` for a fixed in-wasm load buffer
+   - `elfBufferPtr` / `elfBufferCap` for the in-wasm ELF load buffer
+   - `diskBufferPtr` / `diskBufferCap` for the optional disk-image load buffer
 3. `runner.js` is a Web Worker that fetches `ccc.wasm` and the
    selected ELF on demand, copies the ELF bytes into the wasm load
    buffer, and drives `runStep()` in 50 000-instruction chunks via
@@ -60,7 +61,7 @@ takes an empty import object. The browser is the RISC-V machine.
 ## Local development
 
 ```sh
-./scripts/stage-web.sh                    # build + copy ccc.wasm + hello.elf + snake.elf into web/
+./scripts/stage-web.sh                    # build + copy all five wasm/ELF/disk artifacts into web/
 python3 -m http.server -d . 8000          # any static server works
 open http://localhost:8000/web/
 ```

From 1ead6bffb9d8227c77604085e9c8db5af052e86a Mon Sep 17 00:00:00 2001
From: Jimmy Yeh 
Date: Mon, 27 Apr 2026 16:33:45 +0800
Subject: [PATCH 16/25] fix(wasm): bump RAM_SIZE from 16 MB to 128 MB to match
 CLI default
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The wasm hardcoded 16 MB of guest RAM, which was fine for snake/hello
(both M-mode-only programs that fit in a few KB) but broke kernel-fs.elf:
the kernel's trampoline page is mapped at RAM_BASE + 128 MB - 4 KB
(0x87FFF000), and any access there triggered a load/store fault during
kmain's page-table setup.

The fault bubbled M-modeward, the M-mode trap handler wrote 0xFF to the
halt MMIO, and the wasm halted with exit 255 before the kernel ever
printed anything to UART.

Bump RAM_SIZE to 128 MB to match src/emulator/main.zig's default
(--memory 128). The CLI hadn't hit this because it always defaults to
128 MB; the snake-demo plan author punted on RAM size as "plenty for
hello.elf" in 2026-04. Caught by Task 10 manual smoke test.

Confirmed via Node-based smoke test against ccc.wasm + kernel-fs.elf +
shell-fs.img: with 128 MB RAM the kernel boots and prints '$ ' within
~10M instructions (≤1s of wasm setTimeout chunks at 50 000/chunk).
---
 demo/web_main.zig | 8 ++++++--
 1 file changed, 6 insertions(+), 2 deletions(-)

diff --git a/demo/web_main.zig b/demo/web_main.zig
index 3480565..ccb1c62 100644
--- a/demo/web_main.zig
+++ b/demo/web_main.zig
@@ -86,8 +86,12 @@ fn jsClock() i128 {
     return mtime_ns;
 }
 
-// 16 MiB of guest RAM is plenty for hello.elf.
-const RAM_SIZE: usize = 16 * 1024 * 1024;
+// 128 MiB of guest RAM matches the CLI default (`--memory 128` in
+// src/emulator/main.zig). The kernel's trampoline page lives at
+// RAM_BASE + 128 MB - 4 KB (= 0x87FFF000), so anything smaller than
+// 128 MB triggers an access fault during kmain's page-table setup,
+// even though hello.elf alone would happily fit in 16 MB.
+const RAM_SIZE: usize = 128 * 1024 * 1024;
 
 // Module-level emulator state that survives across runStep calls.
 // The arena, devices, memory, and cpu all live here so no heap pointer

From e4f997e35538a0fe3151b9fe5294aae2245a3b5e Mon Sep 17 00:00:00 2001
From: Jimmy Yeh 
Date: Mon, 27 Apr 2026 16:38:25 +0800
Subject: [PATCH 17/25] =?UTF-8?q?fix(wasm):=20wire=20UART=20=E2=86=92=20PL?=
 =?UTF-8?q?IC=20so=20pushInput=20drives=20the=20kernel's=20RX=20IRQ?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Uart.pushRx asserts PLIC src 10 only when the FIFO was previously empty,
gated behind self.plic ?*Plic = null. The CLI sets uart.plic = &plic in
src/emulator/main.zig:180, but the wasm path never did, so browser
keystrokes landed in the FIFO without ever raising the IRQ.

Result: $ prompt printed (kernel boot was unaffected), but typing did
nothing — the kernel's UART driver never woke up and the cooked-mode
console never echoed. Caught by Task 10 manual smoke test.

Confirmed via Node smoke test: pushing 'ls\n' now produces echoed input
+ directory listing + new prompt ('$ ls\n.\n..\nbin\netc\ntmp\n$ ').
---
 demo/web_main.zig | 6 ++++++
 1 file changed, 6 insertions(+)

diff --git a/demo/web_main.zig b/demo/web_main.zig
index ccb1c62..9315fa5 100644
--- a/demo/web_main.zig
+++ b/demo/web_main.zig
@@ -180,6 +180,12 @@ export fn runStart(elf_len: u32, trace: i32, disk_len: u32) i32 {
     state_storage.clint = clint_dev.Clint.init(&jsClock);
     state_storage.plic = plic_dev.Plic.init();
     state_storage.block = block_dev.Block.init();
+
+    // Wire UART → PLIC so pushRx() raises src 10 (UART RX IRQ) when the
+    // FIFO transitions from empty → non-empty. Mirrors src/emulator/main.zig.
+    // Without this, browser keystrokes land in the FIFO but the kernel never
+    // takes the interrupt and never echoes/processes them.
+    state_storage.uart.plic = &state_storage.plic;
     if (disk_len > 0) {
         state_storage.block.disk_slice = disk_buffer[0..disk_len];
     }

From 7b1c244c96f7bd06b7e55a6b188b4d91af2ae04d Mon Sep 17 00:00:00 2001
From: Jimmy Yeh 
Date: Mon, 27 Apr 2026 16:44:33 +0800
Subject: [PATCH 18/25] feat(web): visual backspace + blinking block cursor

Two fixes for "standard CLI behavior" gaps surfaced by Task 10:

1. Backspace was silently dropped. The kernel emits the canonical
   '\b \b' sequence (ESC=0x08, space, ESC=0x08) to visually erase a
   character, but ansi.js had a blanket "if (b < 0x20) return" guard
   in GROUND state that ate every C0 control. Add an explicit 0x08
   handler that moves the cursor left one column (clamped at 0); the
   trailing space + second backspace then write/walk-over the cell
   correctly via the existing _writeCell path.

2. No visible cursor. Standard terminals show a blinking block at the
   current input column. Added a CSS-animated  in a
   new .terminal-wrapper next to 
; demo.js positions
   it on each render via inline `top: calc(20px + ansi.row * 1.55em)`
   and `left: calc(24px + ansi.col * 1ch)`. Animation: opacity steps
   between 0.7 and 0 every 0.5s.

Also wires ESC[?25l/h to a new ansi.cursor_visible flag (was an
explicit no-op before). The editor enters/exits raw mode by toggling
this; demo.js's render() hides/shows the cursor accordingly.

Verified against the Node smoke test: still echoes 'ls\n' correctly
through the cooked-mode kernel; backspace now reaches ansi.js as 0x08
instead of being filtered out at the worker boundary.
---
 web/ansi.js    | 24 ++++++++++++++++++------
 web/demo.css   | 34 +++++++++++++++++++++++++++++++++-
 web/demo.js    | 18 ++++++++++++++++++
 web/index.html |  5 ++++-
 4 files changed, 73 insertions(+), 8 deletions(-)

diff --git a/web/ansi.js b/web/ansi.js
index d932db8..02e286a 100644
--- a/web/ansi.js
+++ b/web/ansi.js
@@ -1,14 +1,19 @@
 // web/ansi.js
 //
 // Minimal ANSI interpreter: enough escape sequences for snake's
-// full-redraw rendering. State machine walks bytes; CSI sequences
-// recognized:
+// full-redraw rendering and the shell's streaming output. State machine
+// walks bytes; CSI sequences recognized:
 //   ESC [ 2 J     → clear screen
 //   ESC [ H       → cursor (0,0)
 //   ESC [ r;c H   → cursor (r-1, c-1)
-//   ESC [ ? 25 l  → hide cursor (no-op visually)
-//   ESC [ ? 25 h  → show cursor (no-op)
-// Unrecognized sequences are consumed and ignored.
+//   ESC [ ? 25 l  → hide cursor (cursor_visible = false)
+//   ESC [ ? 25 h  → show cursor (cursor_visible = true)
+// C0 controls handled in GROUND:
+//   0x08 (BS)  → cursor left one column (clamped at 0)
+//   0x0a (LF)  → line feed (scrolls when at last row)
+//   0x0d (CR)  → cursor to column 0
+// Other bytes < 0x20 are silently dropped.
+// Unrecognized escape sequences are consumed and ignored.
 //
 // UTF-8 multibyte sequences (lead byte 0xC0–0xF7) are reassembled
 // into a single screen cell so box-drawing chars render correctly.
@@ -24,6 +29,10 @@ export class Ansi {
     this.state = "GROUND";
     this.csiBuf = "";
     this.utf8Pending = null;
+    // Tracks ESC[?25h (true, default) / ESC[?25l (false). Read by the
+    // demo's render() to show or hide the on-screen cursor — the editor
+    // toggles this on entry/exit to raw mode.
+    this.cursor_visible = true;
   }
 
   _reset() {
@@ -56,6 +65,7 @@ export class Ansi {
       if (b === 0x1b) { this.state = "ESC"; return; }
       if (b === 0x0a) { this._lineFeed(); return; }
       if (b === 0x0d) { this.col = 0; return; }
+      if (b === 0x08) { this.col = Math.max(0, this.col - 1); return; }
       if (b < 0x20)   return; // other control: ignore
       if (b >= 0xC0 && b <= 0xF7) { this._utf8Start(b); return; }
       if (b >= 0x80)   { this._utf8Continue(b); return; }
@@ -125,7 +135,9 @@ export class Ansi {
       }
       return;
     }
-    // ?25l, ?25h, anything else: ignore.
+    if (final === "l" && params === "?25") { this.cursor_visible = false; return; }
+    if (final === "h" && params === "?25") { this.cursor_visible = true;  return; }
+    // anything else: ignore.
   }
 
   text() {
diff --git a/web/demo.css b/web/demo.css
index 7733399..110ea56 100644
--- a/web/demo.css
+++ b/web/demo.css
@@ -84,6 +84,13 @@ button.run:disabled {
   min-height: 1.5em;
 }
 
+/* The terminal-wrapper anchors the absolutely-positioned #cursor span to
+   the same coordinate space as pre.output's content area. */
+.terminal-wrapper {
+  position: relative;
+  margin: 0 0 32px;
+}
+
 pre.output {
   background: var(--panel);
   border: 1px solid var(--panel-border);
@@ -99,12 +106,37 @@ pre.output {
      program emits more than 24 rows. */
   min-height: 600px;
   overflow: auto;
-  margin: 0 0 32px;
+  margin: 0;
   /* The ANSI interpreter renders a fixed 80×24 grid as a single text string
      with hard \n at row boundaries — let the browser render it as-is, no
      re-wrapping. Horizontal scroll appears at narrow viewports. */
   white-space: pre;
 }
+
+/* Blinking block cursor positioned over the terminal at (row, col).
+   demo.js sets inline `top` / `left` on every render based on the ANSI
+   state. 1ch = width of '0' in the current monospace font; 1.55em
+   matches pre.output's line-height. The (20px, 24px) padding offsets
+   in JS match pre.output's padding above. */
+#cursor {
+  position: absolute;
+  width: 1ch;
+  height: 1.55em;
+  background: var(--accent);
+  opacity: 0.7;
+  pointer-events: none;
+  font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
+  font-size: 15px;
+  line-height: 1.55;
+  animation: cursor-blink 1s steps(2, jump-none) infinite;
+}
+#cursor.hidden {
+  display: none;
+}
+@keyframes cursor-blink {
+  0%, 50%      { opacity: 0.7; }
+  50.01%, 100% { opacity: 0; }
+}
 pre.output .stderr { color: var(--muted); }
 pre.output .meta   { color: var(--dim); }
 
diff --git a/web/demo.js b/web/demo.js
index e363bf7..69fd6d7 100644
--- a/web/demo.js
+++ b/web/demo.js
@@ -11,10 +11,19 @@ const sel = document.getElementById("program-select");
 const hint = document.querySelector(".program-hint");
 const snakeInstructions = document.getElementById("snake-instructions");
 const shellInstructions = document.getElementById("shell-instructions");
+const cursor = document.getElementById("cursor");
 
 const SNAKE_IDX = "1";
 const SHELL_IDX = "2";
 
+// Pixel offsets matching pre.output's padding (`padding: 20px 24px`) so
+// the absolutely-positioned cursor lands inside the content area, not on
+// the box border. Row stride uses `1.55em` (matches line-height); column
+// stride uses `1ch` — both as inline style strings so the browser
+// resolves them against the cursor's own font metrics.
+const PAD_TOP = 20;
+const PAD_LEFT = 24;
+
 function updateProgramInstructions() {
   if (snakeInstructions) snakeInstructions.classList.toggle("hidden", sel.value !== SNAKE_IDX);
   if (shellInstructions) shellInstructions.classList.toggle("hidden", sel.value !== SHELL_IDX);
@@ -115,6 +124,15 @@ function bytesForCurrentProgram(e) {
 
 function render() {
   out.textContent = ansi.text();
+  if (cursor) {
+    if (ansi.cursor_visible) {
+      cursor.classList.remove("hidden");
+      cursor.style.top  = `calc(${PAD_TOP}px + ${ansi.row} * 1.55em)`;
+      cursor.style.left = `calc(${PAD_LEFT}px + ${ansi.col} * 1ch)`;
+    } else {
+      cursor.classList.add("hidden");
+    }
+  }
 }
 
 function startCurrent() {
diff --git a/web/index.html b/web/index.html
index 0a844ad..82b3027 100644
--- a/web/index.html
+++ b/web/index.html
@@ -62,7 +62,10 @@ 

RV32 in your browser

-

+    
+

+      
+