1/* tc2000.js -- drop the TC2000 into a page. 2 * 3 * <link rel="stylesheet" href="vendor/xterm.css"> 4 * <link rel="stylesheet" href="tc2000.css"> 5 * <script src="vendor/xterm.js"></script> 6 * <script src="tc2000.js"></script> 7 * 8 * <tc2000-console></tc2000-console> 9 * 10 * or, imperatively: 11 * 12 * const s = TC2000.boot({ mount: document.querySelector('#machine') }); 13 * s.start(); 14 * 15 * Everything heavy happens in a Worker (nx88-worker.js); this file is the 16 * terminal, the chrome and the wiring. 17 */ 18(function (global) { 19 'use strict'; 20 21 /* Where nx88.js, nx88-worker.js and data/ live. Taken from this script's own 22 URL so the page can sit in a different directory from the emulator. */ 23 const SELF = (document.currentScript && document.currentScript.src) || 24 new URL('tc2000.js', location.href).href; 25 const BASE = new URL('.', SELF).href; 26 27 /* 80x24, ALWAYS. Not a style choice: the emulator answers the guest's 28 TIOCGWINSZ with a hard-coded 24x80 (console.c), and /etc/termcap on the 29 tape describes a vt100. A terminal of any other size would have the 30 curses programs -- vi, the games -- drawing to coordinates that are not 31 where the text is. So the geometry is fixed and the WHOLE terminal is 32 scaled to fit whatever box it is given, rather than reflowed. */ 33 const COLS = 80, ROWS = 24; 34 35 const DEFAULTS = { 36 /* One file: the tape image is a filesystem that CONTAINS the kernel, and 37 the emulator reads /vmunix out of it rather than being handed a separate 38 copy -- so there is nothing else to download. 39 --kmsg is the point of the demo: it echoes the kernel's OWN console 40 output ("nX Operating System (TC2000) #191: Tue Nov 28 18:33:02 1989", 41 the memory sizing, the VMEbus probe) rather than only the emulator's 42 commentary about it. */ 43 /* --color asks the EMULATOR to colour its kernel log, rather than the page 44 doing it: xterm.js renders the ANSI it emits natively, kmsg_line already 45 has each complete line in hand, and one implementation cannot drift out of 46 step with a second one. --no-color, or dropping the flag, gives plain 47 text. The palette is this terminal's `theme' below, so the colours stay 48 the page's to choose. */ 49 args: 'sys /boot.img --shell --kmsg --clock --color', 50 files: [ 51 { path: '/boot.img', url: 'data/boot.img', label: 'boot image' }, 52 ], 53 autostart: false, 54 fontSize: 15, 55 maxScale: 2.5, 56 theme: { 57 background: '#0d0f0e', 58 foreground: '#c8f0c0', 59 cursor: '#7dff7d', 60 selectionBackground: '#2d5a2d', 61 black: '#0d0f0e', red: '#e06c5a', green: '#8fdf7a', yellow: '#e6c86a', 62 blue: '#6aa9e6', magenta: '#c08fe6', cyan: '#6ae0d0', white: '#c8f0c0', 63 }, 64 }; 65 66 const enc = new TextEncoder(); 67 68 function el(tag, cls, parent) { 69 const n = document.createElement(tag); 70 if (cls) n.className = cls; 71 if (parent) parent.appendChild(n); 72 return n; 73 } 74 75 function boot(userOpts) { 76 const opts = Object.assign({}, DEFAULTS, userOpts || {}); 77 const base = opts.base ? new URL(opts.base, location.href).href : BASE; 78 const mount = opts.mount; 79 if (!mount) throw new Error('TC2000.boot: no mount element'); 80 if (typeof global.Terminal !== 'function') 81 throw new Error('TC2000.boot: xterm.js is not loaded (vendor/xterm.js)'); 82 83 mount.classList.remove('tc2000', 'tc2000--auto'); 84 mount.innerHTML = ''; 85 let autoHeight = !!opts.autoHeight; 86 if (opts.autoHeight === undefined) { 87 const prevDisplay = mount.style.display; 88 mount.style.display = 'block'; 89 autoHeight = mount.offsetHeight === 0; 90 mount.style.display = prevDisplay; 91 } 92 mount.classList.add('tc2000'); 93 if (autoHeight) mount.classList.add('tc2000--auto'); 94 const screen = el('div', 'tc2000-screen', mount); 95 const stage = el('div', 'tc2000-stage', screen); 96 const host = el('div', 'tc2000-term', stage); 97 const bar = el('div', 'tc2000-bar', mount); 98 const lamp = el('span', 'tc2000-lamp', bar); 99 const label = el('span', 'tc2000-label', bar); 100 const meter = el('span', 'tc2000-meter', bar); 101 const fill = el('i', '', meter); 102 const restart = opts.restartButton === false ? null 103 : el('button', 'tc2000-restart', bar);
104 const overlay = el('div', 'tc2000-overlay', screen); 105 106 label.textContent = 'BBN TC2000 â nX, 1989'; 107 108 if (restart) { 109 restart.type = 'button'; /* not submit: it may sit in a form */ 110 restart.textContent = 'Restart'; 111 restart.title = 'Reboot the machine from cold'; 112 restart.addEventListener('click', () => { 113 /* Hand the keyboard straight back. Without this the button keeps 114 focus through the reboot and the first thing typed at the new shell 115 goes to the button instead of the machine -- which, since Space and 116 Enter activate a focused button, means an accidental second reboot. */ 117 restart.blur(); 118 start(); 119 }); 120 } 121 122 /* ------------------------------------------------------------ terminal */ 123 const term = new global.Terminal({ 124 cols: COLS, rows: ROWS, 125 fontSize: opts.fontSize, 126 fontFamily: opts.fontFamily || 127 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace', 128 theme: opts.theme, 129 cursorBlink: true, 130 convertEol: false, /* the guest's ONLCR already sends CR LF */ 131 scrollback: 5000, 132 }); 133 term.open(host); 134 135 /* Scale the whole 80x24 terminal to fill the box. xterm's DOM renderer 136 draws real text, so a CSS transform re-rasterises rather than blurring -- 137 which is what makes fixing the geometry affordable in the first place. */ 138 let natW = 0, natH = 0; 139 function refit() { 140 const w = stage.offsetWidth, h = stage.offsetHeight; 141 if (!w || !h) return; 142 /* The natural size settles only once the font is really loaded, so keep 143 republishing it rather than reading it once at startup. */ 144 if (autoHeight && (w !== natW || h !== natH)) { 145 natW = w; natH = h; 146 mount.style.setProperty('--tc-aspect', `${w} / ${h}`); 147 } 148 const k = Math.min(screen.clientWidth / w, screen.clientHeight / h, 149 opts.maxScale); 150 stage.style.transform = `translate(-50%, -50%) scale(${k})`; 151 } 152 const ro = new ResizeObserver(refit); 153 ro.observe(screen); 154 requestAnimationFrame(refit); 155 156 /* --------------------------------------------------------------- state */ 157 let worker = null, running = false, started = false; 158 /* The disk images, kept across reboots. The worker gives them up once it 159 has copied them into the emulator's filesystem, and every subsequent 160 worker is handed a clone instead of downloading them again. */ 161 let images = null; 162 163 function setPhase(phase, text, loaded, total) { 164 mount.dataset.phase = phase; 165 if (text) label.textContent = text; 166 if (total > 0) { 167 meter.style.display = ''; 168 fill.style.width = Math.round((loaded / total) * 100) + '%'; 169 } else { 170 meter.style.display = 'none'; 171 } 172 if (opts.onStatus) opts.onStatus(phase, text); 173 } 174 175 function showOverlay(title, note, buttonText, onClick) { 176 overlay.innerHTML = ''; 177 overlay.style.display = 'flex'; 178 el('div', 'tc2000-overlay-title', overlay).textContent = title; 179 if (note) el('div', 'tc2000-overlay-note', overlay).textContent = note; 180 if (buttonText) { 181 const b = el('button', 'tc2000-button', overlay); 182 b.textContent = buttonText; 183 b.addEventListener('click', onClick); 184 } 185 } 186 function hideOverlay() { overlay.style.display = 'none'; overlay.innerHTML = ''; } 187 188 function stop() { 189 if (worker) { worker.terminate(); worker = null; } 190 running = false; 191 } 192 193 /* A reboot throws the worker away and makes a new one. The emulator's 194 machine state is a page of C globals with no teardown path -- running it 195 twice in one instance would start the second boot on the first one's 196 memory map -- and a fresh worker is a guaranteed-clean machine for the 197 price of re-instantiating a 140 KB module. */ 198 function start() { 199 stop(); 200 started = true; 201 term.reset(); 202 hideOverlay(); 203 setPhase('loading', 'Loading emulator'); 204 205 worker = new Worker(new URL('nx88-worker.js', base).href); 206 worker.onmessage = (e) => { 207 const m = e.data; 208 switch (m.type) { 209 case 'out': 210 term.write(m.data); 211 break; 212 case 'images': 213 images = m.files; 214 break; 215 case 'status': 216 if (m.phase === 'running' && !running) { 217 running = true; 218 setPhase('running', 'Booting nX â MC88100 @ node 0'); 219 term.focus(); 220 } else if (m.phase === 'shell') { 221 setPhase('running', m.text || 'nX â /bin/sh'); 222 } else if (m.phase === 'loading') { 223 setPhase('loading', m.text, m.loaded, m.total); 224 } 225 break; 226 case 'exit': 227 running = false; 228 setPhase('halted', 'Machine halted'); 229 showOverlay('Machine halted', 230 m.code ? `nx88 exited with status ${m.code}.` 231 : 'The shell exited and the machine stopped.', 232 'Reboot', start); 233 if (opts.onExit) opts.onExit(m.code); 234 break; 235 case 'error': 236 term.write('\r\n\x1b[31m[' + m.text + ']\x1b[0m\r\n'); 237 break; 238 } 239 }; 240 worker.onerror = (e) => { 241 setPhase('halted', 'Failed to start'); 242 showOverlay('Could not start', e.message || String(e), 'Try again', start); 243 }; 244 /* `images` is sent WITHOUT a transfer list, so the structured clone 245 leaves the page's own copy intact and the machine can be rebooted 246 any number of times. */ 247 worker.postMessage({ type: 'start', args: opts.args, files: opts.files, 248 base, preloaded: images }); 249 } 250 251 /* Keystrokes. xterm hands us the terminal's own encoding of the key -- 252 an escape sequence for the arrows, \x04 for Ctrl-D -- which is exactly 253 what a serial line would carry, so it goes straight across. */ 254 term.onData((s) => { 255 if (worker) worker.postMessage({ type: 'input', data: enc.encode(s) }); 256 });
257 term.onBinary((s) => { 258 if (!worker) return; 259 const b = new Uint8Array(s.length); 260 for (let i = 0; i < s.length; i++) b[i] = s.charCodeAt(i) & 0xff; 261 worker.postMessage({ type: 'input', data: b }); 262 }); 263 screen.addEventListener('mousedown', () => { if (running) term.focus(); }); 264 265 if (opts.autostart) start(); 266 else { 267 setPhase('idle', 'Ready'); 268 showOverlay('BBN TC2000', 269 'Boot nX from the 1989 install tape.', 270 'Boot the machine', start); 271 } 272 273 return { 274 term, 275 start, 276 stop, 277 reboot: start, 278 get running() { return running; }, 279 get started() { return started; }, 280 /* Feed the guest as if it had been typed -- handy for scripted demos. */ 281 send(text) { 282 if (worker) worker.postMessage({ type: 'input', data: enc.encode(text) }); 283 }, 284 eof() { if (worker) worker.postMessage({ type: 'eof' }); }, 285 destroy() { stop(); ro.disconnect(); term.dispose(); mount.innerHTML = ''; }, 286 }; 287 } 288 289 /* The declarative form. Attributes mirror the option names. */ 290 class TC2000Console extends HTMLElement { 291 connectedCallback() { 292 if (this._session) return; 293 const o = { mount: this, autostart: this.hasAttribute('autostart') }; 294 if (this.hasAttribute('args')) o.args = this.getAttribute('args'); 295 if (this.hasAttribute('base')) o.base = this.getAttribute('base'); 296 if (this.hasAttribute('font-size')) 297 o.fontSize = Number(this.getAttribute('font-size')); 298 if (this.hasAttribute('auto-height')) 299 o.autoHeight = this.getAttribute('auto-height') !== 'false'; 300 this._session = boot(o); 301 } 302 disconnectedCallback() { 303 if (this._session) { this._session.destroy(); this._session = null; } 304 } 305 get session() { return this._session; } 306 } 307 if (!customElements.get('tc2000-console')) 308 customElements.define('tc2000-console', TC2000Console); 309 310 global.TC2000 = { boot, COLS, ROWS, base: BASE }; 311})(window);
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.