jsbeeb 1.22.4 → 1.24.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 (112) hide show
  1. package/README.md +2 -2
  2. package/package.json +16 -11
  3. package/src/6502.js +39 -42
  4. package/src/6502.opcodes.js +198 -123
  5. package/src/6847.js +4 -5
  6. package/src/6847_fontdata.js +0 -2
  7. package/src/acia.js +2 -3
  8. package/src/app/app.js +0 -1
  9. package/src/app/args.js +0 -2
  10. package/src/app/electron.js +4 -6
  11. package/src/app-bench.js +1 -3
  12. package/src/archive.js +243 -0
  13. package/src/basic-loader.js +0 -7
  14. package/src/basic-tokenise.js +19 -6
  15. package/src/bbc-palette.js +0 -2
  16. package/src/bbcdiscs.js +0 -2
  17. package/src/bem-snapshot.js +1 -3
  18. package/src/binary.js +175 -0
  19. package/src/cmos.js +0 -2
  20. package/src/ddnoise.js +0 -1
  21. package/src/debounce.js +7 -0
  22. package/src/disc-surface.js +0 -2
  23. package/src/disc.js +10 -9
  24. package/src/fake6502.js +7 -23
  25. package/src/fdc.js +9 -51
  26. package/src/filestore.js +10 -17
  27. package/src/hex.js +38 -0
  28. package/src/index.js +11 -0
  29. package/src/intel-fdc.js +18 -20
  30. package/src/jsbeeb.css +33 -0
  31. package/src/{utils_atom.js → keymap-atom.js} +2 -18
  32. package/src/{utils.js → keymap.js} +47 -610
  33. package/src/loader.js +63 -0
  34. package/src/machine-session.js +37 -177
  35. package/src/machine-spec.js +59 -0
  36. package/src/main.js +69 -184
  37. package/src/media-resolver.js +68 -0
  38. package/src/mmc.js +3 -2
  39. package/src/models.js +40 -4
  40. package/src/music5000.js +0 -2
  41. package/src/ppia.js +3 -4
  42. package/src/printer.js +7 -14
  43. package/src/relaynoise.js +0 -1
  44. package/src/rewind.js +0 -2
  45. package/src/sample-player.js +2 -3
  46. package/src/serial.js +0 -2
  47. package/src/snapshot-helpers.js +0 -2
  48. package/src/snapshot.js +0 -2
  49. package/src/soundchip.js +1 -1
  50. package/src/state-utils.js +0 -2
  51. package/src/sth.js +2 -4
  52. package/src/tapes.js +10 -4
  53. package/src/teletext.js +2 -3
  54. package/src/teletext_adaptor.js +2 -4
  55. package/src/teletext_data.js +0 -2
  56. package/src/test-machine.js +307 -0
  57. package/src/touchscreen.js +2 -4
  58. package/src/tube.js +5 -6
  59. package/src/typist.js +103 -0
  60. package/src/uef-snapshot.js +0 -2
  61. package/src/url-params.js +4 -5
  62. package/src/vdu-capture.js +113 -0
  63. package/src/via.js +3 -4
  64. package/src/video-filters/pal-composite.js +0 -2
  65. package/src/video-filters/passthrough-filter.js +0 -2
  66. package/src/video-filters/pixel-grid.js +0 -2
  67. package/src/video-filters/shader-program.js +0 -2
  68. package/src/video-filters/xbr-filter.js +0 -2
  69. package/src/video.js +12 -11
  70. package/src/wd-fdc.js +20 -20
  71. package/src/web/analogue-inputs.js +6 -8
  72. package/src/web/analytics.js +13 -0
  73. package/src/web/audio-handler.js +2 -2
  74. package/src/{audio-utils.js → web/audio-utils.js} +0 -2
  75. package/src/web/autoboot.js +10 -13
  76. package/src/{canvas.js → web/canvas.js} +4 -5
  77. package/src/web/config.js +42 -72
  78. package/src/web/console-surface.js +25 -0
  79. package/src/web/debug.js +8 -49
  80. package/src/web/disc-visualiser.js +0 -2
  81. package/src/web/display.js +21 -4
  82. package/src/{dom-utils.js → web/dom-utils.js} +1 -11
  83. package/src/web/drives.js +1 -1
  84. package/src/web/emulation-loop.js +54 -33
  85. package/src/web/front-panel.js +13 -12
  86. package/src/{gamepads.js → web/gamepads.js} +2 -6
  87. package/src/web/google-drive-picker.js +14 -9
  88. package/src/{google-drive.js → web/google-drive.js} +9 -10
  89. package/src/web/hfe-picker.js +5 -4
  90. package/src/web/icons.js +42 -0
  91. package/src/web/keyboard-setup.js +29 -40
  92. package/src/{keyboard.js → web/keyboard.js} +18 -119
  93. package/src/web/layout.js +4 -0
  94. package/src/web/local-disc.js +44 -0
  95. package/src/web/machine.js +27 -20
  96. package/src/web/media-loader.js +38 -147
  97. package/src/{microphone-input.js → web/microphone-input.js} +1 -1
  98. package/src/web/modals.js +6 -9
  99. package/src/web/page-actions.js +88 -0
  100. package/src/web/quick-settings.js +16 -15
  101. package/src/web/reporting.js +0 -7
  102. package/src/{rewind-thumbnail.js → web/rewind-thumbnail.js} +0 -2
  103. package/src/web/rewind-ui.js +29 -29
  104. package/src/web/run-controls.js +39 -0
  105. package/src/web/settings.js +64 -135
  106. package/src/web/snapshot-ui.js +10 -16
  107. package/src/{speech-output.js → web/speech-output.js} +0 -2
  108. package/src/web/sth-picker.js +9 -7
  109. package/src/web/url-state.js +20 -6
  110. package/src/econet/L3FS.dat +0 -0
  111. package/src/econet/scsi.dat +0 -0
  112. package/tests/test-machine.js +0 -609
