jsbeeb 1.17.1 → 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/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.1",
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"
@@ -106,10 +106,11 @@
106
106
  "mirror-sth:upload": "npm-run-all mirror-sth:upload:*",
107
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'",
108
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'",
109
- "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",
110
110
  "mirror-bbcdiscs:check": "node tools/mirror-bbcdiscs.js --csv .bbcdiscs-sheet.csv --check-only",
111
111
  "mirror-bbcdiscs:seed": "aws s3 sync s3://bbc.xania.org/archive/bbcdiscs/hfe/ .bbcdiscs-mirror/hfe/ --no-progress",
112
- "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:*",
113
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'",
114
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'",
115
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) {
@@ -1370,7 +1378,7 @@ export class Cpu6502 extends Base6502 {
1370
1378
  this.fdc.powerOnReset();
1371
1379
  this.adconverter.reset();
1372
1380
 
1373
- this.touchScreen = new TouchScreen(this.scheduler, this.model.cyclesPerSecond);
1381
+ this.touchScreen.reset();
1374
1382
  if (this.econet) this.filestore = new Filestore(this, this.econet);
1375
1383
  }
1376
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);
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/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
  */
package/src/main.js CHANGED
@@ -12,7 +12,7 @@ import * as utils_atom from "./utils_atom.js";
12
12
  import { LoadSD } from "./mmc.js";
13
13
  import { Cmos, localStoragePersistence } from "./cmos.js";
14
14
  import { StairwayToHell } from "./sth.js";
15
- import { BbcDiscArchive, describe as describeHfe } from "./bbcdiscs.js";
15
+ import { BbcDiscArchive, Provenance, describe as describeHfe, matches, provenancesIn } from "./bbcdiscs.js";
16
16
  import { GamePad } from "./gamepads.js";
17
17
  import * as disc from "./fdc.js";
18
18
  import { loadTapeFromData } from "./tapes.js";
@@ -397,6 +397,24 @@ function showError(context, error) {
397
397
 
398
398
  const errorText = (error) => error?.message ?? `${error}`;
399
399
 
400
+ function reportLoadFailure(description, error) {
401
+ console.error(`Error loading ${description}:`, error);
402
+ toast(`Could not load ${description}: ${errorText(error)}`, { title: "Loading" });
403
+ }
404
+
405
+ function reportIgnoredFiles(name, ignored) {
406
+ if (!ignored.length) return;
407
+ toast(`Loaded ${name}. The archive also holds ${ignored.join(", ")}, and only one file is loaded from it.`, {
408
+ title: "Archive",
409
+ });
410
+ }
411
+
412
+ async function unzipAndReport(data) {
413
+ const unzipped = await utils.unzipDiscImage(data);
414
+ reportIgnoredFiles(unzipped.name, unzipped.ignored);
415
+ return unzipped;
416
+ }
417
+
400
418
  function showNotice(event) {
401
419
  const { message, title, quietKey } = event.detail;
402
420
  toast(message, { title, quietKey });
@@ -471,59 +489,57 @@ function noteDriveTracks(driveIndex, discName) {
471
489
  });
472
490
  }
473
491
 
