jsbeeb 1.13.0 → 1.14.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.
@@ -166,12 +166,25 @@ export function buildVideoState(ulaControl, ulaPalette, crtcRegs, nulaCollook, c
166
166
  * @param {object} uservia - jsbeeb user VIA state
167
167
  * @param {object} video - jsbeeb video state (from buildVideoState)
168
168
  * @param {object} soundChip - jsbeeb sound chip state
169
+ * @param {object|null} [tube] - jsbeeb tube state, or null if no co-processor was fitted
169
170
  */
170
- export function buildSnapshot(importedFrom, modelName, cpuState, ram, roms, sysvia, uservia, video, soundChip) {
171
+ export function buildSnapshot(
172
+ importedFrom,
173
+ modelName,
174
+ cpuState,
175
+ ram,
176
+ roms,
177
+ sysvia,
178
+ uservia,
179
+ video,
180
+ soundChip,
181
+ tube = null,
182
+ ) {
171
183
  return {
172
184
  format: "jsbeeb-snapshot",
173
- version: 2,
185
+ version: 3,
174
186
  model: modelName,
187
+ coProcessor: !!tube,
175
188
  timestamp: new Date().toISOString(),
176
189
  importedFrom,
177
190
  state: {
@@ -203,6 +216,7 @@ export function buildSnapshot(importedFrom, modelName, cpuState, ram, roms, sysv
203
216
  soundChip,
204
217
  acia: { ...DefaultAcia },
205
218
  adc: { ...DefaultAdc },
219
+ ...(tube ? { tube } : {}),
206
220
  },
207
221
  };
208
222
  }
package/src/snapshot.js CHANGED
@@ -4,7 +4,15 @@ import { typedArrayToBase64, base64ToTypedArray } from "./state-utils.js";
4
4
  import { findModel } from "./models.js";
5
5
 
6
6
  const SnapshotFormat = "jsbeeb-snapshot";
7
- const SnapshotVersion = 2;
7
+ const SnapshotVersion = 3;
8
+
9
+ /**
10
+ * Whether a snapshot was taken on a machine with a second processor fitted.
11
+ * Nothing before version 3 captured tube state, so those snapshots are always host-only.
12
+ */
13
+ export function hasCoProcessor(snapshot) {
14
+ return !!snapshot.coProcessor;
15
+ }
8
16
 
9
17
  /**
10
18
  * Check if two model names resolve to the same model (accounting for
@@ -70,6 +78,9 @@ export function createSnapshot(cpu, model, media) {
70
78
  format: SnapshotFormat,
71
79
  version: SnapshotVersion,
72
80
  model: model.name,
81
+ // The model name cannot distinguish a Turbo from a plain Master: the co-processor is
82
+ // emulation config rather than part of the model.
83
+ coProcessor: cpu.hasTube,
73
84
  timestamp: new Date().toISOString(),
74
85
  state,
75
86
  };
@@ -94,6 +105,13 @@ export function restoreSnapshot(cpu, model, snapshot) {
94
105
  if (!isSameModel(snapshot.model, model.name)) {
95
106
  throw new Error(`Model mismatch: snapshot is for "${snapshot.model}" but current model is "${model.name}"`);
96
107
  }
108
+ if (hasCoProcessor(snapshot) !== cpu.hasTube) {
109
+ const fitted = (yes) => (yes ? "with a second processor" : "without a second processor");
110
+ throw new Error(
111
+ `Co-processor mismatch: snapshot was taken ${fitted(hasCoProcessor(snapshot))} ` +
112
+ `but this machine is ${fitted(cpu.hasTube)}`,
113
+ );
114
+ }
97
115
  cpu.restoreState(snapshot.state);
98
116
  }
99
117
 
package/src/soundchip.js CHANGED
@@ -2,6 +2,9 @@
2
2
  // The BBC volumeTable[0] (loudest) is 0.25 (1.0 / 4 channels).
3
3
  const speakerVolume = 0.5;
4
4
 
5
+ // Samples per output chunk handed to the onBuffer callback.
6
+ export const SoundBufferSamples = 512;
7
+
5
8
  const volumeTable = new Float32Array(16);
6
9
  (() => {
7
10
  let f = 1.0;
@@ -21,6 +24,11 @@ function makeSineTable(attenuation) {
21
24
  }
22
25
 
23
26
  export class SoundChip {
27
+ /**
28
+ * @param {function(Float32Array): void} onBuffer called with each full
29
+ * SoundBufferSamples-sized buffer of output. Receives the same buffer
30
+ * every call, overwritten afterwards: copy the contents if they are kept.
31
+ */
24
32
  constructor(onBuffer) {
25
33
  this._onBuffer = onBuffer;
26
34
  // 4MHz input signal. Internal divide-by-8
@@ -62,7 +70,7 @@ export class SoundChip {
62
70
 
63
71
  this.residual = 0;
64
72
  this.position = 0;
65
- this.buffer = new Float32Array(512);
73
+ this.buffer = new Float32Array(SoundBufferSamples);
66
74
 
67
75
  this.latchedRegister = 0;
68
76
  this.slowDataBus = 0;
@@ -211,17 +219,27 @@ export class SoundChip {
211
219
  const num = cycles * this.samplesPerCycle + this.residual;
212
220
  let rounded = num | 0;
213
221
  this.residual = num - rounded;
214
- const bufferLength = this.buffer.length;
222
+ // The buffer is deliberately reused for the chip's whole life: the
223
+ // previous transfer-then-reallocate pattern is miscompiled by a V8
224
+ // optimiser bug (Chrome 150, crbug.com/537801199) which allocates the
225
+ // replacement with length 0, even when the reallocation is reordered
226
+ // before the transfer, wedging this loop forever. Reuse is also
227
+ // cheaper, so this needn't be reverted once the crbug is fixed. The
228
+ // guard fails loudly if the buffer is ever detached or the accounting
229
+ // goes bad.
215
230
  while (rounded > 0) {
216
- const leftInBuffer = bufferLength - this.position;
231
+ const leftInBuffer = SoundBufferSamples - this.position;
217
232
  const numSamplesToGenerate = Math.min(rounded, leftInBuffer);
233
+ if (numSamplesToGenerate <= 0 || this.buffer.length !== SoundBufferSamples)
234
+ throw new Error(
235
+ `Sound buffer accounting error (buffer=${this.buffer.length}, position=${this.position}, rounded=${rounded})`,
236
+ );
218
237
  this.generate(this.buffer, this.position, numSamplesToGenerate);
219
238
  this.position += numSamplesToGenerate;
220
239
  rounded -= numSamplesToGenerate;
221
240
 
222
- if (this.position === bufferLength) {
241
+ if (this.position === SoundBufferSamples) {
223
242
  this._onBuffer(this.buffer);
224
- this.buffer = new Float32Array(bufferLength);
225
243
  this.position = 0;
226
244
  }
227
245
  }
@@ -146,7 +146,8 @@ export class TeletextAdaptor {
146
146
  this.teletextStatus &= 0x0f;
147
147
  this.teletextStatus |= 0xd0; // data ready so latch INT, DOR, and FSYN
148
148
 
149
- if (this.teletextEnable) {
149
+ // The stream arrives asynchronously, so software can enable us before there is anything to copy.
150
+ if (this.teletextEnable && this.streamData) {
150
151
  // Copy current stream position into the frame buffer
151
152
  for (let i = 0; i < 16; ++i) {
152
153
  if (this.streamData[offset + i * 43] !== 0) {
package/src/tube.js CHANGED
@@ -219,7 +219,7 @@ export class Tube {
219
219
  if (this.hostStatus[TUBE_ULA_R3] & TUBE_ULA_FLAG_DATA_REGISTER_NOT_FULL) {
220
220
  if (this.internalStatusRegister & TUBE_ULA_FLAG_STATUS_ENABLE_2_BYTE_R3_DATA) {
221
221
  if (this.hostToParasiteFifoByteCount3 < 2) {
222
- this.hostToParasiteData[this.hostToParasiteFifoByteCount3++] = value;
222
+ this.hostToParasiteData[TUBE_ULA_R3][this.hostToParasiteFifoByteCount3++] = value;
223
223
  }
224
224
  if (this.hostToParasiteFifoByteCount3 === 2) {
225
225
  this.parasiteStatus[TUBE_ULA_R3] |= TUBE_ULA_FLAG_DATA_AVAILABLE;
@@ -294,6 +294,38 @@ export class Tube {
294
294
  }
295
295
  return result;
296
296
  }
297
+ snapshotState() {
298
+ return {
299
+ internalStatusRegister: this.internalStatusRegister,
300
+ hostStatus: this.hostStatus.slice(),
301
+ parasiteStatus: this.parasiteStatus.slice(),
302
+ parasiteToHostData: this.parasiteToHostData.map((fifo) => fifo.slice()),
303
+ hostToParasiteData: this.hostToParasiteData.map((fifo) => fifo.slice()),
304
+ parasiteToHostFifoByteCount1: this.parasiteToHostFifoByteCount1,
305
+ parasiteToHostFifoByteCount3: this.parasiteToHostFifoByteCount3,
306
+ hostToParasiteFifoByteCount3: this.hostToParasiteFifoByteCount3,
307
+ };
308
+ }
309
+
310
+ /**
311
+ * The interrupt and reset lines are not saved: they are derived from the status registers
312
+ * and FIFO counts, in the same way the host's `interrupt` is rebuilt by the VIA and ACIA
313
+ * restores.
314
+ */
315
+ restoreState(state) {
316
+ this.internalStatusRegister = state.internalStatusRegister;
317
+ this.hostStatus.set(state.hostStatus);
318
+ this.parasiteStatus.set(state.parasiteStatus);
319
+ for (let i = 0; i < 4; i++) {
320
+ this.parasiteToHostData[i].set(state.parasiteToHostData[i]);
321
+ this.hostToParasiteData[i].set(state.hostToParasiteData[i]);
322
+ }
323
+ this.parasiteToHostFifoByteCount1 = state.parasiteToHostFifoByteCount1;
324
+ this.parasiteToHostFifoByteCount3 = state.parasiteToHostFifoByteCount3;
325
+ this.hostToParasiteFifoByteCount3 = state.hostToParasiteFifoByteCount3;
326
+ this.updateInterrupts();
327
+ }
328
+
297
329
  parasiteWrite(address, value) {
298
330
  // Not implemented - needs to be integrated with the parasite CPU code:
299
331
  // Boot mode is terminated by the software when it selects any one of the Tube addresses.
@@ -123,7 +123,10 @@ export class AudioHandler {
123
123
  }
124
124
 
125
125
  _onBuffer(buffer) {
126
- if (this._jsAudioNode) this._jsAudioNode.port.postMessage({ time: Date.now(), buffer }, [buffer.buffer]);
126
+ // No transfer list, deliberately: the chip reuses this buffer, and
127
+ // transferring would detach it and trip crbug.com/537801199. The clone
128
+ // costs little (512 floats per 1.024ms of chip output, ~2MB/s).
129
+ if (this._jsAudioNode) this._jsAudioNode.port.postMessage({ time: Date.now(), buffer });
127
130
  }
128
131
 
129
132
  // Recent browsers, particularly Safari and Chrome, require a user interaction in order to enable sound playback.
@@ -11,9 +11,9 @@ const MaxCyclesPerIter = 100 * 1000;
11
11
  export class TestMachine {
12
12
  constructor(model, opts) {
13
13
  model = model || "B-DFS1.2";
14
- const modelObj = findModel(model);
15
- this.model = modelObj;
16
- this.processor = fake6502(modelObj, opts || {});
14
+ this.model = findModel(model);
15
+ if (!this.model) throw new Error(`Unknown model "${model}"`);
16
+ this.processor = fake6502(this.model, opts || {});
17
17
  this._capturedChars = [];
18
18
  this._captureHookInstalled = false;
19
19
  }
@@ -362,7 +362,9 @@ export class TestMachine {
362
362
  }
363
363
  const fullText = text + "\n"; // append RETURN
364
364
  const keys = fullText.split("").map((ch) => this._charToKey(ch));
365
- const holdCycles = 40000;
365
+ // Key hold is counted in CPU cycles, so scale it to keep the hold constant in
366
+ // real time: the OS scans the keyboard on a peripheral-rate interrupt.
367
+ const holdCycles = (40000 * this.processor.cpuMultiplier) | 0;
366
368
  let index = 0;
367
369
  let phase = "idle"; // "idle" → "down" → "idle"
368
370
  let nextEventCycle = 0;
@@ -414,7 +416,7 @@ export class TestMachine {
414
416
  // toggle the ROM's internal caps lock state.
415
417
  const keySequence = utils_atom.stringToATOMKeys(text + "\n");
416
418
  const ppia = this.processor.atomppia;
417
- const holdCycles = 80000; // Atom at 1 MHz needs longer hold than BBC at 2 MHz
419
+ const holdCycles = (80000 * this.processor.cpuMultiplier) | 0; // Atom at 1 MHz needs longer hold than BBC at 2 MHz
418
420
  const SHIFT = utils_atom.ATOM.SHIFT;
419
421
 
420
422
  let index = 0;