package/src/loader.js ADDED
@@ -0,0 +1,63 @@
1
+ import { stringToUint8Array } from "./binary.js";
2
+
3
+ export const runningInNode = typeof window === "undefined";
4
+
5
+ function loadDataHttp(url) {
6
+ return new Promise(function (resolve, reject) {
7
+ const request = new XMLHttpRequest();
8
+ request.open("GET", url, true);
9
+ request.overrideMimeType("text/plain; charset=x-user-defined");
10
+ request.onload = function () {
11
+ if (request.status !== 200) {
12
+ reject(new Error("Unable to load " + url + ", http code " + request.status));
13
+ return;
14
+ }
15
+ if (typeof request.response !== "string") {
16
+ resolve(request.response);
17
+ } else {
18
+ resolve(stringToUint8Array(request.response));
19
+ }
20
+ };
21
+ request.onerror = function () {
22
+ reject(new Error("A network error occurred loading " + url));
23
+ };
24
+ request.send(null);
25
+ });
26
+ }
27
+
28
+ let _nodeBasePath = null;
29
+
30
+ export function setNodeBasePath(basePath) {
31
+ _nodeBasePath = basePath;
32
+ }
33
+
34
+ async function loadDataNode(url) {
35
+ if (url.startsWith("file:")) {
36
+ const fs = await import("fs");
37
+ const { fileURLToPath } = await import("url");
38
+ return fs.readFileSync(fileURLToPath(url));
39
+ }
40
+ if (/^https?:\/\//.test(url)) {
41
+ const response = await fetch(url);
42
+ if (!response.ok) throw new Error(`Unable to load ${url}, http code ${response.status}`);
43
+ return new Uint8Array(await response.arrayBuffer());
44
+ }
45
+ const fs = await import("fs");
46
+ const nodePath = await import("path");
47
+ if (_nodeBasePath) {
48
+ const publicPath = nodePath.join(_nodeBasePath, "public", url);
49
+ if (fs.existsSync(publicPath)) return fs.readFileSync(publicPath);
50
+ return fs.readFileSync(nodePath.join(_nodeBasePath, url));
51
+ }
52
+ if (url[0] === "/") url = "." + url;
53
+ if (fs.existsSync("public/" + url)) return fs.readFileSync("public/" + url);
54
+ return fs.readFileSync(url);
55
+ }
56
+
57
+ export function loadData(url) {
58
+ if (runningInNode) {
59
+ return loadDataNode(url);
60
+ } else {
61
+ return loadDataHttp(url);
62
+ }
63
+ }
@@ -8,7 +8,8 @@
8
8
  import { readFileSync } from "fs";
