PageSourceSearch

https://dmitrybrant.com/tc2000/tc2000.js

js dmitrybrant.com collected 2026-10-01 12:03:21 UTC 12,902 bytes, 311 lines download raw bytes

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.