jsbeeb 1.17.0 → 1.18.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/README.md CHANGED
@@ -13,6 +13,7 @@ different peripherals.
13
13
  - [Keyboard Mappings](#keyboard-mappings)
14
14
  - [Remapping Keys](#remapping-keys)
15
15
  - [Emulator Shortcuts](#emulator-shortcuts)
16
+ - [Printer Output](#printer-output)
16
17
  - [Save State and Rewind](#save-state-and-rewind)
17
18
  - [Getting Set Up to Run Locally](#getting-set-up-to-run-locally)
18
19
  - [Running as a Desktop Application](#running-as-a-desktop-application)
@@ -103,8 +104,15 @@ The definitive lists are `keyCodes` (host) and `BBC` (BBC micro) in [`src/utils.
103
104
  | `Ctrl+Home` | Stop and enter debugger |
104
105
  | `Ctrl+Insert` | Toggle turbo (fast-as-possible) |
105
106
  | `Ctrl+End` | Pause emulation |
107
+ | `Ctrl+B` | Open printer output window |
106
108
  | `Alt+PageDown` | Open rewind scrubber |
107
109
 
110
+ ### Printer Output
111
+
112
+ Anything the machine prints is captured whether or not the printer window is open, so programs that print (with `VDU 2`, `*FX5,1` and the like) run rather than waiting for a printer that is not there.
113
+
114
+ Press `Ctrl+B` to open a window showing what has been printed so far; it then keeps up with the output as it arrives. Only the most recent output is kept, roughly a dozen pages' worth, so a program printing forever cannot fill memory.
115
+
108
116
  ### Save State and Rewind
109
117
 
110
118
  Save and load full emulator state snapshots from the **State** menu (or `Ctrl+S` / `Ctrl+O` in the Electron app).
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.17.0",
10
+ "version": "1.18.0",
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"
@@ -30,6 +30,7 @@
30
30
  "argparse": "^3.0.0",
31
31
  "bootstrap": "^5.3.8",
32
32
  "bootswatch": "^5.3.8",
33
+ "pako": "^3.0.1",
33
34
  "sharp": "^0.35.3",
34
35
  "smoothie": "^1.36.1"
35
36
  },
@@ -105,10 +106,11 @@
105
106
  "mirror-sth:upload": "npm-run-all mirror-sth:upload:*",
106
107
  "mirror-sth:upload:blobs": "aws s3 sync .sth-mirror s3://bbc.xania.org/archive/sth/ --no-progress --exclude '*manifest.json' --exclude 'meta/*' --cache-control 'public, max-age=31536000, immutable'",
107
108
  "mirror-sth:upload:index": "aws s3 sync .sth-mirror s3://bbc.xania.org/archive/sth/ --no-progress --exclude '*' --include '*manifest.json' --include 'meta/*' --cache-control 'public, max-age=300'",
108
- "mirror-bbcdiscs": "node tools/mirror-bbcdiscs.js --csv .bbcdiscs-sheet.csv --out .bbcdiscs-mirror",
109
+ "mirror-bbcdiscs": "node tools/mirror-bbcdiscs.js --csv .bbcdiscs-sheet.csv --fsd .bbcdiscs-mirror/cache-fsd --out .bbcdiscs-mirror",
109
110
  "mirror-bbcdiscs:check": "node tools/mirror-bbcdiscs.js --csv .bbcdiscs-sheet.csv --check-only",
110
111
  "mirror-bbcdiscs:seed": "aws s3 sync s3://bbc.xania.org/archive/bbcdiscs/hfe/ .bbcdiscs-mirror/hfe/ --no-progress",
111
- "mirror-bbcdiscs:upload": "npm-run-all mirror-bbcdiscs:upload:*",
112
+ "mirror-bbcdiscs:preflight": "node tools/mirror-bbcdiscs.js --preflight --out .bbcdiscs-mirror",
113
+ "mirror-bbcdiscs:upload": "npm-run-all mirror-bbcdiscs:preflight mirror-bbcdiscs:upload:*",
112
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'",
113
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'",
114
116
  "electron": "npm run build && ELECTRON_DISABLE_SANDBOX=1 electron .",
package/src/6502.js CHANGED
@@ -709,6 +709,7 @@ export class Cpu6502 extends Base6502 {
709
709
  this.acia = new Acia(this, this.soundChip.toneGenerator, this.scheduler, this.relayNoise);
710
710
  this.serial = new Serial(this.acia);
711
711
  this.adconverter = new Adc(this.sysvia, this.scheduler);
712
+ this.touchScreen = new TouchScreen(this.scheduler, this.model.cyclesPerSecond);
712
713
  this.soundChip.setScheduler(this.scheduler);
713
714
  this.fdc = new this.model.Fdc(this, this.ddNoise, this.scheduler, this.debugFlags);
714
715
  }
@@ -1240,7 +1241,9 @@ export class Cpu6502 extends Base6502 {
1240
1241
  soundChip: this.soundChip.snapshotState(),
1241
1242
  acia: this.acia.snapshotState(),
1242
1243
  adc: this.adconverter.snapshotState(),
1244
+ touchScreen: this.touchScreen.snapshotState(),
1243
1245
  fdc: this.fdc.snapshotState(),
1246
+ teletextAdaptor: this.teletextAdaptor ? this.teletextAdaptor.snapshotState() : undefined,
1244
1247
  tube: this.hasTube ? this.tube.snapshotState({ includeRoms }) : undefined,
1245
1248
  };
1246
1249
  }
@@ -1297,6 +1300,11 @@ export class Cpu6502 extends Base6502 {
1297
1300
  this.soundChip.restoreState(state.soundChip);
1298
1301
  this.acia.restoreState(state.acia);
1299
1302
  this.adconverter.restoreState(state.adc);
1303
+ if (this.teletextAdaptor && state.teletextAdaptor) this.teletextAdaptor.restoreState(state.teletextAdaptor);
1304
+
1305
+ // Touchscreen state, added without a version bump. Absent from an older snapshot, whose
1306
+ // touchscreen keeps its current state, unpolled.
1307
+ if (state.touchScreen) this.touchScreen.restoreState(state.touchScreen);
1300
1308
 
1301
1309
  // FDC state (v2+). If absent (v1 snapshot), FDC keeps its current state.
1302
1310
  if (state.fdc) {
@@ -1359,7 +1367,8 @@ export class Cpu6502 extends Base6502 {
1359
1367
  // Override in subclasses for different peripheral sets.
1360
1368
  // Only called on hard reset.
1361
1369
  resetPeripherals() {
1362
- if (this.config.printerPort) this.uservia.ca2changecallback = this.config.printerPort.outputStrobe;
1370
+ if (this.config.printerPort)
1371
+ this.uservia.ca2changecallback = (level, output) => this.config.printerPort.outputStrobe(level, output);
1363
1372
 
1364
1373
  this.sysvia.reset();
1365
1374
  this.uservia.reset();
@@ -1369,7 +1378,7 @@ export class Cpu6502 extends Base6502 {
1369
1378
  this.fdc.powerOnReset();
1370
1379
  this.adconverter.reset();
1371
1380
 
1372
- this.touchScreen = new TouchScreen(this.scheduler, this.model.cyclesPerSecond);
1381
+ this.touchScreen.reset();
1373
1382
  if (this.econet) this.filestore = new Filestore(this, this.econet);
1374
1383
  }
1375
1384
 
package/src/bbcdiscs.js CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  const mirrorBase = "https://bbc.xania.org/archive/bbcdiscs";
4
4
 
5
+ /** How a disc came to be an image, which is the difference between the archives it came from. */
6
+ export const Provenance = {
7
+ /** Read off the disc itself, by flux capture. */
8
+ Captured: "captured",
9
+ /** Rebuilt from a sector dump, so the surface around the data is inferred. */
10
+ Reconstructed: "reconstructed",
11
+ };
12
+
5
13
  // Numeric so a "Disc 2" would sort before a "Disc 10" rather than after it,
6
14
  // and case-insensitive so a lower-cased title stays with its neighbours.
7
15
  const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
@@ -34,6 +42,25 @@ export function describe(file) {
34
42
  };
35
43
  }
36
44
 
45
+ /** The provenances a catalogue actually holds, so a source added later needs no code here. */
46
+ export const provenancesIn = (catalogue) => [...new Set(catalogue.map((file) => file.provenance))].sort();
47
+
48
+ /**
49
+ * Whether a disc belongs in the picker as it is currently filtered.
50
+ *
51
+ * @param {object} file manifest entry
52
+ * @param {string} filter lower cased text to look for
53
+ * @param {?Set<string>} shown provenances to include, or null for all of them
54
+ */
55
+ export function matches(file, filter, shown) {
56
+ if (shown && !shown.has(file.provenance)) return false;
57
+ if (!filter) return true;
58
+ // What the row says the disc is, rather than everything the row renders:
59
+ // the provenance is a word the tickboxes control, not one to search for.
60
+ const { title, publisher, detail } = describe(file);
61
+ return `${title} ${publisher} ${detail}`.toLowerCase().includes(filter);
62
+ }
63
+
37
64
  export class BbcDiscArchive {
38
65
  /** @param {string} [baseUrl] where the mirror lives, to point at a test prefix */
39
66
  constructor(onStart, onCat, onError, baseUrl = mirrorBase) {
@@ -55,7 +82,10 @@ export class BbcDiscArchive {
55
82
  if (!response.ok) throw new Error(`Network response was not ok (${response.status})`);
56
83
  const data = await response.json();
57
84
  if (!Array.isArray(data?.files)) throw new Error("Invalid manifest: missing files array");
58
- this._catalogue = [...data.files].sort(byTitle);
85
+ this._catalogue = data.files
86
+ // The captured discs were published before provenance was recorded.
87
+ .map((file) => ({ ...file, provenance: file.provenance ?? Provenance.Captured }))
88
+ .sort(byTitle);
59
89
  this._loaded = true;
60
90
  } catch (error) {
61
91
  console.error("Failed to fetch HFE archive catalogue:", error);
@@ -1,5 +1,5 @@
1
1
  "use strict";
2
- import { decompress } from "./utils.js";
2
+ import { inflate } from "./utils.js";
3
3
 
4
4
  // B-em snapshot format parser (versions 1 and 3).
5
5
  // v1 (BEMSNAP1): Fixed-size 327,885 byte packed struct. Reference: beebjit state.c
@@ -334,7 +334,7 @@ async function parseBemTube(sections, tubeName) {
334
334
 
335
335
  // Those names come from a config file, so size is the real check. It cannot be exact: the
336
336
  // trailing ROM's size comes from that same config, and b-em records it as zero if unset.
337
- const parasite = await decompress(sections["P"].data, "deflate");
337
+ const parasite = inflate(sections["P"].data);
338
338
  const romBytes = parasite.length - BemTubeRegisterBytes - BemTubeRamSize;
339
339
  if (romBytes < 0 || romBytes > BemTubeRamSize) {
340
340
  throw new Error(
@@ -449,9 +449,8 @@ async function parseBemV3(buffer) {
449
449
  let roms = null;
450
450
  const memSection = sections["M"];
451
451
  if (memSection) {
452
- // Memory is zlib-compressed; decompression is async.
453
452
  // Decompressed layout: 2 bytes (fe30, fe34) + 64KB RAM + 256KB ROM
454
- const memData = await decompress(memSection.data, "deflate");
453
+ const memData = inflate(memSection.data);
455
454
  cpuState.fe30 = memData[0];
456
455
  cpuState.fe34 = memData[1];
457
456
  const ramStart = 2;
package/src/canvas.js CHANGED
@@ -38,6 +38,11 @@ export class Canvas {
38
38
  /** Nothing to release: the 2D context owns no objects of ours. */
39
39
  dispose() {}
40
40
 
41
+ setFilter(filterClass) {
42
+ if (filterClass !== PassthroughFilter)
43
+ throw new Error(`${filterClass.getDisplayConfig().name} needs WebGL, which is not in use here`);
44
+ }
45
+
41
46
  paint(minx, miny, maxx, maxy, _frame) {
42
47
  const width = maxx - minx;
43
48
  const height = maxy - miny;
@@ -78,23 +83,14 @@ export class GlCanvas {
78
83
 
79
84
  checkedGl.depthMask(false);
80
85
 
81
- this.filter = new filterClass(checkedGl);
82
- const program = this.filter.program;
83
- checkedGl.useProgram(program);
84
-
85
- // Filters that pick their own samples want the texels they asked for,
86
- // not a hardware blend of the ones either side.
87
- const sampling = filterClass.getDisplayConfig().nearestSampling ? checkedGl.NEAREST : checkedGl.LINEAR;
88
-
89
86
  this.fb8 = new Uint8Array(width * height * 4);
90
87
  this.fb32 = new Uint32Array(this.fb8.buffer);
91
88
  this.texture = checkedGl.createTexture();
89
+ checkedGl.activeTexture(checkedGl.TEXTURE0);
92
90
  checkedGl.bindTexture(checkedGl.TEXTURE_2D, this.texture);
93
91
  checkedGl.pixelStorei(checkedGl.UNPACK_ALIGNMENT, 4);
94
92
  checkedGl.texParameteri(checkedGl.TEXTURE_2D, checkedGl.TEXTURE_WRAP_S, checkedGl.CLAMP_TO_EDGE);
95
93
  checkedGl.texParameteri(checkedGl.TEXTURE_2D, checkedGl.TEXTURE_WRAP_T, checkedGl.CLAMP_TO_EDGE);
96
- checkedGl.texParameteri(checkedGl.TEXTURE_2D, checkedGl.TEXTURE_MAG_FILTER, sampling);
97
- checkedGl.texParameteri(checkedGl.TEXTURE_2D, checkedGl.TEXTURE_MIN_FILTER, sampling);
98
94
  checkedGl.texImage2D(
99
95
  checkedGl.TEXTURE_2D,
100
96
  0,
@@ -106,44 +102,72 @@ export class GlCanvas {
106
102
  checkedGl.UNSIGNED_BYTE,
107
103
  this.fb8,
108
104
  );
109
- checkedGl.bindTexture(checkedGl.TEXTURE_2D, null);
110
105
 
111
- const vertexPositionAttrLoc = checkedGl.getAttribLocation(program, "pos");
112
- checkedGl.enableVertexAttribArray(vertexPositionAttrLoc);
113
106
  this.vertexPositionBuffer = checkedGl.createBuffer();
114
107
  checkedGl.bindBuffer(checkedGl.ARRAY_BUFFER, this.vertexPositionBuffer);
115
108
  checkedGl.bufferData(checkedGl.ARRAY_BUFFER, new Float32Array([0, 0, 0, 1, 1, 0, 1, 1]), checkedGl.STATIC_DRAW);
116
- checkedGl.vertexAttribPointer(vertexPositionAttrLoc, 2, checkedGl.FLOAT, false, 0, 0);
117
-
118
- const uvAttrLoc = checkedGl.getAttribLocation(program, "uvIn");
119
- checkedGl.enableVertexAttribArray(uvAttrLoc);
120
109
  this.uvBuffer = checkedGl.createBuffer();
121
- checkedGl.bindBuffer(checkedGl.ARRAY_BUFFER, this.uvBuffer);
122
- checkedGl.vertexAttribPointer(uvAttrLoc, 2, checkedGl.FLOAT, false, 0, 0);
123
-
124
- checkedGl.activeTexture(gl.TEXTURE0);
125
- checkedGl.bindTexture(gl.TEXTURE_2D, this.texture);
126
110
 
127
111
  this.checkedGl = checkedGl;
112
+ this.filter = null;
113
+ this.attribLocations = [];
128
114
  this.viewportWidth = this.viewportHeight = 0;
129
115
  this.uvFloatArray = new Float32Array(8);
130
116
  this.lastExtent = {};
131
117
 
118
+ try {
119
+ this.setFilter(filterClass);
120
+ } catch (e) {
121
+ this.dispose();
122
+ throw e;
123
+ }
124
+
132
125
  console.log("GL Canvas set up");
133
126
  }
134
127
 
135
128
  /**
136
- * Release the GL objects this canvas owns.
129
+ * Draw with `filterClass` from here on, keeping the framebuffer texture and
130
+ * the vertex buffers: only the program, the texture sampling mode and the
131
+ * attribute locations differ between filters.
137
132
  *
138
- * Switching display mode builds a new canvas over the same element, and a
139
- * canvas only ever hands out one WebGL context so the new one inherits
140
- * the old one's context and the old one's objects stay resident unless
141
- * they are deleted here. That is a megabytes-per-switch leak: the
142
- * framebuffer texture alone is 1024x1024 RGBA.
133
+ * The new filter is built before the old one is disposed, so a filter that
134
+ * will not build leaves the canvas drawing as it was.
135
+ */
136
+ setFilter(filterClass) {
137
+ const gl = this.checkedGl;
138
+ const filter = new filterClass(gl);
139
+ this.filter?.dispose();
140
+ this.filter = filter;
141
+ gl.useProgram(filter.program);
142
+
143
+ // Filters that pick their own samples want the texels they asked for,
144
+ // not a hardware blend of the ones either side.
145
+ const sampling = filterClass.getDisplayConfig().nearestSampling ? gl.NEAREST : gl.LINEAR;
146
+ gl.activeTexture(gl.TEXTURE0);
147
+ gl.bindTexture(gl.TEXTURE_2D, this.texture);
148
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, sampling);
149
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, sampling);
150
+
151
+ const bindAttribute = (name, buffer) => {
152
+ const location = gl.getAttribLocation(filter.program, name);
153
+ gl.enableVertexAttribArray(location);
154
+ gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
155
+ gl.vertexAttribPointer(location, 2, gl.FLOAT, false, 0, 0);
156
+ return location;
157
+ };
158
+ for (const location of this.attribLocations) gl.disableVertexAttribArray(location);
159
+ this.attribLocations = [bindAttribute("pos", this.vertexPositionBuffer), bindAttribute("uvIn", this.uvBuffer)];
160
+ }
161
+
162
+ /**
163
+ * Release the GL objects this canvas owns. Nothing else will: a canvas
164
+ * element hands out one WebGL context for its lifetime, so anything created
165
+ * through that context stays resident however many wrappers come and go.
143
166
  */
144
167
  dispose() {
145
168
  const gl = this.checkedGl;
146
- this.filter.dispose();
169
+ this.filter?.dispose();
170
+ this.filter = null;
147
171
  gl.deleteTexture(this.texture);
148
172
  gl.deleteBuffer(this.vertexPositionBuffer);
149
173
  gl.deleteBuffer(this.uvBuffer);
@@ -218,6 +242,24 @@ function fellBackBecause(canvas, reason) {
218
242
  return canvas;
219
243
  }
220
244
 
245
+ /**
246
+ * Draw with `filterClass`, or with the unfiltered display if it will not build,
247
+ * in which case `fallbackReason` says why.
248
+ */
249
+ export function useBestFilter(canvas, filterClass) {
250
+ let reason;
251
+ try {
252
+ canvas.setFilter(filterClass);
253
+ return fellBackBecause(canvas, undefined);
254
+ } catch (e) {
255
+ console.log(`Unable to use ${filterClass.getDisplayConfig().name}: ${e}`);
256
+ if (filterClass === PassthroughFilter) throw e;
257
+ reason = e?.message ?? e;
258
+ }
259
+ canvas.setFilter(PassthroughFilter);
260
+ return fellBackBecause(canvas, reason);
261
+ }
262
+
221
263
  export function bestCanvas(canvas, filterClass) {
222
264
  let reason;
223
265
  try {
package/src/cmos.js CHANGED
@@ -33,6 +33,44 @@ function fromBcd(value) {
33
33
 
34
34
  export { defaultCmos };
35
35
 
36
+ /**
37
+ * CMOS persistence backed by a browser storage object, which can be unavailable, full or holding
38
+ * something other than what was last saved.
39
+ *
40
+ * @param {function(): Storage} getStorage typically `() => window.localStorage`. Reading that
41
+ * property is itself what throws when a page is refused storage, so it happens per call, inside
42
+ * the try.
43
+ * @param {function(*)} onSaveFailure called with the error the first time a save fails
44
+ */
45
+ export function localStoragePersistence(getStorage, onSaveFailure) {
46
+ let saveFailureReported = false;
47
+ return {
48
+ load() {
49
+ try {
50
+ const stored = getStorage().cmosRam;
51
+ if (!stored) return null;
52
+ const parsed = JSON.parse(stored);
53
+ if (!Array.isArray(parsed) || parsed.length !== defaultCmos.length)
54
+ throw new Error(`the stored settings are not ${defaultCmos.length} bytes`);
55
+ return parsed;
56
+ } catch (error) {
57
+ console.log(`Unable to read the stored CMOS settings: ${error?.message ?? error}`);
58
+ return null;
59
+ }
60
+ },
61
+ save(data) {
62
+ try {
63
+ getStorage().cmosRam = JSON.stringify(data);
64
+ } catch (error) {
65
+ console.log(`Unable to store the CMOS settings: ${error?.message ?? error}`);
66
+ if (saveFailureReported) return;
67
+ saveFailureReported = true;
68
+ onSaveFailure(error);
69
+ }
70
+ },
71
+ };
72
+ }
73
+
36
74
  export class Cmos {
37
75
  constructor(persistence, cmosOverride, econet) {
38
76
  this.store = persistence ? persistence.load() : null;
package/src/disc-hfe.js CHANGED
@@ -213,7 +213,7 @@ export function loadHfe(disc, data, onChange) {
213
213
  const hfeData = toHfe(disc);
214
214
  // Call the onChange handler with the updated HFE data
215
215
  onChange(hfeData);
216
- });
216
+ }, true);
217
217
  }
218
218
 
219
219
  return disc;
package/src/disc.js CHANGED
@@ -720,6 +720,7 @@ export function loadSsd(disc, data, isDsd, onChange) {
720
720
  if (!sectorShortfall(sector)) dataCopy.set(sector.sectorData, ssdOffsetOf(sector, side, numSides));
721
721
  onChange(dataCopy);
722
722
  },
723
+ true,
723
724
  );
724
725
  }
725
726
  return disc;
@@ -905,6 +906,7 @@ export class Disc {
905
906
  this.is40Track = false;
906
907
 
907
908
  this._trackWriteListeners = new Set();
909
+ this._savingListeners = new Set();
908
910
  this.isWriteable = isWriteable;
909
911
 
910
912
  // Track which tracks have been written since the last snapshot.
@@ -931,14 +933,32 @@ export class Disc {
931
933
  this.initSurface(0);
932
934
  }
933
935
 
934
- /** @param {function(boolean, Number, Track): void} listener called once per flushed track */
935
- addTrackWriteListener(listener) {
936
+ /**
937
+ * @param {function(boolean, Number, Track): void} listener called once per flushed track
938
+ * @param {boolean} [savesChanges] whether the listener puts the image somewhere it is read back from
939
+ */
940
+ addTrackWriteListener(listener, savesChanges = false) {
936
941
  this._trackWriteListeners.add(listener);
942
+ if (savesChanges) this._savingListeners.add(listener);
943
+ }
944
+
945
+ get savesChanges() {
946
+ return this._savingListeners.size > 0;
937
947
  }
938
948
 
939
949
  /** @param {function(boolean, Number, Track): void} listener */
940
950
  removeTrackWriteListener(listener) {
941
951
  this._trackWriteListeners.delete(listener);
952
+ this._savingListeners.delete(listener);
953
+ }
954
+
955
+ /** @param {function(): void} listener called once, when a track is first written to this disc */
956
+ notifyOnFirstTrackWrite(listener) {
957
+ const onFirstWrite = () => {
958
+ this.removeTrackWriteListener(onFirstWrite);
959
+ listener();
960
+ };
961
+ this.addTrackWriteListener(onFirstWrite);
942
962
  }
943
963
 
944
964
  /**
package/src/fdc.js CHANGED
@@ -289,7 +289,16 @@ export function discFor(fdc, name, stringData, onChange, layout = DiscLayout.aut
289
289
  return disc;
290
290
  }
291
291
 
292
- export function localDisc(fdc, name, layout = DiscLayout.auto) {
292
+ /**
293
+ * Create or open a disc held in the browser's local storage.
294
+ * @param {Object} fdc - The FDC controller object
295
+ * @param {string} name - The file name with extension
296
+ * @param {string} [layout] - one of DiscLayout; by default the image is asked what it is
297
+ * @param {function(*): void} [onSaveError] - called with whatever was thrown, the first time a write
298
+ * cannot be stored
299
+ * @returns {Disc} The loaded disc object
300
+ */
301
+ export function localDisc(fdc, name, layout = DiscLayout.auto, onSaveError = () => {}) {
293
302
  const discName = "disc_" + name;
294
303
  let data;
295
304
  const dataString = window.localStorage[discName];
@@ -307,12 +316,16 @@ export function localDisc(fdc, name, layout = DiscLayout.auto) {
307
316
  console.log("Loading browser-local disc " + name);
308
317
  data = utils.stringToUint8Array(dataString);
309
318
  }
319
+ let reportedSaveError = false;
310
320
  const onChange = (data) => {
311
321
  try {
312
322
  const str = utils.uint8ArrayToString(data);
313
323
  window.localStorage.setItem(discName, str);
314
324
  } catch (e) {
315
- window.alert("Writing to localStorage failed: " + e);
325
+ console.log(`Unable to save browser-local disc ${name}: ${e}`);
326
+ if (reportedSaveError) return;
327
+ reportedSaveError = true;
328
+ onSaveError(e);
316
329
  }
317
330
  };
318
331
  return discFor(fdc, name, data, onChange, layout);
@@ -49,10 +49,11 @@ export class GoogleDriveLoader {
49
49
 
50
50
  _loadScript(src) {
51
51
  // https://github.com/google/google-api-javascript-client/issues/319
52
- return new Promise((resolve) => {
52
+ return new Promise((resolve, reject) => {
53
53
  const script = document.createElement("script");
54
54
  script.src = src;
55
55
  script.onload = resolve;
56
+ script.onerror = () => reject(new Error(`Failed to fetch ${src}; a browser extension may be blocking it`));
56
57
  document.body.appendChild(script);
57
58
  });
58
59
  }
package/src/jsbeeb.css CHANGED
@@ -1,5 +1,6 @@
1
1
  body {
2
2
  overflow: hidden;
3
+ visibility: visible !important; /* see index.html */
3
4
  }
4
5
  #outer {
5
6
  display: block;
@@ -171,7 +172,7 @@ th {
171
172
 
172
173
  #hfe-list li a {
173
174
  display: grid;
174
- grid-template-columns: minmax(0, 3fr) minmax(0, 2fr) minmax(0, 2fr);
175
+ grid-template-columns: minmax(0, 3fr) minmax(0, 2fr) minmax(0, 2fr) 7em;
175
176
  gap: 0 1rem;
176
177
  align-items: baseline;
177
178
  padding: 3px 6px;
@@ -198,6 +199,22 @@ th {
198
199
  font-family: monospace;
199
200
  }
200
201
 
202
+ #hfe-list .provenance {
203
+ font-size: 0.8em;
204
+ opacity: 0.6;
205
+ font-style: italic;
206
+ text-align: right;
207
+ }
208
+
209
+ .hfe-provenance label {
210
+ margin-left: 1.5rem;
211
+ opacity: 0.8;
212
+ }
213
+
214
+ .hfe-provenance input {
215
+ margin-right: 0.3em;
216
+ }
217
+
201
218
  .hfe-credit {
202
219
  font-size: 0.9em;
203
220
  opacity: 0.75;
@@ -208,6 +225,11 @@ th {
208
225
  grid-template-columns: minmax(0, 1fr);
209
226
  padding-bottom: 6px;
210
227
  }
228
+
229
+ /* Stacked into one column, so there is no column end to sit against. */
230
+ #hfe-list .provenance {
231
+ text-align: left;
232
+ }
211
233
  }
212
234
 
213
235
  div.filter {
@@ -25,6 +25,9 @@ import sharp from "sharp";
25
25
  const FB_WIDTH = 1024;
26
26
  const FB_HEIGHT = 625;
27
27
 
28
+ // Five times a frame, so only a machine that has stopped painting hits it.
29
+ const BackstopSecondsPerFrame = 0.1;
30
+
28
31
  export class MachineSession {
29
32
  /**
30
33
  * @param {string} modelName - e.g. "B-DFS1.2", "Master"
@@ -46,6 +49,8 @@ export class MachineSession {
46
49
  this._completeFb8 = new Uint8Array(FB_WIDTH * FB_HEIGHT * 4);
47
50
  this._lastPaint = { minx: 0, miny: 0, maxx: FB_WIDTH, maxy: FB_HEIGHT };
48
51
  this._frameDirty = false;
52
+ this._frameCount = 0;
53
+ this._stopAtFrame = Infinity;
49
54
 
50
55
  // Create a real Video instance so we get pixel output
51
56
  const modelObj = findModel(modelName);
@@ -59,6 +64,8 @@ export class MachineSession {
59
64
  // Snapshot the complete frame now, before clearPaintBuffer() wipes _fb32.
60
65
  // This mirrors what the browser does: paint_ext fires → canvas updated → fb32 cleared.
61
66
  this._completeFb8.set(this._fb8);
67
+ this._frameCount++;
68
+ if (this._frameCount >= this._stopAtFrame) this._machine.processor.stop();
62
69
  },
63
70
  { isAtom: modelObj.isAtom },
64
71
  );
@@ -308,6 +315,56 @@ export class MachineSession {
308
315
  await this._machine.runFor(cycles);
309
316
  }
310
317
 
318
+ /** Emulated cycles since power-on, undoing the per-second rebasing execute() applies */
319
+ get elapsedCycles() {
320
+ const cpu = this._machine.processor;
321
+ return cpu.cycleSeconds * cpu.model.cyclesPerSecond + cpu.currentCycles;
322
+ }
323
+
324
+ /**
325
+ * Run until `count` more frames have been painted, stopping on the paint
326
+ * itself. A frame is 40000 cycles interlaced, 39936 not, and whatever a
327
+ * program driving the CRTC makes it, so stepping by cycles instead walks
328
+ * the sample point through the guest's frame.
329
+ *
330
+ * `completed` is false if a breakpoint fired, or the backstop ran out
331
+ * first.
332
+ *
333
+ * @param {number} [count=1] frames to advance
334
+ * @param {Object} [opts]
335
+ * @param {number} [opts.maxCycles] how long to wait on a machine that is not painting
336
+ * @returns {Promise<{framesRun: number, cyclesRun: number, completed: boolean}>}
337
+ */
338
+ async runFrames(count = 1, { maxCycles } = {}) {
339
+ const cpu = this._machine.processor;
340
+ const backstop = maxCycles ?? count * BackstopSecondsPerFrame * cpu.model.cyclesPerSecond;
341
+ const startFrame = this._frameCount;
342
+ const startCycles = this.elapsedCycles;
343
+ // execute() adds each request to a running targetCycles, so budget left
344
+ // unspent by an early stop would silently lengthen the caller's next run.
345
+ const unspentBefore = cpu.targetCycles - cpu.currentCycles;
346
+
347
+ this._stopAtFrame = startFrame + count;
348
+ try {
349
+ await this._machine.runFor(backstop);
350
+ } finally {
351
+ this._stopAtFrame = Infinity;
352
+ cpu.targetCycles = cpu.currentCycles + unspentBefore;
353
+ }
354
+
355
+ const framesRun = this._frameCount - startFrame;
356
+ return {
357
+ framesRun,
358
+ cyclesRun: this.elapsedCycles - startCycles,
359
+ completed: framesRun >= count,
360
+ };
361
+ }
362
+
363
+ /** Frames painted since the session was created; a hard reset does not zero it */
364
+ get frameCount() {
365
+ return this._frameCount;
366
+ }
367
+
311
368
  /**
312
369
  * Run until PC reaches targetAddr (like a breakpoint), or timeout.
313
370
  */