= ({ size, delay = 0, stagger = 2.4, tile = true, glow = true, alpha = 1 }) => {
+ const frame = useCurrentFrame();
+ const { fps } = useVideoConfig();
+ const chip = spring({ frame: frame - delay, fps, config: theme.spring.smooth });
+ const pins = interpolate(frame, [delay + 4, delay + 22], [0, 1], { ...clampOpts, easing: theme.ease.out });
+ const s = size / 512;
+ const breathe = 1 + Math.sin(frame / 26) * 0.012;
+ return (
+
+
+ {tile && }
+
+ {PINS.map((p, i) => {
+ const k = interpolate(pins, [0, 1], [0, 1]);
+ const d = (a: number, b: number) => a + (b - a) * k;
+ return (
+
+
+
+
+
+
+ );
+ })}
+
+
+
+ {ROW_Y.map((y, r) =>
+ [0, 1, 2, 3, 4].map((c) => {
+ const p = spring({ frame: frame - delay - 8 - (r + c) * stagger, fps, config: theme.spring.snappy });
+ const h = BAR_H * p;
+ const x = (ROW_X0[r] as number) + c * STEP;
+ return (
+
+ );
+ }),
+ )}
+
+ {/* keep the unused scale referenced for tooling */}
+
{s}
+
+ );
+};
diff --git a/video/src/fx/motion.ts b/video/src/fx/motion.ts
new file mode 100644
index 0000000..a627971
--- /dev/null
+++ b/video/src/fx/motion.ts
@@ -0,0 +1,29 @@
+import { interpolate, spring } from "remotion";
+import { clampOpts, theme } from "../theme";
+
+/** Eased 0..1 progress between two frames (clamped). */
+export const prog = (frame: number, from: number, to: number, easing = theme.ease.out) =>
+ interpolate(frame, [from, to], [0, 1], { ...clampOpts, easing });
+
+/** Linear map of an eased progress into [a, b]. */
+export const lerp = (p: number, a: number, b: number) => a + (b - a) * p;
+
+export const springAt = (frame: number, fps: number, delay: number, config: object = theme.spring.smooth) =>
+ spring({ frame: frame - delay, fps, config });
+
+/** Keyframed value: [[frame, value], ...] with eased interpolation between neighbours. */
+export function keys(frame: number, frames: [number, number][], easing = theme.ease.inOut): number {
+ const f0 = frames[0];
+ const fl = frames[frames.length - 1];
+ if (!f0 || !fl) return 0;
+ if (frame <= f0[0]) return f0[1];
+ if (frame >= fl[0]) return fl[1];
+ for (let i = 0; i < frames.length - 1; i++) {
+ const a = frames[i] as [number, number];
+ const b = frames[i + 1] as [number, number];
+ if (frame >= a[0] && frame <= b[0]) {
+ return interpolate(frame, [a[0], b[0]], [a[1], b[1]], { ...clampOpts, easing });
+ }
+ }
+ return fl[1];
+}
diff --git a/video/src/fx/primitives.tsx b/video/src/fx/primitives.tsx
new file mode 100644
index 0000000..51346e0
--- /dev/null
+++ b/video/src/fx/primitives.tsx
@@ -0,0 +1,120 @@
+import React from "react";
+import { interpolate, useCurrentFrame, useVideoConfig } from "remotion";
+import { clampOpts, theme } from "../theme";
+import { prog, springAt } from "./motion";
+
+/** Fade + rise + scale entrance (never a lone fade). `delay` in frames. */
+export const Entrance: React.FC<{ delay?: number; from?: number; style?: React.CSSProperties; children: React.ReactNode; spring?: object }> = ({
+ delay = 0,
+ from = 36,
+ style,
+ children,
+ spring: cfg = theme.spring.smooth,
+}) => {
+ const frame = useCurrentFrame();
+ const { fps } = useVideoConfig();
+ const p = springAt(frame, fps, delay, cfg);
+ return (
+
+ {children}
+
+ );
+};
+
+/** Wrap a scene: it slides up and fades over the last `exit` frames (faster than the entrance). */
+export const SceneExit: React.FC<{ durationInFrames: number; exit?: number; children: React.ReactNode }> = ({ durationInFrames, exit = 9, children }) => {
+ const frame = useCurrentFrame();
+ const p = interpolate(frame, [durationInFrames - exit, durationInFrames - 1], [0, 1], { ...clampOpts, easing: theme.ease.in });
+ return (
+ {children}
+ );
+};
+
+/**
+ * Words that land at explicit frames (so they can sync with the voiceover). Each word rises out of its own
+ * mask, the way a line of type is revealed in a studio title. No blur, no glow.
+ */
+export const SyncedWords: React.FC<{
+ words: { text: string; at: number; accent?: boolean }[];
+ size: number;
+ gap?: number;
+ style?: React.CSSProperties;
+ justify?: "center" | "flex-start";
+}> = ({ words, size, gap = 26, style, justify = "center" }) => {
+ const frame = useCurrentFrame();
+ const { fps } = useVideoConfig();
+ return (
+
+ {words.map((w, i) => {
+ const p = springAt(frame, fps, w.at, theme.spring.snappy);
+ return (
+
+
+ {w.text}
+
+
+ );
+ })}
+
+ );
+};
+
+/** Section label chip in the app's own badge style (rounded-full, mono). */
+export const Chip: React.FC<{ children: React.ReactNode; tone?: "accent" | "ok" | "warn" | "err" | "muted"; size?: number; style?: React.CSSProperties }> = ({
+ children,
+ tone = "muted",
+ size = 22,
+ style,
+}) => {
+ const color = tone === "muted" ? theme.colors.muted : theme.colors[tone];
+ return (
+
+ {children}
+
+ );
+};
+
+/** Number that counts up with an eased curve, tabular figures so it never jitters. */
+export const CountUp: React.FC<{ to: number; from?: number; start: number; end: number; format?: (n: number) => string; style?: React.CSSProperties }> = ({
+ to,
+ from = 0,
+ start,
+ end,
+ format = (n) => Math.round(n).toLocaleString("en-US"),
+ style,
+}) => {
+ const frame = useCurrentFrame();
+ const p = prog(frame, start, end, theme.ease.out);
+ return {format(from + (to - from) * p)} ;
+};
diff --git a/video/src/index.css b/video/src/index.css
new file mode 100644
index 0000000..bff92d3
--- /dev/null
+++ b/video/src/index.css
@@ -0,0 +1,39 @@
+@import "tailwindcss";
+@import "@fontsource-variable/inter";
+@import "@fontsource-variable/jetbrains-mono";
+
+/* The real app's components (web/src/components) are styled with Tailwind utility classes. Scan them. */
+@source "../../web/src/components";
+
+/* Typography: Inter for UI text (same as the app), JetBrains Mono instead of the OS monospace for code and numbers. */
+@theme {
+ --font-sans: "Inter Variable", ui-sans-serif, system-ui, sans-serif;
+ --font-mono: "JetBrains Mono Variable", ui-monospace, SFMono-Regular, Menlo, monospace;
+}
+
+/* Tokens copied from web/src/styles/global.css (dark theme). One source of truth in spirit: if the app changes, change here. */
+:root {
+ --bg: #0d1117;
+ --fg: #c9d1d9;
+ --muted: #8b949e;
+ --accent: #58a6ff;
+ --ok: #3fb950;
+ --err: #f85149;
+ --panel: #161b22;
+ --line: #30363d;
+ --active: #1f2d3d;
+ --warn: #d29922;
+}
+
+html,
+body {
+ margin: 0;
+ background: #0d1117;
+ font-family: "Inter Variable", system-ui, sans-serif;
+ -webkit-font-smoothing: antialiased;
+ font-feature-settings: "cv11", "ss03";
+}
+
+textarea {
+ font-family: inherit;
+}
diff --git a/video/src/index.ts b/video/src/index.ts
new file mode 100644
index 0000000..e78af8b
--- /dev/null
+++ b/video/src/index.ts
@@ -0,0 +1,4 @@
+import { registerRoot } from "remotion";
+import { Root } from "./Root";
+
+registerRoot(Root);
diff --git a/video/src/scenes/Cta.tsx b/video/src/scenes/Cta.tsx
new file mode 100644
index 0000000..802f138
--- /dev/null
+++ b/video/src/scenes/Cta.tsx
@@ -0,0 +1,116 @@
+import React from "react";
+import { AbsoluteFill, interpolate, useCurrentFrame, useVideoConfig } from "remotion";
+import { Cursor } from "../fx/Cursor";
+import { LogoMark } from "../fx/Logo";
+import { Chip, Entrance, SyncedWords } from "../fx/primitives";
+import { keys, prog, springAt } from "../fx/motion";
+import { lineEnd, lineStart, sceneLines, sceneStart, sec } from "../schedule";
+import { theme } from "../theme";
+
+const ID = "cta" as const;
+const L = (t: number) => sec(t - sceneStart(ID));
+
+export const Cta: React.FC = () => {
+ const frame = useCurrentFrame();
+ const { fps } = useVideoConfig();
+ const text = sceneLines(ID)[0]?.show ?? "";
+ const t0 = lineStart(ID, 0);
+ const d0 = lineEnd(ID, 0) - t0;
+ let idx = 0;
+ const words = text.split(" ").map((w) => {
+ const at = text.indexOf(w, idx);
+ idx = at + w.length;
+ return { text: w, at: L(t0 + (at / text.length) * d0 - 0.06), accent: w.startsWith("computer") };
+ });
+
+ const tOpen = L(lineStart(ID, 1));
+ const tClick = L(lineStart(ID, 1) + 1.15);
+ const tUrl = tClick + 6;
+ const tFirst = L(lineStart(ID, 2));
+ const urlChars = "tiny-cpu.vercel.app";
+ const typed = Math.round(interpolate(frame, [tUrl, tUrl + 26], [0, urlChars.length], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }));
+ const press = frame >= tClick && frame < tClick + 14;
+ const btn = springAt(frame, fps, tOpen, theme.spring.bouncy);
+ const cursorX = keys(frame, [[tOpen - 14, 1500], [tClick - 4, 962]], theme.ease.soft);
+ const cursorY = keys(frame, [[tOpen - 14, 880], [tClick - 4, 702]], theme.ease.soft);
+ const cursorO = prog(frame, tOpen - 14, tOpen - 4) * (1 - prog(frame, tClick + 30, tClick + 46));
+ const cyc = 1 + Math.floor(Math.max(0, frame - tFirstLocal(tFirst)) / 12);
+ const blink = Math.floor(frame / 15) % 2 === 0;
+ const first = prog(frame, tFirst, tFirst + 16);
+
+ return (
+
+
+
+
+
+
+
+
+
+ {/* the app's primary button, scaled up: this is exactly the "Open the CPU" link on the landing page */}
+
+
+
+
+ https://
+ {urlChars.slice(0, typed)}
+
+
+
+
+
+
+
+ ▸ cycle {String(cyc).padStart(4, "0")}
+
+ free · open source · no install
+ github.com/tinycomputers/tiny-cpu
+
+
+
+ = tClick ? frame - tClick : undefined} opacity={cursorO} />
+
+ );
+};
+
+const tFirstLocal = (t: number) => t;
diff --git a/video/src/scenes/Hook.tsx b/video/src/scenes/Hook.tsx
new file mode 100644
index 0000000..c51d88c
--- /dev/null
+++ b/video/src/scenes/Hook.tsx
@@ -0,0 +1,152 @@
+import React from "react";
+import { AbsoluteFill, interpolate, useCurrentFrame, useVideoConfig } from "remotion";
+import { LogoMark } from "../fx/Logo";
+import { SceneExit, SyncedWords } from "../fx/primitives";
+import { prog, springAt } from "../fx/motion";
+import { lineEnd, lineStart, sceneFrames, sceneLines, sceneStart, sec } from "../schedule";
+import { theme } from "../theme";
+
+const ID = "hook" as const;
+const local = (t: number) => sec(t - sceneStart(ID));
+
+/** Square clock wave scrolling left; each rising edge brightens the marker and advances the cycle counter. */
+const ClockWave: React.FC<{ opacity: number }> = ({ opacity }) => {
+ const frame = useCurrentFrame();
+ const period = 200;
+ const amp = 44;
+ const speed = 5.2;
+ const shift = (frame * speed) % period;
+ const pts: string[] = [];
+ for (let x = -period; x <= 1920 + period; x += period) {
+ const a = x - shift;
+ pts.push(`${a},${amp} ${a},0 ${a + period / 2},0 ${a + period / 2},${amp}`);
+ }
+ const cycles = Math.floor((frame * speed) / period);
+ const nearest = (960 + shift) % period;
+ const flash = Math.max(0, 1 - Math.min(nearest, period - nearest) / 26);
+ return (
+
+
+
+ CLK · CYCLE
+ {String(1 + cycles).padStart(4, "0")}
+
+
+ );
+};
+
+const STAGES = ["IF", "ID", "EX", "MEM", "WB"];
+
+export const Hook: React.FC = () => {
+ const frame = useCurrentFrame();
+ const { fps } = useVideoConfig();
+ const dur = sceneFrames(ID);
+ const line0 = sceneLines(ID)[0];
+ const t0 = lineStart(ID, 0);
+ const d0 = lineEnd(ID, 0) - t0;
+ const text = line0?.show ?? "";
+
+ // words land when they are spoken
+ let idx = 0;
+ const words = text.split(" ").map((w) => {
+ const at = text.indexOf(w, idx);
+ idx = at + w.length;
+ return { text: w, at: local(t0 + (at / text.length) * d0 - 0.06), accent: w.startsWith("think") };
+ });
+
+ const headlineOut = prog(frame, local(lineEnd(ID, 0)) + 4, local(lineEnd(ID, 0)) + 14, theme.ease.in);
+ const waveOut = 1 - prog(frame, local(lineStart(ID, 1)) - 4, local(lineStart(ID, 1)) + 10, theme.ease.in);
+ const waveIn = prog(frame, 0, 14);
+ const lockT = local(lineStart(ID, 1)) - 8; // logo begins just before "This is"
+ const wordT = local(lineStart(ID, 1) + 0.62); // wordmark on "TinyCPU"
+ const lock = springAt(frame, fps, lockT, theme.spring.smooth);
+ const wordmark = "TinyCPU".split("");
+ const tagIn = prog(frame, wordT + 14, wordT + 34);
+
+ return (
+
+
+
+
+ {/* headline */}
+
+
+ {/* five stage bars: the logo's colours, ticking along the clock */}
+
+ {STAGES.map((s, i) => {
+ const on = Math.floor(frame / 6) % 5 === i;
+ return (
+
+ );
+ })}
+
+
+ {/* lockup */}
+
+
+
+
+
+
+ {wordmark.map((ch, i) => {
+ const p = springAt(frame, fps, wordT + i * 2, theme.spring.snappy);
+ return (
+
+ {ch}
+
+ );
+ })}
+
+
+ A real RISC-V CPU, running in your browser
+
+
+
+
+
+ );
+};
diff --git a/video/src/scenes/Trust.tsx b/video/src/scenes/Trust.tsx
new file mode 100644
index 0000000..20b32f0
--- /dev/null
+++ b/video/src/scenes/Trust.tsx
@@ -0,0 +1,179 @@
+import React from "react";
+import { AbsoluteFill, interpolate, useCurrentFrame, useVideoConfig } from "remotion";
+import { RISCV_TESTS } from "../data/riscv-tests";
+import { Chip, CountUp, Entrance, SceneExit } from "../fx/primitives";
+import { prog, springAt } from "../fx/motion";
+import { lineStart, phraseTime, sceneFrames, sceneStart, sec } from "../schedule";
+import { theme } from "../theme";
+
+const ID = "trust" as const;
+const L = (t: number) => sec(t - sceneStart(ID));
+
+const CARD_W = 540;
+const CARD_H = 600;
+const TOP = 170;
+const XS = [110, 690, 1270];
+
+const BigNumber: React.FC<{ children: React.ReactNode; color?: string }> = ({ children, color = theme.colors.fgBright }) => (
+ {children}
+);
+
+const Card: React.FC<{ i: number; delay: number; out: number; children: React.ReactNode; active: number }> = ({ i, delay, out, children, active }) => {
+ const frame = useCurrentFrame();
+ const { fps } = useVideoConfig();
+ const p = springAt(frame, fps, delay, theme.spring.smooth);
+ return (
+ 0.05 ? theme.colors.ok : theme.colors.line}`,
+ boxShadow: "0 30px 70px -32px rgba(0,0,0,0.75)",
+ opacity: Math.min(1, p * 1.7) * (1 - out),
+ transform: `translateY(${interpolate(p, [0, 1], [60, 0]) - out * 30}px) scale(${interpolate(p, [0, 1], [0.94, 1]) * (1 - out * 0.04)})`,
+ padding: 44,
+ display: "flex",
+ flexDirection: "column",
+ }}
+ >
+ {children}
+
+ );
+};
+
+const Check: React.FC<{ progress: number; size: number }> = ({ progress, size }) => (
+
+
+
+
+);
+
+export const Trust: React.FC = () => {
+ const frame = useCurrentFrame();
+ const dur = sceneFrames(ID);
+ const t40 = L(phraseTime(ID, 0, "Forty") - 0.08);
+ const t260 = L(phraseTime(ID, 0, "Two hundred") - 0.08);
+ const tGold = L(phraseTime(ID, 0, "All matched") - 0.08);
+ const tFpga = L(lineStart(ID, 1) - 0.25);
+ const out = prog(frame, tFpga, tFpga + 12, theme.ease.in);
+
+ // FPGA fill: 4,958 of 5,280 logic cells, painted row by row
+ const COLS = 80;
+ const ROWS = 66;
+ const fill = prog(frame, tFpga + 16, tFpga + 74, theme.ease.inOut);
+ const used = Math.round(4958 * fill);
+ const fpgaIn = springAt(frame, useVideoConfig().fps, tFpga + 8, theme.spring.smooth);
+
+ return (
+
+
+ {/* A: 40 official tests */}
+
+ riscv-tests · rv32ui-p-*
+
+
+
+
+
+ official tests, all passing
+
+ {RISCV_TESTS.map((n, i) => {
+ const on = prog(frame, t40 + 4 + i * 1.2, t40 + 14 + i * 1.2);
+ return (
+
+ );
+ })}
+
+
+
+ {/* B: 260 random programs */}
+
+ forwarding on and off
+
+
+
+
+
+ random programs
+
+ {Array.from({ length: 260 }, (_, i) => {
+ const on = prog(frame, t260 + 2 + i * 0.22, t260 + 10 + i * 0.22);
+ return
;
+ })}
+
+
+
+ {/* C: golden model */}
+
+ lockstep, every retired instruction
+
+
+
+ Matches the
+
+ golden model
+
+
+
+ {["add a3, a1, a2", "lw a1, 0(t1)", "sw a2, 0(t1)", "bge a2, a1, 0x38", "addi t0, t0, 1"].map((t, i) => {
+ const on = prog(frame, tGold + 10 + i * 5, tGold + 22 + i * 5);
+ return (
+
+ {t}
+ RTL = ISS ✓
+
+ );
+ })}
+
+
+
+ {/* FPGA hero */}
+
+
+
iCE40UP5K · logic cells used
+
+ {Array.from({ length: COLS * ROWS }, (_, i) => {
+ const lit = i < used;
+ const real = i < 4958;
+ return
;
+ })}
+
+
+
+
+
+ real FPGA · synthesized with Yosys + nextpnr
+
+
+
+
+
+ / 5,280
+
+ logic cells: the whole pipelined core fits
+
+
+ {[
+ ["14.98 MHz", "post-route clock"],
+ ["17 / 30", "block RAMs"],
+ ["0", "latches"],
+ ].map(([v, k], i) => (
+
+
+
+ ))}
+
+
+
+
+
+ );
+};
diff --git a/video/src/scenes/What.tsx b/video/src/scenes/What.tsx
new file mode 100644
index 0000000..4ab491a
--- /dev/null
+++ b/video/src/scenes/What.tsx
@@ -0,0 +1,230 @@
+import React from "react";
+import { AbsoluteFill, interpolate, useCurrentFrame, useVideoConfig } from "remotion";
+import { CodeLine } from "../fx/Highlight";
+import { Chip, Entrance, SceneExit } from "../fx/primitives";
+import { prog, springAt } from "../fx/motion";
+import { lineStart, phraseTime, sceneFrames, sceneStart, sec } from "../schedule";
+import { theme } from "../theme";
+
+const ID = "what" as const;
+const L = (t: number) => sec(t - sceneStart(ID));
+
+// Verbatim lines of rtl/forward_unit.sv (elided with comments), the module whose output drives the badge in the UI.
+const CODE = [
+ "// Operand forwarding into EX.",
+ "module forward_unit (",
+ " input logic forward_en,",
+ " input logic [4:0] ex_rs1,",
+ " output logic [1:0] fwd_a,",
+ " // ...",
+ " else if (mm_wr && mm_rd == rs) pick = FWD_EXMEM;",
+ " else if (wb_wr && wb_rd == rs) pick = FWD_MEMWB;",
+ " assign fwd_a = pick(ex_uses_rs1, ex_rs1);",
+];
+const LINE_H = 31;
+const TOP = 250;
+const CARD_H = 440;
+const ROW_Y = TOP + CARD_H / 2;
+
+const Card: React.FC<{ x: number; w: number; delay: number; glowAt: number; children: React.ReactNode; accent?: string }> = ({ x, w, delay, glowAt, children, accent = theme.colors.accent }) => {
+ const frame = useCurrentFrame();
+ const { fps } = useVideoConfig();
+ const p = springAt(frame, fps, delay, theme.spring.smooth);
+ const g = prog(frame, glowAt, glowAt + 12) * (1 - prog(frame, glowAt + 40, glowAt + 70, theme.ease.inOut) * 0.6);
+ return (
+ 0.02 ? accent : theme.colors.line}`,
+ boxShadow: "0 24px 60px -28px rgba(0,0,0,0.7)",
+ overflow: "hidden",
+ }}
+ >
+ {children}
+
+ );
+};
+
+const CardHead: React.FC<{ left: string; right?: React.ReactNode }> = ({ left, right }) => (
+
+ {left}
+ {right}
+
+);
+
+/** Dashed link between two cards with a pulse that travels when `at` is reached. */
+const Link: React.FC<{ x1: number; x2: number; at: number }> = ({ x1, x2, at }) => {
+ const frame = useCurrentFrame();
+ const draw = prog(frame, at, at + 14);
+ const pulse = prog(frame, at + 6, at + 30, theme.ease.soft);
+ return (
+
+
+
+ 0 && pulse < 1 ? 1 : 0} />
+
+ );
+};
+
+const Badge: React.FC<{ label: string; tone: "accent" | "warn" | "err"; style?: React.CSSProperties }> = ({ label, tone, style }) => {
+ const color = theme.colors[tone];
+ return (
+ {label}
+ );
+};
+
+export const What: React.FC = () => {
+ const frame = useCurrentFrame();
+ const dur = sceneFrames(ID);
+ const tCode = L(phraseTime(ID, 0, "written in") - 0.35);
+ const tVerilate = L(phraseTime(ID, 0, "compiled") - 0.25);
+ const tWasm = L(phraseTime(ID, 0, "WebAssembly") - 0.2);
+ const tBrowser = L(phraseTime(ID, 0, "running live") - 0.25);
+ const tProof = L(lineStart(ID, 1) - 0.05);
+
+ const proof = prog(frame, tProof, tProof + 46, theme.ease.inOut);
+ const proofLabel = prog(frame, tProof + 18, tProof + 34);
+ const badgeOn = prog(frame, tProof + 40, tProof + 52);
+ const hl = prog(frame, tProof, tProof + 10);
+
+ // proof path: from the assign line of the code card, under the middle cards, up into the badge of the browser card
+ const codeY = TOP + 54 + 22 + 9 * LINE_H + LINE_H / 2;
+ const path = `M 640 ${codeY} V 770 H 1690 V 648`;
+ const stageRows = [
+ { name: "IF", text: "lw a2, 4(t1)" },
+ { name: "ID", text: "lw a1, 0(t1)" },
+ { name: "EX", text: "addi t0, t0, 1" },
+ { name: "MEM", text: "add t2, sp, t3" },
+ { name: "WB", text: "sw a2, 0(t1)" },
+ ];
+
+ return (
+
+
+ {/* section label */}
+
+
+
+ rtl → wasm → you
+
+ Real hardware, in a web page
+
+
+
+ {/* 1: SystemVerilog */}
+
+ SystemVerilog} />
+
+ {CODE.map((l, i) => {
+ const on = i === CODE.length - 1;
+ return (
+
+
+
+ );
+ })}
+
+
+
+
+
+ {/* 2: Verilator */}
+
+
+
+
C++
+
Verilator
+
RTL becomes a cycle-exact C++ model
+
Vcpu_pipe
+
+
+
+
+
+ {/* 3: WebAssembly */}
+
+
+
+
WA
+
WebAssembly
+
Both cores in one module, about 77 KB gzipped
+
tiny_cpu.wasm
+
+
+
+
+
+ {/* 4: the browser */}
+
+
+ {["#ff5f57", "#febc2e", "#28c840"].map((c) => (
+
+ ))}
+ tiny-cpu.vercel.app
+
+
+ {stageRows.map((s, i) => {
+ const live = prog(frame, tBrowser + 10 + i * 4, tBrowser + 24 + i * 4);
+ const on = Math.floor(frame / 8) % 5 === i;
+ return (
+
+ {s.name}
+ {s.text}
+
+ );
+ })}
+
+
+
+
+
+
+ {/* proof: the RTL signal travels to the badge it drives */}
+
+ 0 ? 1 : 0} />
+
+
+
+
+ dbg_fwd_a → the badge you see
+
+
+
+
+
+ );
+};
diff --git a/video/src/schedule.ts b/video/src/schedule.ts
new file mode 100644
index 0000000..c5102af
--- /dev/null
+++ b/video/src/schedule.ts
@@ -0,0 +1,58 @@
+// Scene schedule and voiceover timing. timing.json is written by scripts/make_voice.py: every scene starts on a
+// beat of the 120 BPM music bed and is exactly as long as its narration plus a lead-in and a tail.
+import timing from "./timing.json";
+
+export const FPS = 30;
+export const WIDTH = 1920;
+export const HEIGHT = 1080;
+
+export type SceneId = "hook" | "what" | "write" | "pipeline" | "hazards" | "views" | "trust" | "cta";
+export const SCENE_IDS: SceneId[] = ["hook", "what", "write", "pipeline", "hazards", "views", "trust", "cta"];
+
+interface TimedLine {
+ show: string;
+ start: number;
+ end: number;
+}
+interface TimedScene {
+ start: number;
+ length: number;
+ lead: number;
+ duration: number;
+ lines: TimedLine[];
+}
+const T = timing.scenes as Record;
+
+export const BEAT = timing.beat; // seconds
+export const TOTAL_SECONDS = timing.total;
+export const TOTAL_FRAMES = Math.round(timing.total * FPS);
+
+export const sec = (s: number) => Math.round(s * FPS);
+
+/** Scene start and length in seconds. */
+export const sceneStart = (id: SceneId) => T[id].start;
+export const sceneLength = (id: SceneId) => T[id].length;
+export const sceneEnd = (id: SceneId) => T[id].start + T[id].length;
+export const sceneFrom = (id: SceneId) => sec(T[id].start);
+export const sceneFrames = (id: SceneId) => sec(T[id].length);
+
+/** Global time (seconds) at which narration line `i` of a scene starts / ends. */
+export const lineStart = (id: SceneId, i: number) => T[id].start + T[id].lead + (T[id].lines[i]?.start ?? 0);
+export const lineEnd = (id: SceneId, i: number) => T[id].start + T[id].lead + (T[id].lines[i]?.end ?? 0);
+
+/**
+ * Global time at which `phrase` begins being spoken in line `i`, estimated from its position in the text
+ * (characters are a good proxy for duration in a steady narration).
+ */
+export function phraseTime(id: SceneId, i: number, phrase: string): number {
+ const line = T[id].lines[i];
+ if (!line) throw new Error(`no line ${i} in ${id}`);
+ const at = line.show.toLowerCase().indexOf(phrase.toLowerCase());
+ if (at < 0) throw new Error(`"${phrase}" not in "${line.show}"`);
+ const frac = at / line.show.length;
+ return lineStart(id, i) + frac * (line.end - line.start);
+}
+
+export const voiceStart = (id: SceneId) => T[id].start + T[id].lead;
+export const sceneLines = (id: SceneId) => T[id].lines;
+export const sceneLead = (id: SceneId) => T[id].lead;
diff --git a/video/src/theme.ts b/video/src/theme.ts
new file mode 100644
index 0000000..2d8d997
--- /dev/null
+++ b/video/src/theme.ts
@@ -0,0 +1,41 @@
+// One theme for the whole video. Colors are the app's own tokens (web/src/styles/global.css) plus the five
+// pipeline-stage colors from the TinyCPU logo (web/public/logo.svg). Nothing else is inlined in components.
+import { Easing } from "remotion";
+
+export const theme = {
+ colors: {
+ bg: "#0d1117",
+ bgDeep: "#090c10",
+ panel: "#161b22",
+ line: "#30363d",
+ fg: "#c9d1d9",
+ fgBright: "#e6edf3",
+ muted: "#8b949e",
+ accent: "#58a6ff", // THE hero color
+ ok: "#3fb950",
+ err: "#f85149",
+ warn: "#d29922",
+ active: "#1f2d3d",
+ // logo bars = the five pipeline stages IF ID EX MEM WB
+ stage: ["#58a6ff", "#7ee787", "#d2a8ff", "#ffa657", "#ff7b72"],
+ // GitHub-dark syntax colors for the SystemVerilog card
+ syn: { keyword: "#ff7b72", type: "#79c0ff", fn: "#d2a8ff", comment: "#8b949e", num: "#79c0ff", str: "#a5d6ff", ident: "#c9d1d9" },
+ },
+ fonts: {
+ sans: '"Inter Variable", system-ui, sans-serif',
+ mono: '"JetBrains Mono Variable", ui-monospace, monospace',
+ },
+ ease: {
+ out: Easing.bezier(0.16, 1, 0.3, 1), // entrances
+ inOut: Easing.bezier(0.83, 0, 0.17, 1), // camera moves
+ soft: Easing.bezier(0.45, 0, 0.15, 1), // cursor travel
+ in: Easing.bezier(0.7, 0, 0.84, 0), // exits
+ },
+ spring: {
+ snappy: { damping: 15, stiffness: 170, mass: 0.6 },
+ smooth: { damping: 22, stiffness: 95, mass: 1 },
+ bouncy: { damping: 11, stiffness: 170, mass: 0.7 },
+ },
+} as const;
+
+export const clampOpts = { extrapolateLeft: "clamp", extrapolateRight: "clamp" } as const;
diff --git a/video/src/timing.json b/video/src/timing.json
new file mode 100644
index 0000000..e0e7e06
--- /dev/null
+++ b/video/src/timing.json
@@ -0,0 +1,161 @@
+{
+ "bpm": 120.0,
+ "beat": 0.5,
+ "total": 77.5,
+ "scenes": {
+ "hook": {
+ "duration": 4.622,
+ "lines": [
+ {
+ "show": "What if you could watch a real processor think?",
+ "start": 0.0,
+ "end": 2.413
+ },
+ {
+ "show": "This is TinyCPU.",
+ "start": 3.013,
+ "end": 4.622
+ }
+ ],
+ "start": 0.0,
+ "length": 6.0,
+ "lead": 0.8
+ },
+ "what": {
+ "duration": 8.835,
+ "lines": [
+ {
+ "show": "A real RISC-V processor, written in SystemVerilog, compiled to WebAssembly, running live in your browser.",
+ "start": 0.0,
+ "end": 6.998
+ },
+ {
+ "show": "Nothing here is faked.",
+ "start": 7.498,
+ "end": 8.835
+ }
+ ],
+ "start": 6.0,
+ "length": 10.0,
+ "lead": 0.4
+ },
+ "write": {
+ "duration": 5.555,
+ "lines": [
+ {
+ "show": "Pick a program, or write your own.",
+ "start": 0.0,
+ "end": 1.97
+ },
+ {
+ "show": "Hit assemble and run, and the hardware executes it.",
+ "start": 2.47,
+ "end": 5.555
+ }
+ ],
+ "start": 16.0,
+ "length": 6.5,
+ "lead": 0.4
+ },
+ "pipeline": {
+ "duration": 12.312,
+ "lines": [
+ {
+ "show": "Watch five instructions share a five-stage pipeline.",
+ "start": 0.0,
+ "end": 3.39
+ },
+ {
+ "show": "Fetch. Decode. Execute. Memory. Write-back.",
+ "start": 3.89,
+ "end": 6.828
+ },
+ {
+ "show": "Step through every cycle, and watch registers, memory, and output update live.",
+ "start": 7.328,
+ "end": 12.312
+ }
+ ],
+ "start": 22.5,
+ "length": 14.0,
+ "lead": 0.4
+ },
+ "hazards": {
+ "duration": 10.541,
+ "lines": [
+ {
+ "show": "When instructions collide, you see exactly why.",
+ "start": 0.0,
+ "end": 2.962
+ },
+ {
+ "show": "Forwarding paths. Load-use stalls. Branch flushes.",
+ "start": 3.462,
+ "end": 6.821
+ },
+ {
+ "show": "Turn forwarding off, and watch the cycle count climb.",
+ "start": 7.321,
+ "end": 10.541
+ }
+ ],
+ "start": 36.5,
+ "length": 12.0,
+ "lead": 0.4
+ },
+ "views": {
+ "duration": 4.909,
+ "lines": [
+ {
+ "show": "Zoom into the datapath. Compare programs. Explore memory as a heat map.",
+ "start": 0.0,
+ "end": 4.909
+ }
+ ],
+ "start": 48.5,
+ "length": 6.5,
+ "lead": 0.4
+ },
+ "trust": {
+ "duration": 10.005,
+ "lines": [
+ {
+ "show": "Forty official RISC-V tests. Two hundred sixty random programs. All matched against a golden model.",
+ "start": 0.0,
+ "end": 7.126
+ },
+ {
+ "show": "It even fits on a real FPGA.",
+ "start": 7.626,
+ "end": 10.005
+ }
+ ],
+ "start": 55.0,
+ "length": 11.5,
+ "lead": 0.4
+ },
+ "cta": {
+ "duration": 8.188,
+ "lines": [
+ {
+ "show": "Ready to look inside a computer?",
+ "start": 0.0,
+ "end": 1.912
+ },
+ {
+ "show": "Open TinyCPU, right now, in your browser.",
+ "start": 2.512,
+ "end": 5.779
+ },
+ {
+ "show": "Your first clock cycle is waiting.",
+ "start": 6.279,
+ "end": 8.188
+ }
+ ],
+ "start": 66.5,
+ "length": 11.0,
+ "lead": 0.4
+ }
+ }
+}
\ No newline at end of file
diff --git a/video/src/ui/AppPage.tsx b/video/src/ui/AppPage.tsx
new file mode 100644
index 0000000..b13b26d
--- /dev/null
+++ b/video/src/ui/AppPage.tsx
@@ -0,0 +1,318 @@
+// The /app page of the web app, driven by props instead of the WASM hook. Layout, classes and sub-components are
+// the app's own (web/src/components/*, web/src/pages/app.astro, CpuApp.tsx); only the state is fed from the
+// exported run data, so every pixel of the interface is the real interface.
+import React, { useLayoutEffect, useRef } from "react";
+import { AsmEditor } from "@web/components/AsmEditor";
+import { ConsolePanel } from "@web/components/ConsolePanel";
+import { DatapathView } from "@web/components/DatapathView";
+import { LevelTabs, type Level } from "@web/components/LevelTabs";
+import { MemoryView } from "@web/components/MemoryView";
+import { Panel } from "@web/components/Panel";
+import { PipelineView } from "@web/components/PipelineView";
+import { RegisterFile } from "@web/components/RegisterFile";
+import { StatsBar } from "@web/components/StatsBar";
+import { DATAPATH, PRESET_LIST, type RunBundle } from "./runData";
+import { HazardLabView } from "./HazardLabView";
+import { ProgramsTableView } from "./ProgramsTableView";
+
+export type PressKey = "assemble" | "start" | "back" | "play" | "step" | "end" | null;
+
+export interface AppProps {
+ presetId: string;
+ /** characters of the source currently typed in (undefined = all of it) */
+ typed?: number;
+ core: "PIPE" | "SINGLE";
+ forwarding: boolean;
+ /** is there a finished run to look at? */
+ run: RunBundle | null;
+ index: number;
+ playing: boolean;
+ speed: number;
+ level: Level;
+ hazardId: string;
+ hazardRow?: number;
+ datapathKey: "add" | "load" | "branch";
+ programId: string;
+ heatProgress: number;
+ stale: boolean;
+ press: PressKey;
+ /** editor scroll position in px */
+ editorScroll: number;
+ /** the line of the program being executed gets a soft band behind it */
+ bandLine?: number;
+ /** badge pulse: highlights the hazard badges row */
+ badgePulse?: number;
+ sourceText: string;
+}
+
+const button = "rounded-md border border-[var(--line)] px-3 py-1 font-mono text-sm hover:bg-[var(--active)] disabled:opacity-40";
+const pressStyle = (on: boolean): React.CSSProperties => (on ? { background: "var(--active)", borderColor: "var(--accent)", transform: "scale(0.94)" } : {});
+
+/** The app's stage timeline scrolls sideways; keep the newest cycles (the highlighted column) in view. */
+function ScrollRight({ children }: { children: React.ReactNode }) {
+ const ref = useRef(null);
+ useLayoutEffect(() => {
+ // Keep the "stage" column and the newest 4 cycles, hide older columns, so the highlighted current cycle is never off-screen.
+ ref.current?.querySelectorAll("tr").forEach((tr) => {
+ const cells = Array.from(tr.children) as HTMLElement[];
+ cells.forEach((c, i) => {
+ c.style.display = i >= 1 && i < cells.length - 4 ? "none" : "";
+ });
+ });
+ });
+ return (
+
+ {children}
+
+ );
+}
+
+function EditorBox({ value, activeLine, scroll, band }: { value: string; activeLine?: number; scroll: number; band?: number }) {
+ const ref = useRef(null);
+ useLayoutEffect(() => {
+ const el = ref.current;
+ if (!el) return;
+ const ta = el.querySelector("textarea");
+ const gutter = el.querySelector("[aria-hidden]");
+ if (ta) ta.scrollTop = scroll;
+ if (gutter) gutter.scrollTop = scroll;
+ });
+ const top = band !== undefined ? (band - 1) * 24 - scroll + 1 : -100;
+ return (
+
+
{}} errors={[]} activeLine={activeLine} />
+ {band !== undefined && top > -24 && top < 416 && (
+
+ )}
+
+ );
+}
+
+export function AppPage(p: AppProps) {
+ const run = p.run;
+ const win = run ? run.at(p.index) : null;
+ const prevWin = run && p.index > 0 ? run.at(p.index - 1) : null;
+ const state = win?.state ?? null;
+ const prev = prevWin?.state ?? null;
+ const stats = win ? win.stats : null;
+ const consoleText = win?.console ?? "";
+ const activePc = state ? (state.stages[2]?.valid ? state.stages[2].pc : state.stages[0]?.pc) : undefined;
+ const activeLine = run && activePc !== undefined ? run.asm.lineOf[activePc] : undefined;
+ const preset = PRESET_LIST.find((x) => x.id === p.presetId);
+ const status = (() => {
+ if (!run) return "Ready. Press Assemble & run.";
+ if (state?.halted) return `Halted at cycle ${state.cycle}${state.tohost === 1 ? " — PASS" : state.tohost !== null ? ` — exit ${state.tohost}` : ""}.`;
+ return `Cycle ${state?.cycle ?? 0} of ${run.total}.`;
+ })();
+ const last = run ? run.total - 1 : 0;
+ const dp = DATAPATH[p.datapathKey];
+ const descriptions: Record = {
+ hello: "Prints a string with a byte loop and a call.",
+ fib: "fib(20) in a loop, printed in decimal by repeated subtraction.",
+ sort: "Sorts 12 words in data memory, then prints them. Data hazards everywhere.",
+ memcpy: "Byte copy, then a word-by-word check that prints OK.",
+ primes: "Sieve of Eratosthenes up to 60.",
+ };
+ const shown = p.typed === undefined ? p.sourceText : p.sourceText.slice(0, p.typed);
+ const memLength = 128;
+
+ return (
+
+
+
+
+
+
+
+ Program
+ {}}
+ className="rounded border border-[var(--line)] bg-[var(--bg)] px-2 py-1 text-sm text-[var(--fg)]"
+ >
+ {PRESET_LIST.map((x) => (
+
+ {x.title}
+
+ ))}
+
+
+
+ Core
+ {}}
+ className="rounded border border-[var(--line)] bg-[var(--bg)] px-2 py-1 text-sm text-[var(--fg)]"
+ >
+ 5-stage pipeline
+ single-cycle
+
+
+
+ {}} />
+ forwarding
+
+
+ Assemble & run
+
+ {preset &&
{descriptions[preset.id]}
}
+ {p.stale && (
+
+ The source or settings changed since this run. Press Assemble & run to update the trace.
+
+ )}
+
+
+
+ {}} />
+
+
+ {(p.level === "pipeline" || p.level === "datapath") && (
+
+
+
+ {p.level === "pipeline" ? (
+
+
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+ )}
+
+ {p.level === "hazards" && (
+
+ )}
+
+ {p.level === "programs" && (
+
+ )}
+
+ {(p.level === "pipeline" || p.level === "datapath") && (
+ <>
+
+
+
+
+ ⏮
+ ◀
+
+ {p.playing ? "❚❚" : "▶︎"}
+
+ ▶
+ ⏭
+ {}}
+ className="min-w-32 flex-1"
+ />
+
+ speed
+ {}}
+ className="rounded border border-[var(--line)] bg-[var(--bg)] px-1 py-0.5 text-[var(--fg)]"
+ >
+ {[1, 2, 4, 8, 16, 32].map((s) => (
+
+ {s}/s
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {run ? (
+
+ ) : (
+ Data starts at 0x10000.
+ )}
+
+
+
+
+ >
+ )}
+
+
+
+ );
+}
+
+const emptyAsm = { ok: false, errors: [], imem: new Uint32Array(0), dmem: new Uint8Array(0), symbols: {}, lineOf: {}, textBytes: 0, dataBytes: 0 };
+const emptyModel = { states: [], stats: () => ({ cycles: 0, instret: 0, cpi: 0, stalls: 0, loadUse: 0, flushes: 0, forwards: 0 }), memoryAt: () => new Uint8Array(0), consoleAt: () => "" };
diff --git a/video/src/ui/AppStage.tsx b/video/src/ui/AppStage.tsx
new file mode 100644
index 0000000..7f4bcc6
--- /dev/null
+++ b/video/src/ui/AppStage.tsx
@@ -0,0 +1,450 @@
+// The persistent "screen recording" of the real app: one continuous camera move through the real UI,
+// with a cursor that clicks the real controls. Everything is a pure function of global time, so
+// a still at any frame is exactly what the video shows there.
+import React from "react";
+import { AbsoluteFill, interpolate, useCurrentFrame } from "remotion";
+import { Cursor } from "../fx/Cursor";
+import { keys, prog } from "../fx/motion";
+import { FPS, lineStart, phraseTime, sceneEnd, sceneStart } from "../schedule";
+import { clampOpts, theme } from "../theme";
+import { AppPage, type PressKey } from "./AppPage";
+import { HELLO_SOURCE, HERO, NOFWD, EVENTS, PRESET_LIST } from "./runData";
+import LAYOUT_RAW from "./layout.json";
+
+type Rect = [number, number, number, number]; // x, y, w, h in page coordinates
+type Layout = Record>;
+const LAYOUT = LAYOUT_RAW as unknown as Layout;
+const CHROME_H = 52; // browser bar height above the page
+const PAGE_W = 1536;
+
+const R = (level: keyof Layout, name: string): Rect => {
+ const r = LAYOUT[level]?.[name];
+ if (!r) throw new Error(`missing layout ${level}/${name}`);
+ return r;
+};
+const center = (r: Rect): [number, number] => [r[0] + r[2] / 2, r[1] + r[3] / 2];
+const union = (...rs: Rect[]): Rect => {
+ const x0 = Math.min(...rs.map((r) => r[0]));
+ const y0 = Math.min(...rs.map((r) => r[1]));
+ const x1 = Math.max(...rs.map((r) => r[0] + r[2]));
+ const y1 = Math.max(...rs.map((r) => r[1] + r[3]));
+ return [x0, y0, x1 - x0, y1 - y0];
+};
+const pad = (r: Rect, p: number): Rect => [r[0] - p, r[1] - p, r[2] + 2 * p, r[3] + 2 * p];
+
+interface Cam {
+ cx: number;
+ cy: number;
+ z: number;
+}
+/** Camera that frames a page rect inside the 1920x(1080 minus caption band) safe area. */
+const fit = (level: keyof Layout, r: Rect, maxZ = 3.2): Cam => {
+ const [cx, cy] = center(r);
+ const z = Math.min((1920 * 0.9) / r[2], 800 / r[3], maxZ);
+ return { cx, cy: cy + CHROME_H, z };
+};
+
+// ---------------------------------------------------------------------------------------------------------------
+// Timeline (seconds, global). Narration phrases pin the moments that matter.
+const T = {
+ appIn: sceneStart("write") - 0.5,
+ openSelect: 15.6,
+ pickSort: 16.75,
+ assembleClick: phraseTime("write", 1, "and run") + 0.05,
+ // pipeline scene: four steps on the beat, then play
+ stepStart: sceneStart("pipeline"), // 21.0, 21.5, 22.0, 22.5
+ playStart: sceneStart("pipeline") + 2.0, // 23.0
+ speedUp: sceneStart("pipeline") + 7.1, // free-run faster for the register / memory tour
+ endClick: sceneStart("pipeline") + 9.9, // 30.9
+ // hazards
+ startClick: sceneStart("hazards") + 0.3,
+ playHaz: sceneStart("hazards") + 1.0,
+ fwdHold: phraseTime("hazards", 1, "Forwarding paths") - 0.05,
+ stallHold: phraseTime("hazards", 1, "Load-use stalls") - 0.15,
+ flushHold: phraseTime("hazards", 1, "Branch flushes") - 0.15,
+ uncheck: phraseTime("hazards", 2, "forwarding off") + 0.02,
+ rerun: phraseTime("hazards", 2, "watch the cycle count") - 0.55,
+ endClick2: phraseTime("hazards", 2, "watch the cycle count") + 0.05,
+ // views
+ tabDatapath: phraseTime("views", 0, "Zoom into") - 0.25,
+ datapathSwap: phraseTime("views", 0, "Zoom into") + 0.95,
+ tabPrograms: phraseTime("views", 0, "Compare programs") - 0.3,
+ heatStart: phraseTime("views", 0, "Explore memory") + 0.1,
+ appOut: sceneEnd("views") - 0.45,
+};
+export const STAGE_T = T;
+const STEP_TIMES = [0, 1, 2, 3].map((i) => T.stepStart + i * 0.5);
+
+const idxHero = (t: number): number => {
+ const total = HERO.total - 1;
+ if (t < T.assembleClick + 0.25) return 0;
+ // four ▶ clicks on the beat
+ if (t < T.playStart) return STEP_TIMES.filter((s) => t >= s).length;
+ if (t < T.endClick) {
+ // free-running: 4 cycles/s, then faster, easing to a near-stop on the first store, then on
+ const f = t * FPS;
+ return Math.round(
+ keys(
+ f,
+ [
+ [T.playStart * FPS, 4],
+ [T.speedUp * FPS, 24],
+ [(T.speedUp + 0.8) * FPS, 37],
+ [(T.speedUp + 1.35) * FPS, 41],
+ [(T.speedUp + 1.75) * FPS, 42],
+ [T.endClick * FPS - 2, 52],
+ ],
+ theme.ease.soft,
+ ),
+ );
+ }
+ if (t < T.startClick) return total;
+ // hazards: ⏮ then a run that eases to a stop on each real event
+ if (t < T.playHaz) return 0;
+ if (t < T.flushHold + 0.9) {
+ const k: [number, number][] = [
+ [T.playHaz * FPS, 0],
+ [(T.fwdHold - 0.35) * FPS, 2],
+ [T.fwdHold * FPS, EVENTS.forward],
+ [(T.fwdHold + 0.5) * FPS, EVENTS.forward],
+ [(T.stallHold - 0.1) * FPS, EVENTS.stall - 1],
+ [T.stallHold * FPS, EVENTS.stall],
+ [(T.stallHold + 0.55) * FPS, EVENTS.stall],
+ [(T.flushHold - 0.1) * FPS, EVENTS.flush - 1],
+ [T.flushHold * FPS, EVENTS.flush],
+ [(T.flushHold + 0.8) * FPS, EVENTS.flush],
+ ];
+ return Math.round(keys(t * FPS, k, theme.ease.inOut));
+ }
+ return EVENTS.flush;
+};
+
+// ---------------------------------------------------------------------------------------------------------------
+
+export const AppStage: React.FC = () => {
+ const frame = useCurrentFrame();
+ const t = frame / FPS;
+
+ // ---- app state at time t
+ const rerun = t >= T.rerun;
+ const run = t < T.assembleClick + 0.2 ? null : rerun ? NOFWD : HERO;
+ const index = rerun ? (t >= T.endClick2 ? NOFWD.total - 1 : 0) : idxHero(t);
+ const forwarding = t < T.uncheck;
+ const stale = t >= T.uncheck && t < T.rerun;
+ const level = t >= T.tabPrograms ? "programs" : t >= T.tabDatapath ? "datapath" : "pipeline";
+ const presetId = t >= T.pickSort ? "sort" : "hello";
+ const sourceText = t >= T.pickSort ? HERO.source : HELLO_SOURCE;
+ const typed = t >= T.pickSort ? Math.round(interpolate(t, [T.pickSort + 0.05, T.pickSort + 1.0], [0, HERO.source.length], { ...clampOpts, easing: theme.ease.out })) : undefined;
+ const typedDone = typed === undefined || typed >= HERO.source.length;
+ const playing = (() => {
+ if (t >= T.playStart && t < T.endClick) return true;
+ if (t >= T.playHaz && t < T.flushHold + 0.9) return idxHero(t) !== idxHero(t + 0.12);
+ return false;
+ })();
+ const speed = t >= T.speedUp && t < T.endClick ? 16 : playing ? 4 : 1;
+
+ // ---- cursor script (page coords) + clicks
+ const programSel = R("pipeline", "program");
+ const assembleBtn = R("pipeline", "assemble");
+ const fwdChk = R("pipeline", "forwarding");
+ const startBtn = R("pipeline", "start");
+ const stepBtn = R("pipeline", "step");
+ const playBtn = R("pipeline", "play");
+ const endBtn = R("pipeline", "end");
+ const tabDp = R("pipeline", "tab-datapath");
+ const tabPr = R("pipeline", "tab-programs");
+ const pt = (r: Rect, dx = 0.5, dy = 0.5): [number, number] => [r[0] + r[2] * dx, r[1] + r[3] * dy];
+
+ // [time, x, y] keyframes; clicks listed separately
+ const stops: { t: number; p: [number, number] }[] = [
+ { t: T.appIn + 0.3, p: [pt(programSel)[0] + 320, pt(programSel)[1] + 190] },
+ { t: T.openSelect - 0.08, p: pt(programSel, 0.5, 0.5) },
+ { t: T.pickSort - 0.1, p: [pt(programSel)[0] + 8, programSel[1] + programSel[3] + 4 + 4 + 28 * 2 + 14] },
+ { t: T.assembleClick - 0.1, p: pt(assembleBtn) },
+ { t: STEP_TIMES[0] as number - 0.25, p: pt(stepBtn) },
+ { t: T.playStart - 0.1, p: pt(playBtn) },
+ { t: T.endClick - 0.12, p: pt(endBtn) },
+ { t: T.startClick - 0.12, p: pt(startBtn) },
+ { t: T.playHaz - 0.1, p: pt(playBtn) },
+ { t: T.uncheck - 0.1, p: pt(fwdChk, 0.1, 0.5) },
+ { t: T.rerun - 0.1, p: pt(assembleBtn) },
+ { t: T.endClick2 - 0.1, p: pt(endBtn) },
+ { t: T.tabDatapath - 0.12, p: pt(tabDp) },
+ { t: T.tabPrograms - 0.12, p: pt(tabPr) },
+ { t: T.appOut, p: [pt(tabPr)[0] + 120, pt(tabPr)[1] + 220] },
+ ];
+ const clicks = [T.openSelect, T.pickSort, T.assembleClick, ...STEP_TIMES, T.playStart, T.endClick, T.startClick, T.playHaz, T.uncheck, T.rerun, T.endClick2, T.tabDatapath, T.tabPrograms];
+ const cx = keys(frame, stops.map((s) => [s.t * FPS, s.p[0]]), theme.ease.soft);
+ const cy = keys(frame, stops.map((s) => [s.t * FPS, s.p[1]]), theme.ease.soft);
+ const lastClick = clicks.filter((c) => c * FPS <= frame).pop();
+ const since = lastClick === undefined ? undefined : frame - lastClick * FPS;
+ const near = (a: number, b: number) => Math.abs(a - b) < 0.001;
+ const press: PressKey = (() => {
+ if (since === undefined || since > 8 || lastClick === undefined) return null;
+ const c = lastClick;
+ if (near(c, T.assembleClick) || near(c, T.rerun)) return "assemble";
+ if (near(c, T.playStart) || near(c, T.playHaz)) return "play";
+ if (near(c, T.endClick) || near(c, T.endClick2)) return "end";
+ if (near(c, T.startClick)) return "start";
+ if (STEP_TIMES.some((s) => near(c, s))) return "step";
+ return null;
+ })();
+
+ // ---- camera
+ const L = "pipeline" as const;
+ const toolbarTabs = pad(union(R(L, "toolbar"), R(L, "tabs")), 30);
+ const overview = pad(union(R(L, "source"), R(L, "view"), R(L, "cycle")), 14);
+ const pipeRect = pad(R(L, "pipeline"), 16);
+ const hm = R("programs", "heatmap");
+ const heatRect: Rect = [hm[0], hm[1] - 20, 820, 250];
+ const lowerRight: Rect = [640, 790, 880, 335];
+ const cams: { t: number; cam: Cam }[] = [
+ { t: T.appIn - 0.2, cam: fit(L, [R(L, "toolbar")[0], R(L, "toolbar")[1], 900, 560], 1.9) },
+ { t: T.appIn + 1.0, cam: fit(L, [R(L, "toolbar")[0], R(L, "toolbar")[1], 900, 560], 1.9) },
+ { t: T.assembleClick + 0.5, cam: fit(L, [R(L, "toolbar")[0], R(L, "toolbar")[1], 900, 560], 1.9) },
+ { t: T.assembleClick + 1.3, cam: fit(L, overview, 1.2) },
+ { t: STEP_TIMES[3] as number + 0.4, cam: fit(L, overview, 1.2) },
+ { t: T.playStart + 0.9, cam: fit(L, pipeRect, 2.3) },
+ { t: T.speedUp - 0.3, cam: fit(L, pipeRect, 2.3) },
+ { t: T.speedUp + 0.5, cam: fit(L, pad(R(L, "registers"), 14), 2.3) },
+ { t: T.speedUp + 1.0, cam: fit(L, pad(R(L, "registers"), 14), 2.3) },
+ { t: T.speedUp + 1.6, cam: fit(L, pad(R(L, "memory"), 14), 2.3) },
+ { t: T.endClick - 0.55, cam: fit(L, pad(union(R(L, "cycle"), R(L, "lower")), 14), 1.2) },
+ { t: T.endClick + 0.9, cam: fit(L, pad(union(R(L, "cycle"), R(L, "lower")), 14), 1.2) },
+ { t: T.endClick + 1.7, cam: fit(L, lowerRight, 2.5) },
+ { t: T.startClick - 0.1, cam: fit(L, lowerRight, 2.5) },
+ { t: T.startClick + 0.5, cam: fit(L, overview, 1.2) },
+ { t: T.playHaz + 0.1, cam: fit(L, overview, 1.2) },
+ { t: T.playHaz + 1.0, cam: fit(L, pipeRect, 2.3) },
+ { t: T.flushHold + 0.8, cam: fit(L, pipeRect, 2.3) },
+ { t: T.uncheck - 0.7, cam: fit(L, overview, 1.2) },
+ { t: T.endClick2 + 0.5, cam: fit(L, overview, 1.2) },
+ { t: T.endClick2 + 1.1, cam: fit(L, [R(L, "controls")[0] + 600, R(L, "controls")[1] - 10, 900, 160], 2.1) },
+ { t: T.tabDatapath - 0.55, cam: fit(L, toolbarTabs, 1.35) },
+ { t: T.tabDatapath + 0.45, cam: fit("datapath", pad(R("datapath", "view"), 14), 2.0) },
+ { t: T.tabPrograms - 0.55, cam: fit("datapath", pad(R("datapath", "view"), 14), 2.0) },
+ { t: T.tabPrograms - 0.15, cam: fit(L, toolbarTabs, 1.35) },
+ { t: T.tabPrograms + 0.6, cam: fit("programs", pad(R("programs", "view"), 8), 1.2) },
+ { t: T.heatStart + 0.5, cam: fit("programs", heatRect, 2.1) },
+ { t: T.appOut, cam: fit("programs", heatRect, 2.1) },
+ ];
+ const seg = (() => {
+ for (let i = 0; i < cams.length - 1; i++) {
+ const a = cams[i] as { t: number; cam: Cam };
+ const b = cams[i + 1] as { t: number; cam: Cam };
+ if (t >= a.t && t <= b.t) return { a, b };
+ }
+ return t < (cams[0] as { t: number }).t ? { a: cams[0], b: cams[0] } : { a: cams[cams.length - 1], b: cams[cams.length - 1] };
+ })() as { a: { t: number; cam: Cam }; b: { t: number; cam: Cam } };
+ const sp = seg.a === seg.b ? 1 : interpolate(t, [seg.a.t, seg.b.t], [0, 1], { ...clampOpts, easing: theme.ease.inOut });
+ const cam: Cam = {
+ cx: seg.a.cam.cx + (seg.b.cam.cx - seg.a.cam.cx) * sp,
+ cy: seg.a.cam.cy + (seg.b.cam.cy - seg.a.cam.cy) * sp,
+ z: seg.a.cam.z * Math.pow(seg.b.cam.z / seg.a.cam.z, sp),
+ };
+ // idle drift keeps the shot alive during holds
+ const driftX = Math.sin(frame / 70) * 5;
+ const driftY = Math.cos(frame / 90) * 4;
+ const tx = 960 - cam.cx * cam.z + driftX;
+ const ty = 520 - cam.cy * cam.z + driftY;
+ const toScreen = (p: [number, number]): [number, number] => [tx + p[0] * cam.z, ty + (p[1] + CHROME_H) * cam.z];
+ const [sx, sy] = toScreen([cx, cy]);
+
+ // ---- window entrance / exit
+ const enter = interpolate(t, [T.appIn, T.appIn + 0.9], [0, 1], { ...clampOpts, easing: theme.ease.out });
+ const exit = interpolate(t, [T.appOut, T.appOut + 0.35], [0, 1], { ...clampOpts, easing: theme.ease.in });
+ const visible = enter > 0 && exit < 1;
+ if (!visible) return null;
+ const winScale = interpolate(enter, [0, 1], [0.86, 1]) * (1 + exit * 0.06);
+
+ // ---- editor scroll: follow the line being executed (never scroll while the new source is still typing)
+ const win = run ? run.at(index) : null;
+ const pc = win ? (win.state.stages[2]?.valid ? win.state.stages[2].pc : win.state.stages[0]?.pc) : undefined;
+ const execLine = run && pc !== undefined ? run.asm.lineOf[pc] : undefined;
+ const editorScroll = typedDone && execLine ? Math.min(Math.max(0, (execLine - 9) * 24), 520) : 0;
+
+ // ---- dropdown overlay
+ const selOpen = t >= T.openSelect + 0.05 && t < T.pickSort + 0.12;
+ const listProg = prog(t * FPS, (T.openSelect + 0.05) * FPS, (T.openSelect + 0.3) * FPS);
+ const hoverRow = t < T.pickSort - 0.5 ? 0 : Math.min(2, Math.floor((t - (T.pickSort - 0.5)) / 0.2));
+
+ // ---- guide overlays in page coordinates
+ const stageNames = ["Fetch", "Decode", "Execute", "Memory", "Write-back"];
+ const stageGlow = stageNames.map((n, i) => {
+ const at = phraseTime("pipeline", 1, n === "Write-back" ? "Write-back" : n);
+ return prog(t * FPS, (at - 0.05) * FPS, (at + 0.1) * FPS) * (1 - prog(t * FPS, (at + 0.38) * FPS, (at + 0.62) * FPS));
+ });
+ const badgePulse = (() => {
+ const evs = [T.fwdHold, T.stallHold, T.flushHold];
+ let best = 0;
+ for (const e of evs) best = Math.max(best, prog(t * FPS, e * FPS, (e + 0.18) * FPS) * (1 - prog(t * FPS, (e + 0.62) * FPS, (e + 0.85) * FPS)));
+ return best;
+ })();
+
+ const heatProgress = prog(t * FPS, T.heatStart * FPS, (T.heatStart + 1.0) * FPS, theme.ease.inOut);
+
+ // ---- cycle comparison card (screen space)
+ const cmpIn = prog(t * FPS, (T.rerun + 0.3) * FPS, (T.rerun + 0.9) * FPS);
+ const cmpOut = prog(t * FPS, (T.tabDatapath - 0.3) * FPS, (T.tabDatapath + 0.1) * FPS, theme.ease.in);
+ const cmpCount = prog(t * FPS, T.endClick2 * FPS, (T.endClick2 + 1.1) * FPS, theme.ease.out);
+
+ return (
+
+
+ {/* camera */}
+
+ {/* browser window */}
+
+
+ {["#ff5f57", "#febc2e", "#28c840"].map((c) => (
+
+ ))}
+
+ ●
+ tiny-cpu.vercel.app/app
+
+
+
+
= T.datapathSwap ? "load" : "add"}
+ programId="sort"
+ heatProgress={level === "programs" ? heatProgress : 1}
+ stale={stale}
+ press={press}
+ editorScroll={editorScroll}
+ sourceText={sourceText}
+ />
+
+ {/* stage highlights while the narration names each stage */}
+ {stageGlow.map((g, i) => {
+ const r = R("pipeline", `stage${i}`);
+ return (
+ g > 0.01 && (
+
+ )
+ );
+ })}
+ {badgePulse > 0.01 && level === "pipeline" && (
+
+ )}
+
+ {/* the app prints its verdict in a small status line; call it out once the run halts */}
+ {level === "pipeline" && win?.state.halted && (
+
+
+ ✓ halted at cycle {win.state.cycle} · PASS
+
+
+ )}
+
+ {/* program dropdown (a native popup is not capturable, so this mirrors Chrome's) */}
+ {selOpen && (
+
+ {PRESET_LIST.map((p, i) => {
+ const hot = i === (hoverRow === 2 ? 2 : hoverRow);
+ return (
+
+ {p.title}
+
+ );
+ })}
+
+ )}
+
+
+
+
+ {/* forwarding on vs off: the numbers behind the climb */}
+ {cmpIn > 0.01 && cmpOut < 0.99 && (
+
+
+
bubble sort · total cycles
+ {[
+ { label: "forwarding on", v: HERO.total, color: theme.colors.accent, w: 1 },
+ { label: "forwarding off", v: HERO.total + (NOFWD.total - HERO.total) * cmpCount, color: theme.colors.warn, w: (HERO.total + (NOFWD.total - HERO.total) * cmpCount) / NOFWD.total },
+ ].map((b) => (
+
+
+ {b.label}
+ {Math.round(b.v).toLocaleString("en-US")}
+
+
+
+ ))}
+
+ +{Math.round((NOFWD.total - HERO.total) * cmpCount)} cycles (+{Math.round(((NOFWD.total - HERO.total) / HERO.total) * 100 * cmpCount)}%)
+
+
+
+ )}
+
+
+
+
+ );
+};
diff --git a/video/src/ui/HazardLabView.tsx b/video/src/ui/HazardLabView.tsx
new file mode 100644
index 0000000..cb40c32
--- /dev/null
+++ b/video/src/ui/HazardLabView.tsx
@@ -0,0 +1,98 @@
+// Presentational copy of web/src/components/HazardLab.tsx: same markup and classes, fed with the numbers
+// the real WASM core produced (scripts/gen-data.ts) instead of running the core in the browser.
+import React from "react";
+import { HAZARD, type HazardData } from "./runData";
+
+const TIMELINE = 8;
+
+function Timeline({ rows, title }: { rows: (number | null)[][]; title: string }) {
+ const cols = Math.min(TIMELINE, rows.length);
+ return (
+
+
{title}
+
+
+ {["IF", "ID", "EX", "MEM", "WB"].map((name, row) => (
+
+ {name}
+ {Array.from({ length: cols }, (_, c) => {
+ const pc = rows[c]?.[row];
+ const valid = pc !== null && pc !== undefined;
+ return (
+
+ {valid ? (pc as number).toString(16).padStart(4, "0") : "··"}
+
+ );
+ })}
+
+ ))}
+
+
+
+ );
+}
+
+export function HazardLabView({ id, highlightRow }: { id: string; highlightRow?: number }) {
+ const c: HazardData = HAZARD.find((x) => x.id === id) ?? (HAZARD[0] as HazardData);
+ const base = c.rows[2]?.cycles ?? 0;
+ return (
+
+
+ {HAZARD.map((x) => (
+
+ {x.title}
+
+ ))}
+
+
+
+
+
{c.title}
+
{c.why}
+
+ What to look for:
+ {c.expect}
+
+
{c.source}
+
+
+
+
+
+
+ configuration
+ cycles
+ stalls
+ flushes
+ vs single
+
+
+
+ {c.rows.map((row, i) => {
+ const d = row.cycles - base;
+ const single = row.name.startsWith("single");
+ return (
+
+ {row.name}
+ {row.cycles}
+ {single ? "—" : row.stalls}
+ {single ? "—" : row.flushes}
+ {d >= 0 ? `+${d}` : d}
+
+ );
+ })}
+
+
+
+
+
+
+
+ );
+}
diff --git a/video/src/ui/ProgramsTableView.tsx b/video/src/ui/ProgramsTableView.tsx
new file mode 100644
index 0000000..e2251fc
--- /dev/null
+++ b/video/src/ui/ProgramsTableView.tsx
@@ -0,0 +1,66 @@
+// Presentational copy of web/src/components/ProgramsView.tsx (same markup and classes) fed with real run results.
+import React from "react";
+import { Heatmap } from "@web/components/Heatmap";
+import { FMAX_MHZ, PROGRAMS } from "./runData";
+
+export function ProgramsTableView({ id, heatProgress = 1 }: { id: string; heatProgress?: number }) {
+ const chosen = PROGRAMS.find((r) => r.id === id) ?? PROGRAMS[0];
+ if (!chosen) return null;
+ const words = chosen.heat.words;
+ // reveal the heat map progressively so the video can "paint" it; progress 1 = the real final map
+ const shown = Math.round(words * heatProgress);
+ const reads = Uint32Array.from(chosen.heat.reads.map((v, i) => (i < shown ? v : 0)));
+ const writes = Uint32Array.from(chosen.heat.writes.map((v, i) => (i < shown ? v : 0)));
+ return (
+
+
+
+
+
+ program
+ instructions
+ single-cycle
+ pipeline
+
+ time on iCE40
+
+ pipeline CPI
+ stalls
+ flushes
+ result
+
+
+
+ {PROGRAMS.map((r) => (
+
+
+
+ {r.title}
+
+
+ {r.instructions}
+ {r.singleCycles}
+ {r.pipeCycles}
+ {r.timeUs.toFixed(1)} µs
+ {r.pipeCpi.toFixed(2)}
+ {r.stalls}
+ {r.flushes}
+
+ {r.pass ? "PASS" : "FAIL"}
+
+
+ ))}
+
+
+
+
+ Single-cycle cycles equal the instruction count. The pipeline's cycle count is what the same program takes with forwarding on.
+ Time on an iCE40UP5K is cycles divided by the post-route clock of the pipeline (see the README's FPGA section).
+
+
+
Data memory: {chosen.title}
+
+
+
+ );
+}
diff --git a/video/src/ui/layout.json b/video/src/ui/layout.json
new file mode 100644
index 0000000..b7b0748
--- /dev/null
+++ b/video/src/ui/layout.json
@@ -0,0 +1,610 @@
+{
+ "pipeline": {
+ "page": [
+ 0,
+ 0,
+ 1536,
+ 1149
+ ],
+ "header": [
+ 16,
+ 24,
+ 1504,
+ 35
+ ],
+ "toolbar": [
+ 16,
+ 75,
+ 1504,
+ 77
+ ],
+ "program": [
+ 29,
+ 88,
+ 115,
+ 51
+ ],
+ "forwarding": [
+ 311,
+ 115,
+ 94,
+ 24
+ ],
+ "assemble": [
+ 417,
+ 107,
+ 142,
+ 32
+ ],
+ "tabs": [
+ 16,
+ 168,
+ 1504,
+ 34
+ ],
+ "tab-pipeline": [
+ 16,
+ 168,
+ 97,
+ 33
+ ],
+ "tab-datapath": [
+ 117,
+ 168,
+ 106,
+ 33
+ ],
+ "tab-hazards": [
+ 227,
+ 168,
+ 115,
+ 33
+ ],
+ "tab-programs": [
+ 347,
+ 168,
+ 109,
+ 33
+ ],
+ "source": [
+ 16,
+ 218,
+ 744,
+ 479
+ ],
+ "editor": [
+ 29,
+ 268,
+ 718,
+ 416
+ ],
+ "view": [
+ 776,
+ 218,
+ 744,
+ 479
+ ],
+ "pipeline": [
+ 789,
+ 268,
+ 718,
+ 277
+ ],
+ "cycle": [
+ 16,
+ 713,
+ 1504,
+ 191
+ ],
+ "controls": [
+ 29,
+ 763,
+ 1478,
+ 30
+ ],
+ "start": [
+ 29,
+ 763,
+ 39,
+ 30
+ ],
+ "back": [
+ 76,
+ 763,
+ 37,
+ 30
+ ],
+ "play": [
+ 120,
+ 763,
+ 37,
+ 30
+ ],
+ "step": [
+ 165,
+ 763,
+ 37,
+ 30
+ ],
+ "end": [
+ 210,
+ 763,
+ 39,
+ 30
+ ],
+ "stats": [
+ 29,
+ 805,
+ 1478,
+ 86
+ ],
+ "lower": [
+ 16,
+ 920,
+ 1504,
+ 205
+ ],
+ "registers": [
+ 16,
+ 920,
+ 640,
+ 205
+ ],
+ "memory": [
+ 672,
+ 920,
+ 450,
+ 205
+ ],
+ "console": [
+ 1138,
+ 920,
+ 382,
+ 205
+ ],
+ "stage0": [
+ 789,
+ 268,
+ 137,
+ 54
+ ],
+ "stage1": [
+ 934,
+ 268,
+ 137,
+ 54
+ ],
+ "stage2": [
+ 1079,
+ 268,
+ 137,
+ 54
+ ],
+ "stage3": [
+ 1225,
+ 268,
+ 137,
+ 54
+ ],
+ "stage4": [
+ 1370,
+ 268,
+ 137,
+ 54
+ ],
+ "badges": [
+ 789,
+ 338,
+ 718,
+ 22
+ ]
+ },
+ "datapath": {
+ "page": [
+ 0,
+ 0,
+ 1536,
+ 1152
+ ],
+ "header": [
+ 16,
+ 24,
+ 1504,
+ 35
+ ],
+ "toolbar": [
+ 16,
+ 75,
+ 1504,
+ 77
+ ],
+ "program": [
+ 29,
+ 88,
+ 115,
+ 51
+ ],
+ "forwarding": [
+ 311,
+ 115,
+ 94,
+ 24
+ ],
+ "assemble": [
+ 417,
+ 107,
+ 142,
+ 32
+ ],
+ "tabs": [
+ 16,
+ 168,
+ 1504,
+ 34
+ ],
+ "tab-pipeline": [
+ 16,
+ 168,
+ 97,
+ 33
+ ],
+ "tab-datapath": [
+ 117,
+ 168,
+ 106,
+ 33
+ ],
+ "tab-hazards": [
+ 227,
+ 168,
+ 115,
+ 33
+ ],
+ "tab-programs": [
+ 347,
+ 168,
+ 109,
+ 33
+ ],
+ "source": [
+ 16,
+ 218,
+ 744,
+ 482
+ ],
+ "editor": [
+ 29,
+ 268,
+ 718,
+ 416
+ ],
+ "view": [
+ 776,
+ 218,
+ 744,
+ 482
+ ],
+ "cycle": [
+ 16,
+ 716,
+ 1504,
+ 191
+ ],
+ "controls": [
+ 29,
+ 766,
+ 1478,
+ 30
+ ],
+ "start": [
+ 29,
+ 766,
+ 39,
+ 30
+ ],
+ "back": [
+ 76,
+ 766,
+ 37,
+ 30
+ ],
+ "play": [
+ 120,
+ 766,
+ 37,
+ 30
+ ],
+ "step": [
+ 165,
+ 766,
+ 37,
+ 30
+ ],
+ "end": [
+ 210,
+ 766,
+ 39,
+ 30
+ ],
+ "stats": [
+ 29,
+ 808,
+ 1478,
+ 86
+ ],
+ "lower": [
+ 16,
+ 923,
+ 1504,
+ 205
+ ],
+ "registers": [
+ 16,
+ 923,
+ 640,
+ 205
+ ],
+ "memory": [
+ 672,
+ 923,
+ 450,
+ 205
+ ],
+ "console": [
+ 1138,
+ 923,
+ 382,
+ 205
+ ]
+ },
+ "hazards": {
+ "0": [
+ 776,
+ 343,
+ 731,
+ 29
+ ],
+ "1": [
+ 776,
+ 372,
+ 731,
+ 29
+ ],
+ "2": [
+ 776,
+ 401,
+ 731,
+ 29
+ ],
+ "page": [
+ 0,
+ 0,
+ 1536,
+ 740
+ ],
+ "header": [
+ 16,
+ 24,
+ 1504,
+ 35
+ ],
+ "toolbar": [
+ 16,
+ 75,
+ 1504,
+ 77
+ ],
+ "program": [
+ 29,
+ 88,
+ 115,
+ 51
+ ],
+ "forwarding": [
+ 311,
+ 115,
+ 94,
+ 24
+ ],
+ "assemble": [
+ 417,
+ 107,
+ 142,
+ 32
+ ],
+ "tabs": [
+ 16,
+ 168,
+ 1504,
+ 34
+ ],
+ "tab-pipeline": [
+ 16,
+ 168,
+ 97,
+ 33
+ ],
+ "tab-datapath": [
+ 117,
+ 168,
+ 106,
+ 33
+ ],
+ "tab-hazards": [
+ 227,
+ 168,
+ 115,
+ 33
+ ],
+ "tab-programs": [
+ 347,
+ 168,
+ 109,
+ 33
+ ],
+ "view": [
+ 16,
+ 218,
+ 1504,
+ 498
+ ],
+ "independent": [
+ 29,
+ 268,
+ 129,
+ 30
+ ],
+ "alu-raw-1": [
+ 166,
+ 268,
+ 173,
+ 30
+ ],
+ "alu-raw-2": [
+ 348,
+ 268,
+ 173,
+ 30
+ ],
+ "load-use": [
+ 529,
+ 268,
+ 86,
+ 30
+ ],
+ "store-after-load": [
+ 623,
+ 268,
+ 177,
+ 30
+ ],
+ "store-then-load": [
+ 808,
+ 268,
+ 245,
+ 30
+ ],
+ "branch-taken": [
+ 1061,
+ 268,
+ 114,
+ 30
+ ],
+ "branch-not-taken": [
+ 1183,
+ 268,
+ 141,
+ 30
+ ]
+ },
+ "programs": {
+ "page": [
+ 0,
+ 0,
+ 1536,
+ 950.75
+ ],
+ "header": [
+ 16,
+ 24,
+ 1504,
+ 35
+ ],
+ "toolbar": [
+ 16,
+ 75,
+ 1504,
+ 77
+ ],
+ "program": [
+ 29,
+ 88,
+ 115,
+ 51
+ ],
+ "forwarding": [
+ 311,
+ 115,
+ 94,
+ 24
+ ],
+ "assemble": [
+ 417,
+ 107,
+ 142,
+ 32
+ ],
+ "tabs": [
+ 16,
+ 168,
+ 1504,
+ 34
+ ],
+ "tab-pipeline": [
+ 16,
+ 168,
+ 97,
+ 33
+ ],
+ "tab-datapath": [
+ 117,
+ 168,
+ 106,
+ 33
+ ],
+ "tab-hazards": [
+ 227,
+ 168,
+ 115,
+ 33
+ ],
+ "tab-programs": [
+ 347,
+ 168,
+ 109,
+ 33
+ ],
+ "view": [
+ 16,
+ 218,
+ 1504,
+ 709
+ ],
+ "hello": [
+ 29,
+ 297,
+ 1478,
+ 29
+ ],
+ "fib": [
+ 29,
+ 326,
+ 1478,
+ 29
+ ],
+ "sort": [
+ 29,
+ 355,
+ 1478,
+ 29
+ ],
+ "memcpy": [
+ 29,
+ 384,
+ 1478,
+ 29
+ ],
+ "primes": [
+ 29,
+ 413,
+ 1478,
+ 29
+ ],
+ "heatmap": [
+ 29,
+ 489,
+ 1478,
+ 425
+ ]
+ }
+}
\ No newline at end of file
diff --git a/video/src/ui/runData.ts b/video/src/ui/runData.ts
new file mode 100644
index 0000000..98942b8
--- /dev/null
+++ b/video/src/ui/runData.ts
@@ -0,0 +1,131 @@
+// Turns the exported JSON (scripts/gen-data.ts) into the shapes the app's own components take.
+import raw from "../data/run-data.json";
+import type { CycleState } from "@web/lib/state-schema";
+import type { AsmResult } from "@web/lib/assembler";
+import type { DatapathInfo } from "@web/lib/datapath";
+import type { RunModel, RunStats } from "@web/lib/run-model";
+
+export interface Win {
+ index: number;
+ state: CycleState;
+ stats: RunStats;
+ console: string;
+ mem: Uint8Array;
+}
+
+export interface RunBundle {
+ total: number;
+ source: string;
+ asm: AsmResult;
+ model: RunModel;
+ windows: Win[];
+ /** The exported window at `index`, or the nearest earlier one. */
+ at(index: number): Win;
+}
+
+interface RawWin {
+ index: number;
+ state: CycleState;
+ stats: RunStats;
+ console: string;
+ mem: string;
+}
+
+const unhex = (h: string) => Uint8Array.from(h.match(/../g) ?? [], (b) => parseInt(b, 16));
+
+function bundle(total: number, source: string, imem: number[], lineOf: Record, windows: RawWin[], memLen: number): RunBundle {
+ const wins: Win[] = windows.map((w) => ({ ...w, mem: unhex(w.mem) }));
+ const byIndex = new Map(wins.map((w) => [w.index, w]));
+ const states: CycleState[] = new Array(total);
+ for (const w of wins) states[w.index] = w.state;
+ const sorted = [...wins].sort((a, b) => a.index - b.index);
+ const at = (index: number): Win => {
+ const hit = byIndex.get(index);
+ if (hit) return hit;
+ let best = sorted[0] as Win;
+ for (const w of sorted) {
+ if (w.index <= index) best = w;
+ else break;
+ }
+ return best;
+ };
+ const asm = {
+ ok: true,
+ errors: [],
+ imem: Uint32Array.from(imem),
+ dmem: new Uint8Array(memLen),
+ symbols: {},
+ lineOf: Object.fromEntries(Object.entries(lineOf).map(([k, v]) => [Number(k), v])),
+ textBytes: imem.length * 4,
+ dataBytes: 0,
+ } as AsmResult;
+ const model: RunModel = {
+ states,
+ stats: (i) => at(i).stats,
+ memoryAt: (i) => at(i).mem,
+ consoleAt: (i) => at(i).console,
+ };
+ return { total, source, asm, model, windows: sorted, at };
+}
+
+const hero = raw.fib as unknown as {
+ total: number;
+ source: string;
+ imem: number[];
+ lineOf: Record;
+ windows: RawWin[];
+ memLen: number;
+};
+export const HERO: RunBundle = bundle(hero.total, hero.source, hero.imem, hero.lineOf, hero.windows, hero.memLen);
+
+const nf = raw.noFwd as unknown as { total: number; windows: RawWin[] };
+export const NOFWD: RunBundle = bundle(nf.total, hero.source, hero.imem, hero.lineOf, nf.windows, hero.memLen);
+
+export const EVENTS = raw.events as { stall: number; forward: number; flush: number; firstSwap: number };
+export const DATAPATH = raw.datapath as unknown as Record<"add" | "load" | "branch", DatapathInfo>;
+export const HELLO_SOURCE = (raw as unknown as { helloSource: string }).helloSource;
+export const FMAX_MHZ = raw.fmaxMhz as number;
+
+export interface HazardRow {
+ name: string;
+ cycles: number;
+ stalls: number | null;
+ flushes: number | null;
+}
+export interface HazardData {
+ id: string;
+ title: string;
+ why: string;
+ expect: string;
+ source: string;
+ rows: HazardRow[];
+ timelineFwd: (number | null)[][];
+ timelineNoFwd: (number | null)[][];
+}
+export const HAZARD = raw.hazard as unknown as HazardData[];
+
+export interface ProgramRow {
+ id: string;
+ title: string;
+ description: string;
+ instructions: number;
+ singleCycles: number;
+ pipeCycles: number;
+ noFwdCycles: number;
+ pipeCpi: number;
+ stalls: number;
+ flushes: number;
+ pass: boolean;
+ timeUs: number;
+ output: string;
+ heat: { words: number; reads: number[]; writes: number[] };
+}
+export const PROGRAMS = raw.programs as unknown as ProgramRow[];
+
+export const PRESET_LIST = [
+ { id: "hello", title: "Hello" },
+ { id: "fib", title: "Fibonacci" },
+ { id: "sort", title: "Bubble sort" },
+ { id: "memcpy", title: "memcpy" },
+ { id: "primes", title: "Primes" },
+];
diff --git a/video/tsconfig.json b/video/tsconfig.json
new file mode 100644
index 0000000..7167faa
--- /dev/null
+++ b/video/tsconfig.json
@@ -0,0 +1,17 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUncheckedIndexedAccess": true,
+ "resolveJsonModule": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "noEmit": true,
+ "lib": ["ES2022", "DOM"],
+ "paths": { "@web/*": ["../web/src/*"] }
+ },
+ "include": ["src", "scripts", "remotion.config.ts"]
+}
diff --git a/video/webpack-override.mjs b/video/webpack-override.mjs
new file mode 100644
index 0000000..9a92cec
--- /dev/null
+++ b/video/webpack-override.mjs
@@ -0,0 +1,28 @@
+// Shared webpack override: used by remotion.config.ts (CLI, studio, render) and by scripts/stills.mjs.
+import path from "node:path";
+import { enableTailwind } from "@remotion/tailwind-v4";
+
+// The video reuses the web app's real React components, so they must resolve from here:
+// @web -> ../web/src
+// react -> ONE copy (this project's), never web/node_modules/react, or hooks break
+// ?raw -> source text, the same trick Vite uses for the .s presets
+export const webpackOverride = (config) => {
+ const withTailwind = enableTailwind(config);
+ const here = process.cwd();
+ return {
+ ...withTailwind,
+ resolve: {
+ ...withTailwind.resolve,
+ alias: {
+ ...(withTailwind.resolve?.alias ?? {}),
+ "@web": path.resolve(here, "../web/src"),
+ react: path.resolve(here, "node_modules/react"),
+ "react-dom": path.resolve(here, "node_modules/react-dom"),
+ },
+ },
+ module: {
+ ...withTailwind.module,
+ rules: [{ resourceQuery: /raw/, type: "asset/source" }, ...(withTailwind.module?.rules ?? [])],
+ },
+ };
+};