blockyard 0.0.9 → 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.
- package/CHANGELOG.md +251 -1
- package/README.md +42 -23
- package/bin/blockyard.js +2 -1
- package/docs/API.md +16 -14
- package/docs/ARCHITECTURE.md +92 -5
- package/docs/CONFIGURATION.md +33 -26
- package/docs/GETTING-STARTED.md +5 -2
- package/docs/INSTALL.md +90 -33
- package/docs/MEASUREMENTS.md +147 -0
- package/docs/SECURITY.md +32 -15
- package/docs/TROUBLESHOOTING.md +35 -1
- package/docs/USER-GUIDE.md +266 -26
- package/package.json +1 -1
- package/public/404.html +1 -1
- package/public/css/app.css +306 -82
- package/public/donate-qr.png +0 -0
- package/public/index.html +295 -103
- package/public/js/agents.js +228 -51
- package/public/js/app.js +82 -8
- package/public/js/blockscene3d.js +179 -27
- package/public/js/charts.js +21 -21
- package/public/js/depthchart.js +31 -27
- package/public/js/details3d.js +1456 -71
- package/public/js/doom.js +31 -0
- package/public/js/dosaudio.js +48 -0
- package/public/js/dosgame.js +389 -0
- package/public/js/dosio.js +186 -0
- package/public/js/dospc.js +1353 -0
- package/public/js/dosworker.js +196 -0
- package/public/js/login.js +5 -0
- package/public/js/markets.js +46 -8
- package/public/js/mining.js +310 -32
- package/public/js/panels.js +14 -10
- package/public/js/pricechart.js +14 -13
- package/public/js/quake.js +20 -0
- package/public/js/settings.js +103 -21
- package/public/js/soundcard.js +459 -0
- package/public/js/theme.js +235 -0
- package/public/js/wolf3d.js +22 -0
- package/public/js/x86.js +1978 -0
- package/scripts/donate-qr.py +12 -9
- package/scripts/dos-bench.js +56 -0
- package/scripts/setup.js +34 -12
- package/scripts/shots.mjs +6 -0
- package/scripts/smoke.sh +1 -1
- package/scripts/tls.js +31 -0
- package/server/chain/index/build.js +21 -4
- package/server/collect/monitor.js +30 -1
- package/server/collect/network.js +295 -0
- package/server/config.js +46 -22
- package/server/http/api.js +49 -5
- package/server/http/games.js +77 -0
- package/server/http/server.js +8 -0
- package/server/main.js +53 -8
- package/server/tls/selfsigned.js +160 -0
- package/systemd/blockyard.service +7 -5
- package/docs/PRIVATE-LEADERBOARD.md +0 -230
- package/docs/STATE-2026-09-09.md +0 -200
|
@@ -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
|
+
}
|