jsbeeb 1.13.1 → 1.15.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/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
  }
@@ -34,9 +34,20 @@ Status register:
34
34
 
35
35
  */
36
36
 
37
- export class TeletextAdaptor {
37
+ /**
38
+ * Emulates the Acorn teletext adaptor. Dispatches a `showError` CustomEvent, carrying
39
+ * `context` and `error` in its detail, when a channel's stream cannot be loaded.
40
+ */
41
+ export class TeletextAdaptor extends EventTarget {
38
42
  constructor(cpu) {
43
+ super();
39
44
  this.cpu = cpu;
45
+ // Not cleared by a reset, so a fetch still in flight across one is recognised as stale.
46
+ this.streamRequest = 0;
47
+ this.clearState();
48
+ }
49
+
50
+ clearState() {
40
51
  this.teletextStatus = 0x0f; /* low nibble comes from LK4-7 and mystery links which are left floating */
41
52
  this.teletextInts = false;
42
53
  this.teletextEnable = false;
@@ -45,26 +56,40 @@ export class TeletextAdaptor {
45
56
  this.totalFrames = 0;
46
57
  this.rowPtr = 0x00;
47
58
  this.colPtr = 0x00;
48
- this.frameBuffer = new Array(16).fill(0).map(() => new Array(64).fill(0));
49
59
  this.streamData = null;
50
60
  this.pollCount = 0;
61
+ this.frameBuffer = new Array(16).fill(0).map(() => new Array(64).fill(0));
62
+ // Only a register access clears our IRQ, so an interrupt latched before the reset would hang the machine.
63
+ this.cpu.interrupt &= ~(1 << TELETEXT_IRQ);
51
64
  }
52
65
 
53
66
  reset(hard) {
54
- if (hard) {
55
- console.log("Teletext adaptor: initialisation");
56
- this.loadChannelStream(this.channel);
57
- }
67
+ if (!hard) return;
68
+ this.clearState();
69
+ this.loadChannelStream(this.channel);
58
70
  }
59
71
 
60
- loadChannelStream(channel) {
72
+ async loadChannelStream(channel) {
61
73
  console.log("Teletext adaptor: switching to channel " + channel);
62
- const teletextRef = this;
63
- utils.loadData("teletext/txt" + channel + ".dat").then(function (data) {
64
- teletextRef.streamData = data;
65
- teletextRef.totalFrames = data.length / TELETEXT_FRAME_SIZE;
66
- teletextRef.currentFrame = 0;
67
- });
74
+ const request = ++this.streamRequest;
75
+ let data;
76
+ try {
77
+ data = await utils.loadData(`teletext/txt${channel}.dat`);
78
+ } catch (error) {
79
+ if (request !== this.streamRequest) return;
80
+ console.error(`Teletext adaptor: failed to load channel ${channel}`, error);
81
+ this.dispatchEvent(
82
+ new CustomEvent("showError", {
83
+ detail: { context: `loading teletext channel ${channel}`, error },
84
+ }),
85
+ );
86
+ return;
87
+ }
88
+ // Fetches can resolve out of order; only the newest request may apply its data.
89
+ if (request !== this.streamRequest) return;
90
+ this.streamData = data;
91
+ this.totalFrames = Math.floor(data.length / TELETEXT_FRAME_SIZE);
92
+ this.currentFrame = 0;
68
93
  }
69
94
 
70
95
  read(addr) {
@@ -146,7 +171,8 @@ export class TeletextAdaptor {
146
171
  this.teletextStatus &= 0x0f;
147
172
  this.teletextStatus |= 0xd0; // data ready so latch INT, DOR, and FSYN
148
173
 
149
- if (this.teletextEnable) {
174
+ // The stream arrives asynchronously, so software can enable us before there is anything to copy.
175
+ if (this.teletextEnable && this.streamData) {
150
176
  // Copy current stream position into the frame buffer
151
177
  for (let i = 0; i < 16; ++i) {
152
178
  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.
package/src/url-params.js CHANGED
@@ -161,52 +161,56 @@ export function buildUrlFromParams(baseUrl, parsedQuery, paramTypes = {}) {
161
161
  }
162
162
 
163
163
  /**
164
- * Process keyboard mapping parameters from query string
164
+ * Process keyboard and gamepad mapping parameters from query string
165
165
  * @param {Object} parsedQuery - The parsed query parameters
166
- * @param {Object} BBC - BBC key constants
166
+ * @param {Object} machineKeys - Emulated machine's key constants (`BBC`, or `ATOM` for the Atom)
167
167
  * @param {Object} keyCodes - Key code constants
168
168
  * @param {Array} userKeymap - Array to store user key mappings
169
169
  * @param {Object} gamepad - Gamepad object for handling mapping
170
- * @returns {Object} Updated query parameters
170
+ * @returns {string[]} descriptions of any mappings that were skipped, for showing to the user
171
171
  */
172
- export function processKeyboardParams(parsedQuery, BBC, keyCodes, userKeymap, gamepad) {
172
+ export function processInputParams(parsedQuery, machineKeys, keyCodes, userKeymap, gamepad) {
173
+ const warnings = [];
174
+
173
175
  Object.entries(parsedQuery).forEach(([key, val]) => {
174
176
  if (!val) return;
175
177
 
176
- // eg KEY.CAPSLOCK=CTRL
178
+ // `KEY.<host key>=<machine key>`, eg `KEY.CAPSLOCK=CTRL`. Host names come from
179
+ // `keyCodes`, so the BBC's RETURN is ENTER here; both lists are in the README.
177
180
  if (key.toUpperCase().indexOf("KEY.") === 0) {
178
- const bbcKey = val.toUpperCase();
181
+ const machineKey = val.toUpperCase();
182
+ const nativeKey = key.substring(4).toUpperCase(); // remove KEY.
179
183
 
180
- if (BBC[bbcKey]) {
181
- const nativeKey = key.substring(4).toUpperCase(); // remove KEY.
182
- if (keyCodes[nativeKey]) {
183
- console.log("mapping " + nativeKey + " to " + bbcKey);
184
- userKeymap.push({ native: nativeKey, bbc: bbcKey });
185
- } else {
186
- console.log("unknown key: " + nativeKey);
187
- }
184
+ if (!machineKeys[machineKey]) {
185
+ warnings.push(`${key}=${val}: "${machineKey}" is not a key on the emulated machine.`);
186
+ } else if (!keyCodes[nativeKey]) {
187
+ warnings.push(`${key}=${val}: "${nativeKey}" is not a key on your keyboard.`);
188
188
  } else {
189
- console.log("unknown BBC key: " + val);
189
+ console.log("mapping " + nativeKey + " to " + machineKey);
190
+ userKeymap.push({ native: nativeKey, key: machineKey });
190
191
  }
191
192
  } else if (key.indexOf("GP.") === 0) {
192
193
  // gamepad mapping
193
194
  // eg ?GP.FIRE2=RETURN
194
195
  const gamepadKey = key.substring(3).toUpperCase(); // remove GP. prefix
195
- gamepad.remap(gamepadKey, val.toUpperCase());
196
+ const problem = gamepad.remap(gamepadKey, val.toUpperCase());
197
+ if (problem) warnings.push(`${key}=${val}: ${problem}`);
196
198
  } else {
197
199
  switch (key) {
198
200
  case "LEFT":
199
201
  case "RIGHT":
200
202
  case "UP":
201
203
  case "DOWN":
202
- case "FIRE":
203
- gamepad.remap(key, val.toUpperCase());
204
+ case "FIRE": {
205
+ const problem = gamepad.remap(key, val.toUpperCase());
206
+ if (problem) warnings.push(`${key}=${val}: ${problem}`);
204
207
  break;
208
+ }
205
209
  }
206
210
  }
207
211
  });
208
212
 
209
- return parsedQuery;
213
+ return warnings;
210
214
  }
211
215
 
212
216
  /**
package/src/utils.js CHANGED
@@ -657,6 +657,12 @@ export function getKeyMap(keyLayout) {
657
657
  keys2[shiftDown][s] = colRow;
658
658
  }
659
659
 
660
+ // Overriding a default is the point here, so unlike `map` this doesn't warn about the clash.
661
+ function remap(s, colRow) {
662
+ keys2[true][s] = colRow;
663
+ keys2[false][s] = colRow;
664
+ }
665
+
660
666
  // shiftDown undefined -> map both
661
667
  function map(s, colRow, shiftDown) {
662
668
  if ((!s && s !== 0) || !colRow) {
@@ -935,11 +941,10 @@ export function getKeyMap(keyLayout) {
935
941
  // eg Master Dunjunz needs # Del 3 , * Enter
936
942
  // https://web.archive.org/web/20080305042238/http://bbc.nvg.org/doc/games/Dunjunz-docs.txt
937
943
 
938
- // user keymapping
939
- // do last (to override defaults)
940
- while (userKeymap.length > 0) {
941
- const mapping = userKeymap.pop();
942
- map(keyCodes[mapping.native], BBC[mapping.bbc]);
944
+ // `KEY.` URL parameters, applied last so they win. Not consumed: this map is rebuilt on
945
+ // layout and model changes, and the user's mapping has to survive that.
946
+ for (const mapping of userKeymap) {
947
+ remap(keyCodes[mapping.native], BBC[mapping.key]);
943
948
  }
944
949
 
945
950
  return keys2;
package/src/utils_atom.js CHANGED
@@ -249,6 +249,12 @@ export function getKeyMapAtom(keyLayout) {
249
249
  keys2[shiftDown][s] = colRow;
250
250
  }
251
251
 
252
+ // Overriding a default is the point here, so unlike `map` this doesn't warn about the clash.
253
+ function remap(s, colRow) {
254
+ keys2[true][s] = colRow;
255
+ keys2[false][s] = colRow;
256
+ }
257
+
252
258
  // shiftDown undefined -> map both
253
259
  function map(s, colRow, shiftDown) {
254
260
  if ((!s && s !== 0) || !colRow) {
@@ -477,11 +483,9 @@ export function getKeyMapAtom(keyLayout) {
477
483
  // Z - M normal
478
484
  }
479
485
 
480
- // user keymapping
481
- // do last (to override defaults)
482
- while (userKeymap.length > 0) {
483
- const mapping = userKeymap.pop();
484
- map(keyCodes[mapping.native], ATOM[mapping.atom]);
486
+ // `KEY.` URL parameters, applied last so they win. See the equivalent in `getKeyMap`.
487
+ for (const mapping of userKeymap) {
488
+ remap(keyCodes[mapping.native], ATOM[mapping.key]);
485
489
  }
486
490
 
487
491
  return keys2;
@@ -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;