blockyard 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/CHANGELOG.md +929 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +4 -0
  4. package/README.md +191 -4
  5. package/SECURITY.md +38 -0
  6. package/bin/blockyard.js +41 -0
  7. package/config/pool-map.json +2620 -0
  8. package/docs/API.md +1577 -0
  9. package/docs/ARCHITECTURE.md +1394 -0
  10. package/docs/AUTO-UPDATE.md +269 -0
  11. package/docs/CONFIGURATION.md +847 -0
  12. package/docs/DEFECTS.md +813 -0
  13. package/docs/EFFECTS-AGENTS.md +448 -0
  14. package/docs/GETTING-STARTED.md +205 -0
  15. package/docs/INSTALL.md +547 -0
  16. package/docs/MEASUREMENTS.md +1401 -0
  17. package/docs/RULES.md +681 -0
  18. package/docs/SECURITY-AUDIT-2026-09-14.md +177 -0
  19. package/docs/SECURITY-AUDIT.md +258 -0
  20. package/docs/SECURITY.md +212 -0
  21. package/docs/TROUBLESHOOTING.md +332 -0
  22. package/docs/USER-GUIDE.md +1262 -0
  23. package/package.json +53 -5
  24. package/public/404.html +9 -0
  25. package/public/css/app.css +2009 -0
  26. package/public/donate-qr.png +0 -0
  27. package/public/index.html +1085 -0
  28. package/public/js/about.js +112 -0
  29. package/public/js/agents.js +1141 -0
  30. package/public/js/app.js +1386 -0
  31. package/public/js/arkanoid.js +806 -0
  32. package/public/js/blockanoid.js +347 -0
  33. package/public/js/blockout.js +347 -0
  34. package/public/js/blockpack.js +428 -0
  35. package/public/js/blockscene3d.js +2830 -0
  36. package/public/js/breakout.js +224 -0
  37. package/public/js/charts.js +635 -0
  38. package/public/js/depthchart.js +315 -0
  39. package/public/js/details3d.js +4342 -0
  40. package/public/js/doom.js +31 -0
  41. package/public/js/dosaudio.js +48 -0
  42. package/public/js/dosgame.js +389 -0
  43. package/public/js/dosio.js +186 -0
  44. package/public/js/dospc.js +1353 -0
  45. package/public/js/dosworker.js +196 -0
  46. package/public/js/explorer.js +405 -0
  47. package/public/js/feepalette.js +149 -0
  48. package/public/js/fmt.js +162 -0
  49. package/public/js/goggles.js +886 -0
  50. package/public/js/kiosk.js +41 -0
  51. package/public/js/login.js +88 -0
  52. package/public/js/markets.js +395 -0
  53. package/public/js/mining.js +1416 -0
  54. package/public/js/panels.js +970 -0
  55. package/public/js/pricechart.js +189 -0
  56. package/public/js/quake.js +20 -0
  57. package/public/js/settings.js +1096 -0
  58. package/public/js/soundcard.js +459 -0
  59. package/public/js/tetris.js +226 -0
  60. package/public/js/tetrust.js +356 -0
  61. package/public/js/tetsound.js +175 -0
  62. package/public/js/theme.js +235 -0
  63. package/public/js/wolf3d.js +22 -0
  64. package/public/js/x86.js +1978 -0
  65. package/public/login.html +33 -0
  66. package/scripts/blockfile-measure.js +156 -0
  67. package/scripts/browser-check.mjs +286 -0
  68. package/scripts/check.js +173 -0
  69. package/scripts/decode-check.js +81 -0
  70. package/scripts/doc-counts.js +109 -0
  71. package/scripts/donate-qr.py +23 -0
  72. package/scripts/dos-bench.js +56 -0
  73. package/scripts/fake-node.js +534 -0
  74. package/scripts/index-bench.js +216 -0
  75. package/scripts/index-benchmark.js +117 -0
  76. package/scripts/index-build.js +40 -0
  77. package/scripts/live-render-check.mjs +89 -0
  78. package/scripts/manage-users.js +132 -0
  79. package/scripts/motion-check.mjs +138 -0
  80. package/scripts/pool-map.js +157 -0
  81. package/scripts/setup.js +432 -0
  82. package/scripts/shots.mjs +278 -0
  83. package/scripts/smoke.sh +327 -0
  84. package/scripts/tls.js +31 -0
  85. package/scripts/ui.js +174 -0
  86. package/server/auth/sessions.js +221 -0
  87. package/server/auth/users.js +243 -0
  88. package/server/chain/blockfile.js +234 -0
  89. package/server/chain/index/build.js +210 -0
  90. package/server/chain/index/heights.js +36 -0
  91. package/server/chain/index/live.js +276 -0
  92. package/server/chain/index/rows.js +145 -0
  93. package/server/chain/index/store.js +154 -0
  94. package/server/chain/index/worker.js +109 -0
  95. package/server/chain/tx.js +310 -0
  96. package/server/collect/gbt.js +229 -0
  97. package/server/collect/logparse.js +765 -0
  98. package/server/collect/logtail.js +189 -0
  99. package/server/collect/markets.js +333 -0
  100. package/server/collect/mining.js +333 -0
  101. package/server/collect/monitor.js +2545 -0
  102. package/server/collect/network.js +295 -0
  103. package/server/collect/nextblock.js +275 -0
  104. package/server/collect/sync.js +386 -0
  105. package/server/config.js +644 -0
  106. package/server/http/api.js +1319 -0
  107. package/server/http/explorer.js +418 -0
  108. package/server/http/games.js +77 -0
  109. package/server/http/server.js +420 -0
  110. package/server/http/sse.js +176 -0
  111. package/server/http/static.js +212 -0
  112. package/server/main.js +673 -0
  113. package/server/netinfo.js +253 -0
  114. package/server/rpc/allowlist.js +130 -0
  115. package/server/rpc/client.js +414 -0
  116. package/server/store/audit.js +148 -0
  117. package/server/store/history.js +220 -0
  118. package/server/store/ledger.js +290 -0
  119. package/server/store/ring.js +173 -0
  120. package/server/tls/selfsigned.js +160 -0
  121. package/server/util/fmt.js +29 -0
  122. package/systemd/blockyard.service +102 -0
