jsbeeb 1.21.0 → 1.22.1

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/package.json CHANGED
@@ -7,7 +7,7 @@
7
7
  "name": "jsbeeb",
8
8
  "description": "Emulate a BBC Micro",
9
9
  "repository": "git@github.com:mattgodbolt/jsbeeb.git",
10
- "version": "1.21.0",
10
+ "version": "1.22.1",
11
11
  "//engines": "If you change the version of Node, it must also be updated at the top of the Dockerfile.",
12
12
  "engines": {
13
13
  "node": ">=24.15.0"
@@ -114,7 +114,8 @@
114
114
  "mirror-bbcdiscs:upload:blobs": "aws s3 sync .bbcdiscs-mirror/hfe s3://bbc.xania.org/archive/bbcdiscs/hfe/ --no-progress --exclude 'manifest.json' --delete --content-encoding br --cache-control 'public, max-age=31536000, immutable'",
115
115
  "mirror-bbcdiscs:upload:index": "aws s3 cp .bbcdiscs-mirror/hfe/manifest.json s3://bbc.xania.org/archive/bbcdiscs/hfe/manifest.json --cache-control 'public, max-age=300' && aws s3 cp .bbcdiscs-mirror/manifest.json s3://bbc.xania.org/archive/bbcdiscs/manifest.json --cache-control 'public, max-age=300'",
116
116
  "electron": "npm run build && ELECTRON_DISABLE_SANDBOX=1 electron .",
117
- "electron:build": "electron-builder"
117
+ "electron:build": "electron-builder",
118
+ "test:smoke": "npm run build && node tests/browser/smoke.js"
118
119
  },