9
9
  import { fileURLToPath } from "url";
10
10
  import path from "path";
11
- import { TestMachine } from "../tests/test-machine.js";
11
+ import { TestMachine } from "./test-machine.js";
12
+ import { VduTextCapture } from "./vdu-capture.js";
12
13
  import { InstrumentedSoundChip, FakeSoundChip } from "./soundchip.js";
13
14
 
14
15
  // Resolve the jsbeeb package root from our own location (src/machine-session.js
@@ -16,10 +17,12 @@ import { InstrumentedSoundChip, FakeSoundChip } from "./soundchip.js";
16
17
  // files relative to this package regardless of the calling process's cwd.
17
18
  const _jsbeebRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
18
19
  import * as fdc from "./fdc.js";
20
+ import { MediaResolver } from "./media-resolver.js";
21
+ import { StairwayToHell } from "./sth.js";
22
+ import { BbcDiscArchive } from "./bbcdiscs.js";
19
23
  import { Video } from "./video.js";
20
24
  import { findModel } from "./models.js";
21
- import { setNodeBasePath } from "./utils.js";
22
- import sharp from "sharp";
25
+ import { setNodeBasePath } from "./loader.js";
23
26
 
24
27
  // BBC framebuffer is 1024×625 pixels
25
28
  const FB_WIDTH = 1024;
