jsbeeb 1.14.0 → 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/README.md CHANGED
@@ -11,6 +11,7 @@ different peripherals.
11
11
  ## Table of Contents
12
12
 
13
13
  - [Keyboard Mappings](#keyboard-mappings)
14
+ - [Remapping Keys](#remapping-keys)
14
15
  - [Emulator Shortcuts](#emulator-shortcuts)
15
16
  - [Save State and Rewind](#save-state-and-rewind)
16
17
  - [Getting Set Up to Run Locally](#getting-set-up-to-run-locally)
@@ -36,6 +37,65 @@ The BBC had a somewhat different-looking keyboard to a modern PC, and so it's us
36
37
  To play right now, visit [https://bbc.xania.org/](https://bbc.xania.org/). To load the default disc image (Elite in this
37
38
  case), press shift-F12 (which is shift-Break on the BBC).
38
39
 
40
+ ### Remapping Keys
41
+
42
+ Plenty of games use keys that are awkward on a modern keyboard: `COPY` (which is `End`, or `fn`+`→` on a Mac), or
43
+ `CAPS LOCK` (which on a Mac toggles rather than acting as a key you hold down). Any host key can be made to press any
44
+ BBC key by adding a `KEY.` parameter to the URL:
45
+
46
+ ```
47
+ KEY.<host key>=<BBC key>
48
+ ```
49
+
50
+ Add one for each key you want to change. For example, Superior Software's Space Invaders fires with `COPY`; this makes
51
+ `Enter` fire instead:
52
+
53
+ [`https://bbc.xania.org/?disc1=sth:Superior/SpaceInvaders-Superior.zip&autoboot&KEY.ENTER=COPY`](https://bbc.xania.org/?disc1=sth%3ASuperior%2FSpaceInvaders-Superior.zip&autoboot&KEY.ENTER=COPY)
54
+
55
+ Superior's Frogger uses `A`/`Z`/`DELETE`/`COPY` to move; this puts it on the arrow keys:
56
+
57
+ [`https://bbc.xania.org/?disc1=sth:Superior/Frogger-Superior.zip&autoboot&KEY.UP=A&KEY.DOWN=Z&KEY.LEFT=DELETE&KEY.RIGHT=COPY`](https://bbc.xania.org/?disc1=sth%3ASuperior%2FFrogger-Superior.zip&autoboot&KEY.UP=A&KEY.DOWN=Z&KEY.LEFT=DELETE&KEY.RIGHT=COPY)
58
+
59
+ And Superior's Hunchback steers with `CAPS LOCK` and `CTRL`, which the arrow keys can stand in for:
60
+
61
+ [`https://bbc.xania.org/?disc1=sth:Superior/Hunchback-Superior.zip&autoboot&KEY.LEFT=CAPSLOCK&KEY.RIGHT=CTRL`](https://bbc.xania.org/?disc1=sth%3ASuperior%2FHunchback-Superior.zip&autoboot&KEY.LEFT=CAPSLOCK&KEY.RIGHT=CTRL)
62
+
63
+ The **host key** names are jsbeeb's names for the keys on your own keyboard. Most are what you'd expect, but note:
64
+
65
+ - `ENTER` (the BBC's `RETURN` key is called `ENTER` on the host side)
66
+ - `K0` to `K9` for the number keys, `NUMPAD0` to `NUMPAD9` for the keypad
67
+ - `SHIFT_LEFT` / `SHIFT_RIGHT`, `CTRL_LEFT` / `CTRL_RIGHT`, `ALT_LEFT` / `ALT_RIGHT` to distinguish the two of each
68
+ - `BACK_QUOTE`, `APOSTROPHE`, `SEMICOLON`, `MINUS`, `EQUALS`, `HASH`, `BACKSLASH`, `LEFT_SQUARE_BRACKET`,
69
+ `RIGHT_SQUARE_BRACKET` for punctuation
70
+
71
+ The **BBC key** names are:
72
+
73
+ ```
74
+ RETURN COPY DELETE ESCAPE TAB SPACE SHIFT SHIFTLOCK CAPSLOCK CTRL
75
+ LEFT RIGHT UP DOWN
76
+ A-Z, K0-K9 (the number keys), F0-F9 (the red function keys)
77
+ SEMICOLON_PLUS MINUS COMMA PERIOD SLASH AT COLON_STAR HAT_TILDE
78
+ UNDERSCORE_POUND PIPE_BACKSLASH LEFT_SQUARE_BRACKET RIGHT_SQUARE_BRACKET
79
+
80
+ (and, on the Master's numeric keypad only)
81
+ NUMPAD0-NUMPAD9 NUMPADPLUS NUMPADMINUS NUMPADSLASH NUMPADASTERISK NUMPADCOMMA
82
+ NUMPADHASH NUMPADENTER NUMPAD_DELETE NUMPAD_DECIMAL_POINT
83
+ ```
84
+
85
+ Some things to know:
86
+
87
+ - Names are case-insensitive, and a remapped key ignores the `SHIFT` state, so `KEY.ENTER=COPY` presses `COPY` whether
88
+ or not shift is held.
89
+ - Remapping replaces what that host key normally does; in the Space Invaders example above, `Enter` no longer presses
90
+ `RETURN`.
91
+ - The remapping is applied on top of whichever keyboard layout is selected, and survives changing layout or model.
92
+ - If a name isn't recognised the mapping is skipped, and the emulator says so on startup, naming the parameter that
93
+ was at fault.
94
+ - On the Atom, use the Atom's own key names (`LOCK`, `UP_DOWN`, `LEFT_RIGHT` and so on) rather than the BBC's.
95
+
96
+ The definitive lists are `keyCodes` (host) and `BBC` (BBC micro) in [`src/utils.js`](src/utils.js), and `ATOM` in
97
+ [`src/utils_atom.js`](src/utils_atom.js).
98
+
39
99
  ### Emulator Shortcuts
40
100
 
41
101
  | Shortcut | Action |
@@ -63,6 +123,35 @@ jsbeeb supports both USB/Bluetooth gamepads and mouse-based analogue joystick em
63
123
  - X-axis: Left = 65535, Right = 0
64
124
  - Y-axis: Up = 65535, Down = 0
65
125
 
126
+ A gamepad presses BBC keys, and which key each control presses can be changed from the URL in the same way as
127
+ [remapping the keyboard](#remapping-keys):
128
+
129
+ ```
130
+ GP.<gamepad control>=<BBC key>
131
+ ```
132
+
133
+ By default the D-pad presses the "Snapper" keys (`Z`, `X`, `:`, `/`), the `A` button presses `RETURN` and `Start`
134
+ presses `SPACE`. To play Superior's Space Invaders on a pad, where `COPY` fires:
135
+
136
+ [`https://bbc.xania.org/?disc1=sth:Superior/SpaceInvaders-Superior.zip&autoboot&GP.FIRE=COPY`](https://bbc.xania.org/?disc1=sth%3ASuperior%2FSpaceInvaders-Superior.zip&autoboot&GP.FIRE=COPY)
137
+
138
+ The gamepad control names are:
139
+
140
+ - `FIRE` — every button at once, which is usually what you want for a one-button game
141
+ - `UP` `DOWN` `LEFT` `RIGHT` — both analogue sticks at once, plus one face button each (`UP` is also `A`, `DOWN` is
142
+ `X`, `LEFT` is `Y`, `RIGHT` is `B`)
143
+ - `UP1` `DOWN1` `LEFT1` `RIGHT1` — the left stick only; `UP2` `DOWN2` … — the right stick only; `UP3` `DOWN3` … — the
144
+ face buttons only
145
+ - `A` `B` `X` `Y` `START` `BACK` `LB` `RB` `LT` `RT` — individual buttons, by their Xbox 360 names
146
+ - `FIRE1` `FIRE2` — clicking the left and right sticks
147
+
148
+ The BBC key names are the same as for the keyboard, and digits may be written either way round: `GP.A=1` and `GP.A=K1`
149
+ both press `1`. Unlike `KEY.`, gamepad mappings are BBC-only: there's no Atom equivalent. The D-pad's default mapping
150
+ can't currently be changed.
151
+
152
+ The older `LEFT=`, `RIGHT=`, `UP=`, `DOWN=` and `FIRE=` parameters (no `GP.` prefix) still work and mean the same
153
+ thing.
154
+
66
155
  ## Getting Set Up to Run Locally
67
156
 
68
157
  ### Prerequisites
@@ -157,6 +246,8 @@ sudo rpm -i out/dist/jsbeeb-1.0.1.x86_64.rpm
157
246
  - `disc1=sth:ZZZ` - loads disc ZZZ from the Stairway to Hell archive
158
247
  - `tape=XXX` - loads tape XXX (from the `tapes/` directory)
159
248
  - `tape=sth:ZZZ` - loads tape ZZZ from the Stairway to Hell archive
249
+ - `KEY.X=Y` - makes host key `X` press BBC key `Y`, e.g. `KEY.ENTER=COPY`. See
250
+ [Remapping Keys](#remapping-keys).
160
251
  - `patch=P` - applies a memory patch `P`. See below.
161
252
  - `loadBasic=X` - loads 'X' (a resource on the webserver) as text, tokenises it and puts it in `PAGE` as if you'd typed
162
253
  it in to the emulator
@@ -170,6 +261,9 @@ sudo rpm -i out/dist/jsbeeb-1.0.1.x86_64.rpm
170
261
  - `cpuMultiplier=X` speeds up the CPU by a factor of `X` relative to the peripherals: video, sound and the VIAs keep
171
262
  running at their real-world rates. May be fractional or below one to slow the CPU down. NB disc loads become
172
263
  unreliable with a too-slow CPU, and running too fast might cause the browser to hang.
264
+ - `tubeCpuMultiplier=X` overclocks the second processor by a factor of `X`, which may be fractional. `1`, the default,
265
+ runs it at the real part's own clock: 3MHz for the 6502 second processor a BBC B takes, 4MHz for the 65C102 Turbo
266
+ board a Master takes. Below about 2.2MHz the MOS's unhandshaken tube transfers lose data.
173
267
  - `sbLeft` / `sbRight` / `sbBottom` - a URL to place left of, right of, or below the cub monitor. The left and right
174
268
  should be around 648 high and the bottom image should be around 896 wide. Left and right wider than 300 will run into
175
269
  problems on smaller screens; bottom taller than 100 or so similarly.
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.14.0",
10
+ "version": "1.15.0",
11
11
  "//engines": "If you change the version of Node, it must also be updated at the top of the Dockerfile.",
12
12
  "engines": {
13
13
  "node": ">=24.15.0"
Binary file
package/src/6502.js CHANGED
@@ -15,9 +15,6 @@ import { AtomMMC2 } from "./mmc.js";
15
15
 
16
16
  const signExtend = utils.signExtend;
17
17
 
18
- // Speed of the second processor relative to the host, as fitted to a Master Turbo.
19
- export const DefaultTubeCpuMultiplier = 2;
20
-
21
18
  function _set(byte, mask, set) {
22
19
  return (byte & ~mask) | (set ? mask : 0);
23
20
  }
@@ -410,10 +407,13 @@ class Base6502 {
410
407
  }
411
408
 
412
409
  class Tube6502 extends Base6502 {
413
- constructor(model, cpu, { cpuMultiplier = DefaultTubeCpuMultiplier } = {}) {
410
+ constructor(model, cpu, { cpuMultiplier = 1 } = {}) {
414
411
  super(model, { cycleAccurate: false });
415
412
 
413
+ if (!(cpuMultiplier > 0)) throw new Error(`Tube CPU multiplier must be positive, got ${cpuMultiplier}`);
414
+
416
415
  this.cycles = 0;
416
+ this.cyclesPerHostCycle = model.clockMhz / cpu.model.clockMhz;
417
417
  this.cpuMultiplier = cpuMultiplier;
418
418
  this.romPaged = true;
419
419
  this.memory = new Uint8Array(65536);
@@ -479,7 +479,7 @@ class Tube6502 extends Base6502 {
479
479
  }
480
480
 
481
481
  execute(cycles) {
482
- this.cycles += (cycles * this.cpuMultiplier) | 0;
482
+ this.cycles += cycles * this.cyclesPerHostCycle * this.cpuMultiplier;
483
483
  if (this.cycles < 3) return;
484
484
  while (this.cycles > 0) {
485
485
  const opcode = this.readmem(this.pc);
@@ -670,6 +670,7 @@ export class Cpu6502 extends Base6502 {
670
670
  this.hasTube = !!this.config.tube;
671
671
  this.hasMusic5000 = !!this.config.hasMusic5000;
672
672
  this.hasTeletextAdaptor = !!this.config.hasTeletextAdaptor;
673
+ this.teletextAdaptor = this.hasTeletextAdaptor ? new TeletextAdaptor(this) : null;
673
674
  this.tube = this.hasTube
674
675
  ? new Tube6502(this.config.tube, this, { cpuMultiplier: this.config.tubeCpuMultiplier })
675
676
  : new FakeTube();
@@ -697,7 +698,7 @@ export class Cpu6502 extends Base6502 {
697
698
  getGamepads: this.config.getGamepads,
698
699
  });
699
700
  this.uservia = new via.UserVia(this, this.scheduler, this.model.isMaster, this.config.userPort);
700
- this.acia = new Acia(this, this.soundChip.toneGenerator, this.scheduler, this.touchScreen, this.relayNoise);
701
+ this.acia = new Acia(this, this.soundChip.toneGenerator, this.scheduler, this.relayNoise);
701
702
  this.serial = new Serial(this.acia);
702
703
  this.adconverter = new Adc(this.sysvia, this.scheduler);
703
704
  this.soundChip.setScheduler(this.scheduler);
@@ -1361,7 +1362,6 @@ export class Cpu6502 extends Base6502 {
1361
1362
  this.adconverter.reset();
1362
1363
 
1363
1364
  this.touchScreen = new TouchScreen(this.scheduler);
1364
- if (this.hasTeletextAdaptor) this.teletextAdaptor = new TeletextAdaptor(this);
1365
1365
  if (this.econet) this.filestore = new Filestore(this, this.econet);
1366
1366
  }
1367
1367
 
@@ -1422,8 +1422,7 @@ export class Cpu6502 extends Base6502 {
1422
1422
  buildPolltime() {
1423
1423
  const nop = (_cycles) => {};
1424
1424
  const tubeStuff = this.hasTube ? (cycles) => this.tube.execute(cycles) : nop;
1425
- // The adaptor itself does not exist until reset() runs, so branch on the fitting, not the object.
1426
- const teletextStuff = this.hasTeletextAdaptor ? (cycles) => this.teletextAdaptor.polltime(cycles) : nop;
1425
+ const teletextStuff = this.teletextAdaptor ? (cycles) => this.teletextAdaptor.polltime(cycles) : nop;
1427
1426
  const musicStuff = this.music5000 ? (cycles) => this.music5000.polltime(cycles) : nop;
1428
1427
  const econetStuff = this.econet
1429
1428
  ? (cycles) => {
package/src/acia.js CHANGED
@@ -5,10 +5,10 @@
5
5
  // http://www.classiccmp.org/dunfield/r/6850.pdf
6
6
 
7
7
  export class Acia {
8
- constructor(cpu, toneGen, scheduler, rs423Handler, relayNoise) {
8
+ constructor(cpu, toneGen, scheduler, relayNoise) {
9
9
  this.cpu = cpu;
10
10
  this.toneGen = toneGen;
11
- this.rs423Handler = rs423Handler;
11
+ this.rs423Handler = null;
12
12
  this.relayNoise = relayNoise;
13
13
 
14
14
  this.sr = 0x00;
@@ -22,17 +22,27 @@ export class Acia {
22
22
  this.hadDcdHigh = false;
23
23
  this.serialReceiveRate = 0;
24
24
  this.serialReceiveCyclesPerByte = 0;
25
+ this.serialTransmitRate = 0;
26
+ this.serialTransmitCyclesPerByte = 0;
25
27
 
26
28
  this.setSerialReceive(19200);
29
+ this.setSerialTransmit(19200);
27
30
  this.txCompleteTask = scheduler.newTask(() => {
28
31
  this.sr |= 0x02; // set the TDRE
32
+ this.updateIrq();
29
33
  });
30
34
  this.runTapeTask = scheduler.newTask(() => this.runTape());
31
35
  this.runRs423Task = scheduler.newTask(() => this.runRs423());
32
36
  }
33
37
 
38
+ // MC6850: the transmit interrupt is selected by CR6:CR5 = 01 and follows
39
+ // TDRE, which a high CTS inhibits.
40
+ txIrq() {
41
+ return (this.cr & 0x60) === 0x20 && (this.sr & 0x0a) === 0x02;
42
+ }
43
+
34
44
  updateIrq() {
35
- if (this.sr & this.cr & 0x80) {
45
+ if (this.sr & this.cr & 0x80 || this.txIrq()) {
36
46
  this.cpu.interrupt |= 0x04;
37
47
  } else {
38
48
  this.cpu.interrupt &= ~0x04;
@@ -77,6 +87,7 @@ export class Acia {
77
87
  return this.dr;
78
88
  } else {
79
89
  let result = (this.sr & 0x7f) | (this.sr & this.cr & 0x80);
90
+ if (this.txIrq()) result |= 0x80;
80
91
  // MC6850: "A low CTS indicates that there is a Clear-to-Send
81
92
  // from the modem. In the high state, the Transmit Data Register
82
93
  // Empty bit is inhibited".
@@ -100,10 +111,7 @@ export class Acia {
100
111
  write(addr, val) {
101
112
  if (addr & 1) {
102
113
  this.sr &= ~0x02;
103
- // It's not clear how long this can take; it's when the shift register is loaded.
104
- // That could be straight away if not already tx-ing, but as we don't really tx,
105
- // be conservative here.
106
- this.txCompleteTask.reschedule(2000);
114
+ this.txCompleteTask.reschedule(this.serialTransmitCyclesPerByte);
107
115
  this.updateIrq();
108
116
  if (this.rs423Selected && this.rs423Handler) this.rs423Handler.onTransmit(val);
109
117
  } else {
@@ -114,6 +122,8 @@ export class Acia {
114
122
  } else {
115
123
  this.cr = val;
116
124
  this.setSerialReceive(this.serialReceiveRate);
125
+ this.setSerialTransmit(this.serialTransmitRate);
126
+ this.updateIrq();
117
127
  }
118
128
  }
119
129
  }
@@ -212,7 +222,7 @@ export class Acia {
212
222
  tapeDcdLineLevel: this.tapeDcdLineLevel,
213
223
  hadDcdHigh: this.hadDcdHigh,
214
224
  serialReceiveRate: this.serialReceiveRate,
215
- serialReceiveCyclesPerByte: this.serialReceiveCyclesPerByte,
225
+ serialTransmitRate: this.serialTransmitRate,
216
226
  txCompleteTaskOffset: this.txCompleteTask.scheduled()
217
227
  ? this.txCompleteTask.expireEpoch - scheduler.epoch
218
228
  : null,
@@ -230,8 +240,10 @@ export class Acia {
230
240
  this.tapeCarrierCount = state.tapeCarrierCount;
231
241
  this.tapeDcdLineLevel = state.tapeDcdLineLevel;
232
242
  this.hadDcdHigh = state.hadDcdHigh;
233
- this.serialReceiveRate = state.serialReceiveRate;
234
- this.serialReceiveCyclesPerByte = state.serialReceiveCyclesPerByte;
243
+ // The byte times are derived from the rates and the restored word format, not saved.
244
+ this.setSerialReceive(state.serialReceiveRate);
245
+ // Snapshots predating transmit timing have no saved rate.
246
+ this.setSerialTransmit(state.serialTransmitRate ?? 19200);
235
247
  this.updateIrq();
236
248
 
237
249
  this.txCompleteTask.cancel();
@@ -294,7 +306,8 @@ export class Acia {
294
306
  parityBits = 1;
295
307
  break;
296
308
  }
297
- return wordLength + stopBits + parityBits;
309
+ const startBits = 1;
310
+ return startBits + wordLength + stopBits + parityBits;
298
311
  }
299
312
 
300
313
  rts() {
@@ -307,6 +320,11 @@ export class Acia {
307
320
  this.serialReceiveCyclesPerByte = this.secondsToCycles(this.numBitsPerByte() / rate);
308
321
  }
309
322
 
323
+ setSerialTransmit(rate) {
324
+ this.serialTransmitRate = rate;
325
+ this.serialTransmitCyclesPerByte = this.secondsToCycles(this.numBitsPerByte() / rate);
326
+ }
327
+
310
328
  runTape() {
311
329
  if (this.tape) this.runTapeTask.reschedule(this.tape.poll(this));
312
330
  }
package/src/config.js CHANGED
@@ -1,7 +1,14 @@
1
1
  "use strict";
2
- import { allModels, findModel } from "./models.js";
2
+ import { allModels, findModel, tubeModelFor } from "./models.js";
3
3
  import { getFilterForMode } from "./canvas.js";
4
4
 
5
+ const round = (value) => Number(value.toFixed(2));
6
+
7
+ /** @returns {string} the speed a multiplier gives this machine's co-processor, e.g. "1.6x (4.8MHz)". */
8
+ export function tubeCpuSpeedLabel(multiplier, model) {
9
+ return `${round(multiplier)}x (${round(multiplier * tubeModelFor(model).clockMhz)}MHz)`;
10
+ }
11
+
5
12
  /**
6
13
  * The sideways ROMs the optional fittings need, in the order they claim banks.
7
14
  *
@@ -100,6 +107,7 @@ export class Config extends EventTarget {
100
107
  if (!link) return;
101
108
  this.changed.model = link.dataset.target;
102
109
  this.setDropdownText(link.textContent);
110
+ this.showTubeCpuMultiplier(this.tubeCpuMultiplier, findModel(link.dataset.target));
103
111
  this.showRestartPending();
104
112
  });
105
113
 
@@ -113,8 +121,8 @@ export class Config extends EventTarget {
113
121
  }
114
122
 
115
123
  document.getElementById("tubeCpuMultiplier").addEventListener("input", () => {
116
- const val = parseInt(document.getElementById("tubeCpuMultiplier").value, 10);
117
- document.getElementById("tubeCpuMultiplierValue").textContent = val;
124
+ const val = parseFloat(document.getElementById("tubeCpuMultiplier").value);
125
+ this.showTubeCpuMultiplier(val);
118
126
  this.changed.tubeCpuMultiplier = val;
119
127
  });
120
128
 
@@ -193,7 +201,11 @@ export class Config extends EventTarget {
193
201
  setTubeCpuMultiplier(value) {
194
202
  this.tubeCpuMultiplier = value;
195
203
  document.getElementById("tubeCpuMultiplier").value = value;
196
- document.getElementById("tubeCpuMultiplierValue").textContent = value;
204
+ this.showTubeCpuMultiplier(value);
205
+ }
206
+
207
+ showTubeCpuMultiplier(value, model = this.model) {
208
+ document.getElementById("tubeCpuMultiplierValue").textContent = tubeCpuSpeedLabel(value, model);
197
209
  }
198
210
 
199
211
  setDropdownText(modelName) {
package/src/disc.js CHANGED
@@ -672,11 +672,56 @@ export function loadAdf(disc, data, isDsd) {
672
672
  return disc;
673
673
  }
674
674
 
675
+ /** Why a sector will not fit in an SSD or DSD image, or null if it will. */
676
+ function sectorShortfall(sector, trackNum) {
677
+ if (sector.hasDataCrcError || sector.hasHeaderCrcError) return "with a CRC error";
678
+ // A header whose data mark never arrives leaves the sector with nothing to write.
679
+ if (!sector.sectorData) return "with no data";
680
+ if (sector.sectorNumber >= SsdFormat.sectorsPerTrack)
681
+ return `numbered past the ${SsdFormat.sectorsPerTrack} a track holds`;
682
+ if (sector.sectorData.length !== SsdFormat.sectorSize) return `not ${SsdFormat.sectorSize} bytes`;
683
+ if (trackNum >= SsdFormat.tracksPerDisc) return `past track ${SsdFormat.tracksPerDisc}`;
684
+ return null;
685
+ }
686
+
687
+ /**
688
+ * SSD and DSD images hold sector contents and nothing else, so anything a DFS sector could not
689
+ * have held is lost. Copy protection usually shows up as one of these.
690
+ *
691
+ * @returns {string[]} what `disc` holds that an SSD or DSD cannot, worst first
692
+ * @param {Disc} disc
693
+ */
694
+ export function ssdOrDsdShortfalls(disc) {
695
+ const counts = new Map();
696
+ for (let trackNum = 0; trackNum < disc.tracksUsed; ++trackNum) {
697
+ for (const upper of disc.isDoubleSided ? [false, true] : [false]) {
698
+ for (const sector of disc.getTrack(upper, trackNum).findSectors()) {
699
+ const shortfall = sectorShortfall(sector, trackNum);
700
+ if (shortfall) counts.set(shortfall, (counts.get(shortfall) ?? 0) + 1);
701
+ }
702
+ }
703
+ }
704
+ return [...counts]
705
+ .sort(([, a], [, b]) => b - a)
706
+ .map(([shortfall, count]) => `${count} sector${count === 1 ? "" : "s"} ${shortfall}`);
707
+ }
708
+
675
709
  /**
676
710
  * @returns {Uint8Array}
677
711
  * @param {Disc} disc
712
+ * @param {object} [options]
713
+ * @param {boolean} [options.force] save what fits instead of refusing a disc that will not fit
714
+ * @throws if the disc holds anything an SSD or DSD cannot, and `force` is not set
678
715
  */
679
- export function toSsdOrDsd(disc) {
716
+ export function toSsdOrDsd(disc, { force = false } = {}) {
717
+ if (!force) {
718
+ const shortfalls = ssdOrDsdShortfalls(disc);
719
+ if (shortfalls.length)
720
+ throw new Error(
721
+ `This disc cannot be saved as SSD or DSD: it has ${shortfalls.join(", ")}. ` +
722
+ `Save it as HFE to keep everything.`,
723
+ );
724
+ }
680
725
  const numSides = disc.isDoubleSided ? 2 : 1;
681
726
  const result = new Uint8Array(
682
727
  numSides * SsdFormat.tracksPerDisc * SsdFormat.sectorsPerTrack * SsdFormat.sectorSize,
@@ -686,11 +731,8 @@ export function toSsdOrDsd(disc) {
686
731
  for (let side = 0; side < numSides; ++side) {
687
732
  const trackObj = disc.getTrack(side === 1, trackNum);
688
733
  for (const sector of trackObj.findSectors()) {
734
+ if (sectorShortfall(sector, trackNum)) continue;
689
735
  const sectorOffset = offset + sector.sectorNumber * SsdFormat.sectorSize;
690
- if (sector.hasDataCrcError || sector.hasHeaderCrcError) {
691
- console.log(`Skipping sector ${sector.description} with bad CRC`);
692
- continue;
693
- }
694
736
  for (let x = 0; x < SsdFormat.sectorSize; ++x) result[sectorOffset + x] = sector.sectorData[x];
695
737
  }
696
738
  offset += SsdFormat.sectorsPerTrack * SsdFormat.sectorSize;
package/src/dom-utils.js CHANGED
@@ -30,3 +30,19 @@ export function fadeOut(el, duration = 400) {
30
30
  if (el.style.opacity === "0") el.style.display = "none";
31
31
  }, duration);
32
32
  }
33
+
34
+ // Safari fetches the blob a task or more after the click, so the URL must outlive it.
35
+ // 40s matches FileSaver.js.
36
+ const BlobUrlLifetimeMs = 40000;
37
+
38
+ /** Save a blob to the user's downloads under the given file name. */
39
+ export function downloadBlob(blob, fileName) {
40
+ const url = URL.createObjectURL(blob);
41
+ const a = document.createElement("a");
42
+ a.href = url;
43
+ a.download = fileName;
44
+ document.body.appendChild(a);
45
+ a.click();
46
+ a.remove();
47
+ setTimeout(() => URL.revokeObjectURL(url), BlobUrlLifetimeMs);
48
+ }
package/src/fake6502.js CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
  import { FakeVideo } from "./video.js";
5
5
  import { FakeSoundChip } from "./soundchip.js";
6
- import { TEST_6502, TEST_65C02, TEST_65C12, TubeModel } from "./models.js";
6
+ import { TEST_6502, TEST_65C02, TEST_65C12, tubeModelFor } from "./models.js";
7
7
  import { FakeDdNoise } from "./ddnoise.js";
8
8
  import { FakeRelayNoise } from "./relaynoise.js";
9
9
  import { Cpu6502, AtomCpu6502 } from "./6502.js";
@@ -30,7 +30,7 @@ export function fake6502(model, opts) {
30
30
  cmos: new Cmos(),
31
31
  cycleAccurate: opts.cycleAccurate,
32
32
  config: {
33
- tube: opts.tube ? TubeModel : null,
33
+ tube: opts.tube ? tubeModelFor(model) : null,
34
34
  tubeCpuMultiplier: opts.tubeCpuMultiplier,
35
35
  cpuMultiplier: opts.cpuMultiplier,
36
36
  hasTeletextAdaptor: opts.hasTeletextAdaptor,
package/src/gamepads.js CHANGED
@@ -39,16 +39,21 @@ export class GamePad {
39
39
  this.gamepadAxisMapping[3][-1] = BBC.COLON_STAR; // up
40
40
  this.gamepadAxisMapping[3][1] = BBC.SLASH; // down
41
41
  */
42
+ /**
43
+ * Maps a gamepad button or stick direction to a BBC key.
44
+ * @param {string} gamepadKey - the gamepad control, eg `FIRE2`
45
+ * @param {string} bbcKey - the BBC key to press, eg `RETURN`
46
+ * @returns {?string} a description of the problem, or null if the mapping was applied
47
+ */
42
48
  remap(gamepadKey, bbcKey) {
43
49
  // convert "1" into "K1"
44
- if ("0123456789".indexOf(bbcKey) > 0) {
50
+ if (bbcKey.length === 1 && bbcKey >= "0" && bbcKey <= "9") {
45
51
  bbcKey = "K" + bbcKey;
46
52
  }
47
53
 
48
54
  const mappedBbcKey = BBC[bbcKey];
49
55
  if (!mappedBbcKey) {
50
- console.log("unknown BBC key: " + bbcKey);
51
- return;
56
+ return `unknown BBC key "${bbcKey}".`;
52
57
  }
53
58
 
54
59
  switch (gamepadKey) {
@@ -152,8 +157,10 @@ export class GamePad {
152
157
  this.gamepadMapping[6] = mappedBbcKey;
153
158
  break;
154
159
  default:
155
- console.log("unknown gamepad key: " + gamepadKey);
160
+ return `unknown gamepad control "${gamepadKey}".`;
156
161
  }
162
+
163
+ return null;
157
164
  }
158
165
 
159
166
  update(sysvia) {
package/src/main.js CHANGED
@@ -7,7 +7,7 @@ import "./jsbeeb.css";
7
7
  import * as utils from "./utils.js";
8
8
  import { FakeVideo, Video } from "./video.js";
9
9
  import { Debugger } from "./web/debug.js";
10
- import { Cpu6502, AtomCpu6502, DefaultTubeCpuMultiplier } from "./6502.js";
10
+ import { Cpu6502, AtomCpu6502 } from "./6502.js";
11
11
  import * as utils_atom from "./utils_atom.js";
12
12
  import { LoadSD } from "./mmc.js";
13
13
  import { Cmos } from "./cmos.js";
@@ -19,7 +19,7 @@ import { GoogleDriveLoader } from "./google-drive.js";
19
19
  import * as tokeniser from "./basic-tokenise.js";
20
20
  import * as canvasLib from "./canvas.js";
21
21
  import { Config } from "./config.js";
22
- import { TubeModel } from "./models.js";
22
+ import { tubeModelFor } from "./models.js";
23
23
  import { initialise as electron } from "./app/electron.js";
24
24
  import { AudioHandler } from "./web/audio-handler.js";
25
25
  import { Econet } from "./econet.js";
@@ -44,6 +44,7 @@ import { isBemSnapshot, parseBemSnapshot } from "./bem-snapshot.js";
44
44
  import { isUefSnapshot, parseUefSnapshot } from "./uef-snapshot.js";
45
45
  import { RewindBuffer } from "./rewind.js";
46
46
  import { RewindUI } from "./rewind-ui.js";
47
+ import { downloadBlob } from "./dom-utils.js";
47
48
  import {
48
49
  buildUrlFromParams,
49
50
  guessModelFromHostname,
@@ -51,7 +52,7 @@ import {
51
52
  parseMediaParams,
52
53
  parseQueryString,
53
54
  processAutobootParams,
54
- processKeyboardParams,
55
+ processInputParams,
55
56
  } from "./url-params.js";
56
57
 
57
58
  let processor;
@@ -138,7 +139,7 @@ const paramTypes = {
138
139
  audiofilterfreq: ParamTypes.FLOAT,
139
140
  audiofilterq: ParamTypes.FLOAT,
140
141
  cpuMultiplier: ParamTypes.FLOAT,
141
- tubeCpuMultiplier: ParamTypes.INT,
142
+ tubeCpuMultiplier: ParamTypes.FLOAT,
142
143
  microphoneChannel: ParamTypes.INT,
143
144
 
144
145
  // String parameters (these are the default but listed for clarity)
@@ -176,9 +177,6 @@ const { discImage: queryDiscImage, secondDiscImage: querySecondDisc, mmcImage }
176
177
  if (queryDiscImage) discImage = queryDiscImage;
177
178
  if (querySecondDisc) secondDiscImage = querySecondDisc;
178
179
 
179
- // Process keyboard mappings
180
- parsedQuery = processKeyboardParams(parsedQuery, BBC, keyCodes, utils.userKeymap, gamepad);
181
-
182
180
  // Handle specific query parameters
183
181
  if (Array.isArray(parsedQuery.rom)) {
184
182
  parsedQuery.rom.forEach((romPath) => {
@@ -284,7 +282,7 @@ config.mapLegacyModels(parsedQuery);
284
282
 
285
283
  config.setModel(parsedQuery.model || guessModelFromHostname(window.location.hostname));
286
284
  config.setKeyLayout(keyLayout);
287
- config.setTubeCpuMultiplier(parsedQuery.tubeCpuMultiplier || DefaultTubeCpuMultiplier);
285
+ config.setTubeCpuMultiplier(parsedQuery.tubeCpuMultiplier || 1);
288
286
  config.setMicrophoneChannel(parsedQuery.microphoneChannel);
289
287
  config.setCheckboxes({
290
288
  coProcessor: !!parsedQuery.coProcessor,
@@ -299,13 +297,22 @@ config.setDisplayMode(displayMode);
299
297
 
300
298
  model = config.model;
301
299
 
300
+ // Must come after we know the model, to validate names against those of the hardware.
301
+ const keyMappingWarnings = processInputParams(
302
+ parsedQuery,
303
+ model.isAtom ? utils_atom.ATOM : BBC,
304
+ keyCodes,
305
+ utils.userKeymap,
306
+ gamepad,
307
+ );
308
+
302
309
  // Depends on the config.setX calls above having applied the URL parameters.
303
310
  const emulationConfig = {
304
311
  keyLayout,
305
312
  cpuMultiplier,
306
313
  tubeCpuMultiplier: config.tubeCpuMultiplier,
307
314
  videoCyclesBatch: parsedQuery.videoCyclesBatch,
308
- tube: config.coProcessor ? TubeModel : null,
315
+ tube: config.coProcessor ? tubeModelFor(config.model) : null,
309
316
  hasMusic5000: config.hasMusic5000,
310
317
  hasTeletextAdaptor: config.hasTeletextAdaptor,
311
318
  // ROM order determines sideways bank allocation, and the fittings' ROMs claim banks
@@ -345,7 +352,7 @@ sbBind(document.querySelector(".sidebar.bottom"), parsedQuery.sbBottom, function
345
352
  });
346
353
 
347
354
  if (cpuMultiplier !== 1) console.log(`CPU multiplier set to ${cpuMultiplier}`);
348
- const cpuSpeed = model.isAtom ? 1 * 1000 * 1000 : 2 * 1000 * 1000;
355
+ const cpuSpeed = model.clockMhz * 1000 * 1000;
349
356
  const clocksPerSecond = (cpuMultiplier * cpuSpeed) | 0;
350
357
  const MaxCyclesPerFrame = clocksPerSecond / 10;
351
358
 
@@ -374,6 +381,10 @@ function showError(context, error) {
374
381
  errorDialogModal.show();
375
382
  }
376
383
 
384
+ if (keyMappingWarnings.length) {
385
+ showError("applying the key mappings in the URL", keyMappingWarnings.join(" "));
386
+ }
387
+
377
388
  function createCanvasForFilter(filterClass) {
378
389
  const newCanvas = tryGl ? canvasLib.bestCanvas(screenCanvas, filterClass) : new canvasLib.Canvas(screenCanvas);
379
390
 
@@ -471,18 +482,8 @@ function replaceOrAddExtension(name, newExt) {
471
482
  * @param {string} extension - The file extension to use
472
483
  */
473
484
  function downloadDriveData(data, name, extension) {
474
- const a = document.createElement("a");
475
- document.body.appendChild(a);
476
- a.style = "display: none";
477
-
478
- const fileName = replaceOrAddExtension(name, extension);
479
485
  const blob = new Blob([data], { type: "application/octet-stream" });
480
- const url = window.URL.createObjectURL(blob);
481
-
482
- a.href = url;
483
- a.download = fileName;
484
- a.click();
485
- window.URL.revokeObjectURL(url);
486
+ downloadBlob(blob, replaceOrAddExtension(name, extension));
486
487
  }
487
488
 
488
489
  async function loadHTMLFile(file) {
@@ -667,6 +668,8 @@ processor = new CpuClass(model, {
667
668
  econet,
668
669
  });
669
670
 
671
+ processor.teletextAdaptor?.addEventListener("showError", (e) => showError(e.detail.context, e.detail.error));
672
+
670
673
  // Create input sources
671
674
  const gamepadSource = new GamepadSource(emulationConfig.getGamepads);
672
675
  // Create MicrophoneInput but don't enable by default
@@ -681,18 +684,16 @@ const mouseJoystickSource = new MouseJoystickSource(screenCanvas);
681
684
  /**
682
685
  * Attach an RS-423 composite handler to the ACIA that combines the touchscreen
683
686
  * (which sends position data to the BBC) with the speech output (which speaks
684
- * text the BBC sends out). Call this once after processor.initialise() and
685
- * again whenever speechOutput.enabled changes.
687
+ * text the BBC sends out).
686
688
  */
687
689
  function setupRs423Handler() {
688
- const touchScreen = processor.touchScreen;
689
690
  processor.acia.setRs423Handler({
690
691
  onTransmit(val) {
691
- touchScreen.onTransmit(val);
692
+ processor.touchScreen.onTransmit(val);
692
693
  speechOutput.onTransmit(val);
693
694
  },
694
695
  tryReceive(rts) {
695
- return touchScreen.tryReceive(rts);
696
+ return processor.touchScreen.tryReceive(rts);
696
697
  },
697
698
  });
698
699
  }
@@ -1445,7 +1446,12 @@ document.querySelector("#google-drive form").addEventListener("submit", async fu
1445
1446
  let data;
1446
1447
  if (document.querySelector("#google-drive .create-from-existing").checked) {
1447
1448
  const discType = disc.guessDiscTypeFromName(name);
1448
- data = discType.saver(processor.fdc.drives[0].disc);
1449
+ try {
1450
+ data = discType.saver(processor.fdc.drives[0].disc);
1451
+ } catch (e) {
1452
+ loadingFinished(`Create failed: ${e.message}`);
1453
+ return;
1454
+ }
1449
1455
  name = replaceOrAddExtension(name, discType.extension);
1450
1456
  console.log(`Saving existing disc: ${name}`);
1451
1457
  } else {
@@ -1474,11 +1480,15 @@ document.querySelector("#google-drive form").addEventListener("submit", async fu
1474
1480
 
1475
1481
  document.getElementById("download-drive-link").addEventListener("click", function () {
1476
1482
  const disc = processor.fdc.drives[0].disc;
1477
- const data = toSsdOrDsd(disc);
1478
- const name = disc.name;
1479
- const extension = disc.isDoubleSided ? ".dsd" : ".ssd";
1480
-
1481
- downloadDriveData(data, name, extension);
1483
+ const save = (options) =>
1484
+ downloadDriveData(toSsdOrDsd(disc, options), disc.name, disc.isDoubleSided ? ".dsd" : ".ssd");
1485
+ try {
1486
+ save();
1487
+ } catch (e) {
1488
+ areYouSure(`${e.message} Save anyway, losing what will not fit?`, "Save anyway", "Cancel", () =>
1489
+ save({ force: true }),
1490
+ );
1491
+ }
1482
1492
  });
1483
1493
 
1484
1494
  document.getElementById("download-drive-hfe-link").addEventListener("click", function () {
@@ -1539,13 +1549,8 @@ document.getElementById("save-state").addEventListener("click", async function (
1539
1549
  const snapshot = createSnapshot(processor, model, Object.keys(media).length > 0 ? media : undefined);
1540
1550
  const json = snapshotToJSON(snapshot);
1541
1551
  const blob = await compressBlob(new Blob([json]));
1542
- const url = URL.createObjectURL(blob);
1543
1552
  const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
1544
- const a = document.createElement("a");
1545
- a.href = url;
1546
- a.download = `jsbeeb-${model.name}-${timestamp}.json.gz`;
1547
- a.click();
1548
- URL.revokeObjectURL(url);
1553
+ downloadBlob(blob, `jsbeeb-${model.name}-${timestamp}.json.gz`);
1549
1554
  } catch (e) {
1550
1555
  showError("saving state", e);
1551
1556
  }
package/src/models.js CHANGED
@@ -19,10 +19,12 @@ const CpuModel = Object.freeze({
19
19
  * to any number of machines without one session's settings leaking into the next.
20
20
  */
21
21
  class Model {
22
- constructor({ name, synonyms, os, cpuModel, isMaster, isAtom, swram, fdc, cmosOverride, banks } = {}) {
22
+ constructor({ name, synonyms, os, cpuModel, isMaster, isAtom, swram, fdc, cmosOverride, banks, clockMhz } = {}) {
23
+ if (!(clockMhz > 0)) throw new Error(`Model ${name} has no clock speed`);
23
24
  this.name = name;
24
25
  this.synonyms = synonyms;
25
26
  this.os = os;
27
+ this.clockMhz = clockMhz;
26
28
  this.banks = banks;
27
29
  this._cpuModel = cpuModel;
28
30
  this.isMaster = isMaster;
@@ -72,6 +74,7 @@ function atomModel({ name, synonyms, os, banks }) {
72
74
  os,
73
75
  cpuModel: CpuModel.MOS6502,
74
76
  isMaster: false,
77
+ clockMhz: 1,
75
78
  isAtom: true,
76
79
  swram: beebSwram,
77
80
  fdc: NoiseAwareIntelFdc,
@@ -124,6 +127,7 @@ export const allModels = [
124
127
  os: ["os.rom", "BASIC.ROM", "b/DFS-1.2.rom"],
125
128
  cpuModel: CpuModel.MOS6502,
126
129
  isMaster: false,
130
+ clockMhz: 2,
127
131
  swram: beebSwram,
128
132
  fdc: NoiseAwareIntelFdc,
129
133
  }),
@@ -133,6 +137,7 @@ export const allModels = [
133
137
  os: ["os.rom", "BASIC.ROM", "b/DFS-0.9.rom"],
134
138
  cpuModel: CpuModel.MOS6502,
135
139
  isMaster: false,
140
+ clockMhz: 2,
136
141
  swram: beebSwram,
137
142
  fdc: NoiseAwareIntelFdc,
138
143
  }),
@@ -142,6 +147,7 @@ export const allModels = [
142
147
  os: ["os.rom", "BASIC.ROM", "b1770/dfs1770.rom", "b1770/zADFS.ROM"],
143
148
  cpuModel: CpuModel.MOS6502,
144
149
  isMaster: false,
150
+ clockMhz: 2,
145
151
  swram: beebSwram,
146
152
  fdc: NoiseAwareWdFdc,
147
153
  }),
@@ -152,6 +158,7 @@ export const allModels = [
152
158
  os: ["os.rom", "BASIC.ROM", "b1770/zADFS.ROM", "b1770/dfs1770.rom"],
153
159
  cpuModel: CpuModel.MOS6502,
154
160
  isMaster: false,
161
+ clockMhz: 2,
155
162
  swram: beebSwram,
156
163
  fdc: NoiseAwareWdFdc,
157
164
  }),
@@ -161,6 +168,7 @@ export const allModels = [
161
168
  os: ["master/mos3.20"],
162
169
  cpuModel: CpuModel.CMOS65C12,
163
170
  isMaster: true,
171
+ clockMhz: 2,
164
172
  swram: masterSwram,
165
173
  fdc: NoiseAwareWdFdc,
166
174
  cmosOverride: pickDfs,
@@ -171,6 +179,7 @@ export const allModels = [
171
179
  os: ["master/mos3.20"],
172
180
  cpuModel: CpuModel.CMOS65C12,
173
181
  isMaster: true,
182
+ clockMhz: 2,
174
183
  swram: masterSwram,
175
184
  fdc: NoiseAwareWdFdc,
176
185
  cmosOverride: pickAdfs,
@@ -181,6 +190,7 @@ export const allModels = [
181
190
  os: ["master/mos3.20"],
182
191
  cpuModel: CpuModel.CMOS65C12,
183
192
  isMaster: true,
193
+ clockMhz: 2,
184
194
  swram: masterSwram,
185
195
  fdc: NoiseAwareWdFdc,
186
196
  cmosOverride: pickAnfs,
@@ -210,13 +220,24 @@ export const allModels = [
210
220
  synonyms: ["Atom-DOS"],
211
221
  os: ["atom/Atom_Kernel.rom", "atom/Atom_DOS.rom", "atom/Atom_FloatingPoint.rom", "atom/Atom_Basic.rom"],
212
222
  }),
213
- // Although this can not be explicitly selected as a model, it is required by the configuration builder later
223
+ // Neither can be selected as a model: they are fitted to one, by the configuration builder later.
214
224
  new Model({
215
225
  name: "Tube65C02",
216
226
  synonyms: [],
217
227
  os: ["tube/6502Tube.rom"],
228
+ // TODO(#746): the external second processor was an NMOS 6502A.
218
229
  cpuModel: CpuModel.CMOS65C02,
219
230
  isMaster: false,
231
+ clockMhz: 3,
232
+ }),
233
+ new Model({
234
+ name: "Tube65C102",
235
+ synonyms: [],
236
+ os: ["tube/65C102Tube.rom"],
237
+ // TODO(#746): Acorn's 65C102 has no Rockwell bit instructions.
238
+ cpuModel: CpuModel.CMOS65C02,
239
+ isMaster: false,
240
+ clockMhz: 4,
220
241
  }),
221
242
  ];
222
243
 
@@ -236,6 +257,7 @@ export const TEST_6502 = new Model({
236
257
  name: "TEST",
237
258
  synonyms: ["TEST"],
238
259
  os: [],
260
+ clockMhz: 2,
239
261
  cpuModel: CpuModel.MOS6502,
240
262
  isMaster: false,
241
263
  swram: beebSwram,
@@ -246,6 +268,7 @@ export const TEST_65C02 = new Model({
246
268
  name: "TEST",
247
269
  synonyms: ["TEST"],
248
270
  os: [],
271
+ clockMhz: 2,
249
272
  cpuModel: CpuModel.CMOS65C02,
250
273
  isMaster: false,
251
274
  swram: masterSwram,
@@ -256,6 +279,7 @@ export const TEST_65C12 = new Model({
256
279
  name: "TEST",
257
280
  synonyms: ["TEST"],
258
281
  os: [],
282
+ clockMhz: 2,
259
283
  cpuModel: CpuModel.CMOS65C12,
260
284
  isMaster: false,
261
285
  swram: masterSwram,
@@ -265,6 +289,7 @@ TEST_65C12.isTest = true;
265
289
 
266
290
  export const basicOnly = new Model({
267
291
  name: "Basic only",
292
+ clockMhz: 2,
268
293
  synonyms: ["Basic only"],
269
294
  os: ["master/mos3.20"],
270
295
  cpuModel: CpuModel.CMOS65C12,
@@ -274,11 +299,18 @@ export const basicOnly = new Model({
274
299
  });
275
300
 
276
301
  /**
277
- * The only second processor jsbeeb emulates. Machine-building code passes this as the
278
- * emulation config's `tube`, so that 6502.js needn't import this module and close an
279
- * import cycle via the FDC modules.
302
+ * The second processors jsbeeb emulates: the external 3MHz box, and the Master Turbo's
303
+ * internal 4MHz board. Machine-building code passes one of these as the emulation config's
304
+ * `tube`, so that 6502.js needn't import this module and close an import cycle via the FDC
305
+ * modules.
280
306
  */
281
307
  export const TubeModel = findModel("Tube65C02");
308
+ export const TurboTubeModel = findModel("Tube65C102");
309
+
310
+ /** @returns {Model} the second processor sold for this machine: the Turbo board for a Master. */
311
+ export function tubeModelFor(model) {
312
+ return model.isMaster ? TurboTubeModel : TubeModel;
313
+ }
282
314
 
283
315
  // After the isTest assignments above, so those still apply.
284
316
  for (const model of [...allModels, TEST_6502, TEST_65C02, TEST_65C12, basicOnly]) {
package/src/serial.js CHANGED
@@ -20,6 +20,7 @@ export class Serial {
20
20
  this.transmitRate = val & 0x07;
21
21
  this.receiveRate = (val >>> 3) & 0x07;
22
22
  this.acia.setSerialReceive(table[this.receiveRate]);
23
+ this.acia.setSerialTransmit(table[this.transmitRate]);
23
24
  this.acia.setMotor(!!(val & 0x80));
24
25
  this.acia.selectRs423(!!(val & 0x40));
25
26
  }
@@ -25,7 +25,7 @@ export const DefaultAcia = {
25
25
  tapeDcdLineLevel: false,
26
26
  hadDcdHigh: false,
27
27
  serialReceiveRate: 19200,
28
- serialReceiveCyclesPerByte: 0,
28
+ serialTransmitRate: 19200,
29
29
  txCompleteTaskOffset: null,
30
30
  runTapeTaskOffset: null,
31
31
  runRs423TaskOffset: null,
@@ -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) {
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;