jsbeeb 1.3.3 → 1.5.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.3.3",
10
+ "version": "1.5.0",
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
@@ -101,12 +101,12 @@ class Flags {
101
101
  }
102
102
 
103
103
  class Base6502 {
104
- constructor(model) {
104
+ constructor(model, { cycleAccurate = true } = {}) {
105
105
  this.model = model;
106
106
  this.a = this.x = this.y = this.s = 0;
107
107
  this.p = new Flags();
108
108
  this.pc = 0;
109
- this.opcodes = model.opcodesFactory(this);
109
+ this.opcodes = model.opcodesFactory(this, { cycleAccurate });
110
110
  this.disassembler = this.opcodes.disassembler;
111
111
  this.forceTracing = false;
112
112
  this.runner = this.opcodes.runInstruction;
@@ -405,9 +405,10 @@ class Base6502 {
405
405
 
406
406
  class Tube6502 extends Base6502 {
407
407
  constructor(model, cpu) {
408
- super(model);
408
+ super(model, { cycleAccurate: false });
409
409
 
410
410
  this.cycles = 0;
411
+ this.cpuMultiplier = 2;
411
412
  this.romPaged = true;
412
413
  this.memory = new Uint8Array(65536);
413
414
  this.rom = new Uint8Array(4096);
@@ -468,7 +469,7 @@ class Tube6502 extends Base6502 {
468
469
  }
469
470
 
470
471
  execute(cycles) {
471
- this.cycles += cycles * 2;
472
+ this.cycles += (cycles * this.cpuMultiplier) | 0;
472
473
  if (this.cycles < 3) return;
473
474
  while (this.cycles > 0) {
474
475
  const opcode = this.readmem(this.pc);
@@ -570,19 +571,22 @@ function is1MHzAccess(addr) {
570
571
  }
571
572
 
572
573
  export class Cpu6502 extends Base6502 {
573
- constructor(model, dbgr, video_, soundChip_, ddNoise_, music5000_, cmos, config, econet_) {
574
- super(model);
574
+ constructor(
575
+ model,
576
+ { dbgr, video, soundChip, ddNoise, music5000, cmos, config, econet, cycleAccurate = true } = {},
577
+ ) {
578
+ super(model, { cycleAccurate });
575
579
  this.config = fixUpConfig(config);
576
580
  this.debugFlags = this.config.debugFlags;
577
581
  this.cmos = cmos;
578
582
  this.debugger = dbgr;
579
583
 
580
- this.video = video_;
584
+ this.video = video;
581
585
  this.crtc = this.video.crtc;
582
586
  this.ula = this.video.ula;
583
- this.soundChip = soundChip_;
584
- this.music5000 = music5000_;
585
- this.ddNoise = ddNoise_;
587
+ this.soundChip = soundChip;
588
+ this.music5000 = music5000;
589
+ this.ddNoise = ddNoise;
586
590
  this.memStatOffsetByIFetchBank = 0;
587
591
  this.memStatOffset = 0;
588
592
  this.memStat = new Uint8Array(512);
@@ -602,8 +606,11 @@ export class Cpu6502 extends Base6502 {
602
606
  this.videoCyclesBatch = this.config.videoCyclesBatch | 0;
603
607
  this.peripheralCyclesPerSecond = 2 * 1000 * 1000;
604
608
  this.tube = model.tube ? new Tube6502(model.tube, this) : new FakeTube();
609
+ if (model.tube && this.config.tubeCpuMultiplier) {
610
+ this.tube.cpuMultiplier = this.config.tubeCpuMultiplier;
611
+ }
605
612
  this.music5000PageSel = 0;
606
- this.econet = econet_;
613
+ this.econet = econet;
607
614
 
608
615
  this.peripheralCycles = 0;
609
616
  this.videoCycles = 0;
@@ -616,16 +623,14 @@ export class Cpu6502 extends Base6502 {
616
623
  this.debugWrite = new DebugHook(this, "_debugWrite");
617
624
 
618
625
  this.scheduler = new Scheduler();
619
- this.sysvia = new via.SysVia(
620
- this,
621
- this.scheduler,
622
- this.video,
623
- this.soundChip,
624
- this.cmos,
625
- this.model.isMaster,
626
- this.config.keyLayout,
627
- this.config.getGamepads,
628
- );
626
+ this.sysvia = new via.SysVia(this, this.scheduler, {
627
+ video: this.video,
628
+ soundChip: this.soundChip,
629
+ cmos: this.cmos,
630
+ isMaster: this.model.isMaster,
631
+ initialLayout: this.config.keyLayout,
632
+ getGamepads: this.config.getGamepads,
633
+ });
629
634
  this.uservia = new via.UserVia(this, this.scheduler, this.model.isMaster, this.config.userPort);
630
635
  this.acia = new Acia(this, this.soundChip.toneGenerator, this.scheduler, this.touchScreen);
631
636
  this.serial = new Serial(this.acia);
@@ -41,8 +41,9 @@ function push(reg) {
41
41
  }
42
42
 
43
43
  class InstructionGen {
44
- constructor(is65c12) {
44
+ constructor(is65c12, cycleAccurate = true) {
45
45
  this.is65c12 = is65c12;
46
+ this.cycleAccurate = cycleAccurate;
46
47
  this.ops = {};
47
48
  this.cycle = 0;
48
49
  }
@@ -106,6 +107,10 @@ class InstructionGen {
106
107
  }
107
108
 
108
109
  spuriousOp(addr, reg) {
110
+ if (!this.cycleAccurate) {
111
+ this.cycle++;
112
+ return;
113
+ }
109
114
  if (this.is65c12) {
110
115
  this.readOp(addr, "", true);
111
116
  } else {
@@ -122,7 +127,7 @@ class InstructionGen {
122
127
  toSkip++;
123
128
  continue;
124
129
  }
125
- if (toSkip && this.ops[i].exact) {
130
+ if (this.cycleAccurate && toSkip && this.ops[i].exact) {
126
131
  if (this.ops[i].addr) {
127
132
  out.push(`cpu.polltimeAddr(${toSkip}, ${this.ops[i].addr});`);
128
133
  } else {
@@ -134,7 +139,7 @@ class InstructionGen {
134
139
  toSkip++;
135
140
  }
136
141
  if (toSkip) {
137
- if (this.ops[this.cycle] && this.ops[this.cycle].addr) {
142
+ if (this.cycleAccurate && this.ops[this.cycle] && this.ops[this.cycle].addr) {
138
143
  out.push(`cpu.polltimeAddr(${toSkip}, ${this.ops[this.cycle].addr});`);
139
144
  } else {
140
145
  out.push(`cpu.polltime(${toSkip});`);
@@ -145,17 +150,17 @@ class InstructionGen {
145
150
  }
146
151
 
147
152
  split(condition) {
148
- return new SplitInstruction(this, condition, this.is65c12);
153
+ return new SplitInstruction(this, condition, this.is65c12, this.cycleAccurate);
149
154
  }
150
155
  }
151
156
 
152
157
  class SplitInstruction {
153
- constructor(preamble, condition, is65c12) {
158
+ constructor(preamble, condition, is65c12, cycleAccurate = true) {
154
159
  this.preamble = preamble;
155
160
  this.condition = condition;
156
- this.ifTrue = new InstructionGen(is65c12);
161
+ this.ifTrue = new InstructionGen(is65c12, cycleAccurate);
157
162
  this.ifTrue.tick(preamble.cycle);
158
- this.ifFalse = new InstructionGen(is65c12);
163
+ this.ifFalse = new InstructionGen(is65c12, cycleAccurate);
159
164
  this.ifFalse.tick(preamble.cycle);
160
165
 
161
166
  ["append", "prepend", "readOp", "writeOp", "spuriousOp"].forEach((op) => {
@@ -1052,7 +1057,7 @@ class Disassemble6502 {
1052
1057
  }
1053
1058
  }
1054
1059
 
1055
- function makeCpuFunctions(cpu, opcodes, is65c12) {
1060
+ function makeCpuFunctions(cpu, opcodes, is65c12, cycleAccurate = true) {
1056
1061
  function getInstruction(opcodeString, needsReg) {
1057
1062
  const split = opcodeString.split(" ");
1058
1063
  const opcode = split[0];
@@ -1060,7 +1065,7 @@ function makeCpuFunctions(cpu, opcodes, is65c12) {
1060
1065
  const op = getOp(opcode, arg);
1061
1066
  if (!op) return null;
1062
1067
 
1063
- let ig = new InstructionGen(is65c12);
1068
+ let ig = new InstructionGen(is65c12, cycleAccurate);
1064
1069
  if (needsReg) ig.append("let REG = 0|0;");
1065
1070
 
1066
1071
  switch (arg) {
@@ -1398,14 +1403,14 @@ ${indent}`)
1398
1403
  };
1399
1404
  }
1400
1405
 
1401
- export function Cpu6502(cpu) {
1402
- return makeCpuFunctions(cpu, opcodes6502, false);
1406
+ export function Cpu6502(cpu, { cycleAccurate = true } = {}) {
1407
+ return makeCpuFunctions(cpu, opcodes6502, false, cycleAccurate);
1403
1408
  }
1404
1409
 
1405
- export function Cpu65c12(cpu) {
1406
- return makeCpuFunctions(cpu, opcodes65c12, true);
1410
+ export function Cpu65c12(cpu, { cycleAccurate = true } = {}) {
1411
+ return makeCpuFunctions(cpu, opcodes65c12, true, cycleAccurate);
1407
1412
  }
1408
1413
 
1409
- export function Cpu65c02(cpu) {
1410
- return makeCpuFunctions(cpu, opcodes65c02, true);
1414
+ export function Cpu65c02(cpu, { cycleAccurate = true } = {}) {
1415
+ return makeCpuFunctions(cpu, opcodes65c02, true, cycleAccurate);
1411
1416
  }
package/src/acia.js CHANGED
@@ -118,20 +118,34 @@ export class Acia {
118
118
  selectRs423(selected) {
119
119
  this.rs423Selected = !!selected;
120
120
  if (this.rs423Selected) {
121
- // RS423 selected.
122
- // CTS is always high, meaning not Clear To Send. This is
123
- // because we don't yet emulate anything on the "other end",
124
- // so there is nothing to pull CTS low.
125
- this.sr |= 0x08;
121
+ // When a handler is present it acts as the remote device and is
122
+ // always ready to receive, so CTS is low (active = clear to send).
123
+ // Without a handler there is nothing on the other end and CTS
124
+ // stays high, which inhibits TDRE so the OS output buffer drains
125
+ // silently rather than hanging.
126
+ if (this.rs423Handler) {
127
+ this.sr &= ~0x08; // CTS low — clear to send
128
+ } else {
129
+ this.sr |= 0x08; // CTS high — not connected
130
+ }
126
131
  } else {
127
- // Cassette selected.
128
- // CTS is always low, meaning actually Clear To Send.
132
+ // Cassette selected — CTS is always low (clear to send).
129
133
  this.sr &= ~0x08;
130
134
  }
131
135
  this.dcdLineUpdated();
132
136
  this.runRs423Task.ensureScheduled(this.rs423Selected, this.serialReceiveCyclesPerByte);
133
137
  }
134
138
 
139
+ /**
140
+ * Attach or detach an RS-423 peripheral handler at runtime.
141
+ * The handler must implement onTransmit(byte) and tryReceive(rts).
142
+ * If RS-423 is already selected, the CTS line is updated immediately.
143
+ */
144
+ setRs423Handler(handler) {
145
+ this.rs423Handler = handler;
146
+ if (this.rs423Selected) this.selectRs423(true);
147
+ }
148
+
135
149
  dcdLineUpdated() {
136
150
  // AUG: "It will always be low when the RS423 interface is selected".
137
151
  const level = this.rs423Selected ? false : this.tapeDcdLineLevel;
@@ -0,0 +1,24 @@
1
+ "use strict";
2
+
3
+ // The 16 standard BBC Micro colours in ABGR format (0xffBBGGRR, little-endian canvas RGBA).
4
+ // Colours 0-7 are the primary palette; 8-15 duplicate them as the default solid/non-flash set.
5
+ // Imported by both video.js (NulaDefaultPalette) and teletext.js (BbcDefaultCollook) to avoid
6
+ // circular imports between those two modules.
7
+ export const BbcDefaultPalette = new Uint32Array([
8
+ 0xff000000, // 0: black
9
+ 0xff0000ff, // 1: red
10
+ 0xff00ff00, // 2: green
11
+ 0xff00ffff, // 3: yellow
12
+ 0xffff0000, // 4: blue
13
+ 0xffff00ff, // 5: magenta
14
+ 0xffffff00, // 6: cyan
15
+ 0xffffffff, // 7: white
16
+ 0xff000000, // 8: black (solid duplicate)
17
+ 0xff0000ff, // 9: red
18
+ 0xff00ff00, // 10: green
19
+ 0xff00ffff, // 11: yellow
20
+ 0xffff0000, // 12: blue
21
+ 0xffff00ff, // 13: magenta
22
+ 0xffffff00, // 14: cyan
23
+ 0xffffffff, // 15: white
24
+ ]);
package/src/cmos.js CHANGED
@@ -1,8 +1,17 @@
1
1
  "use strict";
2
2
 
3
+ // CMOS layout: bytes 0-13 are RTC internal registers; bytes 14-63 are the 50
4
+ // bytes of user storage. Storage byte 11 (= CMOS address 25 = 0x19) holds
5
+ // the BBC Master DFS configuration byte whose bits [1:0] are the WD1770 step
6
+ // rate (*CONFIGURE FDRIVE): 0=6ms 1=12ms 2=20ms 3=30ms.
7
+ // The correct default is FDRIVE 0 (6ms, value 0xc8). The previous default
8
+ // 0xca had bits[1:0]=0b10 (20ms), which caused disc-streaming demos such as
9
+ // STNICC-beeb to hang on the Master 128 because the WD1770 seek overran the
10
+ // vsync window. Confirmed by @tom-seddon (b2 emulator):
11
+ // https://github.com/mattgodbolt/jsbeeb/issues/576
3
12
  const defaultCmos = [
4
13
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfe, 0x00, 0xeb, 0x00,
5
- 0xc9, 0xff, 0xff, 0x12, 0x00, 0x17, 0xca, 0x1e, 0x05, 0x00, 0x35, 0xa6, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
14
+ 0xc9, 0xff, 0xff, 0x12, 0x00, 0x17, 0xc8, 0x1e, 0x05, 0x00, 0x35, 0xa6, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
6
15
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
7
16
  ];
8
17
 
@@ -22,6 +31,8 @@ function fromBcd(value) {
22
31
  return parseInt(value.toString(16), 10);
23
32
  }
24
33
 
34
+ export { defaultCmos };
35
+
25
36
  export class Cmos {
26
37
  constructor(persistence, cmosOverride, econet) {
27
38
  this.store = persistence ? persistence.load() : null;
package/src/config.js CHANGED
@@ -17,6 +17,7 @@ export class Config extends EventEmitter {
17
17
  this.changed = {};
18
18
  this.setDropdownText(this.model.name);
19
19
  this.set65c02(this.model.tube);
20
+ this.setTubeCpuMultiplier(this.tubeCpuMultiplier);
20
21
  this.setTeletext(this.model.hasTeletextAdaptor);
21
22
  this.setMusic5000(this.model.hasMusic5000);
22
23
  this.setEconet(this.model.hasEconet);
@@ -36,6 +37,13 @@ export class Config extends EventEmitter {
36
37
 
37
38
  $("#65c02").on("click", () => {
38
39
  this.changed.coProcessor = $("#65c02").prop("checked");
40
+ $("#tubeCpuMultiplier").prop("disabled", !$("#65c02").prop("checked"));
41
+ });
42
+
43
+ $("#tubeCpuMultiplier").on("input", () => {
44
+ const val = parseInt($("#tubeCpuMultiplier").val(), 10);
45
+ $("#tubeCpuMultiplierValue").text(val);
46
+ this.changed.tubeCpuMultiplier = val;
39
47
  });
40
48
 
41
49
  $("#hasTeletextAdaptor").on("click", () => {
@@ -67,6 +75,10 @@ export class Config extends EventEmitter {
67
75
  this.changed.mouseJoystickEnabled = $("#mouseJoystickEnabled").prop("checked");
68
76
  });
69
77
 
78
+ $("#speechOutput").on("click", () => {
79
+ this.changed.speechOutput = $("#speechOutput").prop("checked");
80
+ });
81
+
70
82
  $(".display-mode-option").on("click", (e) => {
71
83
  const mode = $(e.target).data("mode");
72
84
  this.changed.displayMode = mode;
@@ -87,6 +99,10 @@ export class Config extends EventEmitter {
87
99
  $("#mouseJoystickEnabled").prop("checked", !!enabled);
88
100
  }
89
101
 
102
+ setSpeechOutput(enabled) {
103
+ $("#speechOutput").prop("checked", !!enabled);
104
+ }
105
+
90
106
  setDisplayMode(mode) {
91
107
  const config = getFilterForMode(mode).getDisplayConfig();
92
108
  $(".display-mode-text").text(config.name);
@@ -105,6 +121,13 @@ export class Config extends EventEmitter {
105
121
  enabled = !!enabled;
106
122
  $("#65c02").prop("checked", enabled);
107
123
  this.model.tube = enabled ? findModel("Tube65c02") : null;
124
+ $("#tubeCpuMultiplier").prop("disabled", !enabled);
125
+ }
126
+
127
+ setTubeCpuMultiplier(value) {
128
+ this.tubeCpuMultiplier = value;
129
+ $("#tubeCpuMultiplier").val(value);
130
+ $("#tubeCpuMultiplierValue").text(value);
108
131
  }
109
132
 
110
133
  setEconet(enabled) {
package/src/fake6502.js CHANGED
@@ -17,10 +17,17 @@ const dbgr = {
17
17
 
18
18
  export function fake6502(model, opts) {
19
19
  opts = opts || {};
20
- const video = opts.video || fakeVideo;
21
20
  model = model || TEST_6502;
22
21
  if (opts.tube) model.tube = findModel("Tube65c02");
23
- return new Cpu6502(model, dbgr, video, soundChip, new FakeDdNoise(), new FakeMusic5000(), new Cmos());
22
+ return new Cpu6502(model, {
23
+ dbgr,
24
+ video: opts.video || fakeVideo,
25
+ soundChip: opts.soundChip || soundChip,
26
+ ddNoise: new FakeDdNoise(),
27
+ music5000: new FakeMusic5000(),
28
+ cmos: new Cmos(),
29
+ cycleAccurate: opts.cycleAccurate,
30
+ });
24
31
  }
25
32
 
26
33
  export function fake65C02() {
package/src/fdc.js CHANGED
@@ -13,16 +13,17 @@ export function load(name) {
13
13
  */
14
14
  export class DiscType {
15
15
  /**
16
- * Create a new disc type
17
- * @param {string} extension - File extension for this disc type (e.g. ".ssd", ".hfe")
18
- * @param {function(Disc, Uint8Array, function?): Disc} loader - Function to load this disc type
19
- * @param {function(Disc): Uint8Array} saver - Function to save this disc type
20
- * @param {function(Uint8Array, string): void|null} nameSetter - Function to set the name/label in the disc image
21
- * @param {boolean} isDoubleSided - Whether the disc format is double-sided
22
- * @param {boolean} isDoubleDensity - Whether the disc format is double density
23
- * @param {number|undefined} byteSize - The size in bytes of this disc format, or undefined if variable
16
+ * Create a new disc type.
17
+ * @param {Object} [options] - Configuration options for this disc type.
18
+ * @param {string} options.extension - File extension for this disc type (e.g. ".ssd", ".hfe").
19
+ * @param {function(Disc, Uint8Array, function?): Disc} options.loader - Function to load this disc type.
20
+ * @param {function(Disc): Uint8Array} options.saver - Function to save this disc type.
21
+ * @param {function(Uint8Array, string): void|null} [options.nameSetter] - Function to set the name/label in the disc image, or null if not supported.
22
+ * @param {boolean} [options.isDoubleSided] - Whether the disc format is double-sided.
23
+ * @param {boolean} [options.isDoubleDensity] - Whether the disc format is double density.
24
+ * @param {number|undefined} [options.byteSize] - The size in bytes of this disc format, or undefined if variable.
24
25
  */
25
- constructor(extension, loader, saver, nameSetter, isDoubleSided, isDoubleDensity, byteSize) {
26
+ constructor({ extension, loader, saver, nameSetter, isDoubleSided, isDoubleDensity, byteSize } = {}) {
26
27
  this._extension = extension;
27
28
  this._loader = loader;
28
29
  this._saver = saver;
@@ -128,69 +129,65 @@ function setDfsDiscName(data, name) {
128
129
  }
129
130
 
130
131
  // HFE disc type - variable size
131
- const hfeDiscType = new DiscType(
132
- ".hfe",
133
- loadHfe,
134
- toHfe,
135
- null, // no name setter function yet
136
- true, // double-sided
137
- true, // double density
138
- undefined, // variable size
139
- );
132
+ const hfeDiscType = new DiscType({
133
+ extension: ".hfe",
134
+ loader: loadHfe,
135
+ saver: toHfe,
136
+ isDoubleSided: true,
137
+ isDoubleDensity: true,
138
+ });
140
139
 
141
140
  // ADFS (Large) discs are double density, double sided
142
- const adlDiscType = new DiscType(
143
- ".adl",
144
- (disc, data, _onChange) => {
141
+ const adlDiscType = new DiscType({
142
+ extension: ".adl",
143
+ loader: (disc, data, _onChange) => {
145
144
  // TODO handle onChange
146
145
  return loadAdf(disc, data, true);
147
146
  },
148
- (_data) => {
147
+ saver: (_data) => {
149
148
  throw new Error("ADL unsupported");
150
149
  },
151
- null, // no name setter function yet
152
- true, // double-sided
153
- true, // double density
154
- AdfsLargeByteSize,
155
- );
150
+ isDoubleSided: true,
151
+ isDoubleDensity: true,
152
+ byteSize: AdfsLargeByteSize,
153
+ });
156
154
 
157
155
  // ADFS (Small) discs are standard ADFS (non-double) density, single sided
158
- const adfDiscType = new DiscType(
159
- ".adf",
160
- (disc, data, _onChange) => {
156
+ const adfDiscType = new DiscType({
157
+ extension: ".adf",
158
+ loader: (disc, data, _onChange) => {
161
159
  // TODO handle onChange
162
160
  return loadAdf(disc, data, false);
163
161
  },
164
- (_data) => {
162
+ saver: (_data) => {
165
163
  throw new Error("ADF unsupported");
166
164
  },
167
- null, // no name setter function yet
168
- false, // single-sided
169
- true, // double density
170
- AdfsSmallByteSize,
171
- );
165
+ isDoubleSided: false,
166
+ isDoubleDensity: true,
167
+ byteSize: AdfsSmallByteSize,
168
+ });
172
169
 
173
170
  // DSD (Double-sided disc)
174
- const dsdDiscType = new DiscType(
175
- ".dsd",
176
- (disc, data, onChange) => loadSsd(disc, data, true, onChange),
177
- toSsdOrDsd,
178
- setDfsDiscName, // supports setting catalogue name
179
- true, // double-sided
180
- false, // standard density
181
- DsdByteSize,
182
- );
171
+ const dsdDiscType = new DiscType({
172
+ extension: ".dsd",
173
+ loader: (disc, data, onChange) => loadSsd(disc, data, true, onChange),
174
+ saver: toSsdOrDsd,
175
+ nameSetter: setDfsDiscName,
176
+ isDoubleSided: true,
177
+ isDoubleDensity: false,
178
+ byteSize: DsdByteSize,
179
+ });
183
180
 
184
181
  // SSD (Single-sided disc)
185
- const ssdDiscType = new DiscType(
186
- ".ssd",
187
- (disc, data, onChange) => loadSsd(disc, data, false, onChange),
188
- toSsdOrDsd,
189
- setDfsDiscName, // supports setting catalogue name
190
- false, // single-sided
191
- false, // standard density
192
- SsdByteSize,
193
- );
182
+ const ssdDiscType = new DiscType({
183
+ extension: ".ssd",
184
+ loader: (disc, data, onChange) => loadSsd(disc, data, false, onChange),
185
+ saver: toSsdOrDsd,
186
+ nameSetter: setDfsDiscName,
187
+ isDoubleSided: false,
188
+ isDoubleDensity: false,
189
+ byteSize: SsdByteSize,
190
+ });
194
191
  /**
195
192
  * Determine the disc type based on the file name extension
196
193
  * @param {string} name - The file name with extension
package/src/keyboard.js CHANGED
@@ -214,14 +214,17 @@ export class Keyboard extends EventEmitter {
214
214
  const isSpecialHandled = this._handleSpecialKeys(code);
215
215
  if (isSpecialHandled) return;
216
216
 
217
- // Always pass the key to the BBC Micro (unless it was a special key)
218
- this.processor.sysvia.keyDown(code, evt.shiftKey);
219
-
220
- // Check for registered handlers
217
+ // Check for registered handlers first; if one fires, don't also send to the BBC.
218
+ // This lets Alt+key and Ctrl+key handlers cleanly own their keys without the
219
+ // underlying key leaking through to the emulated machine.
221
220
  const handler = this._findKeyHandler(code, evt.altKey, evt.ctrlKey);
222
221
  if (handler) {
223
222
  handler.handler(true, code);
223
+ return;
224
224
  }
225
+
226
+ // No handler claimed the key — pass it to the BBC Micro.
227
+ this.processor.sysvia.keyDown(code, evt.shiftKey);
225
228
  }
226
229
 
227
230
  /**
@@ -9,6 +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
13
 
13
14
  // Resolve the jsbeeb package root from our own location (src/machine-session.js
14
15
  // → go up one level). Passed to setNodeBasePath() so the ROM loader resolves
@@ -27,9 +28,12 @@ const FB_HEIGHT = 625;
27
28
  export class MachineSession {
28
29
  /**
29
30
  * @param {string} modelName - e.g. "B-DFS1.2", "Master"
31
+ * @param {Object} [opts]
32
+ * @param {string} [opts.discImage] - path to an .ssd or .dsd disc image to load on boot
30
33
  */
31
- constructor(modelName = "B-DFS1.2") {
34
+ constructor(modelName = "B-DFS1.2", opts = {}) {
32
35
  this.modelName = modelName;
36
+ this._opts = opts;
33
37
 
34
38
  // Raw RGBA framebuffer — the Video chip renders into _fb32 (cleared each frame).
35
39
  // _completeFb8 is a snapshot taken at paint time (the equivalent of the browser canvas)
@@ -50,8 +54,11 @@ export class MachineSession {
50
54
  this._completeFb8.set(this._fb8);
51
55
  });
52
56
 
53
- // TestMachine forwards opts.video to fake6502, which uses it instead of FakeVideo
54
- this._machine = new TestMachine(modelName, { video: this._video });
57
+ // Use a real (instrumented) sound chip so we can read registers and capture writes
58
+ this._soundChip = new InstrumentedSoundChip();
59
+
60
+ // TestMachine forwards opts.video and opts.soundChip to fake6502
61
+ this._machine = new TestMachine(modelName, { video: this._video, soundChip: this._soundChip });
55
62
 
56
63
  // Accumulated VDU text output — drained by callers
57
64
  this._pendingOutput = [];
@@ -61,6 +68,9 @@ export class MachineSession {
61
68
  async initialise() {
62
69
  setNodeBasePath(_jsbeebRoot);
63
70
  await this._machine.initialise();
71
+ if (this._opts.discImage) {
72
+ this.loadDisc(this._opts.discImage);
73
+ }
64
74
  this._installCaptureHook();
65
75
  }
66
76
 
@@ -201,6 +211,30 @@ export class MachineSession {
201
211
  });
202
212
  }
203
213
 
214
+ /**
215
+ * Press a key (by browser keyCode).
216
+ * Use utils.keyCodes for named keys, or ASCII charCode for letters/digits.
217
+ */
218
+ keyDown(keyCode, shiftDown = false) {
219
+ this._machine.processor.sysvia.keyDown(keyCode, shiftDown);
220
+ }
221
+
222
+ /**
223
+ * Release a key (by browser keyCode).
224
+ */
225
+ keyUp(keyCode) {
226
+ this._machine.processor.sysvia.keyUp(keyCode);
227
+ }
228
+
229
+ /**
230
+ * Reset the machine.
231
+ * @param {boolean} [hard=true] - true for power-on reset, false for soft reset
232
+ */
233
+ reset(hard = true) {
234
+ this._machine.processor.reset(hard);
235
+ this._pendingOutput = [];
236
+ }
237
+
204
238
  /** Tokenise BBC BASIC source and write it into PAGE */
205
239
  async loadBasic(source) {
206
240
  await this._machine.loadBasic(source);