jsbeeb 1.8.0 → 1.9.1

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.8.0",
10
+ "version": "1.9.1",
11
11
  "//": "If you change the version of Node, it must also be updated at the top of the Dockerfile.",
12
12
  "engines": {
13
13
  "node": "22"
package/src/6502.js CHANGED
@@ -1131,7 +1131,7 @@ export class Cpu6502 extends Base6502 {
1131
1131
  this.resetLine = !resetOn;
1132
1132
  }
1133
1133
 
1134
- snapshotState() {
1134
+ snapshotState({ includeRoms = false } = {}) {
1135
1135
  return {
1136
1136
  // CPU registers
1137
1137
  a: this.a,
@@ -1155,8 +1155,10 @@ export class Cpu6502 extends Base6502 {
1155
1155
  peripheralCycles: this.peripheralCycles,
1156
1156
  videoCycles: this.videoCycles,
1157
1157
  music5000PageSel: this.music5000PageSel,
1158
- // RAM only (ROMs don't change at runtime)
1158
+ // RAM only by default; pass includeRoms:true to also capture
1159
+ // sideways ROM/RAM slots (adds ~256KB to the snapshot).
1159
1160
  ram: this.ramRomOs.slice(0, this.romOffset),
1161
+ roms: includeRoms ? this.ramRomOs.slice(this.romOffset, this.romOffset + 16 * 16384) : undefined,
1160
1162
  // Sub-component state
1161
1163
  scheduler: this.scheduler.snapshotState(),
1162
1164
  sysvia: this.sysvia.snapshotState(),
package/src/disc.js CHANGED
@@ -535,7 +535,10 @@ export function loadSsd(disc, data, isDsd, onChange) {
535
535
  const blankSector = new Uint8Array(SsdFormat.sectorSize);
536
536
  const numSides = isDsd ? 2 : 1;
537
537
  if (data.length % SsdFormat.sectorSize !== 0) {
538
- throw new Error("SSD file size is not a multiple of sector size");
538
+ const paddedLength = Math.ceil(data.length / SsdFormat.sectorSize) * SsdFormat.sectorSize;
539
+ const padded = new Uint8Array(paddedLength);
540
+ padded.set(data);
541
+ data = padded;
539
542
  }
540
543
  const maxSize = SsdFormat.sectorSize * SsdFormat.sectorsPerTrack * SsdFormat.tracksPerDisc * numSides;
541
544
  if (data.length > maxSize) {
package/src/soundchip.js CHANGED
@@ -424,4 +424,10 @@ export class FakeSoundChip {
424
424
  tone: () => {},
425
425
  };
426
426
  }
427
+
428
+ snapshotState() {
429
+ return {};
430
+ }
431
+
432
+ restoreState() {}
427
433
  }
package/src/utils.js CHANGED
@@ -437,6 +437,14 @@ export function getKeyMap(keyLayout) {
437
437
  // shift not pressed
438
438
  keys2[false] = {};
439
439
 
440
+ // Create a key map entry that overrides the BBC SHIFT state while held.
441
+ // Used in natural keyboard for keys where the PC and BBC shift states
442
+ // differ for the same character (e.g. US Shift+6 = ^ needs BBC HAT_TILDE
443
+ // without shift, even though the physical shift key is held).
444
+ function withShiftOverride(bbcKey, bbcShift) {
445
+ return [bbcKey[0], bbcKey[1], bbcShift];
446
+ }
447
+
440
448
  // shiftDown MUST be true or false (not undefined)
441
449
  function doMap(s, colRow, shiftDown) {
442
450
  if (keys2[shiftDown][s] && keys2[shiftDown][s] !== colRow) {
@@ -554,7 +562,8 @@ export function getKeyMap(keyLayout) {
554
562
  map(keyCodes.K1, BBC.K1);
555
563
  map(keyCodes.K4, BBC.K4);
556
564
  map(keyCodes.K5, BBC.K5);
557
- map(keyCodes.K6, BBC.K6);
565
+ map(keyCodes.K6, BBC.K6, false);
566
+ map(keyCodes.K6, withShiftOverride(BBC.HAT_TILDE, false), true);
558
567
 
559
568
  map(keyCodes.MINUS, BBC.MINUS);
560
569
 
package/src/via.js CHANGED
@@ -541,6 +541,9 @@ export class SysVia extends Via {
541
541
  this.mouseButton1 = false;
542
542
  this.mouseButton2 = false;
543
543
  this.keyboardEnabled = true;
544
+ this._physicalShiftDown = false;
545
+ this._shiftOverrideActive = false;
546
+ this._shiftOverrideDesiredShift = false;
544
547
  this.setKeyLayout(initialLayout);
545
548
  this.video = video;
546
549
  this.soundChip = soundChip;
@@ -589,6 +592,9 @@ export class SysVia extends Via {
589
592
  this.keys[i][j] = false;
590
593
  }
591
594
  }
595
+ this._physicalShiftDown = false;
596
+ this._shiftOverrideActive = false;
597
+ this._shiftOverrideDesiredShift = false;
592
598
  this.updateKeys();
593
599
  }
594
600
 
@@ -604,9 +610,34 @@ export class SysVia extends Via {
604
610
 
605
611
  set(key, val, shiftDown) {
606
612
  if (!this.keyboardEnabled) return;
607
- const colrow = this.keycodeToRowCol[!!shiftDown][key];
608
- if (!colrow) return;
609
- this.keys[colrow[0]][colrow[1]] = val;
613
+ const mapping = this.keycodeToRowCol[!!shiftDown][key];
614
+ if (!mapping) return;
615
+
616
+ const [col, row, bbcShiftOverride] = mapping;
617
+ const [shiftCol, shiftRow] = utils.BBC.SHIFT;
618
+
619
+ this.keys[col][row] = val;
620
+
621
+ const isPhysicalShiftKey = col === shiftCol && row === shiftRow && bbcShiftOverride === undefined;
622
+
623
+ if (isPhysicalShiftKey) {
624
+ this._physicalShiftDown = !!val;
625
+ }
626
+
627
+ if (bbcShiftOverride !== undefined) {
628
+ this._shiftOverrideActive = !!val;
629
+ this._shiftOverrideDesiredShift = bbcShiftOverride;
630
+ }
631
+
632
+ // When a shift override is active, force BBC SHIFT to the desired
633
+ // state; otherwise follow the physical shift key.
634
+ // Note: keyDownRaw/keyUpRaw bypass this logic intentionally as they
635
+ // are used by gamepad/paste code that manages shift independently.
636
+ if (this._shiftOverrideActive || isPhysicalShiftKey || bbcShiftOverride !== undefined) {
637
+ const shiftPressed = this._shiftOverrideActive ? this._shiftOverrideDesiredShift : this._physicalShiftDown;
638
+ this.keys[shiftCol][shiftRow] = shiftPressed ? 1 : 0;
639
+ }
640
+
610
641
  this.updateKeys();
611
642
  }
612
643
 
@@ -765,10 +796,11 @@ export class SysVia extends Via {
765
796
  const pad2 = pads[1];
766
797
 
767
798
  // Combine gamepad and mouse button states (OR logic)
768
- button1 = button1 || pad.buttons[10].pressed;
769
- // if two gamepads, use button from 2nd
770
- // otherwise use 2nd button from first
771
- button2 = button2 || (pad2 ? pad2.buttons[10].pressed : pad.buttons[11].pressed);
799
+ button1 = button1 || !!pad.buttons[10]?.pressed;
800
+ // FIRE2 on first gamepad always maps to button2
801
+ button2 = button2 || !!pad.buttons[11]?.pressed;
802
+ // If two gamepads, FIRE1 on second gamepad also maps to button2
803
+ if (pad2) button2 = button2 || !!pad2.buttons[10]?.pressed;
772
804
  }
773
805
 
774
806
  return { button1: button1, button2: button2 };
package/src/video.js CHANGED
@@ -1106,4 +1106,10 @@ export class FakeVideo {
1106
1106
  polltime() {}
1107
1107
 
1108
1108
  setScreenHwScroll() {}
1109
+
1110
+ snapshotState() {
1111
+ return {};
1112
+ }
1113
+
1114
+ restoreState() {}
1109
1115
  }
@@ -11,12 +11,60 @@ export class TestMachine {
11
11
  constructor(model, opts) {
12
12
  model = model || "B-DFS1.2";
13
13
  this.processor = fake6502(findModel(model), opts || {});
14
+ this._capturedChars = [];
15
+ this._captureHookInstalled = false;
14
16
  }
15
17
 
16
18
  async initialise() {
17
19
  await this.processor.initialise();
18
20
  }
19
21
 
22
+ /**
23
+ * Install the character capture hook (once). All characters sent
24
+ * through WRCHV are accumulated and can be read with drainText().
25
+ * Safe to call multiple times — only installs one hook.
26
+ */
27
+ startCapture() {
28
+ if (this._captureHookInstalled) return;
29
+ this._captureHookInstalled = true;
30
+ this.processor.debugInstruction.add((addr) => {
31
+ const wrchv = this.readword(0x20e);
32
+ if (addr === wrchv) {
33
+ this._capturedChars.push(this.processor.a);
34
+ }
35
+ return false;
36
+ });
37
+ }
38
+
39
+ /**
40
+ * Return all captured characters since the last drain (or since
41
+ * startCapture was called), then clear the buffer.
42
+ * @returns {number[]} array of character codes
43
+ */
44
+ drainCapturedChars() {
45
+ const chars = this._capturedChars;
46
+ this._capturedChars = [];
47
+ return chars;
48
+ }
49
+
50
+ /**
51
+ * Return captured text as a string (printable chars only, with
52
+ * optional newline preservation), then clear the buffer.
53
+ * @param {Object} [opts]
54
+ * @param {boolean} [opts.raw=false] - if true, preserve newlines
55
+ */
56
+ drainText({ raw = false } = {}) {
57
+ const chars = this.drainCapturedChars();
58
+ return chars
59
+ .map((c) => {
60
+ if (raw && c === 10) return "\n";
61
+ if (c === 13) return "";
62
+ if (c >= 0x20 && c < 0x7f) return String.fromCharCode(c);
63
+ return "";
64
+ })
65
+ .join("");
66
+ }
67
+
20
68
  runFor(cycles) {
21
69
  let left = cycles;
22
70
  let stopped = false;
@@ -118,6 +166,23 @@ export class TestMachine {
118
166
  this.processor.reset(hard);
119
167
  }
120
168
 
169
+ /**
170
+ * Take a snapshot of the entire machine state (CPU, RAM, SWRAM,
171
+ * VIAs, video, FDC, etc). Returns an opaque state object that
172
+ * can be passed to restore().
173
+ */
174
+ snapshot({ includeRoms = true } = {}) {
175
+ return this.processor.snapshotState({ includeRoms });
176
+ }
177
+
178
+ /**
179
+ * Restore a previously saved snapshot. The machine will be in
180
+ * exactly the state it was when snapshot() was called.
181
+ */
182
+ restore(state) {
183
+ this.processor.restoreState(state);
184
+ }
185
+
121
186
  async loadBasic(source) {
122
187
  const tokeniser = await Tokeniser.create();
123
188
  const tokenised = tokeniser.tokenise(source);