jsbeeb 1.11.0 → 1.13.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/keyboard.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  import * as utils from "./utils.js";
3
- import EventEmitter from "event-emitter-es6";
3
+ import { ATOM } from "./utils_atom.js";
4
4
 
5
5
  const isMac = typeof window !== "undefined" && /^Mac/i.test(window.navigator?.platform || "");
6
6
 
@@ -15,7 +15,7 @@ const isMac = typeof window !== "undefined" && /^Mac/i.test(window.navigator?.pl
15
15
  /**
16
16
  * Keyboard class that handles all keyboard related functionality
17
17
  */
18
- export class Keyboard extends EventEmitter {
18
+ export class Keyboard extends EventTarget {
19
19
  /**
20
20
  * Create a new Keyboard instance with specified configuration
21
21
  * @param {KeyboardConfig} config - The configuration object
@@ -29,6 +29,14 @@ export class Keyboard extends EventEmitter {
29
29
  this.inputEnabledFunction = inputEnabledFunction;
30
30
  this.dbgr = dbgr;
31
31
 
32
+ // Key interface: routes key events to SysVia (BBC) or PPIA (Atom).
33
+ // Both provide keyDown, keyUp, keyToggleRaw, setKeyLayout,
34
+ // clearKeys, disableKeyboard, enableKeyboard.
35
+ this.keyInterface = processor.model.isAtom ? processor.atomppia : processor.sysvia;
36
+ // The SHIFT key constant used by stringToMachineKeys in the paste key array.
37
+ // Compared by reference in _deliverPasteKey to avoid toggling shift off.
38
+ this._shiftKey = processor.model.isAtom ? ATOM.SHIFT : utils.BBC.SHIFT;
39
+
32
40
  // State
33
41
  this.emuKeyHandlers = {};
34
42
  this.running = false;
@@ -134,7 +142,7 @@ export class Keyboard extends EventEmitter {
134
142
  */
135
143
  setKeyLayout(layout) {
136
144
  this.keyLayout = layout;
137
- this.processor.sysvia.setKeyLayout(layout);
145
+ this.keyInterface.setKeyLayout(layout);
138
146
  }
139
147
 
140
148
  /**
@@ -182,7 +190,7 @@ export class Keyboard extends EventEmitter {
182
190
  // Handle debugger 'g' key press
183
191
  if (this.dbgr.enabled() && code === LOWERCASE_G) {
184
192
  this.dbgr.hide();
185
- this.emit("resume");
193
+ this.dispatchEvent(new Event("resume"));
186
194
  return;
187
195
  }
188
196
 
@@ -193,7 +201,7 @@ export class Keyboard extends EventEmitter {
193
201
  return;
194
202
  } else if (code === LOWERCASE_N) {
195
203
  this.requestStep();
196
- this.emit("resume");
204
+ this.dispatchEvent(new Event("resume"));
197
205
  return;
198
206
  }
199
207
  }
@@ -226,7 +234,7 @@ export class Keyboard extends EventEmitter {
226
234
  const isSpecialHandled = this._handleSpecialKeys(code);
227
235
  if (isSpecialHandled) return;
228
236
 
229
- // Check for registered handlers first; if one fires, don't also send to the BBC.
237
+ // Check for registered handlers first; if one fires, don't pass to the emulator.
230
238
  // This lets Alt+key and Ctrl+key handlers cleanly own their keys without the
231
239
  // underlying key leaking through to the emulated machine.
232
240
  const handler = this._findKeyHandler(code, evt.altKey, evt.ctrlKey);
@@ -235,8 +243,8 @@ export class Keyboard extends EventEmitter {
235
243
  return;
236
244
  }
237
245
 
238
- // No handler claimed the key pass it to the BBC Micro.
239
- this.processor.sysvia.keyDown(code, evt.shiftKey);
246
+ // No handler claimed the key; pass it to the emulated machine.
247
+ this.keyInterface.keyDown(code, evt.shiftKey);
240
248
  }
241
249
 
242
250
  /**
@@ -247,7 +255,7 @@ export class Keyboard extends EventEmitter {
247
255
  */
248
256
  _handleSpecialKeys(code) {
249
257
  if (code === utils.keyCodes.F12 || code === utils.keyCodes.BREAK) {
250
- this.emit("break", true);
258
+ this.dispatchEvent(new CustomEvent("break", { detail: true }));
251
259
  this.processor.setReset(true);
252
260
  return true;
253
261
  } else if (isMac && code === utils.keyCodes.CAPSLOCK) {
@@ -269,7 +277,7 @@ export class Keyboard extends EventEmitter {
269
277
 
270
278
  // Always let the key ups come through to avoid sticky keys in the debugger
271
279
  const code = this.keyCode(evt);
272
- this.processor.sysvia.keyUp(code);
280
+ this.keyInterface.keyUp(code);
273
281
 
274
282
  // No further special handling needed if not running
275
283
  if (!this.running) return;
@@ -278,7 +286,7 @@ export class Keyboard extends EventEmitter {
278
286
 
279
287
  // Handle special key cases
280
288
  if (code === utils.keyCodes.F12 || code === utils.keyCodes.BREAK) {
281
- this.emit("break", false);
289
+ this.dispatchEvent(new CustomEvent("break", { detail: false }));
282
290
  this.processor.setReset(false);
283
291
  return;
284
292
  } else if (isMac && code === utils.keyCodes.CAPSLOCK) {
@@ -302,39 +310,44 @@ export class Keyboard extends EventEmitter {
302
310
 
303
311
  // Mac browsers seem to model caps lock as a physical key that's down when capslock is on, and up when it's off.
304
312
  // No event is generated when it is physically released on the keyboard. So, we simulate a "tap" here.
305
- this.processor.sysvia.keyDown(utils.keyCodes.CAPSLOCK);
313
+ this.keyInterface.keyDown(utils.keyCodes.CAPSLOCK);
306
314
 
307
315
  // Simulate a key release after a short delay
308
- setTimeout(() => this.processor.sysvia.keyUp(utils.keyCodes.CAPSLOCK), CAPS_LOCK_DELAY);
316
+ setTimeout(() => this.keyInterface.keyUp(utils.keyCodes.CAPSLOCK), CAPS_LOCK_DELAY);
309
317
 
310
318
  if (isMac && window.localStorage && !window.localStorage.getItem("warnedAboutRubbishMacs")) {
311
- this.emit("showError", {
312
- context: "handling caps lock on Mac OS X",
313
- error: `Mac OS X does not generate key up events for caps lock presses.
314
- jsbeeb can only simulate a 'tap' of the caps lock key. This means it doesn't work well for games
315
- that use caps lock for left or fire, as we can't tell if it's being held down. If you need to play
319
+ this.dispatchEvent(
320
+ new CustomEvent("showError", {
321
+ detail: {
322
+ context: "handling caps lock on Mac OS X",
323
+ error: `Mac OS X does not generate key up events for caps lock presses.
324
+ jsbeeb can only simulate a 'tap' of the caps lock key. This means it doesn't work well for games
325
+ that use caps lock for left or fire, as we can't tell if it's being held down. If you need to play
316
326
  such a game, please see the documentation about remapping keys.
317
327
  Close this window to continue (you won't see this error again)`,
318
- });
328
+ },
329
+ }),
330
+ );
319
331
  window.localStorage.setItem("warnedAboutRubbishMacs", "true");
320
332
  }
321
333
  }
322
334
 
323
335
  /**
324
- * Send raw keyboard input to the BBC
325
- * @param {Array} keysToSend - Array of keys to send
336
+ * Send raw keyboard input to the emulated machine (for paste/autotype).
337
+ * @param {Array} keysToSend - Array of machine-specific key codes to send
326
338
  * @param {boolean} checkCapsAndShiftLocks - Whether to check caps and shift locks
327
339
  */
328
- sendRawKeyboardToBBC(keysToSend, checkCapsAndShiftLocks) {
340
+ sendRawKeyboard(keysToSend, checkCapsAndShiftLocks) {
329
341
  if (this.isPasting) this.cancelPaste();
330
342
 
331
- this.processor.sysvia.disableKeyboard();
332
- this._pasteClocksPerMs = Math.floor(this.processor.cpuMultiplier * 2000000) / 1000;
343
+ this.keyInterface.disableKeyboard();
344
+ this._pasteClocksPerMs =
345
+ Math.floor(this.processor.cpuMultiplier * this.processor.peripheralCyclesPerSecond) / 1000;
333
346
 
334
347
  if (checkCapsAndShiftLocks) {
335
348
  let toggleKey = null;
336
- if (!this.processor.sysvia.capsLockLight) toggleKey = utils.BBC.CAPSLOCK;
337
- else if (this.processor.sysvia.shiftLockLight) toggleKey = utils.BBC.SHIFTLOCK;
349
+ if (!this.keyInterface.capsLockLight) toggleKey = utils.BBC.CAPSLOCK;
350
+ else if (this.keyInterface.shiftLockLight) toggleKey = utils.BBC.SHIFTLOCK;
338
351
  if (toggleKey) {
339
352
  keysToSend.unshift(toggleKey);
340
353
  keysToSend.push(toggleKey);
@@ -353,13 +366,22 @@ export class Keyboard extends EventEmitter {
353
366
  * @private
354
367
  */
355
368
  _deliverPasteKey() {
356
- if (this._pasteLastChar && this._pasteLastChar !== utils.BBC.SHIFT) {
357
- this.processor.sysvia.keyToggleRaw(this._pasteLastChar);
369
+ if (this._pasteLastChar && this._pasteLastChar !== this._shiftKey) {
370
+ this.keyInterface.keyToggleRaw(this._pasteLastChar);
358
371
  }
359
372
 
360
373
  if (this._pasteKeys.length === 0) {
361
374
  this._pasteLastChar = undefined;
362
- this.processor.sysvia.enableKeyboard();
375
+ this.keyInterface.enableKeyboard();
376
+ return;
377
+ }
378
+
379
+ // Atom's PPIA keyboard is polled, not interrupt-driven like the BBC's
380
+ // SysVIA. Insert a debounce gap after every key release so the ROM
381
+ // sees the key-up before the next key-down arrives.
382
+ if (this._pasteLastChar && this._pasteLastChar !== this._shiftKey && this.processor.model.isAtom) {
383
+ this._pasteLastChar = undefined;
384
+ this._pasteTask.schedule(30 * this._pasteClocksPerMs);
363
385
  return;
364
386
  }
365
387
 
@@ -372,12 +394,15 @@ export class Keyboard extends EventEmitter {
372
394
  return;
373
395
  }
374
396
 
375
- let delayMs = 50;
397
+ // Atom ROM polls the keyboard once per VSync (~16ms at 60 Hz).
398
+ // 80ms gives the ROM ~5 scan cycles to detect, debounce, and
399
+ // process each keypress.
400
+ let delayMs = this.processor.model.isAtom ? 80 : 50;
376
401
  if (typeof this._pasteLastChar === "number") {
377
402
  delayMs = this._pasteLastChar;
378
403
  this._pasteLastChar = undefined;
379
404
  } else {
380
- this.processor.sysvia.keyToggleRaw(this._pasteLastChar);
405
+ this.keyInterface.keyToggleRaw(this._pasteLastChar);
381
406
  }
382
407
 
383
408
  this._pasteKeys.shift();
@@ -390,12 +415,12 @@ export class Keyboard extends EventEmitter {
390
415
  cancelPaste() {
391
416
  if (!this.isPasting) return;
392
417
  this._pasteTask.cancel();
393
- if (this._pasteLastChar && this._pasteLastChar !== utils.BBC.SHIFT) {
394
- this.processor.sysvia.keyToggleRaw(this._pasteLastChar);
418
+ if (this._pasteLastChar && this._pasteLastChar !== this._shiftKey) {
419
+ this.keyInterface.keyToggleRaw(this._pasteLastChar);
395
420
  }
396
421
  this._pasteLastChar = undefined;
397
422
  this._pasteKeys = [];
398
- this.processor.sysvia.enableKeyboard();
423
+ this.keyInterface.enableKeyboard();
399
424
  }
400
425
 
401
426
  /**
@@ -410,7 +435,7 @@ export class Keyboard extends EventEmitter {
410
435
  * Clears all pressed keys
411
436
  */
412
437
  clearKeys() {
413
- this.processor.sysvia.clearKeys();
438
+ this.keyInterface.clearKeys();
414
439
  }
415
440
 
416
441
  /**
@@ -437,7 +462,7 @@ export class Keyboard extends EventEmitter {
437
462
  */
438
463
  pauseEmulation() {
439
464
  this.pauseEmu = true;
440
- this.emit("pause");
465
+ this.dispatchEvent(new Event("pause"));
441
466
  }
442
467
 
443
468
  /**
@@ -445,6 +470,6 @@ export class Keyboard extends EventEmitter {
445
470
  */
446
471
  resumeEmulation() {
447
472
  this.pauseEmu = false;
448
- this.emit("resume");
473
+ this.dispatchEvent(new Event("resume"));
449
474
  }
450
475
  }
@@ -9,7 +9,7 @@ import { readFileSync } from "fs";
9
9
  import { fileURLToPath } from "url";
10
10
  import path from "path";
11
11
  import { TestMachine } from "../tests/test-machine.js";
12
- import { InstrumentedSoundChip } from "./soundchip.js";
12
+ import { InstrumentedSoundChip, FakeSoundChip } from "./soundchip.js";
13
13
 
14
14
  // Resolve the jsbeeb package root from our own location (src/machine-session.js
15
15
  // → go up one level). Passed to setNodeBasePath() so the ROM loader resolves
@@ -46,22 +46,31 @@ export class MachineSession {
46
46
 
47
47
  // Create a real Video instance so we get pixel output
48
48
  const modelObj = findModel(modelName);
49
- this._video = new Video(modelObj.isMaster, this._fb32, (minx, miny, maxx, maxy) => {
50
- this._lastPaint = { minx, miny, maxx, maxy };
51
- this._frameDirty = true;
52
- // Snapshot the complete frame now, before clearPaintBuffer() wipes _fb32.
53
- // This mirrors what the browser does: paint_ext fires → canvas updated → fb32 cleared.
54
- this._completeFb8.set(this._fb8);
55
- });
56
-
57
- // Use a real (instrumented) sound chip so we can read registers and capture writes
58
- this._soundChip = new InstrumentedSoundChip();
49
+ this._isAtom = modelObj.isAtom;
50
+ this._video = new Video(
51
+ modelObj.isMaster,
52
+ this._fb32,
53
+ (minx, miny, maxx, maxy) => {
54
+ this._lastPaint = { minx, miny, maxx, maxy };
55
+ this._frameDirty = true;
56
+ // Snapshot the complete frame now, before clearPaintBuffer() wipes _fb32.
57
+ // This mirrors what the browser does: paint_ext fires canvas updated fb32 cleared.
58
+ this._completeFb8.set(this._fb8);
59
+ },
60
+ { isAtom: modelObj.isAtom },
61
+ );
62
+
63
+ // Use a real (instrumented) sound chip so we can read registers and capture writes.
64
+ // Atom models use AtomSoundChip which has a different interface (speakerGenerator,
65
+ // toneGenerator); FakeSoundChip provides compatible no-op stubs for headless mode.
66
+ this._soundChip = modelObj.isAtom ? new FakeSoundChip() : new InstrumentedSoundChip();
59
67
 
60
68
  // TestMachine forwards opts.video and opts.soundChip to fake6502
61
69
  this._machine = new TestMachine(modelName, { video: this._video, soundChip: this._soundChip });
62
70
 
63
71
  // Accumulated VDU text output — drained by callers
64
72
  this._pendingOutput = [];
73
+ this._flushCapture = () => {};
65
74
 
66
75
  // Breakpoint management — persistent hooks that survive across run calls
67
76
  this._breakpoints = new Map(); // id → { hook, type, address, hit }
@@ -90,8 +99,9 @@ export class MachineSession {
90
99
  /**
91
100
  * Install the VDU character-output capture hook.
92
101
  *
93
- * WRCHV discovery: RAM at 0x20E/0x20F initialises to 0xFFFF before the
94
- * OS runs. We read directly from cpu.ramRomOs (two array lookups no
102
+ * WRCHV discovery: RAM at the OS write-character vector (0x20E on BBC,
103
+ * 0x208 on Atom) initialises to 0x0000 before the OS runs. We read
104
+ * directly from cpu.ramRomOs (two array lookups — no
95
105
  * readmem() dispatch overhead) on every instruction, waiting for the
96
106
  * value to change from its initial 0xFFFF. Once the OS installs a real
97
107
  * handler we use that address for the lifetime of the session. Programs
@@ -105,7 +115,9 @@ export class MachineSession {
105
115
  _installCaptureHook() {
106
116
  const cpu = this._machine.processor;
107
117
  const ram = cpu.ramRomOs; // direct Uint8Array — no dispatch overhead
108
- const initialWrchv = ram[0x20e] | (ram[0x20f] << 8); // 0xFFFF pre-boot
118
+ // WRCHV vector: BBC at $020E, Atom at $0208.
119
+ const wrchvAddr = this._isAtom ? 0x208 : 0x20e;
120
+ const initialWrchv = ram[wrchvAddr] | (ram[wrchvAddr + 1] << 8); // 0xFFFF pre-boot
109
121
 
110
122
  const attributes = { x: 0, y: 0, text: "", foreground: 7, background: 0, mode: 7 };
111
123
  let currentText = "";
@@ -124,6 +136,12 @@ export class MachineSession {
124
136
  currentText = "";
125
137
  }
126
138
 
139
+ // Expose flush so drainOutput can capture trailing text
140
+ // that hasn't been terminated by a control character.
141
+ this._flushCapture = flush;
142
+
143
+ const isAtom = this._isAtom;
144
+
127
145
  function onChar(c) {
128
146
  if (nextN) {
129
147
  params.push(c);
@@ -135,9 +153,6 @@ export class MachineSession {
135
153
  return;
136
154
  }
137
155
  switch (c) {
138
- case 1: // next char to printer only
139
- nextN = 1;
140
- break;
141
156
  case 10: // LF
142
157
  flush();
143
158
  attributes.y++;
@@ -151,48 +166,58 @@ export class MachineSession {
151
166
  flush();
152
167
  attributes.x = 0;
153
168
  break;
154
- case 17: // COLOUR n
155
- nextN = 1;
156
- vduProc = (p) => {
157
- if (p[0] & 0x80) attributes.background = p[0] & 0xf;
158
- else attributes.foreground = p[0] & 0xf;
159
- };
160
- break;
161
- case 18: // GCOL
162
- nextN = 2;
163
- break;
164
- case 19: // define logical colour
165
- nextN = 5;
166
- break;
167
- case 22: // MODE n
168
- nextN = 1;
169
- vduProc = (p) => {
170
- flush();
171
- attributes.mode = p[0];
172
- attributes.x = 0;
173
- attributes.y = 0;
174
- attributes.foreground = 7;
175
- attributes.background = 0;
176
- };
177
- break;
178
- case 25: // PLOT
179
- nextN = 5;
180
- break;
181
- case 28: // define text window
182
- nextN = 4;
183
- break;
184
- case 29: // define graphics origin
185
- nextN = 4;
186
- break;
187
- case 31: // TAB(x,y)
188
- nextN = 2;
189
- vduProc = (p) => {
190
- flush();
191
- attributes.x = p[0];
192
- attributes.y = p[1];
193
- };
194
- break;
195
169
  default:
170
+ // BBC VDU control codes (multi-byte sequences).
171
+ // The Atom doesn't use these; skip them to avoid
172
+ // swallowing printable characters.
173
+ if (!isAtom) {
174
+ switch (c) {
175
+ case 1: // next char to printer only
176
+ nextN = 1;
177
+ return;
178
+ case 17: // COLOUR n
179
+ nextN = 1;
180
+ vduProc = (p) => {
181
+ if (p[0] & 0x80) attributes.background = p[0] & 0xf;
182
+ else attributes.foreground = p[0] & 0xf;
183
+ };
184
+ return;
185
+ case 18: // GCOL
186
+ nextN = 2;
187
+ return;
188
+ case 19: // define logical colour
189
+ nextN = 5;
190
+ return;
191
+ case 22: // MODE n
192
+ nextN = 1;
193
+ vduProc = (p) => {
194
+ flush();
195
+ attributes.mode = p[0];
196
+ attributes.x = 0;
197
+ attributes.y = 0;
198
+ attributes.foreground = 7;
199
+ attributes.background = 0;
200
+ };
201
+ return;
202
+ case 25: // PLOT
203
+ nextN = 5;
204
+ return;
205
+ case 28: // define text window
206
+ nextN = 4;
207
+ return;
208
+ case 29: // define graphics origin
209
+ nextN = 4;
210
+ return;
211
+ case 31: // TAB(x,y)
212
+ nextN = 2;
213
+ vduProc = (p) => {
214
+ flush();
215
+ attributes.x = p[0];
216
+ attributes.y = p[1];
217
+ };
218
+ return;
219
+ }
220
+ }
196
221
  if (c >= 32 && c < 0x7f) {
197
222
  currentText += String.fromCharCode(c);
198
223
  } else {
@@ -207,7 +232,7 @@ export class MachineSession {
207
232
  // Once the OS sets WRCHV (it changes from 0xFFFF), we start
208
233
  // capturing. Programs that install a custom VDU driver mid-run
209
234
  // are handled transparently because we re-read on every call.
210
- const wrchv = ram[0x20e] | (ram[0x20f] << 8);
235
+ const wrchv = ram[wrchvAddr] | (ram[wrchvAddr + 1] << 8);
211
236
  if (wrchv !== initialWrchv && addr === wrchv) {
212
237
  onChar(cpu.a);
213
238
  }
@@ -391,6 +416,7 @@ export class MachineSession {
391
416
  * Also includes a flat `screenText` reconstruction.
392
417
  */
393
418
  drainOutput({ clear = true } = {}) {
419
+ this._flushCapture();
394
420
  const elements = clear ? this._pendingOutput.splice(0) : [...this._pendingOutput];
395
421
  return {
396
422
  elements,