119
120
  "lint-staged": {
120
121
  "*.js": [
@@ -0,0 +1,20 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Pokes a tokenised BASIC program into memory at PAGE, and sets TOP and
5
+ * VARTOP after it, exactly as if it had just been typed.
6
+ */
7
+ export function installBasic(tokenised, { readByte, writeByte }) {
8
+ const page = readByte(0x18) << 8;
9
+ for (let i = 0; i < tokenised.length; ++i) {
10
+ writeByte(page + i, tokenised.charCodeAt(i));
11
+ }
12
+ // Set VARTOP (0x12/3) and TOP(0x02/3)
13
+ const end = page + tokenised.length;
14
+ const endLow = end & 0xff;
15
+ const endHigh = (end >>> 8) & 0xff;
16
+ writeByte(0x02, endLow);
17
+ writeByte(0x03, endHigh);
18
+ writeByte(0x12, endLow);
19
+ writeByte(0x13, endHigh);
20
+ }
package/src/dom-utils.js CHANGED
@@ -1,5 +1,7 @@
1
1
  "use strict";
2
2
 
3
+ import { replaceOrAddExtension } from "./utils.js";
4
+
3
5
  // Minimal DOM helpers to replace jQuery usage.
4
6
 
5
7
  export function show(el) {
@@ -46,3 +48,9 @@ export function downloadBlob(blob, fileName) {
46
48
  a.remove();
47
49
  setTimeout(() => URL.revokeObjectURL(url), BlobUrlLifetimeMs);
48
50
  }
51
+
52
+ /** Save raw image bytes under the disc's name with the extension of the format they are in. */
53
+ export function downloadDriveData(data, name, extension) {
54
+ const blob = new Blob([data], { type: "application/octet-stream" });
55
+ downloadBlob(blob, replaceOrAddExtension(name, extension));
56
+ }
@@ -41,9 +41,9 @@ export class MachineSession {
41
41
  this.modelName = modelName;
42
42
  this._opts = opts;
43
43
 
44
- // Raw RGBA framebuffer the Video chip renders into _fb32 (cleared each frame).
44
+ // Raw RGBA framebuffer. The Video chip renders into _fb32 (cleared each frame).
45
45
  // _completeFb8 is a snapshot taken at paint time (the equivalent of the browser canvas)
46
- // and is what screenshot() reads from always a complete frame, never mid-render.
46
+ // and is what screenshot() reads from, always a complete frame, never mid-render.
47
47
  this._fb8 = new Uint8Array(FB_WIDTH * FB_HEIGHT * 4);
48
48
  this._fb32 = new Uint32Array(this._fb8.buffer);
49
49
  this._completeFb8 = new Uint8Array(FB_WIDTH * FB_HEIGHT * 4);
@@ -84,16 +84,16 @@ export class MachineSession {
84
84
  hasTeletextAdaptor: opts.hasTeletextAdaptor,
85
85
  });
86
86
 
87
- // Accumulated VDU text output drained by callers
87
+ // Accumulated VDU text output, drained by callers
88
88
  this._pendingOutput = [];
89
89
  this._flushCapture = () => {};
90
90
 
91
- // Breakpoint management persistent hooks that survive across run calls
91
+ // Breakpoint management, with persistent hooks that survive across run calls
92
92
  this._breakpoints = new Map(); // id → { hook, type, address, hit }
93
93
  this._nextBreakpointId = 1;
94
94
  }
95
95
 
96
- /** Load ROMs and hardware call once before anything else */
96
+ /** Load ROMs and hardware; call once before anything else */
97
97
  async initialise() {
98
98
  setNodeBasePath(_jsbeebRoot);
99
99
  await this._machine.initialise();
@@ -117,12 +117,12 @@ export class MachineSession {
117
117
  *
118
118
  * WRCHV discovery: RAM at the OS write-character vector (0x20E on BBC,
119
119
  * 0x208 on Atom) initialises to 0x0000 before the OS runs. We read
120
- * directly from cpu.ramRomOs (two array lookups no
121
- * readmem() dispatch overhead) on every instruction, waiting for the
122
- * value to change from its initial 0xFFFF. Once the OS installs a real
123
- * handler we use that address for the lifetime of the session. Programs
124
- * that later install a custom VDU driver are handled seamlessly because
125
- * we always re-read from the live memory.
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
126
  *
127
127
  * Text elements: { x, y, text, foreground, background, mode }
128
128
  * Screenshots (via the real Video chip) are the right tool for anything
@@ -130,7 +130,7 @@ export class MachineSession {
130
130
  */
131
131
  _installCaptureHook() {
132
132
  const cpu = this._machine.processor;
133
- const ram = cpu.ramRomOs; // direct Uint8Array no dispatch overhead
133
+ const ram = cpu.ramRomOs; // direct Uint8Array, no dispatch overhead
134
134
  // WRCHV vector: BBC at $020E, Atom at $0208.
135
135
  const wrchvAddr = this._isAtom ? 0x208 : 0x20e;
136
136
  const initialWrchv = ram[wrchvAddr] | (ram[wrchvAddr + 1] << 8); // 0xFFFF pre-boot
@@ -156,6 +156,26 @@ export class MachineSession {
156
156
  // that hasn't been terminated by a control character.
157
157
  this._flushCapture = flush;
158
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
+
159
179
  const isAtom = this._isAtom;
160
180
 
161
181
  function onChar(c) {
@@ -244,7 +264,7 @@ export class MachineSession {
244
264
  }
245
265
 
246
266
  cpu.debugInstruction.add((addr) => {
247
- // Two direct array reads no function-call dispatch overhead.
267
+ // Two direct array reads, no function-call dispatch overhead.
248
268
  // Once the OS sets WRCHV (it changes from 0xFFFF), we start
249
269
  // capturing. Programs that install a custom VDU driver mid-run
250
270
  // are handled transparently because we re-read on every call.
@@ -280,6 +300,35 @@ export class MachineSession {
280
300
  this._pendingOutput = [];
281
301
  }
282
302
 
303
+ /**
304
+ * Capture machine state and captured text as an opaque object for restore().
305
+ * Leaves the running session undisturbed.
306
+ *
307
+ * @param {Object} [opts]
308
+ * @param {boolean} [opts.includeRoms=true] - carry the ROM contents too.
309
+ * Only safe to omit when restoring into a session built from the same
310
+ * model, which is then left with whatever ROMs it already had.
311
+ */
312
+ snapshot({ includeRoms = true } = {}) {
313
+ return {
314
+ machine: this._machine.snapshot({ includeRoms }),
315
+ pendingOutput: this._pendingOutput.map((element) => ({ ...element })),
316
+ capture: this._snapshotCapture(),
317
+ };
318
+ }
319
+
320
+ /**
321
+ * Put back a state from snapshot(). `elapsedCycles` rewinds with the
322
+ * machine; breakpoints belong to the session, not the machine, so they and
323
+ * their hit flags are left alone, and `frameCount` keeps counting the way it
324
+ * does across a hard reset.
325
+ */
326
+ restore(state) {
327
+ this._machine.restore(state.machine);
328
+ this._pendingOutput = state.pendingOutput.map((element) => ({ ...element }));
329
+ this._restoreCapture(state.capture);
330
+ }
331
+
283
332
  /** Tokenise BBC BASIC source and write it into PAGE */
284
333
  async loadBasic(source) {
285
334
  await this._machine.loadBasic(source);
@@ -476,7 +525,7 @@ export class MachineSession {
476
525
  *
477
526
  * @param {Object} [opts]
478
527
  * @param {boolean} [opts.clear=true] - If true (default), clear the buffer
479
- * after returning it. Pass false to peek without consuming the same
528
+ * after returning it. Pass false to peek without consuming; the same
480
529
  * elements will be returned again on the next call.
481
530
  *
482
531
  * Each element: { x, y, text, foreground, background, mode }
@@ -527,14 +576,14 @@ export class MachineSession {
527
576
  /**
528
577
  * Capture the current screen as a PNG.
529
578
  * Returns a Buffer containing a 1024×625 PNG (the full emulated display,
530
- * including borders matches what the browser renders).
579
+ * including borders, matching what the browser renders).
531
580
  *
532
581
  * The active display area is roughly:
533
582
  * x: leftBorder .. 1024-rightBorder
534
583
  * y: topBorder .. 625-bottomBorder
535
584
  */
536
585
  async screenshot() {
537
- // Read from _completeFb8 the last fully-painted frame snapshotted in paint_ext.
586
+ // Read from _completeFb8, the last fully-painted frame snapshotted in paint_ext.
538
587
  // _fb8/_fb32 is the live render buffer (cleared and partially refilled each frame).
539
588
  return sharp(Buffer.from(this._completeFb8.buffer), {
540
589
  raw: { width: FB_WIDTH, height: FB_HEIGHT, channels: 4 },