jsbeeb 1.17.1 → 1.19.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.19.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
@@ -14,15 +14,27 @@ export function getFilterForMode(mode) {
14
14
  return DISPLAY_MODE_FILTERS[mode] || DISPLAY_MODE_FILTERS.rgb;
15
15
  }
16
16
 
17
+ // The hint asks the browser to skip the renderer compositor queue and hand the buffer straight to
18
+ // the display controller, saving a frame or so of output latency. It is only a hint, so read back
19
+ // what we actually got.
20
+ // https://developer.chrome.com/blog/desynchronized
21
+ function reportDesynchronized(ctx, asked) {
22
+ // A lost context returns null here rather than an attributes object.
23
+ const honoured = ctx.getContextAttributes?.()?.desynchronized ?? false;
24
+ if (!asked) console.log("Low latency canvas turned off");
25
+ else console.log(`Low latency canvas ${honoured ? "in use" : "not available"}`);
26
+ }
27
+
17
28
  export class Canvas {
18
29
  /** The 2D canvas draws the framebuffer as-is, which is what this filter is. */
19
30
  get filterClass() {
20
31
  return PassthroughFilter;
21
32
  }
22
33
 
23
- constructor(canvas) {
24
- this.ctx = canvas.getContext("2d", { alpha: false });
34
+ constructor(canvas, lowLatency = true) {
35
+ this.ctx = canvas.getContext("2d", { alpha: false, desynchronized: lowLatency });
25
36
  if (this.ctx === null) throw new Error("Unable to get a 2D context");
37
+ reportDesynchronized(this.ctx, lowLatency);
26
38
  this.ctx.fillStyle = "black";
27
39
  this.ctx.fillRect(0, 0, 1024, 625);
28
40
  this.backBuffer = window.document.createElement("canvas");
@@ -38,6 +50,11 @@ export class Canvas {
38
50
  /** Nothing to release: the 2D context owns no objects of ours. */
39
51
  dispose() {}
40
52
 
53
+ setFilter(filterClass) {
54
+ if (filterClass !== PassthroughFilter)
55
+ throw new Error(`${filterClass.getDisplayConfig().name} needs WebGL, which is not in use here`);
56
+ }
57
+
41
58
  paint(minx, miny, maxx, maxy, _frame) {
42
59
  const width = maxx - minx;
43
60
  const height = maxy - miny;
@@ -55,7 +72,7 @@ export class GlCanvas {
55
72
  return this.filter.constructor;
56
73
  }
57
74
 
58
- constructor(canvas, filterClass) {
75
+ constructor(canvas, filterClass, lowLatency = true) {
59
76
  // failIfMajorPerformanceCaveat prevents the use of CPU based WebGL
60
77
  // rendering, which is much worse than simply using a 2D canvas for
61
78
  // rendering.
@@ -63,38 +80,33 @@ export class GlCanvas {
63
80
  alpha: false,
64
81
  antialias: false,
65
82
  depth: false,
66
- preserveDrawingBuffer: false,
83
+ // A desynchronized context can be scanned out while it is cleared but not yet redrawn,
84
+ // which flickers unless the buffer is preserved between frames.
85
+ preserveDrawingBuffer: lowLatency,
67
86
  stencil: false,
68
87
  failIfMajorPerformanceCaveat: true,
88
+ desynchronized: lowLatency,
69
89
  };
70
90
  const gl = canvas.getContext("webgl", glAttrs) || canvas.getContext("experimental-webgl", glAttrs);
71
91
  this.gl = gl;
72
92
  if (!gl) {
73
93
  throw new Error("Unable to create a GL context");
74
94
  }
95
+ reportDesynchronized(gl, lowLatency);
75
96
  const checkedGl = webglDebug.makeDebugContext(gl, function (err, funcName) {
76
97
  throw new Error("Problem creating GL context: " + webglDebug.glEnumToString(err) + " in " + funcName);
77
98
  });
78
99
 
79
100
  checkedGl.depthMask(false);
80
101
 
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
102
  this.fb8 = new Uint8Array(width * height * 4);
90
103
  this.fb32 = new Uint32Array(this.fb8.buffer);
91
104
  this.texture = checkedGl.createTexture();
105
+ checkedGl.activeTexture(checkedGl.TEXTURE0);
92
106
  checkedGl.bindTexture(checkedGl.TEXTURE_2D, this.texture);
93
107
  checkedGl.pixelStorei(checkedGl.UNPACK_ALIGNMENT, 4);
94
108
  checkedGl.texParameteri(checkedGl.TEXTURE_2D, checkedGl.TEXTURE_WRAP_S, checkedGl.CLAMP_TO_EDGE);
95
109
  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
110
  checkedGl.texImage2D(
99
111
  checkedGl.TEXTURE_2D,
100
112
  0,
@@ -106,44 +118,72 @@ export class GlCanvas {
106
118
  checkedGl.UNSIGNED_BYTE,
107
119
  this.fb8,
108
120
  );
109
- checkedGl.bindTexture(checkedGl.TEXTURE_2D, null);
110
121
 
111
- const vertexPositionAttrLoc = checkedGl.getAttribLocation(program, "pos");
112
- checkedGl.enableVertexAttribArray(vertexPositionAttrLoc);
113
122
  this.vertexPositionBuffer = checkedGl.createBuffer();
114
123
  checkedGl.bindBuffer(checkedGl.ARRAY_BUFFER, this.vertexPositionBuffer);
115
124
  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
125
  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
126
 
127
127
  this.checkedGl = checkedGl;
128
+ this.filter = null;
129
+ this.attribLocations = [];
128
130
  this.viewportWidth = this.viewportHeight = 0;
129
131
  this.uvFloatArray = new Float32Array(8);
130
132
  this.lastExtent = {};
131
133
 
134
+ try {
135
+ this.setFilter(filterClass);
136
+ } catch (e) {
137
+ this.dispose();
138
+ throw e;
139
+ }
140
+
132
141
  console.log("GL Canvas set up");
133
142
  }
134
143
 
135
144
  /**
136
- * Release the GL objects this canvas owns.
145
+ * Draw with `filterClass` from here on, keeping the framebuffer texture and
146
+ * the vertex buffers: only the program, the texture sampling mode and the
147
+ * attribute locations differ between filters.
137
148
  *
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.
149
+ * The new filter is built before the old one is disposed, so a filter that
150
+ * will not build leaves the canvas drawing as it was.
151
+ */
152
+ setFilter(filterClass) {
153
+ const gl = this.checkedGl;
154
+ const filter = new filterClass(gl);
155
+ this.filter?.dispose();
156
+ this.filter = filter;
157
+ gl.useProgram(filter.program);
158
+
159
+ // Filters that pick their own samples want the texels they asked for,
160
+ // not a hardware blend of the ones either side.
161
+ const sampling = filterClass.getDisplayConfig().nearestSampling ? gl.NEAREST : gl.LINEAR;
162
+ gl.activeTexture(gl.TEXTURE0);
163
+ gl.bindTexture(gl.TEXTURE_2D, this.texture);
164
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, sampling);
165
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, sampling);
166
+
167
+ const bindAttribute = (name, buffer) => {
168
+ const location = gl.getAttribLocation(filter.program, name);
169
+ gl.enableVertexAttribArray(location);
170
+ gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
171
+ gl.vertexAttribPointer(location, 2, gl.FLOAT, false, 0, 0);
172
+ return location;
173
+ };
174
+ for (const location of this.attribLocations) gl.disableVertexAttribArray(location);
175
+ this.attribLocations = [bindAttribute("pos", this.vertexPositionBuffer), bindAttribute("uvIn", this.uvBuffer)];
176
+ }
177
+
178
+ /**
179
+ * Release the GL objects this canvas owns. Nothing else will: a canvas
180
+ * element hands out one WebGL context for its lifetime, so anything created
181
+ * through that context stays resident however many wrappers come and go.
143
182
  */
144
183
  dispose() {
145
184
  const gl = this.checkedGl;
146
- this.filter.dispose();
185
+ this.filter?.dispose();
186
+ this.filter = null;
147
187
  gl.deleteTexture(this.texture);
148
188
  gl.deleteBuffer(this.vertexPositionBuffer);
149
189
  gl.deleteBuffer(this.uvBuffer);
@@ -218,10 +258,28 @@ function fellBackBecause(canvas, reason) {
218
258
  return canvas;
219
259
  }
220
260
 
221
- export function bestCanvas(canvas, filterClass) {
261
+ /**
262
+ * Draw with `filterClass`, or with the unfiltered display if it will not build,
263
+ * in which case `fallbackReason` says why.
264
+ */
265
+ export function useBestFilter(canvas, filterClass) {
266
+ let reason;
267
+ try {
268
+ canvas.setFilter(filterClass);
269
+ return fellBackBecause(canvas, undefined);
270
+ } catch (e) {
271
+ console.log(`Unable to use ${filterClass.getDisplayConfig().name}: ${e}`);
272
+ if (filterClass === PassthroughFilter) throw e;
273
+ reason = e?.message ?? e;
274
+ }
275
+ canvas.setFilter(PassthroughFilter);
276
+ return fellBackBecause(canvas, reason);
277
+ }
278
+
279
+ export function bestCanvas(canvas, filterClass, lowLatency = true) {
222
280
  let reason;
223
281
  try {
224
- return new GlCanvas(canvas, filterClass);
282
+ return new GlCanvas(canvas, filterClass, lowLatency);
225
283
  } catch (e) {
226
284
  // Either WebGL is unavailable or this particular filter declined it.
227
285
  reason = e?.message ?? e;
@@ -233,11 +291,11 @@ export function bestCanvas(canvas, filterClass) {
233
291
  // 2D fallback below would throw and take the emulator with it.
234
292
  if (filterClass !== PassthroughFilter) {
235
293
  try {
236
- return fellBackBecause(new GlCanvas(canvas, PassthroughFilter), reason);
294
+ return fellBackBecause(new GlCanvas(canvas, PassthroughFilter, lowLatency), reason);
237
295
  } catch (e) {
238
296
  console.log("Unable to fall back to the passthrough filter: " + e);
239
297
  }
240
298
  }
241
299
 
242
- return fellBackBecause(new Canvas(canvas), reason);
300
+ return fellBackBecause(new Canvas(canvas, lowLatency), reason);
243
301
  }
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
  */