@@ -0,0 +1,31 @@
1
+ // DOOM, the tab (operator, 2026-09-15: "I've added doom_dos to the project directory. Get DOOM
2
+ // working as a diversion inside blockyard with zero dependancies").
3
+ //
4
+ // The shareware DOOM.EXE v1.9 from games/doom_dos/, unmodified, on the emulated PC (dosgame.js has
5
+ // the tab, dosworker.js the machine). What is DOOM's own is here: its keys, and the WASD switch,
6
+ // which rebinds the running game in place ("have to refresh for settings to take effect").
7
+ import { createDosGame } from './dosgame.js';
8
+ import { DEFAULT_CONTROLS } from './dosio.js';
9
+
10
+ const CONTROLS_KEY = 'blockyard.doom.controls';
11
+
12
+ export const renderDoom = createDosGame({
13
+ game: 'doom',
14
+ page: 'doom',
15
+ prefix: 'doom',
16
+ title: 'DOOM',
17
+ idleText: 'The shareware episode, Knee-Deep in the Dead: the real DOOM.EXE v1.9 running on a 486 this monitor emulates. Click the screen to use the mouse.',
18
+ loadingText: 'loading the shareware episode…',
19
+ saveName: /\.DSG$/i,
20
+ keysText: (p) => (p[CONTROLS_KEY] === 'wasd'
21
+ ? 'W S walk · A D strafe · ← → turn · E open · Ctrl or left click fire · Shift run · 1–7 weapons · Tab map · Esc menu · F2 save · F3 load'
22
+ : '↑ ↓ walk · ← → turn · Alt+arrows strafe · Space open · Ctrl or left click fire · Shift run · 1–7 weapons · Tab map · Esc menu · F2 save · F3 load'),
23
+ switches: [{
24
+ id: 'Wasd', pref: CONTROLS_KEY, fallback: DEFAULT_CONTROLS,
25
+ on: (v) => v === 'wasd',
26
+ flip: (v) => (v === 'wasd' ? 'classic' : 'wasd'),
27
+ // straight into the running game: no restart, no refresh
28
+ apply: (v, G) => G.worker?.postMessage({ type: 'controls', scheme: v }),
29
+ }],
30
+ bootMessage: (p) => ({ controls: p[CONTROLS_KEY] }),
31
+ });
@@ -0,0 +1,48 @@
1
+ // THE SPEAKERS of the DOS Diversions: an AudioWorklet that plays what the emulated Sound Blaster
2
+ // produced (public/js/soundcard.js, running in dosworker.js). The worker sends interleaved stereo chunks
3
+ // straight to this processor over a MessagePort; the page's thread never touches a sample.
4
+ //
5
+ // A queue with a ceiling: the machine's clock and the audio clock are both wall time, so they agree
6
+ // on average, but a stall on either side leaves one ahead. Too little queued plays silence until the
7
+ // next chunk; too much (over a quarter of a second) drops the oldest, so a hitch costs a click, not
8
+ // a delay that grows for the rest of the game.
9
+ class DosSpeakers extends AudioWorkletProcessor {
10
+ constructor() {
11
+ super();
12
+ this.chunks = [];
13
+ this.pos = 0;
14
+ this.queued = 0;
15
+ this.port.onmessage = (e) => {
16
+ if (e.data?.port) { e.data.port.onmessage = (m) => this.take(m.data); return; }
17
+ if (e.data?.flush) { this.chunks = []; this.pos = 0; this.queued = 0; }
18
+ };
19
+ }
20
+
21
+ take(chunk) {
22
+ if (!(chunk instanceof Float32Array) || chunk.length < 2) return;
23
+ this.chunks.push(chunk);
24
+ this.queued += chunk.length / 2;
25
+ const ceiling = sampleRate / 4;
26
+ while (this.queued > ceiling && this.chunks.length > 1) {
27
+ const old = this.chunks.shift();
28
+ this.queued -= (old.length - this.pos) / 2;
29
+ this.pos = 0;
30
+ }
31
+ }
32
+
33
+ process(_inputs, outputs) {
34
+ const out = outputs[0];
35
+ const l = out[0], r = out[1] ?? out[0];
36
+ for (let i = 0; i < l.length; i++) {
37
+ const c = this.chunks[0];
38
+ if (!c) { l[i] = 0; r[i] = 0; continue; }
39
+ l[i] = c[this.pos]; r[i] = c[this.pos + 1];
40
+ this.pos += 2;
41
+ this.queued--;
42
+ if (this.pos >= c.length) { this.chunks.shift(); this.pos = 0; }
43
+ }
44
+ return true;
45
+ }
46
+ }
47
+
48
+ registerProcessor('dos-speakers', DosSpeakers);
@@ -0,0 +1,389 @@
1
+ // A DOS GAME, THE TAB: what the DOOM and Quake Diversions share (operator, 2026-09-15: "Get DOOM
2
+ // working as a diversion inside blockyard with zero dependancies", then "get Quake working as a
3
+ // diversion").
4
+ //
5
+ // THE REAL GAMES. Not ports and not remakes: the shareware executables from games/, unmodified,
6
+ // running on a PC this repository emulates -- an i386 (x86.js), the DOS extender and the PC around
7
+ // it (dospc.js), a Sound Blaster Pro 2 with its OPL3 (soundcard.js) -- inside a worker
8
+ // (dosworker.js). A game's tab is this module with its own names: the monitor, the keyboard, the
9
+ // mouse and the speakers. It draws the frames the worker sends, and sends the worker scancodes,
10
+ // mouse motion and the audio port.
11
+ //
12
+ // Same manners as the other Diversions: it pauses when you change tabs or look away, holds the
13
+ // machine exactly where it was (the emulated clock stops with it, so nothing moves), and resumes on
14
+ // the button. Savegames and the config a game writes on quit are kept in this browser.
15
+ import { scancodes, textRuns, paletteLut } from './dosio.js';
16
+
17
+ /**
18
+ * One game's tab. `spec`:
19
+ * game the worker's name for it ('doom', 'quake')
20
+ * page the app's page id
21
+ * prefix the element id prefix in index.html ('doom' -> doomScreen, doomPlay, ...)
22
+ * title shown on the overlay
23
+ * idleText, loadingText
24
+ * saveName a RegExp for the files worth a "saved" toast
25
+ * keysText(prefs) the HUD's key list
26
+ * switches [{ id, pref, fallback, on(value), flip(value) -> value, apply(value, G) }]
27
+ * bootMessage(prefs) -> extra fields for the worker's boot message
28
+ * Returns the page's render hook for app.js.
29
+ */
30
+ export function createDosGame(spec) {
31
+ const pfx = spec.prefix;
32
+ const SMOOTH_KEY = `blockyard.${spec.game}.smooth`;
33
+ const MUTE_KEY = `blockyard.${spec.game}.mute`;
34
+
35
+ const G = {
36
+ worker: null, state: null, h: null, bound: false,
37
+ phase: 'idle', // idle | loading | running | paused | exited | failed
38
+ why: '',
39
+ audio: null, gain: null, speakers: null, connect: null,
40
+ lut: new Uint32Array(256), image: null, ctx: null,
41
+ held: new Set(), mouse: { dx: 0, dy: 0, buttons: 0, dirty: false },
42
+ frames: 0, fps: 0, mips: 0, fpsAt: 0, raf: null,
43
+ mode: 'graphics',
44
+ };
45
+
46
+ const el = (name) => document.getElementById(`${pfx}${name}`);
47
+ const pref = (key, fallback) => { try { return globalThis.localStorage?.getItem(key) ?? fallback; } catch { return fallback; } };
48
+ const setPref = (key, v) => { try { globalThis.localStorage?.setItem(key, v); } catch { /* private mode */ } };
49
+
50
+ // ---------------------------------------------------------------- the screen
51
+ function screen() {
52
+ const c = el('Screen');
53
+ if (!c) return null;
54
+ if (!G.ctx || G.ctx.canvas !== c) { G.ctx = c.getContext('2d', { alpha: false }); G.image = null; }
55
+ return c;
56
+ }
57
+
58
+ function drawFrame(pixels, palette) {
59
+ const c = screen();
60
+ if (!c) return;
61
+ if (palette) paletteLut(palette, G.lut);
62
+ if (G.mode !== 'graphics' || c.width !== 320) { c.width = 320; c.height = 200; G.mode = 'graphics'; G.image = null; }
63
+ if (!G.image) G.image = G.ctx.createImageData(320, 200);
64
+ const px = new Uint32Array(G.image.data.buffer);
65
+ const lut = G.lut;
66
+ for (let i = 0; i < 64000; i++) px[i] = lut[pixels[i]];
67
+ G.ctx.putImageData(G.image, 0, 0);
68
+ G.frames++;
69
+ }
70
+
71
+ // text mode: a game's start-up log, and DOOM's ENDOOM when it quits -- 80x25 cells at 8x16 pixels
72
+ function drawText(cells) {
73
+ const c = screen();
74
+ if (!c) return;
75
+ if (G.mode !== 'text') { c.width = 640; c.height = 400; G.mode = 'text'; }
76
+ const ctx = G.ctx;
77
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
78
+ ctx.fillStyle = '#000';
79
+ ctx.fillRect(0, 0, 640, 400);
80
+ ctx.font = '15px ui-monospace, "DejaVu Sans Mono", Menlo, Consolas, monospace';
81
+ ctx.textBaseline = 'top';
82
+ const w = ctx.measureText('M').width || 8;
83
+ for (const run of textRuns(cells)) {
84
+ if (run.bg !== '#000000') { ctx.fillStyle = run.bg; ctx.fillRect(run.x * 8, run.y * 16, run.text.length * 8, 16); }
85
+ if (!run.text.trim()) continue;
86
+ ctx.fillStyle = run.fg;
87
+ for (let i = 0; i < run.text.length; i++) {
88
+ const ch = run.text[i];
89
+ if (ch === ' ' || ch === '\u0000') continue;
90
+ // block elements fill their cell exactly; glyphs are squeezed to the cell's eight pixels
91
+ const x = (run.x + i) * 8, y = run.y * 16;
92
+ if (ch === '█') { ctx.fillRect(x, y, 8, 16); continue; }
93
+ if (ch === '▀') { ctx.fillRect(x, y, 8, 8); continue; }
94
+ if (ch === '▄') { ctx.fillRect(x, y + 8, 8, 8); continue; }
95
+ if (ch === '▌') { ctx.fillRect(x, y, 4, 16); continue; }
96
+ if (ch === '▐') { ctx.fillRect(x + 4, y, 4, 16); continue; }
97
+ ctx.setTransform(8 / w, 0, 0, 1, x, y + 1);
98
+ ctx.fillText(ch, 0, 0);
99
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
100
+ }
101
+ }
102
+ }
103
+
104
+ // ---------------------------------------------------------------- the overlay and HUD
105
+ function overlay(msg, sub, button, dim = true) {
106
+ const ov = el('Over');
107
+ if (!ov) return;
108
+ ov.classList.toggle('hidden', !msg);
109
+ ov.classList.toggle('dim', dim);
110
+ el('Msg').textContent = msg ?? '';
111
+ el('Sub').textContent = sub ?? '';
112
+ const b = el('Play');
113
+ if (b) { b.textContent = button ?? 'play'; b.classList.toggle('hidden', !button); }
114
+ }
115
+
116
+ const switchValue = (sw) => pref(sw.pref, sw.fallback);
117
+ const prefsNow = () => Object.fromEntries((spec.switches ?? []).map((sw) => [sw.pref, switchValue(sw)]));
118
+ function drawHud() {
119
+ const keys = el('Keys');
120
+ if (keys) keys.textContent = spec.keysText(prefsNow());
121
+ const lights = [...(spec.switches ?? []).map((sw) => [sw.id, sw.on(switchValue(sw))]),
122
+ ['Smooth', pref(SMOOTH_KEY, '0') === '1'], ['Sound', pref(MUTE_KEY, '0') !== '1']];
123
+ for (const [id, on] of lights) {
124
+ const b = el(id);
125
+ if (!b) continue;
126
+ b.classList.toggle('on', on);
127
+ b.setAttribute('aria-pressed', on ? 'true' : 'false');
128
+ }
129
+ el('Screen')?.classList.toggle('smooth', pref(SMOOTH_KEY, '0') === '1');
130
+ const st = el('Status');
131
+ if (st) {
132
+ const rows = [
133
+ ['machine', { idle: 'off', loading: 'booting', running: 'running', paused: 'paused', exited: 'powered off', failed: 'halted' }[G.phase]],
134
+ ['cpu', G.mips ? `${G.mips.toFixed(0)} MIPS` : '–'],
135
+ ['frames', G.phase === 'running' && G.fps ? `${G.fps.toFixed(0)} a second` : '–'],
136
+ ['sound', G.audio ? (pref(MUTE_KEY, '0') === '1' ? 'muted' : `Sound Blaster Pro 2 · ${G.audio.sampleRate} Hz`) : '–'],
137
+ ];
138
+ const html = rows.map(([k, v]) => `<i>${k}</i><b>${v}</b>`).join('');
139
+ if (st.innerHTML !== html) st.innerHTML = html;
140
+ }
141
+ }
142
+
143
+ // ---------------------------------------------------------------- audio
144
+ async function startAudio() {
145
+ // one context for the life of the page; each power-on gets a fresh port into it
146
+ if (G.audio) return G.connect?.() ?? null;
147
+ const Ctx = globalThis.AudioContext ?? globalThis.webkitAudioContext;
148
+ if (!Ctx) return null;
149
+ const ctx = new Ctx({ latencyHint: 'interactive' });
150
+ const gain = ctx.createGain();
151
+ gain.gain.value = pref(MUTE_KEY, '0') === '1' ? 0 : 1;
152
+ gain.connect(ctx.destination);
153
+ G.audio = ctx; G.gain = gain;
154
+ // An AudioWorklet needs a secure context; this monitor is often plain HTTP on a LAN address,
155
+ // where `audioWorklet` does not exist. The fallback is a ScriptProcessor fed on this thread.
156
+ if (ctx.audioWorklet && globalThis.AudioWorkletNode) {
157
+ try {
158
+ await ctx.audioWorklet.addModule('/js/dosaudio.js');
159
+ const node = new AudioWorkletNode(ctx, 'dos-speakers', { numberOfInputs: 0, outputChannelCount: [2] });
160
+ node.connect(gain);
161
+ G.speakers = node;
162
+ G.connect = () => {
163
+ const channel = new MessageChannel();
164
+ node.port.postMessage({ flush: true });
165
+ node.port.postMessage({ port: channel.port1 }, [channel.port1]);
166
+ return channel.port2;
167
+ };
168
+ return G.connect();
169
+ } catch { /* fall through to the processor */ }
170
+ }
171
+ const node = ctx.createScriptProcessor(1024, 0, 2);
172
+ const q = { chunks: [], pos: 0, queued: 0 };
173
+ node.onaudioprocess = (e) => {
174
+ const l = e.outputBuffer.getChannelData(0), r = e.outputBuffer.getChannelData(1);
175
+ for (let i = 0; i < l.length; i++) {
176
+ const c = q.chunks[0];
177
+ if (!c) { l[i] = 0; r[i] = 0; continue; }
178
+ l[i] = c[q.pos]; r[i] = c[q.pos + 1]; q.pos += 2; q.queued--;
179
+ if (q.pos >= c.length) { q.chunks.shift(); q.pos = 0; }
180
+ }
181
+ };
182
+ node.connect(gain);
183
+ G.speakers = node;
184
+ G.connect = () => {
185
+ const channel = new MessageChannel();
186
+ q.chunks = []; q.pos = 0; q.queued = 0;
187
+ channel.port1.onmessage = (m) => {
188
+ const c = m.data;
189
+ q.chunks.push(c); q.queued += c.length / 2;
190
+ while (q.queued > ctx.sampleRate / 4 && q.chunks.length > 1) { const o = q.chunks.shift(); q.queued -= (o.length - q.pos) / 2; q.pos = 0; }
191
+ };
192
+ return channel.port2;
193
+ };
194
+ return G.connect();
195
+ }
196
+
197
+ // ---------------------------------------------------------------- the machine
198
+ async function powerOn() {
199
+ if (G.worker) { G.worker.terminate(); G.worker = null; }
200
+ G.phase = 'loading'; G.mips = 0; G.fps = 0;
201
+ overlay(spec.title, spec.loadingText, null, true);
202
+ let port = null;
203
+ try { port = await startAudio(); } catch { port = null; }
204
+ if (G.audio?.state === 'suspended') G.audio.resume().catch(() => {});
205
+ const w = new Worker('/js/dosworker.js', { type: 'module' });
206
+ G.worker = w;
207
+ w.onmessage = (e) => onWorker(e.data);
208
+ w.onerror = (e) => fail(e.message || 'the machine failed to start');
209
+ if (port) w.postMessage({ type: 'audio', port }, [port]);
210
+ w.postMessage({ type: 'boot', game: spec.game, rate: G.audio ? G.audio.sampleRate : 0, ...(spec.bootMessage?.(prefsNow()) ?? {}) });
211
+ drawHud();
212
+ }
213
+
214
+ function onWorker(m) {
215
+ switch (m.type) {
216
+ case 'status':
217
+ if (m.text === 'booted') { G.phase = 'running'; overlay(null); loopUi(); }
218
+ else overlay(spec.title, m.text, null, true);
219
+ break;
220
+ case 'frame':
221
+ drawFrame(m.pixels, m.palette);
222
+ G.worker?.postMessage({ type: 'pixels', buffer: m.pixels.buffer }, [m.pixels.buffer]);
223
+ break;
224
+ case 'text': drawText(m.cells); break;
225
+ case 'stats': G.mips = m.mips; drawHud(); break;
226
+ case 'saved': if (!m.deleted && spec.saveName?.test(m.name)) G.h?.toast?.(`saved ${m.name} in this browser`); break;
227
+ case 'exit':
228
+ G.phase = 'exited';
229
+ releaseKeys();
230
+ drawText(m.cells);
231
+ document.exitPointerLock?.();
232
+ overlay(null);
233
+ el('Play2')?.classList.remove('hidden');
234
+ drawHud();
235
+ break;
236
+ case 'error': fail(m.message); break;
237
+ }
238
+ }
239
+
240
+ function fail(message) {
241
+ G.phase = 'failed';
242
+ G.worker?.terminate(); G.worker = null;
243
+ overlay(`${spec.title} stopped`, message, 'start again', true);
244
+ drawHud();
245
+ }
246
+
247
+ function pause(why) {
248
+ if (G.phase !== 'running') return;
249
+ G.phase = 'paused'; G.why = why;
250
+ releaseKeys();
251
+ G.worker?.postMessage({ type: 'run', on: false });
252
+ G.audio?.suspend().catch(() => {});
253
+ document.exitPointerLock?.();
254
+ overlay(why, 'the machine is holding exactly where it was', 'resume', true);
255
+ drawHud();
256
+ }
257
+
258
+ function resume() {
259
+ if (G.phase === 'paused') {
260
+ G.phase = 'running';
261
+ overlay(null);
262
+ G.audio?.resume().catch(() => {});
263
+ G.worker?.postMessage({ type: 'run', on: true });
264
+ loopUi();
265
+ drawHud();
266
+ return;
267
+ }
268
+ if (G.phase === 'idle' || G.phase === 'exited' || G.phase === 'failed') {
269
+ el('Play2')?.classList.add('hidden');
270
+ powerOn();
271
+ }
272
+ }
273
+
274
+ // the page's own loop: pause on leaving, batch the mouse, count frames
275
+ function loopUi() {
276
+ if (G.raf) return;
277
+ const tick = (t) => {
278
+ G.raf = null;
279
+ if (G.phase !== 'running') return;
280
+ if (document.hidden || G.state?.page !== spec.page) { pause(document.hidden ? 'paused — you looked away' : 'paused — you changed tabs'); return; }
281
+ if (G.mouse.dirty) {
282
+ G.worker?.postMessage({ type: 'mouse', dx: G.mouse.dx, dy: G.mouse.dy, buttons: G.mouse.buttons });
283
+ G.mouse.dx = 0; G.mouse.dy = 0; G.mouse.dirty = false;
284
+ }
285
+ if (t - G.fpsAt >= 1000) { G.fps = G.frames * 1000 / (t - G.fpsAt || 1000); G.frames = 0; G.fpsAt = t; drawHud(); }
286
+ G.raf = requestAnimationFrame(tick);
287
+ };
288
+ G.fpsAt = performance.now(); G.frames = 0;
289
+ G.raf = requestAnimationFrame(tick);
290
+ }
291
+
292
+ // ---------------------------------------------------------------- input
293
+ function releaseKeys() {
294
+ const codes = [];
295
+ for (const code of G.held) codes.push(...(scancodes(code, false) ?? []));
296
+ G.held.clear();
297
+ if (codes.length) G.worker?.postMessage({ type: 'key', codes });
298
+ if (G.mouse.buttons) { G.mouse.buttons = 0; G.mouse.dirty = true; }
299
+ }
300
+
301
+ function onKey(e, down) {
302
+ if (G.state?.page !== spec.page || G.phase !== 'running') return;
303
+ const tag = e.target?.tagName;
304
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
305
+ if (e.metaKey || e.code === 'F12') return; // the browser's own shortcuts and tools
306
+ const codes = scancodes(e.code, down);
307
+ if (!codes) return;
308
+ e.preventDefault();
309
+ if (down) G.held.add(e.code); else G.held.delete(e.code);
310
+ if (codes.length) G.worker?.postMessage({ type: 'key', codes });
311
+ }
312
+
313
+ function bind() {
314
+ if (G.bound) return;
315
+ G.bound = true;
316
+ document.addEventListener('keydown', (e) => onKey(e, true));
317
+ document.addEventListener('keyup', (e) => onKey(e, false));
318
+ window.addEventListener('blur', () => releaseKeys());
319
+ document.addEventListener('visibilitychange', () => { if (document.hidden) pause('paused — you looked away'); });
320
+ const c = el('Screen');
321
+ c?.addEventListener('click', () => { if (G.phase === 'running' && document.pointerLockElement !== c) c.requestPointerLock?.(); });
322
+ c?.addEventListener('contextmenu', (e) => e.preventDefault());
323
+ document.addEventListener('mousemove', (e) => {
324
+ if (document.pointerLockElement !== c || G.phase !== 'running') return;
325
+ G.mouse.dx += e.movementX; G.mouse.dy += e.movementY; G.mouse.dirty = true;
326
+ });
327
+ const buttons = (e) => {
328
+ if (document.pointerLockElement !== c || G.phase !== 'running') return;
329
+ // DOS mouse bits and the DOM's agree: 1 left, 2 right, 4 middle
330
+ G.mouse.buttons = e.buttons & 7; G.mouse.dirty = true;
331
+ };
332
+ document.addEventListener('mousedown', buttons);
333
+ document.addEventListener('mouseup', buttons);
334
+ el('Play')?.addEventListener('click', () => resume());
335
+ el('Play2')?.addEventListener('click', () => resume());
336
+ for (const sw of spec.switches ?? []) {
337
+ el(sw.id)?.addEventListener('click', () => {
338
+ const next = sw.flip(switchValue(sw));
339
+ setPref(sw.pref, next);
340
+ drawHud();
341
+ sw.apply?.(next, G);
342
+ });
343
+ }
344
+ el('Smooth')?.addEventListener('click', () => { setPref(SMOOTH_KEY, pref(SMOOTH_KEY, '0') === '1' ? '0' : '1'); drawHud(); });
345
+ el('Sound')?.addEventListener('click', () => {
346
+ const mute = pref(MUTE_KEY, '0') !== '1';
347
+ setPref(MUTE_KEY, mute ? '1' : '0');
348
+ if (G.gain) G.gain.gain.value = mute ? 0 : 1;
349
+ drawHud();
350
+ });
351
+ // FULL SCREEN, AND A WAY BACK (operator, 2026-09-15: "I can't exit fullscreen in the dos emulated
352
+ // games"). The keyboard lock on Escape is what lets a tap of Esc reach the game's own menu in
353
+ // full screen -- and it is also what takes away the browser's Esc-to-leave. So two ways out:
354
+ // an "exit full screen" button in the corner (drawn only while full screen), and HOLDING Esc
355
+ // for a second, which the page times itself rather than trusting the browser's own hold
356
+ // gesture (Chrome has one under keyboard lock; Firefox has no keyboard lock at all). Leaving
357
+ // unlocks the keyboard and lets the mouse go.
358
+ const leave = () => { if (document.fullscreenElement) document.exitFullscreen?.().catch(() => {}); };
359
+ el('Full')?.addEventListener('click', () => {
360
+ const wrap = el('Wrap');
361
+ if (!wrap?.requestFullscreen) return;
362
+ if (!wrap.querySelector('.dosexit')) {
363
+ const b = document.createElement('button');
364
+ b.type = 'button'; b.className = 'btn dosexit'; b.textContent = 'exit full screen'; b.title = 'or hold Esc for a second';
365
+ b.addEventListener('click', leave);
366
+ wrap.appendChild(b);
367
+ }
368
+ wrap.requestFullscreen().then(() => navigator.keyboard?.lock?.(['Escape'])).catch(() => {});
369
+ });
370
+ document.addEventListener('fullscreenchange', () => {
371
+ if (!document.fullscreenElement) { navigator.keyboard?.unlock?.(); document.exitPointerLock?.(); }
372
+ });
373
+ let escHold = null;
374
+ document.addEventListener('keydown', (e) => {
375
+ if (e.key !== 'Escape' || !document.fullscreenElement || e.repeat) return;
376
+ escHold = setTimeout(() => { escHold = null; leave(); }, 1000);
377
+ });
378
+ document.addEventListener('keyup', (e) => { if (e.key === 'Escape' && escHold) { clearTimeout(escHold); escHold = null; } });
379
+ }
380
+
381
+ /** The page's render hook, called by app.js whenever the app draws. */
382
+ return function render(s, state, h) {
383
+ G.state = state; G.h = h;
384
+ bind();
385
+ drawHud();
386
+ if (G.phase === 'idle') { overlay(spec.title, spec.idleText, 'play', false); return; }
387
+ if (G.phase === 'paused') overlay(G.why, 'the machine is holding exactly where it was', 'resume', true);
388
+ };
389
+ }
@@ -0,0 +1,186 @@
1
+ // DOS I/O: the pure pieces between a browser and the emulated PC, for the DOOM and Quake Diversions
2
+ // (operator, 2026-09-15: "Get DOOM working as a diversion inside blockyard with zero dependancies",
3
+ // then "get Quake working as a diversion"). No DOM, no worker, no clock, so every mapping here runs
4
+ // under node:test.
5
+ //
6
+ // GAMES each game's files, arguments and where its saves are kept (Wolfenstein 3D, DOOM, Quake)
7
+ // scancodes() a KeyboardEvent.code as the bytes a PC/AT keyboard puts on port 60h
8
+ // withControls() DOOM's config with the page's control scheme laid over it
9
+ // rebindKeys() the same, into a running DOOM
10
+ // quakeAutoexec() Quake's first-run WASD and mouse look
11
+ // CP437 / CGA how text mode's bytes and attributes become characters and colours
12
+ // paletteLut() the VGA DAC's 6-bit palette as 32-bit pixels for an ImageData
13
+
14
+ // ------------------------------------------------------------------ the games
15
+ /**
16
+ * What the worker fetches for each game, from /games/<name>/: the executable, the files it cannot
17
+ * start without, the ones it can, the command line, and the IndexedDB database its saves live in.
18
+ * Paths are DOS paths under the game's directory, upper case.
19
+ */
20
+ export const GAMES = Object.freeze({
21
+ // a real-mode program: the data files are the .WL1s, CONFIG.WL1 its settings and high scores
22
+ wolf3d: Object.freeze({ exe: 'WOLF3D.EXE', required: ['AUDIOHED.WL1', 'AUDIOT.WL1', 'GAMEMAPS.WL1', 'MAPHEAD.WL1', 'VGADICT.WL1', 'VGAGRAPH.WL1', 'VGAHEAD.WL1', 'VSWAP.WL1'], optional: ['CONFIG.WL1'], config: 'CONFIG.WL1', args: '', db: 'blockyard-wolf3d', label: 'WOLF3D.EXE and its data files', dir: 'games/wolf3d_dos' }),
23
+ doom: Object.freeze({ exe: 'DOOM.EXE', required: ['DOOM1.WAD'], optional: ['DEFAULT.CFG'], config: 'DEFAULT.CFG', args: '', db: 'blockyard-doom', label: 'DOOM.EXE and DOOM1.WAD', dir: 'games/doom_dos' }),
24
+ // -nocdaudio: there is no CD in the drive, and without it Quake stops at a "press a key" warning
25
+ quake: Object.freeze({ exe: 'QUAKE.EXE', required: ['ID1/PAK0.PAK'], optional: ['ID1/CONFIG.CFG'], config: 'ID1/CONFIG.CFG', args: '-nocdaudio', db: 'blockyard-quake', label: 'QUAKE.EXE and ID1/PAK0.PAK', dir: 'games/quake_dos' }),
26
+ });
27
+
28
+ /**
29
+ * QUAKE'S AUTOEXEC.CFG, which it runs after its config on every start. Mouse look always -- Quake 1
30
+ * did not save it, and a browser game without it is a fight with the mouse -- and, the first time
31
+ * only (no config the game wrote itself yet), W A S D to move and Space to jump. After that the
32
+ * game's own saved bindings are the player's, and this does not overwrite them.
33
+ */
34
+ export function quakeAutoexec({ firstRun }) {
35
+ const lines = ['+mlook'];
36
+ if (firstRun) lines.push('bind "w" "+forward"', 'bind "s" "+back"', 'bind "a" "+moveleft"', 'bind "d" "+moveright"', 'bind "SPACE" "+jump"', 'bind "MOUSE2" "+jump"');
37
+ // A SMALLER VIEW, the first time (operator, 2026-09-15: "we need to render it in a smaller window,
38
+ // so it runs faster"): Quake draws 3D only inside its view, and measured on this PC's emulator
39
+ // timedemo demo1 went 29.7 fps at viewsize 100, 32.5 at 80, 40.5 at 60. 80 keeps the status bar
40
+ // and a border; - and = change it in the game, and Quake saves the choice with its config.
41
+ if (firstRun) lines.push('viewsize 80');
42
+ const text = `${lines.join('\n')}\n`;
43
+ return Uint8Array.from(text, (c) => c.charCodeAt(0));
44
+ }
45
+
46
+ // ------------------------------------------------------------------ the keyboard
47
+ // Scancode set 1: the make code; the break code is the same with bit 7 set. Keys that arrived with
48
+ // the AT's extra cluster are sent after an 0xE0 prefix, the way the hardware does -- DOOM's own
49
+ // keyboard handler skips the prefix and reads the arrows as the keypad's 8, 4, 6 and 2.
50
+ const SET1 = {
51
+ Escape: 0x01, Digit1: 0x02, Digit2: 0x03, Digit3: 0x04, Digit4: 0x05, Digit5: 0x06, Digit6: 0x07, Digit7: 0x08,
52
+ Digit8: 0x09, Digit9: 0x0a, Digit0: 0x0b, Minus: 0x0c, Equal: 0x0d, Backspace: 0x0e, Tab: 0x0f,
53
+ KeyQ: 0x10, KeyW: 0x11, KeyE: 0x12, KeyR: 0x13, KeyT: 0x14, KeyY: 0x15, KeyU: 0x16, KeyI: 0x17, KeyO: 0x18, KeyP: 0x19,
54
+ BracketLeft: 0x1a, BracketRight: 0x1b, Enter: 0x1c, ControlLeft: 0x1d,
55
+ KeyA: 0x1e, KeyS: 0x1f, KeyD: 0x20, KeyF: 0x21, KeyG: 0x22, KeyH: 0x23, KeyJ: 0x24, KeyK: 0x25, KeyL: 0x26,
56
+ Semicolon: 0x27, Quote: 0x28, Backquote: 0x29, ShiftLeft: 0x2a, Backslash: 0x2b,
57
+ KeyZ: 0x2c, KeyX: 0x2d, KeyC: 0x2e, KeyV: 0x2f, KeyB: 0x30, KeyN: 0x31, KeyM: 0x32,
58
+ Comma: 0x33, Period: 0x34, Slash: 0x35, ShiftRight: 0x36, NumpadMultiply: 0x37, AltLeft: 0x38, Space: 0x39,
59
+ CapsLock: 0x3a, F1: 0x3b, F2: 0x3c, F3: 0x3d, F4: 0x3e, F5: 0x3f, F6: 0x40, F7: 0x41, F8: 0x42, F9: 0x43, F10: 0x44,
60
+ NumLock: 0x45, ScrollLock: 0x46, Numpad7: 0x47, Numpad8: 0x48, Numpad9: 0x49, NumpadSubtract: 0x4a,
61
+ Numpad4: 0x4b, Numpad5: 0x4c, Numpad6: 0x4d, NumpadAdd: 0x4e, Numpad1: 0x4f, Numpad2: 0x50, Numpad3: 0x51,
62
+ Numpad0: 0x52, NumpadDecimal: 0x53, IntlBackslash: 0x56, F11: 0x57, F12: 0x58,
63
+ };
64
+ const EXTENDED = {
65
+ NumpadEnter: 0x1c, ControlRight: 0x1d, NumpadDivide: 0x35, AltRight: 0x38, Home: 0x47, ArrowUp: 0x48, PageUp: 0x49,
66
+ ArrowLeft: 0x4b, ArrowRight: 0x4d, End: 0x4f, ArrowDown: 0x50, PageDown: 0x51, Insert: 0x52, Delete: 0x53,
67
+ MetaLeft: 0x5b, MetaRight: 0x5c, ContextMenu: 0x5d,
68
+ };
69
+
70
+ /** The bytes for a key going down (`down`) or up, or null for a key the PC keyboard does not have. */
71
+ export function scancodes(code, down) {
72
+ if (code === 'Pause') return down ? [0xe1, 0x1d, 0x45, 0xe1, 0x9d, 0xc5] : [];
73
+ if (code in SET1) return [down ? SET1[code] : SET1[code] | 0x80];
74
+ if (code in EXTENDED) return [0xe0, down ? EXTENDED[code] : EXTENDED[code] | 0x80];
75
+ return null;
76
+ }
77
+
78
+ // ------------------------------------------------------------------ the config
79
+ /** Scancodes the two control schemes bind, by DOOM's config key. WASD is the default. */
80
+ export const CONTROLS = {
81
+ classic: { key_up: 72, key_down: 80, key_left: 75, key_right: 77, key_strafeleft: 51, key_straferight: 52, key_use: 57 },
82
+ wasd: { key_up: 17, key_down: 31, key_left: 75, key_right: 77, key_strafeleft: 30, key_straferight: 32, key_use: 18 },
83
+ };
84
+ export const DEFAULT_CONTROLS = 'wasd';
85
+
86
+ // DOOM's own key codes for those scancodes (its scantokey table): letters and punctuation are their
87
+ // lowercase ASCII, the arrows are 0xac-0xaf
88
+ const DOOM_KEY = { 17: 119, 31: 115, 30: 97, 32: 100, 18: 101, 72: 0xad, 80: 0xaf, 75: 0xac, 77: 0xae, 51: 44, 52: 46, 57: 32 };
89
+
90
+ /**
91
+ * REBIND A RUNNING DOOM (operator, 2026-09-15: "have to refresh for settings to take effect").
92
+ * DOOM reads its keys from default.cfg once, at start-up, into its defaults table: one 20-byte
93
+ * entry a setting -- the name's address, the address of the live int, the default, whether the
94
+ * value is a scancode, and the scancode as read (which is what the game writes back on quit). This
95
+ * finds each key's entry by its name in the program's memory and writes both the live key code and
96
+ * the scancode, so the change applies to the next key press and survives the config DOOM saves.
97
+ * Returns how many settings it rewrote (0 before the program is loaded, or for an unknown build).
98
+ */
99
+ export function rebindKeys(mem, controls, cache = {}) {
100
+ const scheme = CONTROLS[controls];
101
+ if (!scheme) return 0;
102
+ const dv = new DataView(mem.buffer, mem.byteOffset, mem.length);
103
+ const lo = 0x100000, hi = Math.min(mem.length - 32, 0x400000); // where the loader puts the program
104
+ let n = 0;
105
+ for (const [key, scan] of Object.entries(scheme)) {
106
+ let entry = cache[key];
107
+ if (entry === undefined) {
108
+ entry = null;
109
+ const name = [...key].map((c) => c.charCodeAt(0)).concat(0);
110
+ for (let i = lo; i < hi && entry === null; i++) {
111
+ if (mem[i] !== name[0]) continue;
112
+ let j = 1;
113
+ while (j < name.length && mem[i + j] === name[j]) j++;
114
+ if (j < name.length) continue;
115
+ // a name alone is not proof (the linker packs strings, so no terminator need precede one):
116
+ // the entry is the 4-aligned pointer to it whose scantranslate field says it is a key
117
+ for (let p = lo & ~3; p < hi; p += 4) {
118
+ if (dv.getUint32(p, true) === i && dv.getUint32(p + 12, true) === 1) { entry = p; break; }
119
+ }
120
+ }
121
+ cache[key] = entry;
122
+ }
123
+ if (entry === null) continue;
124
+ const live = dv.getUint32(entry + 4, true);
125
+ if (live < lo || live >= hi) continue;
126
+ dv.setInt32(live, DOOM_KEY[scan], true);
127
+ dv.setInt32(entry + 16, scan, true);
128
+ n++;
129
+ }
130
+ return n;
131
+ }
132
+
133
+ /**
134
+ * DOOM's config (its `default.cfg`, bytes) with a control scheme laid over it. Every scheme turns
135
+ * the mouse on: the shipped file has `use_mouse 0`, and a browser always has one. A key missing
136
+ * from the file is appended rather than dropped, so a config the game wrote itself survives.
137
+ */
138
+ export function withControls(bytes, controls = DEFAULT_CONTROLS) {
139
+ let text = '';
140
+ for (const b of bytes) text += String.fromCharCode(b);
141
+ const set = (key, value) => {
142
+ const re = new RegExp(`^(${key}[ \\t]+)\\S+`, 'm');
143
+ text = re.test(text) ? text.replace(re, `$1${value}`) : `${text.replace(/\s*$/, '')}\n${key}\t\t${value}\n`;
144
+ };
145
+ set('use_mouse', 1);
146
+ for (const [k, v] of Object.entries(CONTROLS[controls] ?? {})) set(k, v);
147
+ const out = new Uint8Array(text.length);
148
+ for (let i = 0; i < text.length; i++) out[i] = text.charCodeAt(i) & 0xff;
149
+ return out;
150
+ }
151
+
152
+ // ------------------------------------------------------------------ text mode
153
+ /** Code page 437 as Unicode, so text mode's box drawing and ENDOOM's blocks draw as they did. */
154
+ export const CP437 = '\u0000☺☻♥♦♣♠•◘○◙♂♀♪♫☼►◄↕‼¶§▬↨↑↓→←∟↔▲▼ !"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~⌂'
155
+ + 'ÇüéâäàåçêëèïîìÄÅÉæÆôöòûùÿÖÜ¢£¥₧ƒáíóúñѪº¿⌐¬½¼¡«»░▒▓│┤╡╢╖╕╣║╗╝╜╛┐└┴┬├─┼╞╟╚╔╩╦╠═╬╧╨╤╥╙╘╒╓╫╪┘┌█▄▌▐▀'
156
+ + 'αßΓπΣσµτΦΘΩδ∞φε∩≡±≥≤⌠⌡÷≈°∙·√ⁿ²■ ';
157
+
158
+ /** The sixteen text-mode colours, as CSS. */
159
+ export const CGA = ['#000000', '#0000aa', '#00aa00', '#00aaaa', '#aa0000', '#aa00aa', '#aa5500', '#aaaaaa',
160
+ '#555555', '#5555ff', '#55ff55', '#55ffff', '#ff5555', '#ff55ff', '#ffff55', '#ffffff'];
161
+
162
+ /** An 80x25 text screen's cells as rows of runs: [{ x, y, text, fg, bg }], same colours merged. */
163
+ export function textRuns(cells) {
164
+ const runs = [];
165
+ for (let y = 0; y < 25; y++) {
166
+ let cur = null;
167
+ for (let x = 0; x < 80; x++) {
168
+ const i = (y * 80 + x) * 2;
169
+ const ch = CP437[cells[i]] ?? ' ', attr = cells[i + 1];
170
+ const fg = CGA[attr & 15], bg = CGA[(attr >> 4) & 7];
171
+ if (cur && cur.fg === fg && cur.bg === bg) cur.text += ch;
172
+ else { cur = { x, y, text: ch, fg, bg }; runs.push(cur); }
173
+ }
174
+ }
175
+ return runs;
176
+ }
177
+
178
+ // ------------------------------------------------------------------ the palette
179
+ /** The DAC's 768 six-bit components as little-endian ABGR pixels (what an ImageData's Uint32 view holds). */
180
+ export function paletteLut(pal, out = new Uint32Array(256)) {
181
+ for (let i = 0; i < 256; i++) {
182
+ const r = Math.round((pal[i * 3] & 63) * 255 / 63), g = Math.round((pal[i * 3 + 1] & 63) * 255 / 63), b = Math.round((pal[i * 3 + 2] & 63) * 255 / 63);
183
+ out[i] = (0xff << 24 | (b << 16) | (g << 8) | r) >>> 0;
184
+ }
185
+ return out;
186
+ }