@@ -86,7 +89,7 @@ export class MachineSession {
86
89
 
87
90
  // Accumulated VDU text output, drained by callers
88
91
  this._pendingOutput = [];
89
- this._flushCapture = () => {};
92
+ this._capture = new VduTextCapture((element) => this._pendingOutput.push(element), { isAtom: this._isAtom });
90
93
 
91
94
  // Breakpoint management, with persistent hooks that survive across run calls
92
95
  this._breakpoints = new Map(); // id → { hook, type, address, hit }
@@ -100,7 +103,7 @@ export class MachineSession {
100
103
  if (this._opts.discImage) {
101
104
  this.loadDisc(this._opts.discImage);
102
105
  }
103
- this._installCaptureHook();
106
+ this._machine.onVduChar((c) => this._capture.onChar(c));
104
107
  }
105
108
 
106
109
  /**
@@ -112,170 +115,6 @@ export class MachineSession {
112
115
  return this.drainOutput();
113
116
  }
114
117
 
115
- /**
116
- * Install the VDU character-output capture hook.
117
- *
118
- * WRCHV discovery: RAM at the OS write-character vector (0x20E on BBC,
119
- * 0x208 on Atom) initialises to 0x0000 before the OS runs. We read
120
- * directly from cpu.ramRomOs (two array lookups, no readmem() dispatch
121
- * overhead) on every instruction, waiting for the value to change from
122
- * its initial 0xFFFF. Once the OS installs a real handler we use that
123
- * address for the lifetime of the session. Programs that later install
124
- * a custom VDU driver are handled seamlessly because we always re-read
125
- * from the live memory.
126
- *
127
- * Text elements: { x, y, text, foreground, background, mode }
128
- * Screenshots (via the real Video chip) are the right tool for anything
129
- * visual; this capture is a lightweight aid for text-mode output only.
130
- */
131
- _installCaptureHook() {
132
- const cpu = this._machine.processor;
133
- const ram = cpu.ramRomOs; // direct Uint8Array, no dispatch overhead
134
- // WRCHV vector: BBC at $020E, Atom at $0208.
135
- const wrchvAddr = this._isAtom ? 0x208 : 0x20e;
136
- const initialWrchv = ram[wrchvAddr] | (ram[wrchvAddr + 1] << 8); // 0xFFFF pre-boot
137
-
138
- const attributes = { x: 0, y: 0, text: "", foreground: 7, background: 0, mode: 7 };
139
- let currentText = "";
140
- let params = [];
141
- let nextN = 0;
142
- let vduProc = null;
143
-
144
- const onElement = (elem) => this._pendingOutput.push({ ...elem });
145
-
146
- function flush() {
147
- if (currentText.length) {
148
- attributes.text = currentText;
149
- onElement({ ...attributes });
150
- attributes.x += currentText.length;
151
- }
152
- currentText = "";
153
- }
154
-
155
- // Expose flush so drainOutput can capture trailing text
156
- // that hasn't been terminated by a control character.
157
- this._flushCapture = flush;
158
-
159
- // Expose the decoder's cursor so snapshot()/restore() can rewind it too:
160
- // text captured after a restore would otherwise carry the coordinates
161
- // and colours the abandoned run had reached.
162
- this._snapshotCapture = () => ({
163
- attributes: { ...attributes },
164
- currentText,
165
- params: [...params],
166
- nextN,
167
- });
168
- this._restoreCapture = (state) => {
169
- Object.assign(attributes, state.attributes);
170
- currentText = state.currentText;
171
- params = [...state.params];
172
- nextN = state.nextN;
173
- // vduProc is a closure and does not survive, so a snapshot taken
174
- // mid-sequence resumes with nextN still swallowing the parameters:
175
- // the sequence is dropped rather than printed as text.
176
- vduProc = null;
177
- };
178
-
179
- const isAtom = this._isAtom;
180
-
181
- function onChar(c) {
182
- if (nextN) {
183
- params.push(c);
184
- if (--nextN === 0) {
185
- if (vduProc) vduProc(params);
186
- params = [];
187
- vduProc = null;
188
- }
189
- return;
190
- }
191
- switch (c) {
192
- case 10: // LF
193
- flush();
194
- attributes.y++;
195
- break;
196
- case 12: // CLS
197
- flush();
198
- attributes.x = 0;
199
- attributes.y = 0;
200
- break;
201
- case 13: // CR
202
- flush();
203
- attributes.x = 0;
204
- break;
205
- default:
206
- // BBC VDU control codes (multi-byte sequences).
207
- // The Atom doesn't use these; skip them to avoid
208
- // swallowing printable characters.
209
- if (!isAtom) {
210
- switch (c) {
211
- case 1: // next char to printer only
212
- nextN = 1;
213
- return;
214
- case 17: // COLOUR n
215
- nextN = 1;
216
- vduProc = (p) => {
217
- if (p[0] & 0x80) attributes.background = p[0] & 0xf;
218
- else attributes.foreground = p[0] & 0xf;
219
- };
220
- return;
221
- case 18: // GCOL
222
- nextN = 2;
223
- return;
224
- case 19: // define logical colour
225
- nextN = 5;
226
- return;
227
- case 22: // MODE n
228
- nextN = 1;
229
- vduProc = (p) => {
230
- flush();
231
- attributes.mode = p[0];
232
- attributes.x = 0;
233
- attributes.y = 0;
234
- attributes.foreground = 7;
235
- attributes.background = 0;
236
- };
237
- return;
238
- case 25: // PLOT
239
- nextN = 5;
240
- return;
241
- case 28: // define text window
242
- nextN = 4;
243
- return;
244
- case 29: // define graphics origin
245
- nextN = 4;
246
- return;
247
- case 31: // TAB(x,y)
248
- nextN = 2;
249
- vduProc = (p) => {
250
- flush();
251
- attributes.x = p[0];
252
- attributes.y = p[1];
253
- };
254
- return;
255
- }
256
- }
257
- if (c >= 32 && c < 0x7f) {
258
- currentText += String.fromCharCode(c);
259
- } else {
260
- flush();
261
- }
262
- break;
263
- }
264
- }
265
-
266
- cpu.debugInstruction.add((addr) => {
267
- // Two direct array reads, no function-call dispatch overhead.
268
- // Once the OS sets WRCHV (it changes from 0xFFFF), we start
269
- // capturing. Programs that install a custom VDU driver mid-run
270
- // are handled transparently because we re-read on every call.
271
- const wrchv = ram[wrchvAddr] | (ram[wrchvAddr + 1] << 8);
272
- if (wrchv !== initialWrchv && addr === wrchv) {
273
- onChar(cpu.a);
274
- }
275
- return false;
276
- });
277
- }
278
-
279
118
  /**
280
119
  * Press a key (by browser keyCode).
281
120
  * Use utils.keyCodes for named keys, or ASCII charCode for letters/digits.
@@ -313,7 +152,7 @@ export class MachineSession {
313
152
  return {
314
153
  machine: this._machine.snapshot({ includeRoms }),
315
154
  pendingOutput: this._pendingOutput.map((element) => ({ ...element })),
316
- capture: this._snapshotCapture(),
155
+ capture: this._capture.snapshot(),
317
156
  };
318
157
  }
319
158
 
@@ -326,7 +165,7 @@ export class MachineSession {
326
165
  restore(state) {
327
166
  this._machine.restore(state.machine);
328
167
  this._pendingOutput = state.pendingOutput.map((element) => ({ ...element }));
329
- this._restoreCapture(state.capture);
168
+ this._capture.restore(state.capture);
330
169
  }
331
170
 
332
171
  /** Tokenise BBC BASIC source and write it into PAGE */
@@ -364,10 +203,9 @@ export class MachineSession {
364
203
  await this._machine.runFor(cycles);
365
204
  }
366
205
 
367
- /** Emulated cycles since power-on, undoing the per-second rebasing execute() applies */
206
+ /** Emulated cycles since power-on */
368
207
  get elapsedCycles() {
369
- const cpu = this._machine.processor;
370
- return cpu.cycleSeconds * cpu.model.cyclesPerSecond + cpu.currentCycles;
208
+ return this._machine.elapsedCycles;
371
209
  }
372
210
 
373
211
  /**
@@ -516,8 +354,28 @@ export class MachineSession {
516
354
  */
517
355
  loadDisc(imagePath) {
518
356
  const data = new Uint8Array(readFileSync(imagePath));
519
- const machineFdc = this._machine.processor.fdc;
520
- machineFdc.loadDisc(0, fdc.discFor(machineFdc, imagePath, data));
357
+ this._machine.processor.fdc.loadDisc(0, fdc.discFor(imagePath, data));
358
+ }
359
+
360
+ /**
361
+ * Put a disc in a drive by any reference the web page's URL accepts: a bare
362
+ * name from the built-in discs, `sth:` or `hfe:` for the archives, or a URL.
363
+ * Returns the name of the image loaded and any others the archive held.
364
+ */
365
+ async loadDiscImage(ref, drive = 0) {
366
+ const { name, data, ignored } = await this.mediaResolver().resolve("disc", ref);
367
+ this._machine.processor.fdc.loadDisc(drive, fdc.discFor(name, data));
368
+ return { name, ignored };
369
+ }
370
+
371
+ mediaResolver() {
372
+ if (!this._mediaResolver) {
373
+ const quiet = () => {};
374
+ this._mediaResolver = new MediaResolver();
375
+ this._mediaResolver.addSource("sth", (file) => new StairwayToHell(quiet, quiet, quiet).fetch(file));
376
+ this._mediaResolver.addSource("hfe", (path) => new BbcDiscArchive(quiet, quiet, quiet).fetch(path));
377
+ }
378
+ return this._mediaResolver;
521
379
  }
522
380
 
523
381
  /**
@@ -532,7 +390,7 @@ export class MachineSession {
532
390
  * Also includes a flat `screenText` reconstruction.
533
391
  */
534
392
  drainOutput({ clear = true } = {}) {
535
- this._flushCapture();
393
+ this._capture.flush();
536
394
  const elements = clear ? this._pendingOutput.splice(0) : [...this._pendingOutput];
537
395
  return {
538
396
  elements,
@@ -585,6 +443,7 @@ export class MachineSession {
585
443
  async screenshot() {
586
444
  // Read from _completeFb8, the last fully-painted frame snapshotted in paint_ext.
587
445
  // _fb8/_fb32 is the live render buffer (cleared and partially refilled each frame).
446
+ const { default: sharp } = await import("sharp");
588
447
  return sharp(Buffer.from(this._completeFb8.buffer), {
589
448
  raw: { width: FB_WIDTH, height: FB_HEIGHT, channels: 4 },
590
449
  })
@@ -609,6 +468,7 @@ export class MachineSession {
609
468
  const w = FB_WIDTH - left - right;
610
469
  const h = FB_HEIGHT - top - bottom;
611
470
 
471
+ const { default: sharp } = await import("sharp");
612
472
  return sharp(Buffer.from(this._completeFb8.buffer), {
613
473
  raw: { width: FB_WIDTH, height: FB_HEIGHT, channels: 4 },
614
474
  })
@@ -0,0 +1,59 @@
1
+ import { Cmos } from "./cmos.js";
2
+ import { FakeDdNoise } from "./ddnoise.js";
3
+ import { FakeMusic5000 } from "./music5000.js";
4
+ import { FakeRelayNoise } from "./relaynoise.js";
5
+ import { FakeSoundChip } from "./soundchip.js";
6
+ import { FakeVideo } from "./video.js";
7
+
8
+ const NullUserPort = {
9
+ write() {},
10
+ read() {
11
+ return 0xff;
12
+ },
13
+ };
14
+
15
+ const SpecDefaults = {
16
+ keyLayout: "physical",
17
+ cpuMultiplier: 1,
18
+ tubeCpuMultiplier: 1,
19
+ videoCyclesBatch: 0,
20
+ tube: null,
21
+ hasMusic5000: false,
22
+ hasTeletextAdaptor: false,
23
+ extraRoms: [],
24
+ userPort: NullUserPort,
25
+ printerPort: null,
26
+ getGamepads: () => [],
27
+ debugFlags: { logFdcCommands: false, logFdcStateChanges: false },
28
+ };
29
+
30
+ /**
31
+ * What a machine is fitted with and how it is driven, complete and frozen:
32
+ * every field is present, an unknown one is an error, and an undefined
33
+ * override means the default. It is the `config` a CPU is built with.
34
+ */
35
+ export function machineSpec(overrides = {}) {
36
+ const unknown = Object.keys(overrides).filter((field) => !Object.hasOwn(SpecDefaults, field));
37
+ if (unknown.length) throw new Error(`Unknown machine spec fields: ${unknown.join(", ")}`);
38
+ const given = Object.fromEntries(Object.entries(overrides).filter(([, value]) => value !== undefined));
39
+ return Object.freeze({
40
+ ...SpecDefaults,
41
+ ...given,
42
+ extraRoms: Object.freeze([...(given.extraRoms ?? SpecDefaults.extraRoms)]),
43
+ debugFlags: Object.freeze({ ...SpecDefaults.debugFlags, ...given.debugFlags }),
44
+ });
45
+ }
46
+
47
+ /** Peripherals that go nowhere, for a machine run headless. */
48
+ export function nullIo({ video = new FakeVideo(), soundChip = new FakeSoundChip() } = {}) {
49
+ return {
50
+ dbgr: { setCpu() {} },
51
+ video,
52
+ soundChip,
53
+ ddNoise: new FakeDdNoise(),
54
+ relayNoise: new FakeRelayNoise(),
55
+ music5000: new FakeMusic5000(),
56
+ cmos: new Cmos(),
57
+ econet: null,
58
+ };
59
+ }