474
- function createCanvasForFilter(filterClass) {
492
+ // Test which filter is actually in use, not merely whether we got WebGL: a
493
+ // filter can decline a context that works perfectly well for other modes, in
494
+ // which case we are quietly left with an unfiltered display.
495
+ function reportAnyFallback(displayCanvas, filterClass) {
496
+ if (displayCanvas.filterClass === filterClass) return;
497
+ const reason = displayCanvas.fallbackReason ? ` (${displayCanvas.fallbackReason})` : "";
498
+ const { name } = filterClass.getDisplayConfig();
499
+ toast(`${name} is not available on this device, so the standard display is in use${reason}.`, {
500
+ title: "Display",
501
+ quietKey: "quietDisplayFallback",
502
+ });
503
+ }
504
+
505
+ function sizeCanvasFor(filterClass) {
475
506
  // Not `config`: that is the emulator's live configuration object, declared
476
507
  // at module scope and used throughout this file.
477
508
  const displayConfig = filterClass.getDisplayConfig();
478
- // Each mode says how many pixels it wants to draw into. Set this before
479
- // creating the context, which fixes its initial viewport.
509
+ if (screenCanvas.width === displayConfig.canvasWidth && screenCanvas.height === displayConfig.canvasHeight) return;
480
510
  screenCanvas.width = displayConfig.canvasWidth;
481
511
  screenCanvas.height = displayConfig.canvasHeight;
512
+ }
482
513
 
483
- const newCanvas = tryGl ? canvasLib.bestCanvas(screenCanvas, filterClass) : new canvasLib.Canvas(screenCanvas);
484
-
485
- // Test which filter was actually built, not merely whether we got WebGL: a
486
- // filter can decline a context that works perfectly well for other modes,
487
- // in which case bestCanvas quietly gives us an unfiltered GL canvas.
488
- if (newCanvas.filterClass !== filterClass) {
489
- const reason = newCanvas.fallbackReason ? ` (${newCanvas.fallbackReason})` : "";
490
- toast(`${displayConfig.name} is not available on this device, so the standard display is in use${reason}.`, {
491
- title: "Display",
492
- quietKey: "quietDisplayFallback",
493
- });
494
- }
514
+ function createCanvasForFilter(filterClass) {
515
+ // Each mode says how many pixels it wants to draw into. Set this before
516
+ // creating the context, which fixes its initial viewport.
517
+ sizeCanvasFor(filterClass);
495
518
 
519
+ const newCanvas = tryGl ? canvasLib.bestCanvas(screenCanvas, filterClass) : new canvasLib.Canvas(screenCanvas);
520
+ reportAnyFallback(newCanvas, filterClass);
496
521
  return newCanvas;
497
522
  }
498
523
 
499
524
  let displayModeFilter = canvasLib.getFilterForMode(parsedQuery.displayMode || "rgb");
500
525
  function swapCanvas(newFilterClass) {
501
- const oldCanvas = canvas;
502
- const newCanvas = createCanvasForFilter(newFilterClass);
503
- // Carry the picture over; the buffers differ in height, so copy what fits.
504
- newCanvas.fb32.set(oldCanvas.fb32.subarray(0, newCanvas.fb32.length));
505
- // Only once the replacement exists, so a failure to build it leaves the
506
- // display we already had. The two share a GL context but no GL objects.
507
- oldCanvas.dispose();
508
- video.fb32 = newCanvas.fb32;
509
- video.paint_ext = function paint(minx, miny, maxx, maxy) {
510
- frames++;
511
- if (frames < frameSkip) return;
512
- frames = 0;
513
- newCanvas.paint(minx, miny, maxx, maxy, { frameCount: this.frameCount, lineGrid: this.lineGrid });
514
- };
515
- canvas = newCanvas;
526
+ // Everything but the filter is the same whatever the mode: the framebuffer
527
+ // texture, the vertex buffers and fb32 all carry over untouched.
528
+ canvasLib.useBestFilter(canvas, newFilterClass);
529
+ reportAnyFallback(canvas, newFilterClass);
516
530
  // Follow the filter we ended up with, not the one we asked for: everything
517
- // downstream the monitor picture, the canvas geometry, how large a
518
- // drawing buffer to ask for comes from its display config.
519
- displayModeFilter = newCanvas.filterClass;
531
+ // downstream (the monitor picture, the canvas geometry, how large a drawing
532
+ // buffer to ask for) comes from its display config.
533
+ displayModeFilter = canvas.filterClass;
534
+ // Back to the mode's own size, undoing any scaling the last one asked for.
535
+ sizeCanvasFor(displayModeFilter);
520
536
  // Nothing else will redraw: the mode is changed from a modal, which stops
521
537
  // the emulator.
522
538
  video.paint();
523
539
  window.setTimeout(() => window.dispatchEvent(new Event("resize")), 1);
524
540
  }
525
541
 
526
- let canvas = createCanvasForFilter(displayModeFilter);
542
+ const canvas = createCanvasForFilter(displayModeFilter);
527
543
  displayModeFilter = canvas.filterClass;
528
544
 
529
545
  video = new Video(
@@ -641,14 +657,19 @@ pastetext.addEventListener("dragover", function (event) {
641
657
  pastetext.addEventListener("drop", async function (event) {
642
658
  utils.noteEvent("local", "drop");
643
659
  const file = event.dataTransfer.files[0];
644
- const arrayBuffer = await file.arrayBuffer();
645
- if (isSnapshotFile(file.name, arrayBuffer)) {
646
- await loadStateFromFile(file, arrayBuffer);
647
- } else if (file.name.toLowerCase().endsWith(".uef")) {
648
- // Regular UEF tape image (not a BeebEm save state)
649
- setProcessorTape(await loadTapeFromData(file.name, new Uint8Array(arrayBuffer), model));
650
- } else {
651
- await loadHTMLFile(file);
660
+ if (!file) return;
661
+ try {
662
+ const arrayBuffer = await file.arrayBuffer();
663
+ if (isSnapshotFile(file.name, arrayBuffer)) {
664
+ await loadStateFromFile(file, arrayBuffer);
665
+ } else if (file.name.toLowerCase().endsWith(".uef")) {
666
+ // Regular UEF tape image (not a BeebEm save state)
667
+ setProcessorTape(await loadTapeFromData(file.name, new Uint8Array(arrayBuffer), model));
668
+ } else {
669
+ await loadHTMLFile(file);
670
+ }
671
+ } catch (error) {
672
+ reportLoadFailure(file.name, error);
652
673
  }
653
674
  });
654
675
 
@@ -1211,13 +1232,15 @@ function hfeOnCat(catalogue) {
1211
1232
  const list = document.getElementById("hfe-list");
1212
1233
  document.querySelector("#hfe .loading").style.display = "none";
1213
1234
  const template = list.querySelector(".template");
1235
+ showProvenanceChoices(catalogue, onHfeFilter);
1214
1236
 
1215
1237
  const addSome = (remaining) => {
1216
1238
  if (ticket !== hfeRender) return;
1217
1239
  const MaxAtATime = 100;
1218
1240
  const Delay = 30;
1219
- // Read per batch: the filter can be typed into while this is still going.
1241
+ // Read per batch: both can be changed while this is still going.
1220
1242
  const filter = document.getElementById("hfe-filter").value.toLowerCase();
1243
+ const shown = shownProvenances();
1221
1244
  for (const file of remaining.slice(0, MaxAtATime)) {
1222
1245
  const { title, publisher, detail } = describeHfe(file);
1223
1246
  const row = template.cloneNode(true);
@@ -1225,6 +1248,8 @@ function hfeOnCat(catalogue) {
1225
1248
  row.querySelector(".name").textContent = title;
1226
1249
  row.querySelector(".publisher").textContent = publisher;
1227
1250
  row.querySelector(".detail").textContent = detail;
1251
+ row.querySelector(".provenance").textContent =
1252
+ file.provenance === Provenance.Reconstructed ? "reconstructed" : "";
1228
1253
  if (file.notes) row.title = file.notes;
1229
1254
  // The row is an anchor, and letting it navigate to "#" would push a
1230
1255
  // history entry of its own on top of the one updateUrl pushes.
@@ -1233,8 +1258,9 @@ function hfeOnCat(catalogue) {
1233
1258
  hfeClick(file);
1234
1259
  $hfeModal.hide();
1235
1260
  });
1236
- row.style.display = row.textContent.toLowerCase().includes(filter) ? "" : "none";
1261
+ row.hfeFile = file;
1237
1262
  list.appendChild(row);
1263
+ showHfeRow(row, file, filter, shown);
1238
1264
  }
1239
1265
  if (remaining.length > MaxAtATime) setTimeout(() => addSome(remaining.slice(MaxAtATime)), Delay);
1240
1266
  };
@@ -1248,7 +1274,53 @@ document.getElementById("hfe").addEventListener("shown.bs.modal", () => {
1248
1274
  document.getElementById("hfe").addEventListener("show.bs.modal", () => hfeArchive.populate());
1249
1275
 
1250
1276
  const hfeFilter = document.getElementById("hfe-filter");
1251
- const onHfeFilter = () => filterArchiveList("hfe-list", hfeFilter.value);
1277
+ const hfeProvenance = document.getElementById("hfe-provenance");
1278
+
1279
+ const HfeProvenanceLabels = {
1280
+ [Provenance.Captured]: ["Captured", "Direct from disc"],
1281
+ [Provenance.Reconstructed]: ["Reconstructed", "Rebuilt from a sector dump"],
1282
+ };
1283
+
1284
+ /** Which provenances the picker is showing, or null when it is not offering the choice. */
1285
+ const shownProvenances = () => {
1286
+ const boxes = [...hfeProvenance.querySelectorAll("input")];
1287
+ return boxes.length ? new Set(boxes.filter((box) => box.checked).map((box) => box.value)) : null;
1288
+ };
1289
+
1290
+ // Offer one tick per provenance the archive actually holds, rather than naming
1291
+ // them here: a source added later should appear without this having to change.
1292
+ function showProvenanceChoices(catalogue, onChange) {
1293
+ const present = provenancesIn(catalogue);
1294
+ // Nothing to choose between: no ticks, and shownProvenances says "all".
1295
+ if (present.length < 2) {
1296
+ hfeProvenance.replaceChildren();
1297
+ return;
1298
+ }
1299
+ const wasShown = shownProvenances();
1300
+ hfeProvenance.replaceChildren(
1301
+ ...present.map((provenance) => {
1302
+ const [text, why] = HfeProvenanceLabels[provenance] ?? [provenance, ""];
1303
+ const label = document.createElement("label");
1304
+ label.title = why;
1305
+ const box = document.createElement("input");
1306
+ box.type = "checkbox";
1307
+ box.value = provenance;
1308
+ box.checked = !wasShown || wasShown.has(provenance);
1309
+ box.addEventListener("change", onChange);
1310
+ label.append(box, text);
1311
+ return label;
1312
+ }),
1313
+ );
1314
+ }
1315
+
1316
+ const showHfeRow = (row, file, filter, shown) => (row.style.display = matches(file, filter, shown) ? "" : "none");
1317
+
1318
+ const onHfeFilter = () => {
1319
+ const filter = hfeFilter.value.toLowerCase();
1320
+ const shown = shownProvenances();
1321
+ for (const row of document.querySelectorAll("#hfe-list li:not(.template)"))
1322
+ showHfeRow(row, row.hfeFile, filter, shown);
1323
+ };
1252
1324
  hfeFilter.addEventListener("change", onHfeFilter);
1253
1325
  hfeFilter.addEventListener("keyup", onHfeFilter);
1254
1326
 
@@ -1385,7 +1457,8 @@ async function loadDiscImage(discImage, layout = DiscLayout.auto) {
1385
1457
  switch (schema) {
1386
1458
  case "|":
1387
1459
  case "sth": {
1388
- const { name, data } = await discSth.fetch(discImage);
1460
+ const { name, data, ignored } = await discSth.fetch(discImage);
1461
+ reportIgnoredFiles(name, ignored);
1389
1462
  return disc.discFor(processor.fdc, name, data, undefined, layout);
1390
1463
  }
1391
1464
 
@@ -1406,7 +1479,7 @@ async function loadDiscImage(discImage, layout = DiscLayout.auto) {
1406
1479
 
1407
1480
  case "data": {
1408
1481
  const arr = Array.prototype.map.call(atob(discImage), (x) => x.charCodeAt(0));
1409
- const { name, data } = await utils.unzipDiscImage(arr);
1482
+ const { name, data } = await unzipAndReport(arr);
1410
1483
  return disc.discFor(processor.fdc, name, data, undefined, layout);
1411
1484
  }
1412
1485
  case "http":
@@ -1417,7 +1490,7 @@ async function loadDiscImage(discImage, layout = DiscLayout.auto) {
1417
1490
  discImage = new URL(asUrl).pathname;
1418
1491
  let discData = await utils.loadData(asUrl);
1419
1492
  if (/\.zip/i.test(discImage)) {
1420
- const unzipped = await utils.unzipDiscImage(discData);
1493
+ const unzipped = await unzipAndReport(discData);
1421
1494
  discData = unzipped.data;
1422
1495
  discImage = unzipped.name;
1423
1496
  }
@@ -1436,13 +1509,14 @@ async function loadTapeImage(tapeImage) {
1436
1509
  switch (schema) {
1437
1510
  case "|":
1438
1511
  case "sth": {
1439
- const { name, data } = await tapeSth.fetch(tapeImage);
1512
+ const { name, data, ignored } = await tapeSth.fetch(tapeImage);
1513
+ reportIgnoredFiles(name, ignored);
1440
1514
  return await loadTapeFromData(name, data, model);
1441
1515
  }
1442
1516
 
1443
1517
  case "data": {
1444
1518
  const arr = Array.prototype.map.call(atob(tapeImage), (x) => x.charCodeAt(0));
1445
- const { name, data } = await utils.unzipDiscImage(arr);
1519
+ const { name, data } = await unzipAndReport(arr);
1446
1520
  return await loadTapeFromData(name, data, model);
1447
1521
  }
1448
1522
 
@@ -1454,7 +1528,7 @@ async function loadTapeImage(tapeImage) {
1454
1528
  tapeImage = new URL(asUrl).pathname;
1455
1529
  let tapeData = await utils.loadData(asUrl);
1456
1530
  if (/\.zip/i.test(tapeImage)) {
1457
- const unzipped = await utils.unzipDiscImage(tapeData);
1531
+ const unzipped = await unzipAndReport(tapeData);
1458
1532
  tapeData = unzipped.data;
1459
1533
  tapeImage = unzipped.name;
1460
1534
  }
@@ -1466,7 +1540,7 @@ async function loadTapeImage(tapeImage) {
1466
1540
  let tapeData = await utils.loadData(tapePath);
1467
1541
  let tapeName = tapeImage;
1468
1542
  if (/\.zip/i.test(tapeName)) {
1469
- const unzipped = await utils.unzipDiscImage(tapeData);
1543
+ const unzipped = await unzipAndReport(tapeData);
1470
1544
  tapeData = unzipped.data;
1471
1545
  tapeName = unzipped.name;
1472
1546
  }
@@ -1479,7 +1553,11 @@ document.getElementById("disc_load").addEventListener("change", async function (
1479
1553
  if (evt.target.files.length === 0) return;
1480
1554
  utils.noteEvent("local", "click"); // NB no filename here
1481
1555
  const file = evt.target.files[0];
1482
- await loadHTMLFile(file);
1556
+ try {
1557
+ await loadHTMLFile(file);
1558
+ } catch (error) {
1559
+ reportLoadFailure(file.name, error);
1560
+ }
1483
1561
  evt.target.value = ""; // clear so if the user picks the same file again after a reset we get a "change"
1484
1562
  });
1485
1563
 
@@ -1487,7 +1565,11 @@ document.getElementById("fs_load").addEventListener("change", async function (ev
1487
1565
  if (evt.target.files.length === 0) return;
1488
1566
  utils.noteEvent("local", "click"); // NB no filename here
1489
1567
  const file = evt.target.files[0];
1490
- await loadSCSIFile(file);
1568
+ try {
1569
+ await loadSCSIFile(file);
1570
+ } catch (error) {
1571
+ reportLoadFailure(file.name, error);
1572
+ }
1491
1573
  evt.target.value = ""; // clear so if the user picks the same file again after a reset we get a "change"
1492
1574
  });
1493
1575
 
@@ -1496,17 +1578,21 @@ document.getElementById("tape_load").addEventListener("change", async function (
1496
1578
  const file = evt.target.files[0];
1497
1579
  utils.noteEvent("local", "clickTape"); // NB no filename here
1498
1580
 
1499
- let tapeData = await readFileAsBinaryString(file);
1500
- let tapeName = file.name;
1501
- if (/\.zip/i.test(tapeName)) {
1502
- const unzipped = await utils.unzipDiscImage(utils.stringToUint8Array(tapeData));
1503
- tapeData = unzipped.data;
1504
- tapeName = unzipped.name;
1581
+ try {
1582
+ let tapeData = await readFileAsBinaryString(file);
1583
+ let tapeName = file.name;
1584
+ if (/\.zip/i.test(tapeName)) {
1585
+ const unzipped = await unzipAndReport(utils.stringToUint8Array(tapeData));
1586
+ tapeData = unzipped.data;
1587
+ tapeName = unzipped.name;
1588
+ }
1589
+ setProcessorTape(await loadTapeFromData(tapeName, tapeData, model));
1590
+ delete parsedQuery.tape;
1591
+ updateUrl();
1592
+ bootstrap.Modal.getInstance(document.getElementById("tapes"))?.hide();
1593
+ } catch (error) {
1594
+ reportLoadFailure(file.name, error);
1505
1595
  }
1506
- setProcessorTape(await loadTapeFromData(tapeName, tapeData, model));
1507
- delete parsedQuery.tape;
1508
- updateUrl();
1509
- bootstrap.Modal.getInstance(document.getElementById("tapes"))?.hide();
1510
1596
 
1511
1597
  evt.target.value = ""; // clear so if the user picks the same file again after a reset we get a "change"
1512
1598
  });
@@ -1623,7 +1709,14 @@ googleDriveEl.addEventListener("show.bs.modal", async function () {
1623
1709
  gdLoading.textContent = "Loading...";
1624
1710
  gdLoading.style.display = "";
1625
1711
  for (const el of googleDriveEl.querySelectorAll("li:not(.template)")) el.remove();
1626
- const cat = await googleDrive.listFiles();
1712
+ let cat;
1713
+ try {
1714
+ cat = await googleDrive.listFiles();
1715
+ } catch (error) {
1716
+ console.error("Error listing Google Drive files:", error);
1717
+ gdLoading.textContent = `Unable to list your Google Drive files: ${errorText(error)}`;
1718
+ return;
1719
+ }
1627
1720
  const dbList = googleDriveEl.querySelector(".list");
1628
1721
  gdLoading.style.display = "none";
1629
1722
  const template = dbList.querySelector(".template");
@@ -1653,7 +1746,11 @@ for (const image of availableImages) {
1653
1746
  utils.noteEvent("images", "click", image.file);
1654
1747
  setDisc1Image(image.file);
1655
1748
  $discsModal.hide();
1656
- putDiscIn(0, await loadDiscImage(parsedQuery.disc1, layoutForDrive(0)));
1749
+ try {
1750
+ putDiscIn(0, await loadDiscImage(parsedQuery.disc1, layoutForDrive(0)));
1751
+ } catch (error) {
1752
+ reportLoadFailure(`${image.name} (${image.file})`, error);
1753
+ }
1657
1754
  });
1658
1755
  }
1659
1756
 
@@ -1946,8 +2043,7 @@ const startPromise = (async () => {
1946
2043
  try {
1947
2044
  await load();
1948
2045
  } catch (error) {
1949
- console.error(`Error loading ${description}:`, error);
1950
- toast(`Could not load ${description}: ${error?.message ?? error}`, { title: "Loading" });
2046
+ reportLoadFailure(description, error);
1951
2047
  }
1952
2048
  })();
1953
2049
  imageLoads.push(loading);
package/src/teletext.js CHANGED
@@ -11,6 +11,7 @@ export class Teletext {
11
11
  this.sep = false;
12
12
  this.dbl = this.oldDbl = this.secondHalfOfDouble = this.wasDbl = false;
13
13
  this.gfx = false;
14
+ this.conceal = false;
14
15
  this.flash = this.flashOn = false;
15
16
  this.flashTime = 0;
16
17
  this.heldChar = 0;
@@ -172,6 +173,7 @@ export class Teletext {
172
173
  secondHalfOfDouble: this.secondHalfOfDouble,
173
174
  wasDbl: this.wasDbl,
174
175
  gfx: this.gfx,
176
+ conceal: this.conceal,
175
177
  flash: this.flash,
176
178
  flashOn: this.flashOn,
177
179
  flashTime: this.flashTime,
@@ -198,6 +200,7 @@ export class Teletext {
198
200
  this.secondHalfOfDouble = state.secondHalfOfDouble;
199
201
  this.wasDbl = state.wasDbl;
200
202
  this.gfx = state.gfx;
203
+ this.conceal = state.conceal ?? false;
201
204
  this.flash = state.flash;
202
205
  this.flashOn = state.flashOn;
203
206
  this.flashTime = state.flashTime;
@@ -245,6 +248,7 @@ export class Teletext {
245
248
  case 7:
246
249
  this.gfx = false;
247
250
  this.col = data;
251
+ this.conceal = false;
248
252
  this.setNextChars();
249
253
  break;
250
254
  case 8:
@@ -267,10 +271,11 @@ export class Teletext {
267
271
  case 23:
268
272
  this.gfx = true;
269
273
  this.col = data & 7;
274
+ this.conceal = false;
270
275
  this.setNextChars();
271
276
  break;
272
277
  case 24:
273
- this.col = this.prevCol = this.bg;
278
+ this.conceal = true;
274
279
  break;
275
280
  case 25:
276
281
  this.sep = false;
@@ -358,6 +363,7 @@ export class Teletext {
358
363
  this.flash = false;
359
364
  this.sep = false;
360
365
  this.gfx = false;
366
+ this.conceal = false;
361
367
  this.dbl = false;
362
368
 
363
369
  this.scanlineCounter++;
@@ -396,6 +402,7 @@ export class Teletext {
396
402
  this.curGlyphs = this.nextGlyphs;
397
403
 
398
404
  let flashThisCell = this.flash;
405
+ let concealThisCell = this.conceal;
399
406
  if (data < 0x20) {
400
407
  data = this.handleControlCode(data);
401
408
  } else if (this.gfx) {
@@ -419,7 +426,10 @@ export class Teletext {
419
426
  // Steady (code 9) is "Set At" — update so this cell stops flashing immediately.
420
427
  if (flashThisCell && !this.flash) flashThisCell = false;
421
428
 
422
- if ((flashThisCell && this.flashOn) || (this.secondHalfOfDouble && !this.dbl)) {
429
+ // Conceal (code 24) is "Set At", and a colour code only reveals from the cell after itself.
430
+ if (this.conceal) concealThisCell = true;
431
+
432
+ if (concealThisCell || (flashThisCell && this.flashOn) || (this.secondHalfOfDouble && !this.dbl)) {
423
433
  const backgroundColour = this.colour[(this.bg & 7) << 5];
424
434
  for (let i = 0; i < 16; ++i) {
425
435
  buf[offset++] = backgroundColour;
@@ -94,6 +94,45 @@ export class TeletextAdaptor extends EventTarget {
94
94
  this.currentFrame = 0;
95
95
  }
96
96
 
97
+ updateIrq() {
98
+ if (this.teletextInts && this.teletextStatus & 0x80) {
99
+ this.cpu.interrupt |= 1 << TELETEXT_IRQ;
100
+ } else {
101
+ this.cpu.interrupt &= ~(1 << TELETEXT_IRQ);
102
+ }
103
+ }
104
+
105
+ snapshotState() {
106
+ return {
107
+ teletextStatus: this.teletextStatus,
108
+ teletextInts: this.teletextInts,
109
+ teletextEnable: this.teletextEnable,
110
+ channel: this.channel,
111
+ currentFrame: this.currentFrame,
112
+ rowPtr: this.rowPtr,
113
+ colPtr: this.colPtr,
114
+ pollCount: this.pollCount,
115
+ frameBuffer: this.frameBuffer.map((row) => row.slice()),
116
+ };
117
+ }
118
+
119
+ restoreState(state) {
120
+ this.teletextStatus = state.teletextStatus;
121
+ this.teletextInts = state.teletextInts;
122
+ this.teletextEnable = state.teletextEnable;
123
+ this.currentFrame = state.currentFrame;
124
+ this.rowPtr = state.rowPtr;
125
+ this.colPtr = state.colPtr;
126
+ this.pollCount = state.pollCount;
127
+ this.frameBuffer = state.frameBuffer.map((row) => row.slice());
128
+ this.updateIrq();
129
+ // Refetching the multi-megabyte stream on every restore would be ruinous for rewind.
130
+ if (this.channel !== state.channel) {
131
+ this.channel = state.channel;
132
+ this.loadChannelStream(this.channel);
133
+ }
134
+ }
135
+
97
136
  read(addr) {
98
137
  let data = 0x00;
99
138
 
@@ -120,11 +159,7 @@ export class TeletextAdaptor extends EventTarget {
120
159
  case 0x00:
121
160
  // Status register
122
161
  this.teletextInts = (value & 0x08) === 0x08;
123
- if (this.teletextInts && this.teletextStatus & 0x80) {
124
- this.cpu.interrupt |= 1 << TELETEXT_IRQ; // Interrupt if INT and interrupts enabled
125
- } else {
126
- this.cpu.interrupt &= ~(1 << TELETEXT_IRQ); // Clear interrupt
127
- }
162
+ this.updateIrq();
128
163
  this.teletextEnable = (value & 0x04) === 0x04;
129
164
  if ((value & 0x03) !== this.channel && this.teletextEnable) {
130
165
  this.channel = value & 0x03;
@@ -13,11 +13,32 @@ export class TouchScreen {
13
13
  constructor(scheduler, cyclesPerSecond) {
14
14
  this.scheduler = scheduler;
15
15
  this.pollCycles = cyclesPerSecond / PollHz;
16
- this.mouse = { x: 0, y: 0, button: 0 };
17
16
  this.outBuffer = new utils.Fifo(16);
18
- this.delay = 0;
19
- this.mode = 0;
20
17
  this.pollTask = this.scheduler.newTask(() => this.poll());
18
+ this.reset();
19
+ }
20
+
21
+ reset() {
22
+ this.mouse = { x: 0, y: 0, button: 0 };
23
+ this.mode = 0;
24
+ this.outBuffer.clear();
25
+ this.pollTask.cancel();
26
+ }
27
+
28
+ snapshotState() {
29
+ return {
30
+ mode: this.mode,
31
+ outBuffer: this.outBuffer.toArray(),
32
+ pollTaskOffset: this.pollTask.scheduled() ? this.pollTask.expireEpoch - this.scheduler.epoch : null,
33
+ };
34
+ }
35
+
36
+ restoreState(state) {
37
+ this.mode = state.mode;
38
+ this.outBuffer.clear();
39
+ for (const byte of state.outBuffer) this.store(byte);
40
+ this.pollTask.cancel();
41
+ if (state.pollTaskOffset !== null) this.pollTask.schedule(state.pollTaskOffset);
21
42
  }
22
43
 
23
44
  tryReceive(rts) {
package/src/utils.js CHANGED
@@ -1013,7 +1013,10 @@ function loadDataHttp(url) {
1013
1013
  request.open("GET", baseUrl + url, true);
1014
1014
  request.overrideMimeType("text/plain; charset=x-user-defined");
1015
1015
  request.onload = function () {
1016
- if (request.status !== 200) reject(new Error("Unable to load " + url + ", http code " + request.status));
1016
+ if (request.status !== 200) {
1017
+ reject(new Error("Unable to load " + url + ", http code " + request.status));
1018
+ return;
1019
+ }
1017
1020
  if (typeof request.response !== "string") {
1018
1021
  resolve(request.response);
1019
1022
  } else {
@@ -1208,6 +1211,7 @@ async function unzipImage(data, knownExtensions) {
1208
1211
 
1209
1212
  let uncompressed = null;
1210
1213
  let loadedFile;
1214
+ const ignored = [];
1211
1215
 
1212
1216
  for (const [filename, fileData] of Object.entries(files)) {
1213
1217
  const match = filename.match(/.*\.([a-z]+)/i);
@@ -1217,6 +1221,7 @@ async function unzipImage(data, knownExtensions) {
1217
1221
  }
1218
1222
  if (uncompressed) {
1219
1223
  console.log("Ignoring", filename, "as already found a file");
1224
+ ignored.push(filename);
1220
1225
  continue;
1221
1226
  }
1222
1227
  loadedFile = filename;
@@ -1228,9 +1233,14 @@ async function unzipImage(data, knownExtensions) {
1228
1233
  }
1229
1234
 
1230
1235
  console.log("Unzipped '" + loadedFile + "'");
1231
- return { data: uncompressed, name: loadedFile };
1236
+ return { data: uncompressed, name: loadedFile, ignored };
1232
1237
  }
1233
1238
 
1239
+ /**
1240
+ * @param {Uint8Array|number[]} data a ZIP archive
1241
+ * @returns {Promise<{data: Uint8Array, name: string, ignored: string[]}>} the one file loaded, and the
1242
+ * names of any other loadable files the archive held
1243
+ */
1234
1244
  export async function unzipDiscImage(data) {
1235
1245
  return unzipImage(data, knownDiscExtensions);
1236
1246
  }
@@ -1290,4 +1300,11 @@ export class Fifo {
1290
1300
  this._size--;
1291
1301
  return res;
1292
1302
  }
1303
+
1304
+ /** @returns {number[]} pending bytes, oldest first */
1305
+ toArray() {
1306
+ const result = [];
1307
+ for (let i = 0; i < this._size; ++i) result.push(this._buffer[(this._rPtr + i) % this._buffer.length]);
1308
+ return result;
1309
+ }
1293
1310
  }
package/src/via.js CHANGED
@@ -24,6 +24,10 @@ const ORB = 0x0,
24
24
  INT_CB1 = 0x10,
25
25
  INT_CB2 = 0x08;
26
26
 
27
+ // Pulse mode holds CA2/CB2 low for one 1MHz VIA cycle, here in the scheduler's 2MHz ticks.
28
+ // Figures 3-4 and 3-6 of https://6502.org/documents/datasheets/wdc/wdc_w65c22s_mar_2004.pdf
29
+ const PulseWidthCycles = 2;
30
+
27
31
  class Via {
28
32
  constructor(cpu, scheduler, irq) {
29
33
  this.cpu = cpu;
@@ -59,6 +63,8 @@ class Via {
59
63
  this.t1_pb7 = 0;
60
64
 
61
65
  this.task = this.scheduler.newTask(() => this._onTimeout());
66
+ this.ca2PulseTask = this.scheduler.newTask(() => this.setca2(true));
67
+ this.cb2PulseTask = this.scheduler.newTask(() => this.setcb2(true));
62
68
  this.lastPolltime = 0;
63
69
  }
64
70
 
@@ -72,6 +78,8 @@ class Via {
72
78
  this.t1hit = this.t2hit = true;
73
79
  this.acr = this.pcr = 0;
74
80
  this.t1_pb7 = 1;
81
+ this.ca2PulseTask.cancel();
82
+ this.cb2PulseTask.cancel();
75
83
  this.updateNextTime();
76
84
  }
77
85
 
@@ -166,7 +174,7 @@ class Via {
166
174
  } else if (mode === 0x0a) {
167
175
  // Pulse mode
168
176
  this.setca2(false);
169
- this.setca2(true);
177
+ this.ca2PulseTask.reschedule(PulseWidthCycles);
170
178
  }
171
179
  break;
172
180
 
@@ -193,7 +201,7 @@ class Via {
193
201
  } else if (mode === 0x0a) {
194
202
  // Pulse mode
195
203
  this.setcb2(false);
196
- this.setcb2(true);
204
+ this.cb2PulseTask.reschedule(PulseWidthCycles);
197
205
  }
198
206
  break;
199
207
 
@@ -408,6 +416,15 @@ class Via {
408
416
  this.portBUpdated();
409
417
  }
410
418
 
419
+ _taskOffset(task) {
420
+ return task.scheduled() ? task.expireEpoch - this.scheduler.epoch : null;
421
+ }
422
+
423
+ _restoreTask(task, offset) {
424
+ if (offset === null || offset === undefined) task.cancel();
425
+ else task.reschedule(offset);
426
+ }
427
+
411
428
  snapshotState() {
412
429
  return {
413
430
  ora: this.ora,
@@ -436,7 +453,9 @@ class Via {
436
453
  justhit: this.justhit,
437
454
  t1_pb7: this.t1_pb7,
438
455
  lastPolltime: this.lastPolltime,
439
- taskOffset: this.task.scheduled() ? this.task.expireEpoch - this.scheduler.epoch : null,
456
+ taskOffset: this._taskOffset(this.task),
457
+ ca2PulseTaskOffset: this._taskOffset(this.ca2PulseTask),
458
+ cb2PulseTaskOffset: this._taskOffset(this.cb2PulseTask),
440
459
  };
441
460
  }
442
461
 
@@ -468,11 +487,9 @@ class Via {
468
487
  this.t1_pb7 = state.t1_pb7;
469
488
  this.lastPolltime = state.lastPolltime;
470
489
  this.updateIFR();
471
- if (state.taskOffset !== null) {
472
- this.task.reschedule(state.taskOffset);
473
- } else {
474
- this.task.cancel();
475
- }
490
+ this._restoreTask(this.task, state.taskOffset);
491
+ this._restoreTask(this.ca2PulseTask, state.ca2PulseTaskOffset);
492
+ this._restoreTask(this.cb2PulseTask, state.cb2PulseTaskOffset);
476
493
  }
477
494
 
478
495
  setca1(level) {
@@ -111,7 +111,9 @@ export class TestMachine {
111
111
  stopped = !this.processor.execute(todo);
112
112
  left -= todo;
113
113
  }
114
- if (left && !stopped) {
114
+ // Not truthiness: a negative or NaN request clamps todo to zero,
115
+ // so left would never move and the loop never end.
116
+ if (left > 0 && !stopped) {
115
117
  setTimeout(runAnIter, 0);
116
118
  } else {
117
119
  resolve(stopped);