commit bb8d187525d3b74bc2d672bd36ac9def7dc058ab
parent 1c785cc930aca52a8537e37851dccc8b346645f9
Author: Michael Neuper <michael@michaelneuper.com>
Date: Mon, 12 Oct 2026 15:01:02 +0200
rework hero
Diffstat:
| M | css/style.css | | | 232 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------- |
| M | index.html | | | 122 | ++++++++++++++++++++++++++++++++++++++++--------------------------------------- |
| M | js/coffee.js | | | 11 | ++++++----- |
| M | js/computer.js | | | 158 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------- |
| A | js/contact.js | | | 28 | ++++++++++++++++++++++++++++ |
| A | js/decode.js | | | 164 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | js/demos/computer.js | | | 1 | + |
| A | js/demos/othello.js | | | 242 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | js/demos/rbudp.js | | | 205 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| M | js/demos/scheduler.js | | | 52 | +++++++++++++++++++++++++++++++++++++++++----------- |
| M | js/main.js | | | 9 | +++++---- |
| M | js/memmap.js | | | 26 | +++++++++++++++++++++++--- |
| D | js/uart.js | | | 146 | ------------------------------------------------------------------------------- |
13 files changed, 1059 insertions(+), 337 deletions(-)
diff --git a/css/style.css b/css/style.css
@@ -367,6 +367,46 @@ code {
letter-spacing: -0.015em;
}
+/* On load the name decodes itself (js/decode.js): each letter starts as a byte
+ with a few bits flipped and changes one bit at a time until it is right. */
+
+.boot .hero-name {
+ visibility: hidden;
+ animation: boot-failsafe 0s 2.5s forwards;
+}
+
+@keyframes boot-failsafe {
+ to { visibility: visible; }
+}
+
+/* each letter sits in a box as wide as the final letter, and whatever
+ character it shows is centred in it */
+.hero-name.is-cells .bc {
+ display: inline-block;
+ text-align: center;
+}
+
+.hero-name.is-cells .bc-ch {
+ display: inline-block;
+ margin-inline: -1em;
+ white-space: pre;
+}
+
+.bc-word {
+ white-space: nowrap;
+}
+
+/* a wrong byte shows as a raw character in the mono face */
+.bc.is-wrong {
+ color: var(--dim);
+}
+
+.bc.is-wrong .bc-ch {
+ font-family: var(--mono);
+ font-size: 0.78em;
+ letter-spacing: 0;
+}
+
.hero-lede {
max-width: 44ch;
margin: 0 0 20px;
@@ -396,6 +436,38 @@ code {
vertical-align: 0.06em;
}
+/* landing: the MN-2026 pinout */
+
+.hero-chip {
+ justify-self: center;
+ width: min(100%, 460px);
+}
+
+.hero-chip .chip {
+ grid-template-columns: minmax(0, 1fr) 128px minmax(0, 1fr);
+ font-size: 13px;
+}
+
+.hero-chip .pin {
+ height: 34px;
+}
+
+.hero-chip .pin .leg {
+ height: 9px;
+}
+
+.hero-chip .pin-readout {
+ min-height: 6.5em;
+}
+
+/* the 8-bit computer, as the project's demo */
+
+.demo-wide {
+ margin-top: 40px;
+ padding-top: 28px;
+ border-top: 1px solid var(--rule);
+}
+
.sim-bar {
display: flex;
flex-wrap: wrap;
@@ -837,6 +909,10 @@ input[type="range"]::-moz-range-thumb {
align-items: start;
}
+.ds-desc {
+ margin: 40px 0 0;
+}
+
.ds-h {
margin: 48px 0 16px;
font-size: 12px;
@@ -1452,6 +1528,103 @@ input[type="range"]::-moz-range-thumb {
white-space: pre;
}
+/* othello */
+
+.oth {
+ display: grid;
+ grid-template-columns: repeat(8, minmax(0, 1fr));
+ gap: 2px;
+ width: min(100%, 336px);
+ aspect-ratio: 1;
+ padding: 2px;
+ background: color-mix(in srgb, var(--fg) 25%, var(--bg));
+}
+
+.oth-cell {
+ position: relative;
+ padding: 0;
+ border: 0;
+ border-radius: 0;
+ background: color-mix(in srgb, var(--green) 28%, var(--bg));
+ cursor: default;
+}
+
+.oth-cell.legal {
+ cursor: pointer;
+}
+
+.oth-cell.legal::after {
+ content: "";
+ position: absolute;
+ inset: 40%;
+ border-radius: 50%;
+ background: var(--fg);
+ opacity: 0.4;
+}
+
+.oth-cell.last {
+ box-shadow: inset 0 0 0 2px var(--yellow);
+}
+
+.oth-cell .disc {
+ position: absolute;
+ inset: 12%;
+ border-radius: 50%;
+}
+
+.oth-cell .disc.b {
+ background: #1b1b1b;
+ box-shadow: 0 0 0 1px rgb(0 0 0 / 0.35);
+}
+
+.oth-cell .disc.w {
+ background: #f1efe8;
+ box-shadow: 0 0 0 1px rgb(0 0 0 / 0.15);
+}
+
+.oth-cell .heat {
+ position: absolute;
+ inset: 0;
+ z-index: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: color-mix(in srgb, var(--blue) var(--hp, 0%), transparent);
+ color: var(--fg);
+ font-size: 10px;
+}
+
+.oth-cell .heat:empty {
+ background: none;
+}
+
+/* rbudp */
+
+.rb-canvas {
+ height: 200px;
+}
+
+.rb-log {
+ max-height: 9.8em;
+ margin: 10px 0 0;
+ padding: 8px 10px;
+ overflow-y: auto;
+ border: 1px solid var(--rule);
+ list-style: none;
+ font-size: 11.5px;
+ line-height: 1.6;
+}
+
+.rb-log li {
+ display: grid;
+ grid-template-columns: 8ch 5ch minmax(0, 1fr);
+ gap: 1ch;
+}
+
+.rb-proto {
+ color: var(--cyan);
+}
+
.sched-legend {
display: flex;
flex-wrap: wrap;
@@ -1579,64 +1752,7 @@ input[type="range"]::-moz-range-thumb {
color: var(--fg);
}
-/* ───────────────────────────── uart / contact ───────────────────────────── */
-
-.uart {
- margin: 0 0 48px;
-}
-
-.uart-scroll {
- overflow-x: auto;
- border: 1px solid var(--rule);
-}
-
-.uart-svg {
- display: block;
- font-family: var(--mono);
-}
-
-.uart-svg .trace {
- fill: none;
- stroke: var(--fg);
- stroke-width: 1.5;
-}
-
-.uart-svg .frame rect {
- fill: transparent;
-}
-
-.uart-svg .frame.is-hot rect {
- fill: var(--wash);
-}
-
-.uart-svg .bubble {
- fill: none;
- stroke: var(--cyan);
-}
-
-.uart-svg .frame.is-hot .bubble {
- fill: color-mix(in srgb, var(--cyan) 22%, transparent);
-}
-
-.uart-svg .lbl {
- fill: var(--fg);
- font-size: 12px;
-}
-
-.uart-svg .sub {
- fill: var(--dim);
- font-size: 10px;
-}
-
-.uart-svg .tick {
- stroke: var(--rule);
-}
-
-.uart-frame {
- min-height: 1.6em;
- margin: 10px 0 0;
- font-size: 12px;
-}
+/* ───────────────────────────── contact ───────────────────────────── */
.contact {
display: flex;
diff --git a/index.html b/index.html
@@ -17,6 +17,13 @@
if (!known.test(t || "")) t = matchMedia("(prefers-color-scheme: light)").matches ? "doom-gruvbox-light" : "";
if (t) document.documentElement.dataset.theme = t;
} catch (e) {}
+ // Hide the name until js/decode.js has cut it into bands, so it never flashes whole first.
+ // CSS shows it anyway after a moment in case the script never runs.
+ try {
+ if (!matchMedia("(prefers-reduced-motion: reduce)").matches && (!location.hash || location.hash === "#top")) {
+ document.documentElement.classList.add("boot");
+ }
+ } catch (e) {}
</script>
<script type="module" src="js/main.js"></script>
</head>
@@ -30,7 +37,7 @@
<span>michael neuper</span>
</a>
<nav class="nav" id="nav" aria-label="Sections">
- <a href="#top">computer</a>
+ <a href="#top">pinout</a>
<a href="#about">about</a>
<a href="#projects">projects</a>
<a href="#posts">posts</a>
@@ -42,8 +49,8 @@
<main>
- <!-- ─────────────────────────── 8-bit computer ─────────────────────────── -->
- <section class="hero" id="top" data-file="sap-1.asm" data-mode="Asm" aria-labelledby="hero-name">
+ <!-- ─────────────────────────── landing ─────────────────────────── -->
+ <section class="hero" id="top" data-file="mn-2026.kicad_sym" data-mode="Lisp Data" aria-labelledby="hero-name">
<div class="hero-id">
<h1 class="hero-name" id="hero-name">Michael Neuper</h1>
<p class="hero-lede">Computer science student at Stellenbosch University. I like low-level software, electronics and the maths underneath both, and I do almost all of it in Emacs.</p>
@@ -55,46 +62,8 @@
</ul>
</div>
- <div class="hero-sim">
- <div class="board" role="group" aria-label="Simulated 8-bit breadboard computer"></div>
- <div class="sim-bar">
- <button type="button" class="btn" data-sim="run" aria-pressed="false">run</button>
- <button type="button" class="btn" data-sim="step">step</button>
- <button type="button" class="btn" data-sim="reset">reset</button>
- <label class="sim-field"><span>program</span>
- <select data-sim="program">
- <option value="fibonacci">fibonacci</option>
- <option value="multiply">multiply 7 × 6</option>
- <option value="count">count up and down</option>
- <option value="add">add 28 + 14</option>
- </select>
- </label>
- <label class="sim-field"><span>clock</span>
- <input type="range" min="0" max="100" value="43" data-sim="hz" aria-describedby="sim-hz">
- <output id="sim-hz">10 Hz</output>
- </label>
- </div>
- <p class="sim-note">The 8-bit breadboard computer I built from Ben Eater’s series, simulated one clock cycle at a time. <kbd>c</kbd> runs or pauses, <kbd>n</kbd> steps one cycle, <kbd>r</kbd> resets. Click a bit in RAM to change the program.</p>
- </div>
- </section>
-
- <!-- ───────────────────────────── datasheet ───────────────────────────── -->
- <section class="sec" id="about" data-file="whoami.pdf" data-mode="PDFView">
- <p class="winbar"><span class="file">whoami.pdf</span><span class="rule" aria-hidden="true"></span><span class="meta">revised June 2026</span></p>
-
- <header class="ds-head">
- <p class="ds-part">MN-2026</p>
- <h2 class="ds-title">Low-level, wide-bandwidth computer science student</h2>
- </header>
-
- <div class="ds-grid">
- <div class="ds-text">
- <h3 class="ds-h">1 Description</h3>
- <p class="prose">I study computer science at Stellenbosch University and have been a teaching assistant for introductory computer science and computer architecture. I like understanding things all the way down, from a line of C to the gates that run it, and I’m happiest when that ends in something I built: a scheduler, a compiler, or a computer on breadboards. Away from that I’m a linguistics nerd and a free-software advocate.</p>
- </div>
-
+ <div class="hero-chip">
<figure class="pinout">
- <h3 class="ds-h">2 Pin configuration and functions</h3>
<div class="chip-wrap"><!-- built from the list below --></div>
<div class="pin-readout" aria-live="polite"><p class="dim">Point at a pin to read its function.</p></div>
<figcaption>Figure 1. MN-2026, DIP-16 package, top view.</figcaption>
@@ -117,8 +86,20 @@
<li data-name="CAN" data-type="I/O">CAN bus, the network inside every car. Cars are my one interest that isn’t a computer, mostly.</li>
<li data-name="NC" data-type="no connect">Reserved for the next obsession.</li>
</ol>
- </figure>
+ </figure>
</div>
+ </section>
+
+ <!-- ───────────────────────────── datasheet ───────────────────────────── -->
+ <section class="sec" id="about" data-file="whoami.pdf" data-mode="PDFView">
+ <p class="winbar"><span class="file">whoami.pdf</span><span class="rule" aria-hidden="true"></span><span class="meta">revised June 2026</span></p>
+
+ <header class="ds-head">
+ <p class="ds-part">MN-2026</p>
+ <h2 class="ds-title">About</h2>
+ </header>
+
+ <p class="prose ds-desc">I study computer science at Stellenbosch University and have been a teaching assistant for introductory computer science and computer architecture. I like understanding things all the way down, from a line of C to the gates that run it, and I’m happiest when that ends in something I built: a scheduler, a compiler, or a computer on breadboards. Away from that I’m a linguistics nerd and a free-software advocate.</p>
<!-- Experience, as the one-line git log that would print it: "<date> - (refs) <subject> <author>".
data-lanes colours each graph character by branch: m main, u undergrad, t teaching-assistant,
@@ -160,15 +141,13 @@ yearly Vac Weeks.</div></div>
</div>
<p class="gitlog-hint dim">Click an underlined commit message to read the rest of it.</p>
- <h3 class="ds-h">3 Ordering information</h3>
- <p class="prose">Interested in systems programming, low-level software and security work. If that’s what you do, <a href="#contact">get in touch</a>.</p>
</section>
<!-- ──────────────────────────── memory map ──────────────────────────── -->
<section class="sec" id="projects" data-file="memory.map" data-mode="Fundamental">
<p class="winbar"><span class="file">memory.map</span><span class="rule" aria-hidden="true"></span><span class="meta" data-mm="meta"></span></p>
<h2 class="h2">Projects</h2>
- <p class="lede">Laid out like a linker map: each project is a region in memory holding its one-line summary, and the hexdump shows exactly those bytes. Select one to read about it; two of them run.</p>
+ <p class="lede">Laid out like a linker map: each project is a region in memory holding its one-line summary, and the hexdump shows exactly those bytes. Select one to read about it and try it out.</p>
<div class="mm">
<div class="mm-bar" aria-hidden="true"></div>
@@ -176,23 +155,35 @@ yearly Vac Weeks.</div></div>
<div class="mm-table">
<p class="mm-head" aria-hidden="true"><span>origin</span><span>length</span><span>name</span><span>source</span></p>
<!-- Edit projects here. data-cat: hw, sw, math or sec. data-src: shown in the source column.
- data-demo: scheduler or bytecode (optional). -->
+ data-demo: computer, scheduler, bytecode, othello or rbudp (optional); data-wide gives the
+ demo the full width under the list. -->
<ol class="mm-list">
- <li class="proj" data-cat="hw" data-src="built">
+ <li class="proj" data-cat="hw" data-src="built" data-demo="computer" data-wide>
<h3 class="proj-name">8-bit-computer</h3>
<p class="proj-sum">Ben Eater's 8-bit breadboard computer, built chip by chip.</p>
<div class="proj-body">
<p>My most recent finished project: a programmable computer on breadboards, built from 74-series logic chips following Ben Eater’s video series. It has 16 bytes of RAM, an 8-bit bus, and microcode in EEPROMs that drives sixteen control lines.</p>
- <p>The simulation at the top of this page runs the same instruction set and microcode, so you can step through it one control word at a time.</p>
+ <p>The simulation below runs the same instruction set and microcode, so you can step through a program one control word at a time.</p>
<p class="proj-stack">74HC logic, EEPROMs, breadboards, a lot of jumper wire</p>
- <p class="proj-links"><a href="#top">Run it</a> <a href="https://eater.net/8bit">Ben Eater’s series</a></p>
+ <p class="proj-links"><a href="https://eater.net/8bit">Ben Eater’s series</a></p>
</div>
</li>
<li class="proj" data-cat="sw" data-src="private" data-demo="scheduler">
<h3 class="proj-name">os-scheduler</h3>
<p class="proj-sum">A multicore operating system scheduler, for a concurrency course.</p>
<div class="proj-body">
- <p>The part of an operating system that decides which process runs on which core, and for how long. I wrote it for a concurrency course, so the source stays private, but the idea fits in a small simulation: processes arrive, wait in a shared ready queue, and get dispatched to cores under a scheduling policy.</p>
+ <p>The part of an operating system that decides which process runs on which core, and for how long. I wrote it for a concurrency course, so the source stays private, but the idea fits in a small simulation: processes arrive, wait in a shared ready queue, and get dispatched to cores under first come, first served, round robin or preemptive priority scheduling.</p>
+ <p class="proj-links dim">Source private (concurrency coursework)</p>
+ </div>
+ </li>
+ <li class="proj" data-cat="sw" data-src="private" data-demo="othello">
+ <h3 class="proj-name">othello-mcts</h3>
+ <p class="proj-sum">An Othello player in C that runs Monte Carlo tree search across MPI processes.</p>
+ <div class="proj-body">
+ <p>The player picks a move by running thousands of random games from the current position and keeping the move that was explored most, using UCT to balance trying new moves against improving promising ones. Each move gets one second.</p>
+ <p>The project was for a concurrency course, so the next step was to parallelise it with MPI, which I did in three ways: independent search trees combined at the end with <code>MPI_Allreduce</code>, trees that share their statistics every so often, and the same sharing done asynchronously with non-blocking sends. In ten-game matches on four processes, each version beat the one before it, except the asynchronous version, which won half its games against the synchronous one.</p>
+ <p>You can play a serial version, rewritten in JavaScript for this page, below. While it thinks, each legal move is shaded by how often the search tried it.</p>
+ <p class="proj-stack">C, MPI, Docker</p>
<p class="proj-links dim">Source private (concurrency coursework)</p>
</div>
</li>
@@ -205,6 +196,22 @@ yearly Vac Weeks.</div></div>
<p class="proj-links dim">Source private (computer architecture coursework)</p>
</div>
</li>
+ <li class="proj" data-cat="sw" data-src="private" data-demo="rbudp">
+ <h3 class="proj-name">chat-voip-p2p</h3>
+ <p class="proj-sum">Four networking projects in Java: chat, fast UDP file transfer, voice calls and P2P sharing.</p>
+ <div class="proj-body">
+ <p>A series of networking projects built with my group for a university course:</p>
+ <ul>
+ <li>A chat server that handles many clients at once, with a JavaFX client, load-tested with dozens of simultaneous users.</li>
+ <li>File transfer over Reliable Blast UDP, measured against TCP across packet sizes, loss rates and file sizes from 1 to 100 MB.</li>
+ <li>The chat extended with voice calls and group calls streamed over UDP, plus voice notes.</li>
+ <li>Peer-to-peer file sharing: a server brokers searches, then peers send files to each other directly over TCP, encrypted with AES-GCM keys exchanged using RSA, with pause and resume. We tested it across four machines over Tailscale.</li>
+ </ul>
+ <p>Below is the RBUDP transfer in miniature: blast every packet over UDP, ask over TCP which ones arrived, and resend the gaps.</p>
+ <p class="proj-stack">Java, JavaFX, TCP and UDP sockets, Java Sound, RSA, AES-GCM</p>
+ <p class="proj-links dim">Source private (coursework, group project)</p>
+ </div>
+ </li>
</ol>
</div>
<article class="mm-detail" aria-live="polite"></article>
@@ -232,19 +239,14 @@ yearly Vac Weeks.</div></div>
</section>
<!-- ───────────────────────────── contact ───────────────────────────── -->
- <section class="sec" id="contact" data-file="uart.log" data-mode="Fundamental">
- <p class="winbar"><span class="file">uart.log</span><span class="rule" aria-hidden="true"></span><span class="meta">115200 8N1</span></p>
+ <section class="sec" id="contact" data-file="*unsent mail*" data-mode="Message">
+ <p class="winbar"><span class="file">*unsent mail*</span><span class="rule" aria-hidden="true"></span><span class="meta">draft</span></p>
<h2 class="h2">Say hello</h2>
- <p class="lede">Email works best. Here’s my address the way a logic analyser sees it on a serial line: 115200 baud, eight data bits, no parity, one stop bit.</p>
-
- <div class="uart">
- <div class="uart-scroll"><svg class="uart-svg" role="img" aria-label="UART waveform of my email address"></svg></div>
- <p class="uart-frame dim" aria-live="polite">Point at a frame to decode it.</p>
- </div>
+ <p class="lede">Email works best. For anything private, encrypt it with my GPG key below.</p>
<div class="contact">
<!-- The address is only ever put together in JavaScript, so scrapers reading this HTML don't see it.
- The link, the copy button and the waveform all read data-user and data-host. -->
+ The link and the copy button read data-user and data-host. -->
<a class="mail" href="#contact" data-user="michael" data-host="michaelneuper.com">michael<span class="at"> <at> </span>michaelneuper.com</a>
<button type="button" class="btn" data-email="copy">copy address</button>
</div>
diff --git a/js/coffee.js b/js/coffee.js
@@ -1,9 +1,10 @@
// M-x coffee-refill: my GitHub bio, made to do something. A mug fills while
-// VCC (the datasheet's coffee supply pin) climbs back to 5 V, then the 8-bit
-// computer at the top of the page runs at four times its clock for 30 seconds.
+// VCC (the chip's coffee supply pin) climbs back to 5 V, then the 8-bit
+// computer in Projects runs at four times its clock for 30 seconds.
import { $, emit, reducedMotion, isTyping } from "./util.js";
-import { defcommand, message, goto } from "./shell.js";
+import { defcommand, message } from "./shell.js";
+import { showComputer } from "./computer.js";
const ROWS = 5;
const WIDTH = 10;
@@ -63,7 +64,7 @@ export function initCoffee() {
watch.className = "linkish";
watch.textContent = "watch the computer";
watch.hidden = true;
- watch.addEventListener("click", () => goto("top"));
+ watch.addEventListener("click", () => showComputer());
links.append(" ", watch);
let raf = 0;
@@ -111,7 +112,7 @@ export function initCoffee() {
: n <= 3 ? `Cup ${n} today, within the typical range.`
: n === 4 ? "Cup 4 today, above typical."
: "Cup 5 today: that’s the absolute maximum rating.";
- msg.textContent = `${note} The computer at the top now runs at ${BOOST.factor}× clock for ${BOOST.seconds} seconds.`;
+ msg.textContent = `${note} The 8-bit computer in Projects now runs at ${BOOST.factor}× clock for ${BOOST.seconds} seconds.`;
watch.hidden = false;
emit("caffeine", BOOST);
message(`Refilled. VCC 5.0 V, ${BOOST.factor}× clock for ${BOOST.seconds}s`, "ok");
diff --git a/js/computer.js b/js/computer.js
@@ -5,7 +5,7 @@
// a 7-segment display, carry and zero flags, and microcode that drives sixteen
// control lines over five steps per instruction.
-import { $, $$, hex, reducedMotion, frameWhileVisible, isTyping, on } from "./util.js";
+import { $, $$, hex, reducedMotion, onFrame, isTyping, on, emit } from "./util.js";
import { defcommand, message, completingRead, setSegment } from "./shell.js";
// Control lines, in the order the control-word LEDs show them.
@@ -128,14 +128,42 @@ const GLYPH = { 0: "abcdef", 1: "bc", 2: "abdeg", 3: "abcdg", 4: "bcfg", 5: "acd
// ── UI ────────────────────────────────────────────────────────────────
-export function initComputer() {
- const hero = $(".hero");
- const board = $(".board", hero);
- if (!board) return;
- const runBtn = $('[data-sim="run"]', hero);
- const progSel = $('[data-sim="program"]', hero);
- const hzIn = $('[data-sim="hz"]', hero);
- const hzOut = $("#sim-hz");
+const fmtHz = (f) => (f < 10 ? `${f.toFixed(1)} Hz` : `${Math.round(f)} Hz`);
+
+// State that outlives any one mounted board: the coffee overclock, and which
+// board is on the page (the 8-bit project's demo mounts and unmounts it).
+let boost = 1;
+let boostTimer = 0;
+let current = null;
+
+const TEMPLATE = `
+ <div class="board" role="group" aria-label="Simulated 8-bit breadboard computer"></div>
+ <div class="sim-bar">
+ <button type="button" class="btn" data-sim="run" aria-pressed="false">run</button>
+ <button type="button" class="btn" data-sim="step">step</button>
+ <button type="button" class="btn" data-sim="reset">reset</button>
+ <label class="sim-field"><span>program</span>
+ <select data-sim="program">
+ <option value="fibonacci">fibonacci</option>
+ <option value="multiply">multiply 7 × 6</option>
+ <option value="count">count up and down</option>
+ <option value="add">add 28 + 14</option>
+ </select>
+ </label>
+ <label class="sim-field"><span>clock</span>
+ <input type="range" min="0" max="100" value="43" data-sim="hz" aria-label="Clock speed">
+ <output>10 Hz</output>
+ </label>
+ </div>
+ <p class="sim-note">Simulated one clock cycle at a time. <kbd>c</kbd> runs or pauses, <kbd>n</kbd> steps one cycle, <kbd>r</kbd> resets. Click a bit in RAM to change the program, or the display to switch to signed.</p>`;
+
+export function mount(host) {
+ host.innerHTML = TEMPLATE;
+ const board = $(".board", host);
+ const runBtn = $('[data-sim="run"]', host);
+ const progSel = $('[data-sim="program"]', host);
+ const hzIn = $('[data-sim="hz"]', host);
+ const hzOut = $("output", host);
const s = { ram: new Uint8Array(16) };
resetState(s);
@@ -146,8 +174,7 @@ export function initComputer() {
let phase = 0;
let signed = false;
let dirty = true;
- let boost = 1; // M-x coffee-refill overclocks the clock for a while
- let boostTimer = 0;
+ let lastBoost = boost;
const effHz = () => Math.min(500, hz * boost);
// ── build the board ────────────────────────────────────────────────
@@ -400,10 +427,13 @@ export function initComputer() {
return { label, text: parts.join(", ") };
}
- const fmtHz = (f) => (f < 10 ? `${f.toFixed(1)} Hz` : `${Math.round(f)} Hz`);
// ── simulation loop ────────────────────────────────────────────────
function frame(t, dt) {
+ if (boost !== lastBoost) {
+ lastBoost = boost;
+ dirty = true;
+ }
if (running && !s.halted) {
acc += dt * effHz();
phase = (phase + dt * effHz()) % 1;
@@ -465,8 +495,8 @@ export function initComputer() {
}
runBtn.addEventListener("click", () => run());
- $('[data-sim="step"]', hero).addEventListener("click", step);
- $('[data-sim="reset"]', hero).addEventListener("click", reset);
+ $('[data-sim="step"]', host).addEventListener("click", step);
+ $('[data-sim="reset"]', host).addEventListener("click", reset);
progSel.addEventListener("change", () => {
load(progSel.value);
if (!reducedMotion.matches) run(true);
@@ -502,49 +532,77 @@ export function initComputer() {
// c, n and r while the computer is on screen
let visible = false;
- new IntersectionObserver(([e]) => { visible = e.isIntersecting; }, { threshold: 0.25 }).observe(board);
- document.addEventListener("keydown", (e) => {
+ let off = null;
+ const io = new IntersectionObserver(([e]) => {
+ visible = e.intersectionRatio >= 0.25;
+ if (e.isIntersecting && !off) off = onFrame(frame);
+ else if (!e.isIntersecting && off) { off(); off = null; }
+ }, { threshold: [0, 0.25] });
+ io.observe(board);
+ const onKey = (e) => {
if (!visible || e.defaultPrevented || e.repeat || isTyping(e) || e.metaKey || e.ctrlKey || e.altKey) return;
if (document.querySelector("dialog[open]")) return;
const act = { c: () => run(), n: step, r: reset }[e.key];
if (!act) return;
e.preventDefault();
act();
- });
-
- defcommand("computer-run", () => run(), { doc: "Run or pause the 8-bit computer", key: "c" });
- defcommand("computer-step", step, { doc: "Advance the clock by one cycle", key: "n" });
- defcommand("computer-reset", reset, { doc: "Reset the computer (RAM is kept)", key: "r" });
- defcommand("computer-load-program", async () => {
- const key = await completingRead("Load program: ", Object.entries(PROGRAMS).map(([k, p]) => ({ value: k, ann: p.title })));
- if (key) {
- load(key);
- run(true);
- document.getElementById("top").scrollIntoView({ behavior: reducedMotion.matches ? "auto" : "smooth" });
- }
- }, { doc: "Load one of the example programs into RAM", key: "SPC c l" });
-
- on("caffeine", ({ factor, seconds }) => {
- boost = factor;
- if (s.halted) reset();
- if (!running) run(true);
- let left = seconds;
- clearInterval(boostTimer);
- setSegment("misc", `${factor}× clock ${left}s`);
- boostTimer = setInterval(() => {
- left--;
- if (left > 0) return setSegment("misc", `${factor}× clock ${left}s`);
- clearInterval(boostTimer);
- boost = 1;
- dirty = true;
- setSegment("misc", "");
- message(`The caffeine wore off. Clock back to ${fmtHz(hz)}.`);
- }, 1000);
- dirty = true;
- });
+ };
+ document.addEventListener("keydown", onKey);
load("fibonacci");
setHz(+hzIn.value);
- run(!reducedMotion.matches);
- frameWhileVisible(board, frame, "0px");
+ run(!reducedMotion.matches || boost > 1);
+ const api = { run, step, reset, load, board, halted: () => s.halted, running: () => running };
+ current = api;
+ return () => {
+ io.disconnect();
+ off?.();
+ document.removeEventListener("keydown", onKey);
+ if (current === api) current = null;
+ };
+}
+
+// The 8-bit project's demo, opened and scrolled to if it isn't on the page yet.
+export async function showComputer() {
+ if (!current) {
+ emit("project", "8-bit-computer");
+ for (let i = 0; i < 90 && !current; i++) await new Promise((r) => requestAnimationFrame(r));
+ }
+ current?.board.scrollIntoView({ behavior: reducedMotion.matches ? "auto" : "smooth", block: "center" });
+ return current;
}
+
+const withComputer = (fn) => async () => {
+ const c = current ?? (await showComputer());
+ if (c) fn(c);
+};
+defcommand("computer-run", withComputer((c) => c.run()), { doc: "Run or pause the 8-bit computer", key: "c" });
+defcommand("computer-step", withComputer((c) => c.step()), { doc: "Advance the clock by one cycle", key: "n" });
+defcommand("computer-reset", withComputer((c) => c.reset()), { doc: "Reset the computer (RAM is kept)", key: "r" });
+defcommand("computer-load-program", async () => {
+ const key = await completingRead("Load program: ", Object.entries(PROGRAMS).map(([k, p]) => ({ value: k, ann: p.title })));
+ if (!key) return;
+ const c = await showComputer();
+ c?.load(key);
+ c?.run(true);
+}, { doc: "Load one of the example programs into RAM", key: "SPC c l" });
+
+// M-x coffee-refill: four times the clock for a while.
+on("caffeine", ({ factor, seconds }) => {
+ boost = factor;
+ if (current) {
+ if (current.halted()) current.reset();
+ if (!current.running()) current.run(true);
+ }
+ let left = seconds;
+ clearInterval(boostTimer);
+ setSegment("misc", `${factor}× clock ${left}s`);
+ boostTimer = setInterval(() => {
+ left--;
+ if (left > 0) return setSegment("misc", `${factor}× clock ${left}s`);
+ clearInterval(boostTimer);
+ boost = 1;
+ setSegment("misc", "");
+ message("The caffeine wore off. The clock is back to normal.");
+ }, 1000);
+});
diff --git a/js/contact.js b/js/contact.js
@@ -0,0 +1,28 @@
+// The page only shows "michael <at> michaelneuper.com"; the address is put
+// together here so a scraper reading the HTML never sees it whole.
+
+import { $ } from "./util.js";
+import { message } from "./shell.js";
+
+export function initContact() {
+ const sec = $("#contact");
+ if (!sec) return;
+ const mail = $(".mail", sec);
+ const address = `${mail.dataset.user}@${mail.dataset.host}`;
+ const arm = () => { mail.href = `mailto:${address}`; };
+ for (const ev of ["pointerenter", "focus", "touchstart"]) mail.addEventListener(ev, arm, { passive: true });
+ mail.addEventListener("click", arm);
+
+ const copy = $('[data-email="copy"]', sec);
+ copy?.addEventListener("click", async () => {
+ try {
+ await navigator.clipboard.writeText(address);
+ copy.textContent = "copied";
+ message(`Copied ${address}`, "ok", 3000);
+ } catch {
+ message(address, "", 8000);
+ copy.textContent = "shown below";
+ }
+ setTimeout(() => { copy.textContent = "copy address"; }, 2000);
+ });
+}
diff --git a/js/decode.js b/js/decode.js
@@ -0,0 +1,164 @@
+// The name arrives as bytes with a few bits flipped, so each letter starts out
+// as some other character. The flipped bits are corrected one at a time, and
+// every correction changes the letter to the character one bit closer, until
+// it lands on the right one. The echo area shows the bytes while that happens.
+//
+// Afterwards, pointing at a letter flips a couple of its bits again, and
+// M-x flip-bits replays the whole thing.
+
+import { $, onFrame, reducedMotion } from "./util.js";
+import { message, defcommand, goto } from "./shell.js";
+
+const rand = (n) => Math.floor(Math.random() * n);
+const bin = (n) => n.toString(2).padStart(8, "0");
+
+// Characters a letter may pass through: printable, and in the serif font
+// (it has no backtick).
+const printable = (v) => v >= 0x20 && v < 0x7f && v !== 0x60;
+
+function shuffle(a) {
+ for (let i = a.length - 1; i > 0; i--) {
+ const j = rand(i + 1);
+ [a[i], a[j]] = [a[j], a[i]];
+ }
+ return a;
+}
+
+// Pick k bits to flip and the order to correct them in, so that every byte on
+// the way back is printable. Letters keep bit 6 (they stay in 0x40-0x7f) and
+// the space keeps bits 5 and 6 (it stays in 0x20-0x3f).
+function corrupt(code, k) {
+ const pool = code === 0x20 ? [0, 1, 2, 3, 4] : [0, 1, 2, 3, 4, 5];
+ for (let tries = 0; tries < 50; tries++) {
+ const order = shuffle([...pool]).slice(0, k);
+ let v = order.reduce((x, b) => x ^ (1 << b), code);
+ const path = [v];
+ for (const b of order) path.push((v ^= 1 << b));
+ if (path.every(printable)) return path; // path[0] corrupted ... path[k] === code
+ }
+ return [code];
+}
+
+function build(h1) {
+ const text = h1.textContent.trim();
+ h1.setAttribute("aria-label", text);
+ h1.textContent = "";
+ // Measure on one line, so the space between the words keeps its width.
+ h1.style.whiteSpace = "nowrap";
+ let word = null;
+ const cells = [...text].map((ch) => {
+ // Each word's letters sit in a span that doesn't wrap: the browser would
+ // otherwise break the line between any two of them.
+ if (!word) {
+ word = document.createElement("span");
+ word.className = "bc-word";
+ h1.append(word);
+ }
+ const el = document.createElement("span");
+ el.className = "bc";
+ const glyph = document.createElement("span");
+ glyph.className = "bc-ch";
+ glyph.textContent = ch;
+ el.append(glyph);
+ word.append(el);
+ if (ch === " ") word = null; // the line may break after the space
+ return { el, glyph, code: ch.charCodeAt(0), path: [], busy: false };
+ });
+ // Fix each cell at its final letter's width, so the other characters a letter
+ // passes through never push the rest of the name around.
+ const fs = parseFloat(getComputedStyle(h1).fontSize);
+ const widths = cells.map((c) => c.el.getBoundingClientRect().width);
+ cells.forEach((c, i) => { c.el.style.width = `${(widths[i] / fs).toFixed(4)}em`; });
+ h1.style.whiteSpace = "";
+ h1.classList.add("is-cells");
+ return cells;
+}
+
+function show(c, v) {
+ c.glyph.textContent = String.fromCharCode(v);
+ c.el.classList.toggle("is-wrong", v !== c.code);
+}
+
+// Step every cell along its path on its own schedule until all are correct.
+// onStep runs after any change, for the echo area.
+function run(cells, onStep) {
+ const t0 = performance.now();
+ return new Promise((resolve) => {
+ const off = onFrame(() => {
+ const t = performance.now() - t0;
+ let changed = false;
+ let left = 0;
+ for (const c of cells) {
+ while (c.plan.length && c.plan[0].t <= t) {
+ show(c, c.plan.shift().v);
+ changed = true;
+ }
+ left += c.plan.length;
+ }
+ if (changed) onStep?.();
+ if (!left) {
+ off();
+ resolve();
+ }
+ });
+ });
+}
+
+export async function initDecode() {
+ const root = document.documentElement;
+ const h1 = $(".hero-name");
+ const reveal = () => root.classList.remove("boot");
+ if (!h1 || reducedMotion.matches) return reveal();
+
+ let cells;
+ try {
+ // The cells are measured in the serif, so wait for it (briefly).
+ await Promise.race([document.fonts.load(`400 64px "Computer Modern"`, "Michael Neuper"), new Promise((r) => setTimeout(r, 1500))]);
+ cells = build(h1);
+ } catch (err) {
+ console.error(err);
+ return reveal();
+ }
+ // the byte each cell shows right now
+ const current = (c) => c.glyph.textContent.charCodeAt(0);
+
+ async function decode() {
+ if (cells.some((c) => c.busy)) return;
+ let flipped = 0;
+ cells.forEach((c, j) => {
+ c.busy = true;
+ const path = corrupt(c.code, 3 + rand(3));
+ flipped += path.length - 1;
+ show(c, path[0]);
+ let t = 150 + j * 55;
+ c.plan = path.slice(1).map((v) => ({ v, t: (t += 70 + Math.random() * 50) }));
+ });
+ const echo = () => message(cells.map((c) => bin(current(c))).join(" "), "", 0);
+ reveal();
+ echo();
+ await run(cells, echo);
+ cells.forEach((c) => { c.busy = false; });
+ message(`Corrected ${flipped} flipped bits in ${cells.length} bytes.`, "ok", 4000);
+ }
+
+ // Point at a letter to flip a couple of its bits.
+ for (const c of cells) {
+ if (c.code === 0x20) continue;
+ c.el.addEventListener("pointerenter", () => {
+ if (c.busy || reducedMotion.matches) return;
+ c.busy = true;
+ const path = corrupt(c.code, 2);
+ show(c, path[0]);
+ c.plan = path.slice(1).map((v, n) => ({ v, t: 110 + n * 90 }));
+ run([c]).then(() => { c.busy = false; });
+ });
+ }
+
+ defcommand("flip-bits", () => {
+ goto("top", true);
+ setTimeout(decode, 400);
+ }, { doc: "Flip random bits in my name and watch them get corrected" });
+
+ if (root.classList.contains("boot")) await decode();
+ else reveal();
+}
diff --git a/js/demos/computer.js b/js/demos/computer.js
@@ -0,0 +1 @@
+export { mount } from "../computer.js";
diff --git a/js/demos/othello.js b/js/demos/othello.js
@@ -0,0 +1,242 @@
+// Othello against a serial Monte Carlo tree search player, rewritten from my C
+// version: UCT selection (C = 1), random playouts, and the most-visited move
+// at the end of the think time. While it thinks, each candidate move is shaded
+// by its share of the search.
+
+const C = 1;
+const DIRS = [[-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 1], [1, -1], [1, 0], [1, 1]];
+const BLACK = 1; // you
+const WHITE = -1; // the search
+const PASS = -1;
+const name = (i) => "abcdefgh"[i % 8] + (Math.floor(i / 8) + 1);
+
+function flips(b, i, p) {
+ if (b[i]) return [];
+ const out = [];
+ const r0 = Math.floor(i / 8);
+ const c0 = i % 8;
+ for (const [dr, dc] of DIRS) {
+ const run = [];
+ let r = r0 + dr;
+ let c = c0 + dc;
+ while (r >= 0 && r < 8 && c >= 0 && c < 8 && b[r * 8 + c] === -p) {
+ run.push(r * 8 + c);
+ r += dr;
+ c += dc;
+ }
+ if (run.length && r >= 0 && r < 8 && c >= 0 && c < 8 && b[r * 8 + c] === p) out.push(...run);
+ }
+ return out;
+}
+
+function moves(b, p) {
+ const out = [];
+ for (let i = 0; i < 64; i++) if (!b[i] && flips(b, i, p).length) out.push(i);
+ return out;
+}
+
+function play(b, i, p) {
+ if (i === PASS) return;
+ for (const f of flips(b, i, p)) b[f] = p;
+ b[i] = p;
+}
+
+function score(b) {
+ let n = 0;
+ for (const v of b) n += v;
+ return n; // > 0 black ahead
+}
+
+// One random game to the end; returns the winner (BLACK, WHITE or 0).
+function playout(board, toPlay) {
+ const b = board.slice();
+ let p = toPlay;
+ let passes = 0;
+ while (passes < 2) {
+ const ms = moves(b, p);
+ if (ms.length) {
+ play(b, ms[(Math.random() * ms.length) | 0], p);
+ passes = 0;
+ } else passes++;
+ p = -p;
+ }
+ return Math.sign(score(b));
+}
+
+function node(parent, move, board, toPlay) {
+ let untried = moves(board, toPlay);
+ if (!untried.length && moves(board, -toPlay).length) untried = [PASS];
+ return { parent, move, board, toPlay, untried, children: [], wins: 0, visits: 0 };
+}
+
+function iterate(root) {
+ let n = root;
+ // select
+ while (!n.untried.length && n.children.length) {
+ const logN = Math.log(n.visits);
+ let best = null;
+ let bestU = -Infinity;
+ for (const ch of n.children) {
+ const u = ch.wins / ch.visits + C * Math.sqrt(logN / ch.visits);
+ if (u > bestU) { bestU = u; best = ch; }
+ }
+ n = best;
+ }
+ // expand
+ if (n.untried.length) {
+ const k = (Math.random() * n.untried.length) | 0;
+ const m = n.untried.splice(k, 1)[0];
+ const b = n.board.slice();
+ play(b, m, n.toPlay);
+ const ch = node(n, m, b, -n.toPlay);
+ n.children.push(ch);
+ n = ch;
+ }
+ // simulate and back-propagate: wins count for whoever moved into the node
+ const winner = playout(n.board, n.toPlay);
+ for (let x = n; x; x = x.parent) {
+ x.visits++;
+ const mover = -x.toPlay;
+ if (winner === mover) x.wins += 1;
+ else if (winner === 0) x.wins += 0.5;
+ }
+}
+
+export function mount(host) {
+ host.innerHTML = `
+ <div class="demo-bar">
+ <button type="button" class="btn" data-k="new">new game</button>
+ <label class="sim-field" style="margin:0"><span>think time</span>
+ <select data-k="time">
+ <option value="250">0.25 s</option>
+ <option value="600" selected>0.6 s</option>
+ <option value="1000">1 s</option>
+ </select>
+ </label>
+ <span class="dim" data-k="score"></span>
+ </div>
+ <div class="oth" role="grid" aria-label="Othello board, you play black"></div>
+ <p class="cap" data-k="status" aria-live="polite"></p>`;
+ const grid = host.querySelector(".oth");
+ const status = host.querySelector('[data-k="status"]');
+ const scoreEl = host.querySelector('[data-k="score"]');
+ const timeSel = host.querySelector('[data-k="time"]');
+
+ const cells = Array.from({ length: 64 }, (_, i) => {
+ const b = document.createElement("button");
+ b.type = "button";
+ b.className = "oth-cell";
+ b.dataset.i = i;
+ b.innerHTML = '<span class="heat"></span><span class="disc"></span>';
+ grid.append(b);
+ return b;
+ });
+
+ let board;
+ let turn;
+ let last = -1;
+ let thinking = false;
+ let alive = true;
+ let heat = null;
+
+ function reset() {
+ board = new Int8Array(64);
+ board[27] = board[36] = WHITE;
+ board[28] = board[35] = BLACK;
+ turn = BLACK;
+ last = -1;
+ heat = null;
+ status.textContent = "Your move: you play black. Legal moves are dotted.";
+ paint();
+ }
+
+ function paint() {
+ const legal = turn === BLACK && !thinking ? new Set(moves(board, BLACK)) : new Set();
+ let b = 0;
+ let w = 0;
+ cells.forEach((cell, i) => {
+ const v = board[i];
+ if (v === BLACK) b++;
+ if (v === WHITE) w++;
+ cell.querySelector(".disc").className = `disc${v === BLACK ? " b" : v === WHITE ? " w" : ""}`;
+ cell.classList.toggle("legal", legal.has(i));
+ cell.classList.toggle("last", i === last);
+ const h = heat?.get(i);
+ const hs = cell.querySelector(".heat");
+ hs.textContent = h ? `${Math.round(h * 100)}` : "";
+ hs.style.setProperty("--hp", h ? `${Math.round(15 + h * 75)}%` : "0%");
+ cell.setAttribute("aria-label", `${name(i)}, ${v === BLACK ? "black" : v === WHITE ? "white" : legal.has(i) ? "legal move" : "empty"}`);
+ });
+ scoreEl.textContent = `black ${b}, white ${w}`;
+ }
+
+ function over() {
+ if (moves(board, BLACK).length || moves(board, WHITE).length) return false;
+ const s = score(board);
+ status.textContent = s > 0 ? "Game over: you win." : s < 0 ? "Game over: the search wins." : "Game over: a draw.";
+ turn = 0;
+ paint();
+ return true;
+ }
+
+ async function think() {
+ thinking = true;
+ paint();
+ const root = node(null, null, board.slice(), WHITE);
+ const budget = +timeSel.value;
+ const t0 = performance.now();
+ while (alive && performance.now() - t0 < budget) {
+ const slice = performance.now() + 14;
+ while (performance.now() < slice) iterate(root);
+ const total = root.visits || 1;
+ heat = new Map(root.children.filter((c) => c.move !== PASS).map((c) => [c.move, c.visits / total]));
+ status.textContent = `Searching: ${root.visits.toLocaleString("en")} random games so far.`;
+ paint();
+ await new Promise((r) => setTimeout(r, 0));
+ }
+ if (!alive) return;
+ let best = root.children[0];
+ for (const c of root.children) if (c.visits > best.visits) best = c;
+ play(board, best.move, WHITE);
+ last = best.move;
+ heat?.delete(best.move);
+ thinking = false;
+ turn = BLACK;
+ const pct = Math.round((best.wins / best.visits) * 100);
+ status.textContent = `White played ${name(best.move)} after ${root.visits.toLocaleString("en")} random games, expecting to win ${pct}% of them.`;
+ paint();
+ if (over()) return;
+ if (!moves(board, BLACK).length) {
+ status.textContent += " You have no legal move, so you pass.";
+ turn = WHITE;
+ setTimeout(() => alive && think(), 700);
+ }
+ heat = null;
+ setTimeout(() => alive && !thinking && paint(), 1200);
+ }
+
+ grid.addEventListener("click", (e) => {
+ const cell = e.target.closest(".oth-cell");
+ if (!cell || turn !== BLACK || thinking) return;
+ const i = +cell.dataset.i;
+ if (!flips(board, i, BLACK).length) return;
+ play(board, i, BLACK);
+ last = i;
+ heat = null;
+ paint();
+ if (over()) return;
+ if (moves(board, WHITE).length) {
+ turn = WHITE;
+ think();
+ } else {
+ status.textContent = "White has no legal move and passes. Your move again.";
+ paint();
+ }
+ });
+ host.querySelector('[data-k="new"]').addEventListener("click", () => {
+ if (!thinking) reset();
+ });
+
+ reset();
+ return () => { alive = false; };
+}
diff --git a/js/demos/rbudp.js b/js/demos/rbudp.js
@@ -0,0 +1,205 @@
+// Reliable Blast UDP in miniature, following our sender: blast every packet
+// over UDP, send BLAST_COMPLETE over TCP, get back MISSING_PACKETS (or
+// TRANSFER_DONE), and blast just the missing ones until nothing is missing.
+
+import { fitCanvas, themeColors, onFrame, reducedMotion } from "../util.js";
+
+const PACKETS = 64;
+const FLIGHT = 0.7; // seconds for a datagram to cross
+const TCP_TRIP = 0.55;
+
+export function mount(host) {
+ host.innerHTML = `
+ <div class="demo-bar">
+ <button type="button" class="btn" data-k="send">send file</button>
+ <label class="sim-field" style="margin:0"><span>packet loss</span>
+ <input type="range" min="0" max="60" value="20" data-k="loss" aria-label="Packet loss rate">
+ <output data-k="lossOut">20%</output>
+ </label>
+ <span class="dim" data-k="stats"></span>
+ </div>
+ <canvas class="rb-canvas" height="190" role="img" aria-label="Packets travelling from sender to receiver"></canvas>
+ <ol class="rb-log" aria-live="polite"></ol>`;
+ const cv = host.querySelector("canvas");
+ const log = host.querySelector(".rb-log");
+ const stats = host.querySelector('[data-k="stats"]');
+ const lossIn = host.querySelector('[data-k="loss"]');
+ const lossOut = host.querySelector('[data-k="lossOut"]');
+
+ let received = new Array(PACKETS).fill(false);
+ let toSend = [];
+ let round = 0;
+ let datagrams = 0;
+ let phase = "idle"; // idle | blast | tcp | done
+ let flights = [];
+ let tcp = null;
+ let clock = 0;
+ const loss = () => +lossIn.value / 100;
+
+ const line = (r, proto, text) => {
+ const li = document.createElement("li");
+ li.innerHTML = `<span class="dim">round ${r}</span><span class="rb-proto">${proto}</span><span></span>`;
+ li.lastChild.textContent = text;
+ log.append(li);
+ log.scrollTop = log.scrollHeight;
+ };
+
+ function start() {
+ received.fill(false);
+ toSend = Array.from({ length: PACKETS }, (_, i) => i);
+ round = 0;
+ datagrams = 0;
+ log.replaceChildren();
+ blast();
+ }
+
+ function blast() {
+ round++;
+ datagrams += toSend.length;
+ line(round, "UDP", `blast ${toSend.length} packet${toSend.length === 1 ? "" : "s"}`);
+ const gap = Math.min(0.03, 0.9 / toSend.length);
+ flights = toSend.map((seq, k) => ({ seq, lost: Math.random() < loss(), t0: clock + k * gap }));
+ phase = "blast";
+ if (reducedMotion.matches) {
+ for (const f of flights) if (!f.lost) received[f.seq] = true;
+ report();
+ }
+ }
+
+ function report() {
+ const missing = toSend.filter((s) => !received[s]);
+ line(round, "TCP", `BLAST_COMPLETE (${toSend.length} sequence numbers)`);
+ if (missing.length) {
+ const shown = missing.slice(0, 10).join(", ") + (missing.length > 10 ? ", …" : "");
+ line(round, "TCP", `MISSING_PACKETS ${missing.length}: ${shown}`);
+ } else {
+ line(round, "TCP", "TRANSFER_DONE");
+ }
+ toSend = missing;
+ if (reducedMotion.matches) {
+ if (missing.length && round < 50) return blast();
+ return finish();
+ }
+ tcp = { t0: clock, done: missing.length === 0 };
+ phase = "tcp";
+ }
+
+ function finish() {
+ phase = "done";
+ const extra = datagrams - PACKETS;
+ stats.textContent = `${round} round${round === 1 ? "" : "s"}, ${datagrams} datagrams (${extra} resent)`;
+ line(round, "done", `${PACKETS} packets delivered in ${round} round${round === 1 ? "" : "s"}`);
+ }
+
+ function step(dt) {
+ clock += dt;
+ if (phase === "blast") {
+ let pending = false;
+ for (const f of flights) {
+ const t = (clock - f.t0) / FLIGHT;
+ if (t >= 1 && !f.lost) received[f.seq] = true;
+ if (t < (f.lost ? 1.4 : 1)) pending = true;
+ }
+ if (!pending) report();
+ } else if (phase === "tcp" && clock - tcp.t0 > TCP_TRIP) {
+ if (tcp.done) finish();
+ else blast();
+ }
+ if (phase !== "done" && phase !== "idle") {
+ const got = received.filter(Boolean).length;
+ stats.textContent = `round ${round}, ${got} of ${PACKETS} received`;
+ }
+ }
+
+ function draw() {
+ if (!cv.isConnected) return;
+ fitCanvas(cv);
+ const g = cv.getContext("2d");
+ const W = cv.clientWidth;
+ const H = cv.clientHeight;
+ const c = themeColors();
+ g.clearRect(0, 0, W, H);
+ g.font = "11px 'JetBrains Mono', monospace";
+ g.textBaseline = "middle";
+
+ const tile = Math.max(7, Math.min(13, Math.floor((H - 70) / 8) - 3));
+ const step8 = tile + 3;
+ const gridW = 8 * step8 - 3;
+ const top = 24;
+ const sx = 4;
+ const rx = W - gridW - 4;
+ const pos = (x0, seq) => [x0 + (seq % 8) * step8, top + Math.floor(seq / 8) * step8];
+
+ g.fillStyle = c.dim;
+ g.textAlign = "left";
+ g.fillText("sender", sx, 10);
+ g.textAlign = "right";
+ g.fillText("receiver", rx + gridW, 10);
+ g.textAlign = "center";
+ g.fillText("UDP datagrams", W / 2, 10);
+
+ const resend = new Set(phase === "blast" || phase === "tcp" ? toSend : []);
+ for (let s = 0; s < PACKETS; s++) {
+ const [x, y] = pos(sx, s);
+ g.fillStyle = resend.has(s) && round > 1 ? c.yellow : c.faint;
+ g.fillRect(x, y, tile, tile);
+ const [x2, y2] = pos(rx, s);
+ g.fillStyle = received[s] ? c.green : c.rule;
+ g.fillRect(x2, y2, tile, tile);
+ }
+
+ if (phase === "blast") {
+ for (const f of flights) {
+ const t = (clock - f.t0) / FLIGHT;
+ if (t <= 0 || (!f.lost && t >= 1) || t >= 1.4) continue;
+ const [ax, ay] = pos(sx, f.seq);
+ const [bx, by] = pos(rx, f.seq);
+ if (f.lost && t > 0.55) {
+ // dropped somewhere in the network
+ const k = (t - 0.55) / 0.85;
+ const x = ax + (bx - ax) * 0.55;
+ const y = ay + (by - ay) * 0.55 + k * k * 60;
+ g.globalAlpha = Math.max(0, 1 - k);
+ g.fillStyle = c.red;
+ g.fillRect(x, y, tile, tile);
+ g.globalAlpha = 1;
+ } else {
+ g.fillStyle = c.fg;
+ g.fillRect(ax + (bx - ax) * t, ay + (by - ay) * t, tile, tile);
+ }
+ }
+ }
+
+ // the TCP control channel along the bottom
+ const ty = top + 8 * step8 + 16;
+ g.strokeStyle = c.rule;
+ g.beginPath();
+ g.moveTo(sx, ty + 0.5);
+ g.lineTo(rx + gridW, ty + 0.5);
+ g.stroke();
+ g.fillStyle = c.dim;
+ g.textAlign = "center";
+ g.fillText("TCP control channel", W / 2, ty + 14);
+ if (phase === "tcp") {
+ const k = Math.min(1, (clock - tcp.t0) / TCP_TRIP);
+ const x = rx + gridW - (rx + gridW - sx) * k;
+ g.fillStyle = c.cyan;
+ g.beginPath();
+ g.arc(x, ty + 0.5, 4, 0, Math.PI * 2);
+ g.fill();
+ g.textAlign = "center";
+ g.fillText(tcp.done ? "TRANSFER_DONE" : `MISSING_PACKETS (${toSend.length})`, x, ty - 10);
+ }
+ }
+
+ host.querySelector('[data-k="send"]').addEventListener("click", start);
+ lossIn.addEventListener("input", () => { lossOut.value = `${lossIn.value}%`; });
+
+ const off = onFrame((t, dt) => {
+ if (!host.isConnected) { off(); return; }
+ step(dt);
+ draw();
+ });
+ stats.textContent = "64 packets ready to send";
+ return () => off();
+}
diff --git a/js/demos/scheduler.js b/js/demos/scheduler.js
@@ -11,16 +11,16 @@ export function mount(host) {
host.innerHTML = `
<div class="demo-bar">
<select aria-label="Scheduling policy">
- <option value="rr">round robin, quantum ${QUANTUM}</option>
- <option value="sjf">shortest job first</option>
<option value="fcfs">first come, first served</option>
+ <option value="rr" selected>round robin, quantum ${QUANTUM}</option>
+ <option value="prio">preemptive priority</option>
</select>
<button type="button" class="btn" data-k="add">add process</button>
<button type="button" class="btn" data-k="pause" aria-pressed="false">pause</button>
<span class="dim" data-k="stats"></span>
</div>
<canvas height="150" aria-label="Gantt chart of processes running on four cores" role="img"></canvas>
- <p class="cap">A small simulation written for this page: four cores share one ready queue. Round robin preempts a process after ${QUANTUM} ticks; the other two policies let it run to completion.</p>`;
+ <p class="cap">A small simulation written for this page: four cores share one ready queue. First come, first served runs each process to completion. Round robin preempts a process after ${QUANTUM} ticks. Preemptive priority always runs the highest-priority processes (p1 is highest), so a new arrival can take a core from a lower-priority one.</p>`;
const cv = host.querySelector("canvas");
const sel = host.querySelector("select");
const stats = host.querySelector('[data-k="stats"]');
@@ -33,34 +33,58 @@ export function mount(host) {
let hist = Array.from({ length: CORES }, () => []);
let done = 0;
let waitSum = 0;
+ let preempted = 0;
let paused = false;
+ const prioOf = new Map();
const spawn = () => {
const burst = 2 + Math.floor(Math.random() * 10);
- queue.push({ id: nextId++, burst, left: burst, arrive: t, waited: 0 });
+ const prio = 1 + Math.floor(Math.random() * 4);
+ prioOf.set(nextId, prio);
+ prioOf.delete(nextId - 200);
+ queue.push({ id: nextId++, burst, left: burst, prio, arrive: t, waited: 0 });
};
for (let i = 0; i < 7; i++) spawn();
function tick() {
const active = queue.length + cores.filter((c) => c.p).length;
- if (active < 12 && Math.random() < 0.38) spawn();
+ if (active < 12 && Math.random() < 0.55) spawn();
// preempt
if (sel.value === "rr") {
for (const c of cores) {
if (c.p && c.slice >= QUANTUM && queue.length) {
queue.push(c.p);
c.p = null;
+ preempted++;
}
}
}
+ // the first of the highest-priority waiting processes
+ const bestWaiting = () => {
+ let i = 0;
+ queue.forEach((p, j) => { if (p.prio < queue[i].prio) i = j; });
+ return i;
+ };
// dispatch
for (const c of cores) {
if (c.p || !queue.length) continue;
- let i = 0;
- if (sel.value === "sjf") queue.forEach((p, j) => { if (p.left < queue[i].left) i = j; });
+ const i = sel.value === "prio" ? bestWaiting() : 0;
c.p = queue.splice(i, 1)[0];
c.slice = 0;
}
+ if (sel.value === "prio") {
+ // once every core is busy, a waiting process that outranks the lowest-priority
+ // running one takes its core
+ while (queue.length && cores.every((c) => c.p)) {
+ const i = bestWaiting();
+ const worst = cores.reduce((a, b) => (b.p.prio > a.p.prio ? b : a));
+ if (queue[i].prio >= worst.p.prio) break;
+ queue.push(worst.p);
+ worst.p = queue.splice(i, 1)[0];
+ worst.slice = 0;
+ preempted++;
+ }
+ }
// run one tick
cores.forEach((c, k) => {
hist[k].push(c.p ? c.p.id : 0);
@@ -76,7 +100,8 @@ export function mount(host) {
});
for (const p of queue) p.waited++;
t++;
- stats.textContent = `t = ${t}, finished ${done}, mean wait ${done ? (waitSum / done).toFixed(1) : "0.0"} ticks`;
+ const pre = sel.value === "fcfs" ? "" : `, ${preempted} preempted`;
+ stats.textContent = `t = ${t}, finished ${done}, mean wait ${done ? (waitSum / done).toFixed(1) : "0.0"} ticks${pre}`;
}
function draw() {
@@ -114,7 +139,8 @@ export function mount(host) {
g.globalAlpha = 0.85;
g.fillRect(x, y, w, rh);
g.globalAlpha = 1;
- const label = `P${id}`;
+ const long = `P${id} p${prioOf.get(id)}`;
+ const label = sel.value === "prio" && g.measureText(long).width + 6 < w ? long : `P${id}`;
if (g.measureText(label).width + 6 < w) {
g.fillStyle = c.bg;
g.fillText(label, x + 3, y + rh / 2);
@@ -128,8 +154,11 @@ export function mount(host) {
g.fillStyle = c.dim;
g.fillText("ready", 0, qy + 9);
let x = lw;
- for (const p of queue) {
- const label = `P${p.id}·${p.left}`;
+ // under priority scheduling, show the queue in the order it will be served
+ const prio = sel.value === "prio";
+ const shown = prio ? [...queue].sort((a, b) => a.prio - b.prio) : queue;
+ for (const p of shown) {
+ const label = prio ? `P${p.id} p${p.prio}` : `P${p.id}·${p.left}`;
const w = g.measureText(label).width + 10;
if (x + w > W) break;
g.strokeStyle = color(p.id);
@@ -160,6 +189,7 @@ export function mount(host) {
hist = hist.map(() => []);
done = 0;
waitSum = 0;
+ preempted = 0;
});
return () => off();
diff --git a/js/main.js b/js/main.js
@@ -1,11 +1,12 @@
import { fontsReady } from "./util.js";
import "./shell.js";
-import { initComputer } from "./computer.js";
+import "./computer.js"; // registers the computer commands; the 8-bit project mounts it
+import { initDecode } from "./decode.js";
import { initDatasheet } from "./datasheet.js";
import { initGitlog } from "./gitlog.js";
import { initMemmap } from "./memmap.js";
import { initOrg } from "./org.js";
-import { initUart } from "./uart.js";
+import { initContact } from "./contact.js";
import { initCtf } from "./ctf.js";
import { initCoffee } from "./coffee.js";
@@ -13,11 +14,11 @@ function safely(fn) {
try { fn(); } catch (err) { console.error(err); }
}
-safely(initComputer);
+initDecode().catch((err) => { console.error(err); document.documentElement.classList.remove("boot"); });
safely(initDatasheet);
safely(initGitlog);
safely(initOrg);
-safely(initUart);
+safely(initContact);
safely(initCtf);
safely(initCoffee);
diff --git a/js/memmap.js b/js/memmap.js
@@ -3,7 +3,7 @@
// cumulative, and the hexdump shows the region's actual bytes. Projects with a
// data-demo attribute mount a small interactive demo under their write-up.
-import { $, $$, hex, reducedMotion } from "./util.js";
+import { $, $$, hex, reducedMotion, on } from "./util.js";
const GLYPHS = "░▒▓/\\<>+=*#%&@$0123456789ABCDEF";
@@ -23,6 +23,7 @@ export function initMemmap() {
cat: li.dataset.cat,
src: li.dataset.src || "",
demo: li.dataset.demo || "",
+ wide: "wide" in li.dataset,
name: $(".proj-name", li).textContent.trim(),
sum,
bytes: enc.encode(sum),
@@ -69,6 +70,7 @@ export function initMemmap() {
let current = -1;
let anim = 0;
let unmount = null;
+ let wideHost = null;
async function select(i, user = false) {
const p = projects[i];
@@ -83,6 +85,8 @@ export function initMemmap() {
});
unmount?.();
unmount = null;
+ wideHost?.remove();
+ wideHost = null;
detail.dataset.cat = p.cat;
const dump = document.createElement("pre");
@@ -97,8 +101,9 @@ export function initMemmap() {
if (p.demo) {
const host = document.createElement("div");
- host.className = "demo";
- detail.append(host);
+ host.className = p.wide ? "demo demo-wide" : "demo";
+ if (p.wide) wideHost = host;
+ placeDemo(host, p);
try {
const mod = await import(`./demos/${p.demo}.js`);
if (current === i && host.isConnected) unmount = mod.mount(host) ?? null;
@@ -112,6 +117,8 @@ export function initMemmap() {
function close() {
unmount?.();
unmount = null;
+ wideHost?.remove();
+ wideHost = null;
projects[current]?.btn.setAttribute("aria-pressed", "false");
projects[current]?.seg.classList.remove("is-active");
detail.replaceChildren();
@@ -124,7 +131,20 @@ export function initMemmap() {
if (!p) return;
if (narrow.matches) p.li.append(detail);
else $(".mm-cols", mm).append(detail);
+ if (wideHost) placeDemo(wideHost, p);
}
+
+ // Most demos sit under the write-up; a wide one gets the full width under the list.
+ function placeDemo(host, p) {
+ if (p.wide && !narrow.matches) mm.append(host);
+ else detail.append(host);
+ }
+
+ // Other parts of the page can open a project by name (the computer commands do).
+ on("project", (name) => {
+ const i = projects.findIndex((q) => q.name === name);
+ if (i >= 0 && i !== current) select(i);
+ });
narrow.addEventListener("change", place);
function hexdump(pre, p) {
diff --git a/js/uart.js b/js/uart.js
@@ -1,146 +0,0 @@
-// Draws the email address as a UART capture: 115200 baud, 8N1. Each byte is a
-// low start bit, eight data bits least-significant first, and a high stop bit,
-// with the decoded character above it the way a logic analyser shows it.
-
-import { $, $$, hex, reducedMotion } from "./util.js";
-import { message } from "./shell.js";
-
-const SVG = "http://www.w3.org/2000/svg";
-const BIT_US = 1e6 / 115200;
-const IDLE = 3;
-
-function el(name, attrs = {}, parent) {
- const n = document.createElementNS(SVG, name);
- for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, v);
- parent?.append(n);
- return n;
-}
-
-export function initUart() {
- const sec = $("#contact");
- if (!sec) return;
- const mail = $(".mail", sec);
- const svg = $(".uart-svg", sec);
- const scroller = $(".uart-scroll", sec);
- const info = $(".uart-frame", sec);
- // The page only shows "michael <at> michaelneuper.com"; the address is put
- // together here so a scraper reading the HTML never sees it whole.
- const address = `${mail.dataset.user}@${mail.dataset.host}`;
- const arm = () => { mail.href = `mailto:${address}`; };
- for (const ev of ["pointerenter", "focus", "touchstart"]) mail.addEventListener(ev, arm, { passive: true });
- mail.addEventListener("click", arm);
-
- const copy = $('[data-email="copy"]', sec);
- copy?.addEventListener("click", async () => {
- try {
- await navigator.clipboard.writeText(address);
- copy.textContent = "copied";
- message(`Copied ${address}`, "ok", 3000);
- } catch {
- message(address, "", 8000);
- copy.textContent = "shown below";
- }
- setTimeout(() => { copy.textContent = "copy address"; }, 2000);
- });
- const bytes = [...new TextEncoder().encode(address)];
- const total = IDLE * 2 + bytes.length * 10;
- let drawn = false;
-
- function render() {
- const avail = scroller.clientWidth;
- const bw = Math.max(3.6, (avail - 24) / total);
- const W = Math.round(bw * total + 24);
- const H = 132;
- const x0 = 12;
- const hi = 62;
- const lo = 96;
- svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
- svg.setAttribute("width", W);
- svg.setAttribute("height", H);
- svg.replaceChildren();
-
- // frames: hover areas, decoder bubbles, labels
- const framesG = el("g", {}, svg);
- bytes.forEach((b, i) => {
- const fx = x0 + (IDLE + i * 10) * bw;
- const fw = 10 * bw;
- const g = el("g", { class: "frame" }, framesG);
- el("rect", { x: fx, y: 0, width: fw, height: H }, g);
- el("rect", { class: "bubble", x: fx + 1.5, y: 10, width: fw - 3, height: fw > 34 ? 34 : 22, rx: 3 }, g);
- const ch = String.fromCharCode(b);
- const t = el("text", { class: "lbl", x: fx + fw / 2, y: 26, "text-anchor": "middle" }, g);
- t.textContent = ch;
- if (fw > 34) {
- const s = el("text", { class: "sub", x: fx + fw / 2, y: 39, "text-anchor": "middle" }, g);
- s.textContent = hex(b);
- }
- g.addEventListener("pointerenter", () => hot(g, i, b));
- });
-
- // bit-boundary ticks and a time axis every few frames
- const ticks = el("g", {}, svg);
- for (let k = 0; k <= total; k++) {
- const x = Math.round(x0 + k * bw) + 0.5;
- const frameEdge = k >= IDLE && (k - IDLE) % 10 === 0 && k <= total - IDLE;
- el("line", { class: "tick", x1: x, x2: x, y1: lo + 6, y2: lo + (frameEdge ? 14 : 9) }, ticks);
- }
- const every = Math.max(1, Math.ceil(70 / (10 * bw)));
- for (let i = 0; i <= bytes.length; i += every) {
- const k = IDLE + i * 10;
- const t = el("text", { class: "sub", x: x0 + k * bw, y: H - 6, "text-anchor": i ? "middle" : "start" }, svg);
- t.textContent = `${Math.round(i * 10 * BIT_US)} µs`;
- }
-
- // the logic trace
- const levels = [];
- for (let k = 0; k < IDLE; k++) levels.push(1);
- for (const b of bytes) {
- levels.push(0);
- for (let i = 0; i < 8; i++) levels.push((b >> i) & 1);
- levels.push(1);
- }
- for (let k = 0; k < IDLE; k++) levels.push(1);
- let d = `M${x0} ${hi}`;
- let prev = 1;
- levels.forEach((v, k) => {
- if (v !== prev) d += `V${v ? hi : lo}`;
- d += `H${x0 + (k + 1) * bw}`;
- prev = v;
- });
- const path = el("path", { class: "trace", d }, svg);
-
- if (!drawn && !reducedMotion.matches) {
- const len = path.getTotalLength();
- path.style.strokeDasharray = len;
- path.style.strokeDashoffset = len;
- framesG.style.opacity = 0;
- const io = new IntersectionObserver(([e]) => {
- if (!e.isIntersecting) return;
- io.disconnect();
- path.animate([{ strokeDashoffset: len }, { strokeDashoffset: 0 }], { duration: 1600, easing: "cubic-bezier(.3,0,.2,1)", fill: "forwards" })
- .finished.then(() => { path.style.strokeDasharray = ""; path.style.strokeDashoffset = ""; }).catch(() => {});
- framesG.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 500, delay: 1100, fill: "forwards" })
- .finished.then(() => { framesG.style.opacity = ""; }).catch(() => {});
- }, { threshold: 0.6 });
- io.observe(scroller);
- }
- drawn = true;
- }
-
- function hot(g, i, b) {
- $$(".frame.is-hot", svg).forEach((f) => f.classList.remove("is-hot"));
- g.classList.add("is-hot");
- const bits = Array.from({ length: 8 }, (_, k) => (b >> k) & 1).join("");
- info.textContent = `frame ${String(i + 1).padStart(2, "0")} of ${bytes.length} 0x${hex(b)} '${String.fromCharCode(b)}' start 0 data ${bits.slice(0, 4)} ${bits.slice(4)} (LSB first) stop 1`;
- }
- svg.addEventListener("pointerleave", () => $$(".frame.is-hot", svg).forEach((f) => f.classList.remove("is-hot")));
-
- render();
- let w = scroller.clientWidth;
- new ResizeObserver(() => {
- if (scroller.clientWidth !== w) {
- w = scroller.clientWidth;
- render();
- }
- }).observe(scroller);
-}