diff --git a/SHA256SUMS b/SHA256SUMS
index bfd4e31..76f3c4d 100644
--- a/SHA256SUMS
+++ b/SHA256SUMS
@@ -290,6 +290,25 @@ f9b29a035613690ad4621d87338c116492022265f37e933753adb3579c9be11e ./results/ulx3
eb3d81acee7562549af79f6c6d03d713c86f5a46a0892b6fd056f2c073cb45d2 ./results/ulx3s-smoke-uart-20260725/ulx3s_smoke.bit
8972d87123e82539a632ba3bd4b462a73cbe7323c9e8a169e36c9da2e489f9b8 ./results/ulx3s-smoke-uart-20260725/yosys.log
2678fd4ecc86ca360d1b52b5d5b0e96ee38f3b8ed308e62a4442cfbc5761ef6e ./results/ulx3s-smoke-uart-20260725/yosys_uart.log
+5f7f650b4900b1ed1f9e40c2394c02e3db52caa6a983825ab0b08d380faa711a ./silicon_live/README.md
+baa03bd05265b4fc3d1ca0df19452238a9c2605cc654acd7f67ea8c3eeff6e1d ./silicon_live/__init__.py
+a67c172046323b0efcafe92cb83abc266d5dd4bc931d9312cde1b7bed50643db ./silicon_live/__main__.py
+45feb50ad6be24f9de9205f5fe5a9efa083193246245150909575d6cff6ce91a ./silicon_live/app.py
+f942c876c8f681be7f1dae50d60842a5c0efb99319cd16bab8fede502a2d1f5d ./silicon_live/docs/VISUAL_QA.md
+2caf1e17344181fec7a79883ae50b085d1a697d6d8dc60fc16538ff566685b13 ./silicon_live/docs/screenshots/01-initial-shell.png
+fc9150d627b0b3c3d0bfcd306e87f597a67b7cde38460f759bb336e239bdfb15 ./silicon_live/docs/screenshots/01-initial-shell.svg
+92a8134eb0b25752f831ae8f4ddf231eb62f5cc11f13860e930447de285c3aa4 ./silicon_live/docs/screenshots/02-active-middle-step.png
+2757e00ca5585c254a9dd2ea8bac659f57d9708222bd9283b77620233bba98d2 ./silicon_live/docs/screenshots/02-active-middle-step.svg
+b80921bbaf1e8789ff5b0938b44ca912b15d95adc34c4bf57489791ef05cdb22 ./silicon_live/docs/screenshots/03-narrow-terminal.png
+74c89850fc223ccf0aaa95e6b845d25ebdcfb9b4b670223a971de7ed8e8c3896 ./silicon_live/docs/screenshots/03-narrow-terminal.svg
+77a6cda6f5a7e5de86c2aadee2f9a62ce73e8e339f1f9ccde9c7f6e66f821970 ./silicon_live/docs/screenshots/04-prefix-match.png
+f6840b309b09cd2ba0f4de3befcce949d5b3a1cd3183770922e124c0b462768a ./silicon_live/docs/screenshots/04-prefix-match.svg
+6f19437c46011618327ccfda2a5f034b6bb164135af81721eca57cfb9e6cb36d ./silicon_live/model.py
+d73753a1ff86b7d1e6d5461abcc3ec8af2f125df8f095598e5ad15125adcba7d ./silicon_live/pyproject.toml
+701f529ac3a7cdf09bd735f9e7ba7719353973f592d68eb806752f4f15af89b8 ./silicon_live/requirements.txt
+d2762ab9638e9ecffaf828424f46e4853b734f763e61bfee274a571f86dd23a6 ./silicon_live/tests/capture_screenshots.py
+19fa58b786fe67ef3661281e130595f94dcf7b808a8cccd85b091fa12f14b3dd ./silicon_live/tests/test_model.py
+b3c4aed66a1343ac59f32f1084e551940bbd34f7a14d4aad1619b7f838c95910 ./silicon_live/transport.py
2ce193a38f1a8c96ec1cbfe2c8b7555d025944cb450859c3e09dd2cbab66a03c ./sim/__init__.py
56f95ab49ce35604c5b30cd95d142a4d630b2d503af5398b1f975ef84492b48c ./sim/cycle_model.py
3ebec652b76c20903fde598d748dbaff596cbc3f962f80ca23f30b31e1327da5 ./sim/lsc1_transaction.py
diff --git a/silicon_live/README.md b/silicon_live/README.md
new file mode 100644
index 0000000..9fadccc
--- /dev/null
+++ b/silicon_live/README.md
@@ -0,0 +1,93 @@
+# Silicon Live
+
+Silicon Live is an evidence-first Textual dashboard for the 12-step physical
+ULX3S LSC-1 run committed in this repository. The default demo is offline,
+deterministic, opens no serial port, and ends at **PREFIX MATCH ✓**. This is
+deliberately not called PASS: hardware stopped before the unsupported `Jump`.
+
+## Install and run
+
+From the repository root:
+
+```console
+python3 -m venv .venv
+.venv/bin/pip install -r silicon_live/requirements.txt
+.venv/bin/python -m silicon_live --demo
+```
+
+Or, when `uv` is available:
+
+```console
+uv run --with-requirements silicon_live/requirements.txt python3 -m silicon_live --demo
+```
+
+The dashboard starts replay immediately and needs no board. Run with an
+explicit compatible artifact using `--evidence PATH`.
+
+## What is on screen
+
+The dashboard keeps zkDSL source, PC-local disassembly, exact 128-bit VM
+memory, cell reads/writes, the current HOST → FPGA → HOST exchange, and a
+structured event stream visible together. HOST is warm amber; FPGA is teal.
+Green is reserved for validated writes and the prefix-match terminal. The
+source highlight is explicitly marked `≈ approximate`, because the committed
+compiler artifact has no exact source spans.
+
+Representative real Textual captures:
+
+- [Initial shell](docs/screenshots/01-initial-shell.svg)
+- [Active middle step](docs/screenshots/02-active-middle-step.svg)
+- [Narrow terminal](docs/screenshots/03-narrow-terminal.svg)
+- [Final prefix match](docs/screenshots/04-prefix-match.svg)
+
+## Controls
+
+| Key | Action |
+|---|---|
+| `Space` | Run / pause |
+| `S` | Execute one evidence step |
+| `R` | Restart |
+| `+` / `-` | Replay speed |
+| `/` | Search events |
+| `I` | Inspect exact current transaction and destination cell |
+| `Tab` | Move panel focus |
+| `?` | Keyboard help |
+| `Ctrl+P` | Textual command palette |
+| `Q` | Quit |
+
+Memory cells always retain and render all 32 hexadecimal digits. Narrow
+terminals switch to a vertically scrollable single-column layout.
+
+## Architecture
+
+- `model.py`: artifact loader, exact-u128 state, deterministic replay, and
+ UI-independent prepare/send/compute/response/validate/write/halt/error events.
+- `transport.py`: fake serial plus a lazy live adapter over the existing PR #16
+ `MinCoreSerialDriver`. It never enumerates, opens, or loads hardware.
+- `app.py`: presentation and commands only.
+- `tests/`: loaders, events, memory, semantics, errors, fake serial, commands,
+ progression, and responsive pilot coverage.
+
+The evidence adapter is production-ready for the committed run. The live
+adapter intentionally requires callers to provide an already-open serial
+object; Silicon Live itself does not expose a hardware mode yet. HostRuntime
+integration remains a presentation-compatible future adapter: its output
+should be translated into the same event vocabulary.
+
+## Visual QA
+
+See [Visual QA log](docs/VISUAL_QA.md). Regenerate the four artifacts with:
+
+```console
+.venv/bin/python -m silicon_live.tests.capture_screenshots
+```
+
+## Limitations
+
+- Source-to-instruction highlighting is approximate; the artifact contains no
+ exact span table.
+- Evidence ends at PC 12 because `Jump` is unsupported by the seed-0 UART
+ runner. The correct conclusion is prefix match, never full-program pass.
+- Search currently filters structured event messages; visible memory and
+ instruction text remain directly inspectable but do not yet jump to a row.
+- Live serial use is adapter-only and intentionally never automatic.
diff --git a/silicon_live/__init__.py b/silicon_live/__init__.py
new file mode 100644
index 0000000..eb53497
--- /dev/null
+++ b/silicon_live/__init__.py
@@ -0,0 +1,3 @@
+"""Silicon Live — evidence-first LeanSilicon execution dashboard."""
+
+__version__ = "0.1.0"
diff --git a/silicon_live/__main__.py b/silicon_live/__main__.py
new file mode 100644
index 0000000..f0d96ec
--- /dev/null
+++ b/silicon_live/__main__.py
@@ -0,0 +1,3 @@
+from .app import main
+
+raise SystemExit(main())
diff --git a/silicon_live/app.py b/silicon_live/app.py
new file mode 100644
index 0000000..dc6289d
--- /dev/null
+++ b/silicon_live/app.py
@@ -0,0 +1,274 @@
+"""Textual dashboard for replaying LeanSilicon FPGA evidence."""
+from __future__ import annotations
+import argparse
+from pathlib import Path
+
+from rich.markup import escape
+from textual.app import App, ComposeResult
+from textual.binding import Binding
+from textual.containers import Container, Horizontal, Vertical
+from textual.reactive import reactive
+from textual.widgets import Footer, Header, Input, Label, RichLog, Static
+
+from .model import EventKind, Replay, load_evidence
+
+CSS = """
+$bg: #101217; $surface: #181b22; $raised: #222731; $border: #3b4352;
+$text: #eee9df; $muted: #9aa3b2; $host: #f2ae72; $fpga: #64d2c8;
+$ok: #80d98b; $warn: #f0c36a; $error: #f07b7b; $pc: #d5a6e6;
+Screen { background: $bg; color: $text; }
+Header { height: 3; background: $surface; color: $text; }
+#status { height: 4; padding: 1 2; background: $surface; color: $muted; }
+#status .host { color: $host; } #status .fpga { color: $fpga; }
+#workspace { height: 1fr; padding: 0 1; }
+.column { width: 1fr; }
+.panel { background: $surface; border: round $border; margin: 0 1 1 0; padding: 0 1; }
+.panel:focus-within { border: round $pc; }
+.title { height: 2; color: $muted; text-style: bold; padding-top: 1; }
+#source { height: 9; } #instructions { height: 1fr; min-height: 11; }
+#transaction { height: 14; border: round $pc; background: $raised; }
+#transaction .title { color: $pc; }
+#memory { height: 1fr; min-height: 11; }
+#events { width: 30%; min-width: 32; }
+RichLog { scrollbar-color: $border; scrollbar-background: $surface; }
+#searchbar { display: none; height: 3; margin: 0 1 1 0; border: round $pc; background: $raised; }
+#searchbar.visible { display: block; }
+#terminal { height: 3; padding: 1 2; background: $raised; color: $muted; }
+#terminal.complete { height: 6; padding: 1 2; color: $ok; background: #183020; border: round $ok; text-style: bold; }
+#terminal.error { color: $error; text-style: bold; }
+Footer { height: 1; background: $surface; color: $muted; }
+Screen.narrow #workspace { layout: vertical; overflow-y: auto; }
+Screen.narrow .column, Screen.narrow #events { width: 100%; min-width: 0; height: auto; }
+Screen.narrow Header { height: 2; }
+Screen.narrow #status { height: 6; padding: 1; }
+Screen.narrow #source { height: 8; } Screen.narrow #instructions { height: 9; }
+Screen.narrow #transaction { height: 12; } Screen.narrow #memory { height: 14; }
+Screen.narrow #events { height: 12; }
+Screen.narrow #terminal { height: 4; padding: 1; }
+Screen.narrow #terminal.complete { height: 7; }
+Screen.narrow Footer { display: none; }
+"""
+
+
+class SiliconLive(App):
+ TITLE = "SILICON LIVE"
+ SUB_TITLE = "evidence replay · no board"
+ CSS = CSS
+ BINDINGS = [
+ Binding("space", "toggle", "Run/Pause", priority=True),
+ Binding("s", "step", "Step"), Binding("r", "restart", "Restart"),
+ Binding("+", "faster", "Faster"), Binding("-", "slower", "Slower"),
+ Binding("/", "search", "Search"), Binding("i", "inspect", "Inspect"),
+ Binding("tab", "focus_next", "Next panel"),
+ Binding("question_mark", "help", "Help"), Binding("ctrl+p", "command_palette", "Commands"),
+ Binding("q", "quit", "Quit"),
+ ]
+ playing = reactive(False)
+ speed = reactive(1.0)
+
+ def __init__(self, evidence_path: Path | None = None, auto_run: bool = True) -> None:
+ super().__init__()
+ self.replay = Replay(load_evidence(evidence_path) if evidence_path else load_evidence())
+ self.auto_run = auto_run
+ self._timer = None
+
+ def compose(self) -> ComposeResult:
+ yield Header(icon="")
+ yield Static(id="status")
+ with Horizontal(id="workspace"):
+ with Vertical(classes="column"):
+ with Container(classes="panel", id="source"):
+ yield Label("SOURCE · zkDSL · mapping ≈ approximate", classes="title")
+ yield Static(id="source_text")
+ with Container(classes="panel", id="instructions"):
+ yield Label("DISASSEMBLY · PC CONTEXT", classes="title")
+ yield RichLog(id="instruction_log", highlight=True, markup=True)
+ with Vertical(classes="column"):
+ with Container(classes="panel", id="transaction"):
+ yield Label("TRANSACTION · HOST → FPGA → HOST", classes="title")
+ yield Static(id="transaction_text")
+ with Container(classes="panel", id="memory"):
+ yield Label("VM MEMORY · exact u128 hex", classes="title")
+ yield RichLog(id="memory_log", highlight=True, markup=True)
+ with Container(classes="panel", id="events"):
+ yield Label("EVENT STREAM · execution only", classes="title")
+ yield RichLog(id="event_log", highlight=True, markup=True)
+ yield Input(placeholder="Search instruction, cell, op, or event…", id="searchbar")
+ yield Static("READY · SPACE run · S step · ? help", id="terminal")
+ yield Footer()
+
+ def on_mount(self) -> None:
+ self._timer = self.set_interval(.45, self._tick, pause=True)
+ self._render_all()
+ if self.auto_run:
+ self.playing = True
+ self._timer.resume()
+
+ def on_resize(self, event) -> None:
+ self.screen.set_class(event.size.width < 92, "narrow")
+
+ def _tick(self) -> None:
+ if not self.playing:
+ return
+ if not self.replay.advance():
+ self.playing = False
+ self._timer.pause()
+ self._render_all()
+
+ def _render_all(self) -> None:
+ ev, cursor = self.replay.evidence, self.replay.cursor
+ pc = ev.steps[cursor].pc if cursor < len(ev.steps) else cursor
+ state = "PAUSED" if not self.playing and self.replay.state == "RUNNING" else self.replay.state
+ if self.size.width < 92:
+ status = (
+ f"[#9aa3b2]PROGRAM ·[/] [#eee9df b]assert_set_xor_mul[/]\n"
+ f"[#9aa3b2]BACKEND ·[/] [#64d2c8 b]FPGA EVIDENCE[/] [#9aa3b2]LINK ·[/] replay · offline\n"
+ f"[#9aa3b2]STATE ·[/] [#f0c36a b]{state}[/] [#9aa3b2]STEP ·[/] [#eee9df b]{cursor} / 12[/] "
+ f"[#9aa3b2]PC ·[/] [#d5a6e6 b]{pc:02d}[/] [#9aa3b2]FP ·[/] {ev.fp}"
+ )
+ else:
+ status = (
+ f"[#9aa3b2]PROGRAM[/] [#eee9df b]assert_set_xor_mul[/] "
+ f"[#9aa3b2]BACKEND[/] [#64d2c8 b]FPGA EVIDENCE[/] "
+ f"[#9aa3b2]LINK[/] [#f2ae72]offline · replay[/]\n"
+ f"[#9aa3b2]STATE[/] [#f0c36a b]{state}[/] "
+ f"[#9aa3b2]STEP[/] [#eee9df b]{cursor} / 12[/] "
+ f"[#9aa3b2]PC[/] [#d5a6e6 b]{pc:02d}[/] [#9aa3b2]FP[/] {ev.fp}"
+ )
+ self.query_one("#status", Static).update(status)
+ source_lines = ev.source.splitlines()
+ approx = min(len(source_lines) - 1, 1 + (cursor * max(1, len(source_lines)-2) // 12))
+ source = "\n".join(
+ f"[#d5a6e6]▸[/] [reverse]{escape(line)}[/reverse]" if i == approx
+ else f" [#9aa3b2]{i+1:02d} │[/] {escape(line)}"
+ for i, line in enumerate(source_lines)
+ )
+ self.query_one("#source_text", Static).update(source)
+ ilog = self.query_one("#instruction_log", RichLog); ilog.clear()
+ for ins in ev.instructions[max(0, pc-3):min(len(ev.instructions), pc+5)]:
+ marker = "[#d5a6e6 b]▶[/]" if ins.pc == pc else " "
+ instruction = ins.text.split(maxsplit=1)[1]
+ ilog.write(
+ f"{marker} [on #44334f #fff4ff b] PC {ins.pc:02d} │ {escape(instruction)} [/]"
+ if ins.pc == pc else f"{marker} [#9aa3b2]PC {ins.pc:02d} │[/] {escape(instruction)}"
+ )
+ mlog = self.query_one("#memory_log", RichLog); mlog.clear()
+ active = ev.steps[cursor-1] if cursor else None
+ for address in range(12):
+ value = self.replay.memory.get(address, 0)
+ tag = "[on #5a451e #ffe2a1 b] READ [/]" if active and address in active.reads else (
+ "[on #214b2c #aaf5b4 b] WRITE[/]" if active and address in active.writes else " ")
+ exact = f"{value:032x}"
+ grouped = f"{exact[:8]} · {exact[8:16]} · {exact[-8:]}"
+ mlog.write(f"{tag} [#9aa3b2]m{address:02d}[/] {grouped}")
+ t = self.query_one("#transaction_text", Static)
+ if active:
+ def compact(wire: bytes) -> str:
+ value = wire.hex()
+ return f"{value[:8]} … {value[-8:]}" if len(value) > 20 else " ".join(
+ value[i:i+8] for i in range(0, len(value), 8))
+ exact_result = f"{active.result:032x}"
+ result = f"{exact_result[:12]} … {exact_result[-12:]}"
+ t.update(
+ f"[on #573721 #ffd1a8 b] HOST [/][#eee9df b] PREPARE[/] {active.kind}\n"
+ f" [#9aa3b2]TX · {len(active.request):02d} bytes[/] {compact(active.request)}\n"
+ f" [#9aa3b2]exact value: press I[/]\n"
+ f"[on #174b4a #a7fff6 b] FPGA [/][#eee9df b] COMPUTE[/] PC {active.pc:02d}\n"
+ f" [#9aa3b2]RX · {len(active.response):02d} bytes[/] {compact(active.response)}\n"
+ f"[on #573721 #ffd1a8 b] HOST [/][#eee9df b] VALIDATE[/] → [on #214b2c #aaf5b4 b] WRITE m{active.writes[0]:02d} [/]\n"
+ f" [#80d98b]0x{result}[/]"
+ )
+ else:
+ t.update("[on #573721 #ffd1a8 b] HOST [/][#9aa3b2] waiting[/]\n\n"
+ "[on #174b4a #a7fff6 b] FPGA [/][#9aa3b2] ready for replay[/]\n\n"
+ "[#9aa3b2]Step once to inspect the exact PC → FPGA → PC exchange.[/]")
+ elog = self.query_one("#event_log", RichLog); elog.clear()
+ actor_colors = {"HOST": "#d9a36c", "FPGA": "#70b8b0"}
+ labels = {
+ EventKind.PREPARE: "PREP", EventKind.UART_SEND: "TX",
+ EventKind.FPGA_COMPUTE: "COMPUTE", EventKind.RESPONSE: "RX",
+ EventKind.VALIDATE: "CHECK", EventKind.MEMORY_WRITE: "WRITE",
+ EventKind.HALT: "HALT", EventKind.ERROR: "FAULT",
+ }
+ for event in self.replay.events[-18:]:
+ color = "#f07b7b" if event.kind == EventKind.ERROR else actor_colors[event.actor]
+ message = event.message.replace("response ", "").replace("memory ", "")
+ message = message.replace(" bytes", " B").replace("write mem", "m")
+ message = message.removeprefix("prepare ").removeprefix("compute ")
+ if message.startswith("m["):
+ message = f"m{int(message[2:-1]):02d}"
+ if event.kind == EventKind.HALT:
+ message = "prefix matched"
+ actor_bg = "#573721" if event.actor == "HOST" else "#174b4a"
+ elog.write(f"[#9aa3b2]{event.pc:02d}[/] [on {actor_bg} {color} b] {event.actor:<4} [/]"
+ f" [#9aa3b2]{labels[event.kind]:<7} │[/] {escape(message)}")
+ term = self.query_one("#terminal", Static)
+ term.remove_class("complete", "error")
+ if self.replay.terminal:
+ term.update(f"[#80d98b b]PREFIX MATCH ✓[/] [#d7f4dc]expected execution prefix reproduced exactly[/]\n"
+ f"[#9aa3b2]evidence replay · no board[/] [#f0c36a]LIMITATION —[/] {ev.reason}")
+ term.add_class("complete" if self.replay.terminal.startswith("PREFIX") else "error")
+ else:
+ compact_nav = "SPACE run · S step · R restart · / search · ? help"
+ term.update(
+ f"[#f0c36a b]{state}[/] · {compact_nav}" if self.size.width < 92 else
+ f"[#f0c36a b]{state}[/] · SPACE run/pause · S step · R restart · +/- {self.speed:.1f}× · / search · I exact data · ? help"
+ )
+
+ def action_toggle(self) -> None:
+ if self.replay.terminal:
+ return
+ self.playing = not self.playing
+ self._timer.resume() if self.playing else self._timer.pause()
+ self._render_all()
+
+ def action_step(self) -> None:
+ self.playing = False; self._timer.pause()
+ self.replay.advance(); self._render_all()
+
+ def action_restart(self) -> None:
+ self.playing = False; self._timer.pause()
+ self.replay.restart(); self._render_all()
+
+ def action_faster(self) -> None:
+ self.speed = min(8.0, self.speed * 2); self._timer.interval = .45 / self.speed
+ def action_slower(self) -> None:
+ self.speed = max(.25, self.speed / 2); self._timer.interval = .45 / self.speed
+ def action_search(self) -> None:
+ box = self.query_one("#searchbar", Input); box.add_class("visible"); box.focus()
+ def action_help(self) -> None:
+ self.notify("SPACE run/pause · S step · R restart · +/- speed · / search · I inspect · TAB panels · Ctrl+P commands · Q quit",
+ title="Keyboard help", timeout=8)
+ def action_inspect(self) -> None:
+ if not self.replay.cursor:
+ self.notify("No transaction has executed yet.", title="Inspector")
+ return
+ step = self.replay.evidence.steps[self.replay.cursor - 1]
+ self.notify(
+ f"PC {step.pc} {step.kind}\nTX {step.request.hex()}\nRX {step.response.hex()}\n"
+ f"m[{step.writes[0]}] = 0x{step.result:032x}",
+ title="Exact transaction", timeout=10,
+ )
+
+ def on_input_submitted(self, event: Input.Submitted) -> None:
+ query = event.value.lower().strip()
+ event_matches = [e for e in self.replay.events if query in e.message.lower() or query in e.kind.value]
+ instruction_matches = [i for i in self.replay.evidence.instructions
+ if query in i.text.lower() or query == str(i.pc)]
+ memory_matches = [address for address, value in self.replay.memory.items()
+ if query in {str(address), f"m{address}", f"m[{address}]"} or query in f"{value:032x}"]
+ self.notify(f"{len(instruction_matches)} instructions · {len(memory_matches)} cells · "
+ f"{len(event_matches)} events",
+ title=f"Search: {query or 'all'}")
+ event.input.remove_class("visible"); self.set_focus(None)
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(description="Silicon Live FPGA execution dashboard")
+ parser.add_argument("--demo", action="store_true", help="animate the committed 12-step FPGA evidence")
+ parser.add_argument("--evidence", type=Path, help="compatible program-run.json")
+ args = parser.parse_args(argv)
+ if not args.demo and not args.evidence:
+ parser.error("choose --demo or --evidence PATH")
+ SiliconLive(args.evidence, auto_run=True).run()
+ return 0
diff --git a/silicon_live/docs/VISUAL_QA.md b/silicon_live/docs/VISUAL_QA.md
new file mode 100644
index 0000000..3dfc05d
--- /dev/null
+++ b/silicon_live/docs/VISUAL_QA.md
@@ -0,0 +1,34 @@
+# Visual QA log
+
+All captures below were produced by separate real Textual `run_test` pilot
+launches and `App.save_screenshot`; none are mockups. PNGs are rendered from
+those exact SVG captures. This log records the defects found during three
+capture/revise passes, not merely artifact existence.
+
+1. **Initial shell** — `docs/screenshots/01-initial-shell.svg`
+ Checked hierarchy and idle-state density. Kept the persistent status strip,
+ quiet muted metadata, and empty transaction state so the first frame
+ communicates program/backend/connection before animation.
+2. **Active middle step** — `docs/screenshots/02-active-middle-step.svg`
+ Pass 1 exposed near-monochrome actors, concatenated `HOSTprepare` rows,
+ orphaned UART hex, and an event stream wider than the focal transaction.
+ Pass 2 added high-contrast HOST/FPGA pills, fixed label columns, bounded
+ prefix/suffix wire previews with exact data available via `I`, and reduced
+ the event panel. Pass 3 strengthened the violet current-PC selection and
+ PC→FPGA→PC card border. READ is amber and WRITE is green.
+3. **Narrow terminal** — `docs/screenshots/03-narrow-terminal.svg`
+ Pass 1 clipped the command strip (`Sear…`) and produced awkward panel
+ overflow. Pass 2 hid the redundant Textual footer below 92 columns, added a
+ compact command line, and tightened panel heights. Pass 3 verified deliberate
+ source/disassembly → transaction/memory → recent-events order, spaced status
+ rows, and stable abbreviated UART previews at 76×48.
+4. **Final prefix match** — `docs/screenshots/04-prefix-match.svg`
+ Pass 1 left the outcome as a low-salience footer sentence. Pass 2 promoted it
+ to a bordered green result card. Pass 3 verified the explicit
+ `evidence replay · no board` qualifier and amber unsupported-Jump limitation
+ remain visible. The exact result is `PREFIX MATCH ✓`; no PASS label appears.
+
+Palette: `#101217` background, `#181b22` surfaces, `#eee9df` primary text,
+`#9aa3b2` metadata, `#f2ae72` HOST, `#64d2c8` FPGA, `#80d98b` success/write,
+`#f0c36a` read/warning, `#f07b7b` faults, and `#d5a6e6` PC/focus. Semantic color
+is deliberately limited to avoid rainbow noise.
diff --git a/silicon_live/docs/screenshots/01-initial-shell.png b/silicon_live/docs/screenshots/01-initial-shell.png
new file mode 100644
index 0000000..0651316
Binary files /dev/null and b/silicon_live/docs/screenshots/01-initial-shell.png differ
diff --git a/silicon_live/docs/screenshots/01-initial-shell.svg b/silicon_live/docs/screenshots/01-initial-shell.svg
new file mode 100644
index 0000000..74fd809
--- /dev/null
+++ b/silicon_live/docs/screenshots/01-initial-shell.svg
@@ -0,0 +1,244 @@
+
diff --git a/silicon_live/docs/screenshots/02-active-middle-step.png b/silicon_live/docs/screenshots/02-active-middle-step.png
new file mode 100644
index 0000000..6460c42
Binary files /dev/null and b/silicon_live/docs/screenshots/02-active-middle-step.png differ
diff --git a/silicon_live/docs/screenshots/02-active-middle-step.svg b/silicon_live/docs/screenshots/02-active-middle-step.svg
new file mode 100644
index 0000000..b13c3a0
--- /dev/null
+++ b/silicon_live/docs/screenshots/02-active-middle-step.svg
@@ -0,0 +1,248 @@
+
diff --git a/silicon_live/docs/screenshots/03-narrow-terminal.png b/silicon_live/docs/screenshots/03-narrow-terminal.png
new file mode 100644
index 0000000..6e9dc73
Binary files /dev/null and b/silicon_live/docs/screenshots/03-narrow-terminal.png differ
diff --git a/silicon_live/docs/screenshots/03-narrow-terminal.svg b/silicon_live/docs/screenshots/03-narrow-terminal.svg
new file mode 100644
index 0000000..4bb41ee
--- /dev/null
+++ b/silicon_live/docs/screenshots/03-narrow-terminal.svg
@@ -0,0 +1,268 @@
+
diff --git a/silicon_live/docs/screenshots/04-prefix-match.png b/silicon_live/docs/screenshots/04-prefix-match.png
new file mode 100644
index 0000000..6117014
Binary files /dev/null and b/silicon_live/docs/screenshots/04-prefix-match.png differ
diff --git a/silicon_live/docs/screenshots/04-prefix-match.svg b/silicon_live/docs/screenshots/04-prefix-match.svg
new file mode 100644
index 0000000..626fe4f
--- /dev/null
+++ b/silicon_live/docs/screenshots/04-prefix-match.svg
@@ -0,0 +1,252 @@
+
diff --git a/silicon_live/model.py b/silicon_live/model.py
new file mode 100644
index 0000000..84a63a5
--- /dev/null
+++ b/silicon_live/model.py
@@ -0,0 +1,194 @@
+"""UI-independent execution model and structured event vocabulary."""
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from enum import Enum
+from pathlib import Path
+from typing import Any
+import json
+
+MASK128 = (1 << 128) - 1
+ROOT = Path(__file__).resolve().parents[1]
+DEFAULT_EVIDENCE = ROOT / "results/fpga-lsc1-20260726/program-run.json"
+DEFAULT_PROGRAM = ROOT / "host/fixtures/assert_set_xor_mul.program.json"
+
+
+class EventKind(str, Enum):
+ PREPARE = "prepare"
+ UART_SEND = "uart_send"
+ FPGA_COMPUTE = "fpga_compute"
+ RESPONSE = "response"
+ VALIDATE = "validate"
+ MEMORY_WRITE = "memory_write"
+ HALT = "halt"
+ ERROR = "error"
+
+
+@dataclass(frozen=True)
+class Event:
+ kind: EventKind
+ pc: int
+ actor: str
+ message: str
+ data: dict[str, Any] = field(default_factory=dict)
+
+
+@dataclass(frozen=True)
+class Instruction:
+ pc: int
+ op: str
+ text: str
+ reads: tuple[int, ...]
+ writes: tuple[int, ...]
+
+
+@dataclass(frozen=True)
+class Step:
+ pc: int
+ kind: str
+ addresses: tuple[int, ...]
+ inputs: tuple[int, ...]
+ result: int
+ request: bytes
+ response: bytes
+
+ @property
+ def reads(self) -> tuple[int, ...]:
+ return self.addresses[:-1] if self.kind != "Set" else ()
+
+ @property
+ def writes(self) -> tuple[int, ...]:
+ return self.addresses[-1:]
+
+
+@dataclass
+class ProgramEvidence:
+ source_path: str
+ source: str
+ instructions: list[Instruction]
+ steps: list[Step]
+ expected_memory: list[int]
+ result: str
+ reason: str
+ fp: int
+
+
+def _value(raw: str | int) -> int:
+ value = int(raw, 0) if isinstance(raw, str) else raw
+ if not 0 <= value <= MASK128:
+ raise ValueError("VM cell is not an unsigned 128-bit value")
+ return value
+
+
+def load_evidence(evidence_path: Path = DEFAULT_EVIDENCE,
+ program_path: Path = DEFAULT_PROGRAM) -> ProgramEvidence:
+ run = json.loads(evidence_path.read_text())
+ artifact = json.loads(program_path.read_text())
+ bytecode = artifact["program"]["bytecode"]
+ lines = artifact["program"]["disassembly"].splitlines()
+ instructions = []
+ for op, text in zip(bytecode, lines):
+ kind = op["op"]
+ if kind in {"Xor", "Mul"}:
+ reads, writes = (op["a"], op["b"]), (op["c"],)
+ elif kind == "Set":
+ reads, writes = (), (op["o"],)
+ else:
+ reads, writes = (op.get("oc", 0), op.get("od", 0)), (op.get("of", 0),)
+ instructions.append(Instruction(op["index"], kind, text.strip(), reads, writes))
+ steps = [
+ Step(
+ pc=s["pc"], kind=s["kind"], addresses=tuple(s["addresses"]),
+ inputs=tuple(_value(v) for v in s["inputs"]), result=_value(s["result"]),
+ request=b"", response=bytes.fromhex(s["response_hex"]),
+ )
+ for s in run["steps"]
+ ]
+ # Reconstruct request bytes through the reviewed operation grammar. Evidence
+ # hashes remain visible in the raw artifact; the UI displays deterministic bytes.
+ from .transport import encode_request
+ steps = [
+ Step(s.pc, s.kind, s.addresses, s.inputs, s.result,
+ encode_request(s.kind, s.inputs), s.response) for s in steps
+ ]
+ return ProgramEvidence(
+ artifact["source"]["path"], artifact["source"]["text"], instructions, steps,
+ [_value(v) for v in run["memory"]], run["comparison"]["result"],
+ run["reason"], run["fp"],
+ )
+
+
+def events_for(step: Step) -> list[Event]:
+ common = {"kind": step.kind, "reads": step.reads, "writes": step.writes}
+ expected = step.result.to_bytes(16, "little")
+ valid = step.response == expected
+ events = [
+ Event(EventKind.PREPARE, step.pc, "HOST", f"prepare {step.kind}", common),
+ Event(EventKind.UART_SEND, step.pc, "HOST", f"send {len(step.request)} bytes",
+ {**common, "request": step.request.hex()}),
+ Event(EventKind.FPGA_COMPUTE, step.pc, "FPGA", f"compute {step.kind}", common),
+ Event(EventKind.RESPONSE, step.pc, "FPGA", f"return {len(step.response)} bytes",
+ {**common, "response": step.response.hex()}),
+ Event(EventKind.VALIDATE, step.pc, "HOST",
+ "response matches" if valid else "response mismatch",
+ {**common, "valid": valid}),
+ ]
+ if valid:
+ events.append(Event(EventKind.MEMORY_WRITE, step.pc, "HOST",
+ f"write mem[{step.writes[0]}]", {**common, "value": step.result}))
+ else:
+ events.append(Event(EventKind.ERROR, step.pc, "HOST", "hardware mismatch", common))
+ return events
+
+
+def fault_event(error: BaseException, pc: int) -> Event:
+ """Normalize transport failures for every UI and adapter."""
+ label = "TIMEOUT" if isinstance(error, TimeoutError) else (
+ "PROTOCOL FAULT" if isinstance(error, (ValueError, UnicodeError)) else "TRANSPORT FAULT"
+ )
+ return Event(EventKind.ERROR, pc, "HOST", f"{label}: {error}",
+ {"fault": label.lower().replace(" ", "_")})
+
+
+@dataclass
+class Replay:
+ evidence: ProgramEvidence
+ cursor: int = 0
+ memory: dict[int, int] = field(default_factory=lambda: {0: 1, 1: 0})
+ events: list[Event] = field(default_factory=list)
+ state: str = "READY"
+ terminal: str = ""
+
+ def restart(self) -> None:
+ self.cursor, self.memory, self.events = 0, {0: 1, 1: 0}, []
+ self.state, self.terminal = "READY", ""
+
+ def advance(self) -> bool:
+ if self.cursor >= len(self.evidence.steps):
+ self.finish()
+ return False
+ step = self.evidence.steps[self.cursor]
+ generated = events_for(step)
+ self.events.extend(generated)
+ mismatch = next((e for e in generated if e.kind == EventKind.ERROR), None)
+ if mismatch:
+ self.state, self.terminal = "ERROR", "MISMATCH"
+ return False
+ self.memory[step.writes[0]] = step.result
+ self.cursor += 1
+ self.state = "RUNNING"
+ if self.cursor == len(self.evidence.steps):
+ self.finish()
+ return True
+
+ def finish(self) -> None:
+ actual = [self.memory.get(i, 0) for i in range(len(self.evidence.expected_memory))]
+ if self.evidence.result == "PREFIX_MATCH" and actual == self.evidence.expected_memory:
+ self.state, self.terminal = "COMPLETE", "PREFIX MATCH ✓"
+ message = f"validated {len(self.evidence.steps)}-step hardware prefix"
+ self.events.append(Event(EventKind.HALT, self.cursor, "HOST", message,
+ {"reason": self.evidence.reason, "prefix": True}))
+ else:
+ self.state, self.terminal = "ERROR", "MISMATCH"
+ self.events.append(Event(EventKind.ERROR, self.cursor, "HOST",
+ "evidence memory mismatch", {}))
diff --git a/silicon_live/pyproject.toml b/silicon_live/pyproject.toml
new file mode 100644
index 0000000..c73f018
--- /dev/null
+++ b/silicon_live/pyproject.toml
@@ -0,0 +1,14 @@
+[build-system]
+requires = ["setuptools>=68"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "silicon-live"
+version = "0.1.0"
+description = "Evidence-first LeanSilicon Textual dashboard"
+requires-python = ">=3.10"
+dependencies = ["textual>=0.79,<2", "rich>=13.7"]
+
+[tool.pytest.ini_options]
+asyncio_mode = "auto"
+testpaths = ["tests"]
diff --git a/silicon_live/requirements.txt b/silicon_live/requirements.txt
new file mode 100644
index 0000000..ed10e7e
--- /dev/null
+++ b/silicon_live/requirements.txt
@@ -0,0 +1,3 @@
+textual>=0.79,<2
+rich>=13.7
+pytest>=8
diff --git a/silicon_live/tests/capture_screenshots.py b/silicon_live/tests/capture_screenshots.py
new file mode 100644
index 0000000..3fecfc3
--- /dev/null
+++ b/silicon_live/tests/capture_screenshots.py
@@ -0,0 +1,40 @@
+"""Generate deterministic, committed visual-QA artifacts with real Textual pilots."""
+from __future__ import annotations
+import asyncio
+import os
+from pathlib import Path
+import subprocess
+
+os.environ.pop("NO_COLOR", None)
+os.environ["TERM"] = "xterm-256color"
+os.environ["COLORTERM"] = "truecolor"
+
+from silicon_live.app import SiliconLive
+
+OUT = Path(__file__).resolve().parents[1] / "docs/screenshots"
+
+
+async def capture(name: str, size: tuple[int, int], steps: int) -> None:
+ app = SiliconLive(auto_run=False)
+ async with app.run_test(size=size) as pilot:
+ for _ in range(steps):
+ app.action_step()
+ await pilot.pause()
+ svg = Path(app.save_screenshot(filename=f"{name}.svg", path=str(OUT)))
+ subprocess.run(
+ ["rsvg-convert", str(svg), "-o", str(svg.with_suffix(".png"))],
+ check=True,
+ )
+
+
+async def main() -> None:
+ OUT.mkdir(parents=True, exist_ok=True)
+ # Separate app/pilot launches ensure mount, layout, and rendering are all real.
+ await capture("01-initial-shell", (140, 42), 0)
+ await capture("02-active-middle-step", (140, 42), 6)
+ await capture("03-narrow-terminal", (76, 48), 7)
+ await capture("04-prefix-match", (140, 42), 12)
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/silicon_live/tests/test_model.py b/silicon_live/tests/test_model.py
new file mode 100644
index 0000000..92ac699
--- /dev/null
+++ b/silicon_live/tests/test_model.py
@@ -0,0 +1,97 @@
+from pathlib import Path
+import asyncio
+import pytest
+
+from silicon_live.app import SiliconLive
+from silicon_live.model import EventKind, MASK128, Replay, events_for, fault_event, load_evidence
+from silicon_live.transport import FakeSerial, HostRuntimeTransport, encode_request
+
+
+def test_loader_preserves_full_u128(tmp_path: Path):
+ evidence = load_evidence()
+ assert evidence.steps[0].result == 3
+ assert all(0 <= value <= MASK128 for value in evidence.expected_memory)
+ assert len(evidence.steps[0].response) == 16
+
+
+def test_structured_event_pipeline():
+ events = events_for(load_evidence().steps[2])
+ assert [e.kind for e in events] == [
+ EventKind.PREPARE, EventKind.UART_SEND, EventKind.FPGA_COMPUTE,
+ EventKind.RESPONSE, EventKind.VALIDATE, EventKind.MEMORY_WRITE,
+ ]
+ assert {e.actor for e in events} == {"HOST", "FPGA"}
+
+
+def test_replay_prefix_not_pass():
+ replay = Replay(load_evidence())
+ while replay.advance():
+ pass
+ assert replay.terminal == "PREFIX MATCH ✓"
+ assert "PASS" not in replay.terminal
+ assert replay.cursor == 12
+ assert replay.memory[11] == 0x8000
+
+
+def test_mismatch_is_error():
+ evidence = load_evidence()
+ step = evidence.steps[0]
+ evidence.steps[0] = type(step)(step.pc, step.kind, step.addresses, step.inputs,
+ step.result, step.request, b"bad")
+ replay = Replay(evidence)
+ assert replay.advance() is False
+ assert replay.terminal == "MISMATCH"
+
+
+def test_timeout_and_protocol_fault_events_are_explicit():
+ assert fault_event(TimeoutError("late"), 4).message.startswith("TIMEOUT:")
+ assert fault_event(ValueError("bad frame"), 4).message.startswith("PROTOCOL FAULT:")
+
+
+def test_fake_serial_and_request():
+ fake = FakeSerial(b"\x01" * 16)
+ request = encode_request("Set", (3,))
+ assert fake.write(request) == 17
+ assert request[0] == 0x03
+ assert fake.read(16) == b"\x01" * 16
+ assert bytes(fake.written) == request
+
+
+def test_xor_request_uses_historical_interleaving():
+ request = encode_request("Xor", (0x0102, 0x0304))
+ assert len(request) == 33 and request[0] == 0x01
+ assert request[1:5] == bytes([0x02, 0x04, 0x01, 0x03])
+
+
+def test_host_runtime_adapter_preserves_128_bits():
+ model = HostRuntimeTransport()
+ maximum = MASK128.to_bytes(16, "little")
+ request, response = model.exchange("xor", a=maximum, b=b"\0" * 16)
+ assert len(request) == 33
+ assert response == maximum
+
+
+@pytest.mark.asyncio
+async def test_pilot_navigation_progression_and_narrow():
+ app = SiliconLive(auto_run=False)
+ async with app.run_test(size=(120, 40)) as pilot:
+ await pilot.press("s", "s")
+ assert app.replay.cursor == 2
+ await pilot.press("+")
+ assert app.speed == 2.0
+ assert app._timer.interval == pytest.approx(.225)
+ await pilot.resize_terminal(76, 42)
+ await pilot.pause()
+ assert app.screen.has_class("narrow")
+ await pilot.press("r")
+ assert app.replay.cursor == 0
+
+
+@pytest.mark.asyncio
+async def test_demo_auto_animates_to_prefix_match():
+ app = SiliconLive(auto_run=True)
+ async with app.run_test(size=(120, 40)) as pilot:
+ await asyncio.sleep(6.0)
+ await pilot.pause()
+ assert app.replay.cursor == 12
+ assert app.replay.terminal == "PREFIX MATCH ✓"
diff --git a/silicon_live/transport.py b/silicon_live/transport.py
new file mode 100644
index 0000000..9b916c5
--- /dev/null
+++ b/silicon_live/transport.py
@@ -0,0 +1,64 @@
+"""Transport adapters. Importing this module never opens or enumerates a port."""
+from __future__ import annotations
+from dataclasses import dataclass
+from typing import Protocol
+
+
+def encode_request(kind: str, inputs: tuple[int, ...]) -> bytes:
+ """Encode the exact historical candidate framing (no later ABORT prefix)."""
+ from fpga_harness.ulx3s_uart import encode_request as pr16_encode
+ values = [v.to_bytes(16, "little") for v in inputs]
+ if kind == "Set":
+ return pr16_encode("set", value=values[0], include_resync=False)
+ return pr16_encode(kind.lower(), a=values[0], b=values[1], include_resync=False)
+
+
+class ArithmeticTransport(Protocol):
+ def exchange(self, operation: str, *, a: bytes = b"", b: bytes = b"",
+ value: bytes = b"") -> tuple[bytes, bytes]: ...
+
+
+class HostRuntimeTransport:
+ """Board-free arithmetic model with the same exchange boundary as PR #16."""
+
+ def exchange(self, operation: str, *, a: bytes = b"", b: bytes = b"",
+ value: bytes = b"") -> tuple[bytes, bytes]:
+ from host.protocol import protocol
+ if operation == "set":
+ inputs, response = (int.from_bytes(value, "little"),), value
+ elif operation in {"xor", "mul"}:
+ left, right = int.from_bytes(a, "little"), int.from_bytes(b, "little")
+ result = left ^ right if operation == "xor" else protocol.field_mul(left, right)
+ inputs, response = (left, right), result.to_bytes(16, "little")
+ else:
+ raise ValueError(f"host model does not implement {operation}")
+ request = encode_request(operation.title(), inputs)
+ return request, response
+
+
+@dataclass
+class FakeSerial:
+ """Small pyserial-compatible fake used by adapter tests and demos."""
+ response: bytes
+ written: bytearray = None # type: ignore[assignment]
+
+ def __post_init__(self) -> None:
+ self.written = bytearray()
+
+ def write(self, data: bytes) -> int:
+ self.written.extend(data)
+ return len(data)
+
+ def read(self, size: int) -> bytes:
+ chunk, self.response = self.response[:size], self.response[size:]
+ return chunk
+
+
+class LiveFPGATransport:
+ """Explicit wrapper around PR #16; caller supplies an already-open serial object."""
+ def __init__(self, serial_object: object, timeout: float = 1.0) -> None:
+ from fpga_harness.ulx3s_uart import MinCoreSerialDriver
+ self._driver = MinCoreSerialDriver(serial_object, timeout)
+
+ def exchange(self, operation: str, **values: bytes) -> tuple[bytes, bytes]:
+ return self._driver.exchange(operation, **values)