jsbeeb 1.22.4 → 1.24.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.
Files changed (112) hide show
  1. package/README.md +2 -2
  2. package/package.json +16 -11
  3. package/src/6502.js +39 -42
  4. package/src/6502.opcodes.js +198 -123
  5. package/src/6847.js +4 -5
  6. package/src/6847_fontdata.js +0 -2
  7. package/src/acia.js +2 -3
  8. package/src/app/app.js +0 -1
  9. package/src/app/args.js +0 -2
  10. package/src/app/electron.js +4 -6
  11. package/src/app-bench.js +1 -3
  12. package/src/archive.js +243 -0
  13. package/src/basic-loader.js +0 -7
  14. package/src/basic-tokenise.js +19 -6
  15. package/src/bbc-palette.js +0 -2
  16. package/src/bbcdiscs.js +0 -2
  17. package/src/bem-snapshot.js +1 -3
  18. package/src/binary.js +175 -0
  19. package/src/cmos.js +0 -2
  20. package/src/ddnoise.js +0 -1
  21. package/src/debounce.js +7 -0
  22. package/src/disc-surface.js +0 -2
  23. package/src/disc.js +10 -9
  24. package/src/fake6502.js +7 -23
  25. package/src/fdc.js +9 -51
  26. package/src/filestore.js +10 -17
  27. package/src/hex.js +38 -0
  28. package/src/index.js +11 -0
  29. package/src/intel-fdc.js +18 -20
  30. package/src/jsbeeb.css +33 -0
  31. package/src/{utils_atom.js → keymap-atom.js} +2 -18
  32. package/src/{utils.js → keymap.js} +47 -610
  33. package/src/loader.js +63 -0
  34. package/src/machine-session.js +37 -177
  35. package/src/machine-spec.js +59 -0
  36. package/src/main.js +69 -184
  37. package/src/media-resolver.js +68 -0
  38. package/src/mmc.js +3 -2
  39. package/src/models.js +40 -4
  40. package/src/music5000.js +0 -2
  41. package/src/ppia.js +3 -4
  42. package/src/printer.js +7 -14
  43. package/src/relaynoise.js +0 -1
  44. package/src/rewind.js +0 -2
  45. package/src/sample-player.js +2 -3
  46. package/src/serial.js +0 -2
  47. package/src/snapshot-helpers.js +0 -2
  48. package/src/snapshot.js +0 -2
  49. package/src/soundchip.js +1 -1
  50. package/src/state-utils.js +0 -2
  51. package/src/sth.js +2 -4
  52. package/src/tapes.js +10 -4
  53. package/src/teletext.js +2 -3
  54. package/src/teletext_adaptor.js +2 -4
  55. package/src/teletext_data.js +0 -2
  56. package/src/test-machine.js +307 -0
  57. package/src/touchscreen.js +2 -4
  58. package/src/tube.js +5 -6
  59. package/src/typist.js +103 -0
  60. package/src/uef-snapshot.js +0 -2
  61. package/src/url-params.js +4 -5
  62. package/src/vdu-capture.js +113 -0
  63. package/src/via.js +3 -4
  64. package/src/video-filters/pal-composite.js +0 -2
  65. package/src/video-filters/passthrough-filter.js +0 -2
  66. package/src/video-filters/pixel-grid.js +0 -2
  67. package/src/video-filters/shader-program.js +0 -2
  68. package/src/video-filters/xbr-filter.js +0 -2
  69. package/src/video.js +12 -11
  70. package/src/wd-fdc.js +20 -20
  71. package/src/web/analogue-inputs.js +6 -8
  72. package/src/web/analytics.js +13 -0
  73. package/src/web/audio-handler.js +2 -2
  74. package/src/{audio-utils.js → web/audio-utils.js} +0 -2
  75. package/src/web/autoboot.js +10 -13
  76. package/src/{canvas.js → web/canvas.js} +4 -5
  77. package/src/web/config.js +42 -72
  78. package/src/web/console-surface.js +25 -0
  79. package/src/web/debug.js +8 -49
  80. package/src/web/disc-visualiser.js +0 -2
  81. package/src/web/display.js +21 -4
  82. package/src/{dom-utils.js → web/dom-utils.js} +1 -11
  83. package/src/web/drives.js +1 -1
  84. package/src/web/emulation-loop.js +54 -33
  85. package/src/web/front-panel.js +13 -12
  86. package/src/{gamepads.js → web/gamepads.js} +2 -6
  87. package/src/web/google-drive-picker.js +14 -9
  88. package/src/{google-drive.js → web/google-drive.js} +9 -10
  89. package/src/web/hfe-picker.js +5 -4
  90. package/src/web/icons.js +42 -0
  91. package/src/web/keyboard-setup.js +29 -40
  92. package/src/{keyboard.js → web/keyboard.js} +18 -119
  93. package/src/web/layout.js +4 -0
  94. package/src/web/local-disc.js +44 -0
  95. package/src/web/machine.js +27 -20
  96. package/src/web/media-loader.js +38 -147
  97. package/src/{microphone-input.js → web/microphone-input.js} +1 -1
  98. package/src/web/modals.js +6 -9
  99. package/src/web/page-actions.js +88 -0
  100. package/src/web/quick-settings.js +16 -15
  101. package/src/web/reporting.js +0 -7
  102. package/src/{rewind-thumbnail.js → web/rewind-thumbnail.js} +0 -2
  103. package/src/web/rewind-ui.js +29 -29
  104. package/src/web/run-controls.js +39 -0
  105. package/src/web/settings.js +64 -135
  106. package/src/web/snapshot-ui.js +10 -16
  107. package/src/{speech-output.js → web/speech-output.js} +0 -2
  108. package/src/web/sth-picker.js +9 -7
  109. package/src/web/url-state.js +20 -6
  110. package/src/econet/L3FS.dat +0 -0
  111. package/src/econet/scsi.dat +0 -0
  112. package/tests/test-machine.js +0 -609
package/src/web/config.js CHANGED
@@ -1,6 +1,5 @@
1
- "use strict";
2
1
  import { allModels, findModel, tubeModelFor } from "../models.js";
3
- import { getFilterForMode } from "../canvas.js";
2
+ import { getFilterForMode } from "./canvas.js";
4
3
  import { AudioOutputs } from "../audio-output.js";
5
4
 
6
5
  const round = (value) => Number(value.toFixed(2));
@@ -50,45 +49,45 @@ export function restartPending(settings, running) {
50
49
  return RestartRequiredFields.some((field) => settings[field] !== running[field]);
51
50
  }
52
51
 
52
+ /**
53
+ * The Settings dialog: shows the settings, applies the ones the machine can
54
+ * follow live as they are picked, and saves the rest when it closes. Dispatches
55
+ * "restart-required" after saving a change the running machine cannot follow.
56
+ */
53
57
  export class Config extends EventTarget {
54
- /**
55
- * @param {function(object)} onChange called as soon as a setting the emulator can follow live changes
56
- * @param {function(object)} onClose called with the settings to apply and persist
57
- * @param {function()} onRestartRequired called after the settings have been saved, when some of them
58
- * will only take effect once the machine is rebuilt
59
- */
60
- constructor(onChange, onClose, onRestartRequired) {
58
+ constructor(settings) {
61
59
  super();
62
- this.onChange = onChange;
63
- this.onClose = onClose;
64
- this.onRestartRequired = onRestartRequired;
60
+ this.settings = settings;
65
61
  this.changed = {};
66
- this.model = null;
67
- for (const { field } of CheckboxSettings) this[field] = false;
68
- this.runningSettings = null;
62
+ // Built before anything can change, so this is what the running machine was built with.
63
+ this.runningSettings = this.proposedSettings();
64
+ this.setModel(settings.model);
65
+ this.setKeyLayout(settings.keyLayout);
66
+ this.setMicrophoneChannel(settings.microphoneChannel);
67
+ this.setDisplayMode(settings.displayMode);
68
+ this.setAudioOutput(settings.audioOutput);
69
+ this.setSpeakerAmount(settings.speakerAmount);
70
+ settings.on("audioOutput", (audioOutput) => this.setAudioOutput(audioOutput));
71
+ settings.on("speakerAmount", (speakerAmount) => this.setSpeakerAmount(speakerAmount));
72
+ settings.on("displayMode", (displayMode) => this.setDisplayMode(displayMode));
73
+
69
74
  const configuration = document.getElementById("configuration");
70
75
  configuration.addEventListener("show.bs.modal", () => {
71
76
  this.changed = {};
72
- // The startup settings are pushed in after construction, so what the running machine was
73
- // built with is only knowable from the first time the dialog is opened.
74
- if (!this.runningSettings) this.runningSettings = this.proposedSettings();
75
- this.setDropdownText(this.model.name);
76
- this.setTubeCpuMultiplier(this.tubeCpuMultiplier);
77
- this.setCheckboxes(this);
77
+ this.setDropdownText(settings.model.name);
78
+ this.setTubeCpuMultiplier(settings.tubeCpuMultiplier);
79
+ this.setCheckboxes(settings);
78
80
  this.showRestartPending();
79
81
  });
80
82
 
81
83
  configuration.addEventListener("hide.bs.modal", () => {
82
84
  const changed = this.changed;
83
- // Not setModel: that also renames the machine in the title bar, which has not changed yet.
84
- if (changed.model !== undefined) this.model = findModel(changed.model);
85
- this.setCheckboxes(changed);
86
- this.onClose(changed);
85
+ this.changed = {};
87
86
  if (Object.keys(changed).length === 0) return;
88
- this.dispatchEvent(new CustomEvent("settings-changed", { detail: changed }));
87
+ settings.set(changed);
89
88
  // changed records which controls were touched, so a value in it can be what is already running.
90
89
  if (needsRestart(changed) && restartPending(this.proposedSettings(), this.runningSettings))
91
- this.onRestartRequired();
90
+ this.dispatchEvent(new Event("restart-required"));
92
91
  });
93
92
 
94
93
  const modelMenu = document.querySelector(".model-menu");
@@ -108,7 +107,10 @@ export class Config extends EventTarget {
108
107
  if (!link) return;
109
108
  this.changed.model = link.dataset.target;
110
109
  this.setDropdownText(link.textContent);
111
- this.showTubeCpuMultiplier(this.tubeCpuMultiplier, findModel(link.dataset.target));
110
+ this.showTubeCpuMultiplier(
111
+ this.changed.tubeCpuMultiplier ?? this.settings.tubeCpuMultiplier,
112
+ findModel(link.dataset.target),
113
+ );
112
114
  this.showRestartPending();
113
115
  });
114
116
 
@@ -149,23 +151,18 @@ export class Config extends EventTarget {
149
151
  for (const option of document.querySelectorAll(".audio-output-option")) {
150
152
  option.addEventListener("click", (e) => {
151
153
  e.preventDefault();
152
- const audioOutput = e.currentTarget.dataset.output;
153
- this.changed.audioOutput = audioOutput;
154
- this.onChange({ audioOutput });
154
+ settings.set({ audioOutput: e.currentTarget.dataset.output });
155
155
  });
156
156
  }
157
157
 
158
158
  document.getElementById("speakerAmountSetting").addEventListener("input", (e) => {
159
- this.onChange({ speakerAmount: parseFloat(e.currentTarget.value) });
159
+ settings.set({ speakerAmount: parseFloat(e.currentTarget.value) });
160
160
  });
161
161
 
162
162
  for (const option of document.querySelectorAll(".display-mode-option")) {
163
163
  option.addEventListener("click", (e) => {
164
164
  e.preventDefault();
165
- const mode = e.currentTarget.dataset.mode;
166
- this.changed.displayMode = mode;
167
- this.setDisplayMode(mode);
168
- this.onChange({ displayMode: mode });
165
+ settings.set({ displayMode: e.currentTarget.dataset.mode });
169
166
  });
170
167
  }
171
168
  }
@@ -175,7 +172,7 @@ export class Config extends EventTarget {
175
172
  * menu and the URL use: the model by synonym rather than resolved, the fittings as booleans.
176
173
  */
177
174
  proposedSettings() {
178
- const saved = (field) => (field === "model" ? this.model.synonyms[0] : this[field]);
175
+ const saved = (field) => (field === "model" ? this.settings.model.synonyms[0] : this.settings[field]);
179
176
  return Object.fromEntries(RestartRequiredFields.map((field) => [field, this.changed[field] ?? saved(field)]));
180
177
  }
181
178
 
@@ -184,13 +181,12 @@ export class Config extends EventTarget {
184
181
  document.getElementById("restart-pending").classList.toggle("d-none", !pending);
185
182
  }
186
183
 
187
- /** Ticks the boxes named in `values` and adopts them, leaving any the object does not mention alone. */
184
+ /** Ticks the boxes named in `values`, leaving any the object does not mention alone. */
188
185
  setCheckboxes(values) {
189
186
  for (const { id, field, enables } of CheckboxSettings) {
190
187
  if (values[field] === undefined) continue;
191
188
  const checked = !!values[field];
192
189
  document.getElementById(id).checked = checked;
193
- this[field] = checked;
194
190
  if (enables) document.getElementById(enables).disabled = !checked;
195
191
  }
196
192
  }
@@ -213,9 +209,12 @@ export class Config extends EventTarget {
213
209
  for (const el of document.querySelectorAll(".display-mode-text")) el.textContent = config.name;
214
210
  }
215
211
 
216
- setModel(modelName) {
217
- this.model = findModel(modelName);
218
- for (const el of document.querySelectorAll(".bbc-model")) el.textContent = this.model.name;
212
+ /** Names the running machine everywhere the page shows it. */
213
+ setModel(model) {
214
+ for (const el of document.querySelectorAll(".bbc-model")) el.textContent = model.name;
215
+ for (const el of document.querySelectorAll(".bbc-model-short")) el.textContent = model.shortName;
216
+ const settingsLink = document.getElementById("model-settings");
217
+ if (settingsLink) settingsLink.title = `${model.name}: emulation settings`;
219
218
  }
220
219
 
221
220
  setKeyLayout(keyLayout) {
@@ -224,12 +223,11 @@ export class Config extends EventTarget {
224
223
  }
225
224
 
226
225
  setTubeCpuMultiplier(value) {
227
- this.tubeCpuMultiplier = value;
228
226
  document.getElementById("tubeCpuMultiplier").value = value;
229
227
  this.showTubeCpuMultiplier(value);
230
228
  }
231
229
 
232
- showTubeCpuMultiplier(value, model = this.model) {
230
+ showTubeCpuMultiplier(value, model = this.settings.model) {
233
231
  document.getElementById("tubeCpuMultiplierValue").textContent = tubeCpuSpeedLabel(value, model);
234
232
  }
235
233
 
@@ -237,32 +235,4 @@ export class Config extends EventTarget {
237
235
  const el = document.querySelector("#bbc-model-dropdown .bbc-model");
238
236
  if (el) el.textContent = modelName;
239
237
  }
240
-
241
- get extraRoms() {
242
- return fittedRoms(this);
243
- }
244
-
245
- mapLegacyModels(parsedQuery) {
246
- if (!parsedQuery.model) {
247
- return;
248
- }
249
-
250
- // "MasterTurbo" = Master + 6502 second processor
251
- if (parsedQuery.model.toLowerCase() === "masterturbo") {
252
- parsedQuery.model = "Master";
253
- parsedQuery.coProcessor = true;
254
- }
255
-
256
- // "BMusic5000" = BBC DFS 1.2 + Music 5000
257
- if (parsedQuery.model.toLowerCase() === "bmusic5000") {
258
- parsedQuery.model = "B-DFS1.2";
259
- parsedQuery.hasMusic5000 = true;
260
- }
261
-
262
- // "BTeletext" = BBC DFS 1.2 + Teletext adaptor
263
- if (parsedQuery.model.toLowerCase() === "bteletext") {
264
- parsedQuery.model = "B-DFS1.2";
265
- parsedQuery.hasTeletextAdaptor = true;
266
- }
267
- }
268
238
  }
@@ -0,0 +1,25 @@
1
+ import { hd } from "../hex.js";
2
+ import { debounce } from "../debounce.js";
3
+
4
+ /**
5
+ * The debugging surface the wiki documents, put on `target` (window in the
6
+ * app). The bench/profile calls are debounced so that they can be safely run
7
+ * from the JS console in firefox.
8
+ */
9
+ export function exposeConsoleSurface(target, { loop, processor, video, audioHandler }) {
10
+ target.benchmarkCpu = debounce((numCycles) => loop.benchmarkCpu(numCycles), 1);
11
+ target.profileCpu = debounce((arg) => loop.profileCpu(arg), 1);
12
+ target.benchmarkVideo = debounce((numCycles) => loop.benchmarkVideo(numCycles), 1);
13
+ target.profileVideo = debounce((arg) => loop.profileVideo(arg), 1);
14
+ target.go = () => loop.go();
15
+ target.stop = (debug) => loop.stop(debug);
16
+ target.soundChip = audioHandler.soundChip;
17
+ target.processor = processor;
18
+ target.video = video;
19
+ target.hd = (start, end) => {
20
+ console.log(hd((x) => processor.readmem(x), start, end));
21
+ };
22
+ target.m7dump = () => {
23
+ console.log(hd((x) => processor.readmem(x) & 0x7f, 0x7c00, 0x7fe8, { width: 40, gap: false }));
24
+ };
25
+ }
package/src/web/debug.js CHANGED
@@ -1,6 +1,7 @@
1
- "use strict";
2
- import { hexbyte, hexword, noop, parseAddr } from "../utils.js";
3
- import { toggle } from "../dom-utils.js";
1
+ import { toggle } from "./dom-utils.js";
2
+ import { hexbyte, hexword, parseAddr } from "../hex.js";
3
+
4
+ const noop = () => {};
4
5
 
5
6
  const numToShow = 16;
6
7
 
@@ -60,7 +61,7 @@ class MemoryView {
60
61
  }
61
62
  }
62
63
 
63
- // todo extract memory part from view part and reuse in disassembler etc. also peekmem can go then
64
+ // TODO(#1062) extract memory part from view part and reuse in disassembler etc. also peekmem can go then
64
65
  dump(from, to) {
65
66
  const hex = [];
66
67
  const asc = [];
@@ -89,48 +90,6 @@ class MemoryView {
89
90
  }
90
91
  }
91
92
 
92
- /**
93
- * Guesses the address of the instruction before the given one by scoring each
94
- * possible run of instructions leading up to it: a point per "common"
95
- * instruction (loads, stores, branches, compares, arithmetic and
96
- * carry-set/clear that avoid unusual indexing modes) and a large boost for a
97
- * run that passes through the current program counter. The highest-scoring
98
- * run wins and its last instruction is the answer.
99
- * Good test cases:
100
- * Repton 2 @ 2cbb
101
- * MOS @ cfc8
102
- * also, just starting from the back of ROM and going up...
103
- */
104
- export function prevInstruction(disassemble, pc, address) {
105
- const commonInstructions =
106
- /(RTS|B..|JMP|JSR|LD[AXY]|ST[AXY]|TA[XY]|T[XY]A|AD[DC]|SUB|SBC|CLC|SEC|CMP|EOR|ORR|AND|INC|DEC).*/;
107
- const uncommonInstrucions = /.*,\s*([XY]|X\))$/;
108
-
109
- address &= 0xffff;
110
- let bestAddr = address - 1;
111
- let bestScore = 0;
112
- for (let startingPoint = address - 20; startingPoint !== address; startingPoint++) {
113
- let score = 0;
114
- let addr = startingPoint & 0xffff;
115
- while (addr < address) {
116
- const result = disassemble(addr);
117
- if (addr === pc) score += 10;
118
- if (result[0].match(commonInstructions) && !result[0].match(uncommonInstrucions)) {
119
- score++;
120
- }
121
- if (result[1] === address) {
122
- if (score > bestScore) {
123
- bestScore = score;
124
- bestAddr = addr;
125
- break;
126
- }
127
- }
128
- addr = result[1];
129
- }
130
- }
131
- return bestAddr;
132
- }
133
-
134
93
  export class Debugger {
135
94
  constructor() {
136
95
  this.patchInstructions = new Map();
@@ -424,7 +383,7 @@ export class Debugger {
424
383
  }
425
384
 
426
385
  prevInstruction(address) {
427
- return prevInstruction((addr) => this.disassemble(addr), this.cpu.pc, address);
386
+ return this.cpu.disassembler.prevInstruction(address, this.cpu.pc);
428
387
  }
429
388
 
430
389
  updatePrevMem() {
@@ -436,7 +395,7 @@ export class Debugger {
436
395
  }
437
396
 
438
397
  stepUntil(f) {
439
- this.cpu.targetCycles = this.cpu.currentCycles; // TODO: this prevents the cpu from running any residual cycles. look into a better solution
398
+ this.cpu.targetCycles = this.cpu.currentCycles; // TODO(#1062) this prevents the cpu from running any residual cycles. look into a better solution
440
399
  for (let i = 0; i < 65536; i++) {
441
400
  this.cpu.execute(1);
442
401
  if (f()) break;
@@ -483,7 +442,7 @@ export class Debugger {
483
442
  }
484
443
 
485
444
  nextInstruction(address) {
486
- return this.disassemble(address)[1] & 0xffff;
445
+ return this.cpu.disassembler.nextInstruction(address);
487
446
  }
488
447
 
489
448
  instrClick(e) {
@@ -1,5 +1,3 @@
1
- "use strict";
2
-
3
1
  import { IbmDiscFormat } from "../disc.js";
4
2
  import {
5
3
  DensityPalette,
@@ -1,7 +1,12 @@
1
- import * as canvasLib from "../canvas.js";
1
+ import * as canvasLib from "./canvas.js";
2
2
  import { FakeVideo, Video } from "../video.js";
3
3
  import { toast } from "./toast.js";
4
4
 
5
+ // While running fast the state machines still run accurately, but painting is
6
+ // the most expensive part of jsbeeb, so most frames are skipped. Odd, so that
7
+ // interlaced modes (MODE 7) alternate fields across the frames that do paint.
8
+ const SpeedyFrameSkip = 9;
9
+
5
10
  /**
6
11
  * The picture: the canvas and its filter, the video chip that paints into a
7
12
  * framebuffer of our own, and the animation frame that presents it. A stalled
@@ -64,10 +69,22 @@ export class Display {
64
69
  this.setCrtPic();
65
70
  }
66
71
 
72
+ /**
73
+ * While running fast the skip moves into the video chip, which skips the
74
+ * render as well as the present; the deeper of the chip's skip and any
75
+ * configured frameSkip wins, so the two never multiply together.
76
+ */
77
+ setSpeedy(speedy) {
78
+ const skip = Math.max(SpeedyFrameSkip, this.frameSkip);
79
+ this.video.frameSkipCount = speedy ? (skip % 2 ? skip : skip + 1) : 0;
80
+ }
81
+
67
82
  onPaint(video, minx, miny, maxx, maxy) {
68
- this.frames++;
69
- if (this.frames < this.frameSkip) return;
70
- this.frames = 0;
83
+ if (!video.frameSkipCount) {
84
+ this.frames++;
85
+ if (this.frames < this.frameSkip) return;
86
+ this.frames = 0;
87
+ }
71
88
  const start = performance.now();
72
89
  this.canvas.fb32.set(this.videoFb32.subarray(miny * 1024, maxy * 1024), miny * 1024);
73
90
  if (this.pendingFrame.lineGrid.length !== video.lineGrid.length)
@@ -1,17 +1,7 @@
1
- "use strict";
2
-
3
- import { replaceOrAddExtension } from "./utils.js";
1
+ import { replaceOrAddExtension } from "../archive.js";
4
2
 
5
3
  // Minimal DOM helpers to replace jQuery usage.
6
4
 
7
- export function show(el) {
8
- el.style.display = "";
9
- }
10
-
11
- export function hide(el) {
12
- el.style.display = "none";
13
- }
14
-
15
5
  export function toggle(el, visible) {
16
6
  el.style.display = visible ? "" : "none";
17
7
  }
package/src/web/drives.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { toast } from "./toast.js";
2
2
  import { DiscLayout, toSsdOrDsd } from "../disc.js";
3
3
  import { toHfe } from "../disc-hfe.js";
4
- import { downloadDriveData } from "../dom-utils.js";
4
+ import { downloadDriveData } from "./dom-utils.js";
5
5
  import { DriveTracks } from "../url-params.js";
6
6
 
7
7
  const tracksPerStepFor = (tracks) => (tracks === "40" ? 2 : 1);
@@ -1,4 +1,4 @@
1
- import * as utils from "../utils.js";
1
+ import { noteEvent } from "./analytics.js";
2
2
 
3
3
  // A timer, not requestAnimationFrame: a display presentation stall withholds
4
4
  // animation frames, and with them the sound chip's samples (issue #885).
@@ -49,9 +49,14 @@ class VirtualSpeedUpdater {
49
49
 
50
50
  /**
51
51
  * Runs the machine in real time: the tick that turns wall-clock time into
52
- * cycles, starting and stopping, the audio lead, fast-forward, rewind capture
53
- * and the speed readout. Owns `running`, and dispatches a "running" event
54
- * whenever it changes hands.
52
+ * cycles, starting and stopping, the audio lead, fast-forward and the speed
53
+ * readout. Owns `running`, and dispatches a "running" event whenever it
54
+ * changes hands, "tick" on every tick while running (the first only takes the
55
+ * time), and "rewind-capture" every RewindCaptureInterval frames for whoever
56
+ * keeps the rewind history. Anything that needs the machine held still while
57
+ * it works (a dialog, a snapshot, the rewind panel, a hidden tab) takes a
58
+ * `pause()`; the loop runs again once every hold has let go, provided the user
59
+ * still wants it running.
55
60
  */
56
61
  export class EmulationLoop extends EventTarget {
57
62
  constructor({
@@ -61,9 +66,6 @@ export class EmulationLoop extends EventTarget {
61
66
  dbgr,
62
67
  gamepad,
63
68
  keyboard,
64
- syncLights,
65
- rewindBuffer,
66
- onRewindCaptured,
67
69
  clocksPerSecond,
68
70
  cpuSpeed,
69
71
  fastTape,
@@ -76,9 +78,6 @@ export class EmulationLoop extends EventTarget {
76
78
  this.dbgr = dbgr;
77
79
  this.gamepad = gamepad;
78
80
  this.keyboard = keyboard;
79
- this.syncLights = syncLights;
80
- this.rewindBuffer = rewindBuffer;
81
- this.onRewindCaptured = onRewindCaptured;
82
81
  this.clocksPerSecond = clocksPerSecond;
83
82
  this.maxCyclesPerTick = clocksPerSecond / 10;
84
83
  this.rewindCaptureCycles = (RewindCaptureInterval * clocksPerSecond) / 50;
@@ -92,7 +91,9 @@ export class EmulationLoop extends EventTarget {
92
91
  this.tickToken = null;
93
92
  this.emulationLeadMs = 0;
94
93
  this.rewindCycleCounter = 0;
95
- this.wasPreviouslyRunning = false;
94
+ this.wanted = false;
95
+ this.holds = new Set();
96
+ this.resumeOnVisible = null;
96
97
 
97
98
  this.virtualSpeedUpdater = new VirtualSpeedUpdater(cpuSpeed);
98
99
  this.audioDebugLog = { start: 0, ticks: 0, cycles: 0, maxIdle: 0, maxExecute: 0, maxPaint: 0, maxSnapshot: 0 };
@@ -105,17 +106,47 @@ export class EmulationLoop extends EventTarget {
105
106
  }
106
107
 
107
108
  go() {
109
+ this.wanted = true;
110
+ if (this.holds.size === 0) this.start();
111
+ else
112
+ console.warn(
113
+ `Emulator held by ${[...this.holds].map((hold) => hold.reason).join(", ")}; it runs when they let go`,
114
+ );
115
+ }
116
+
117
+ stop(debug) {
118
+ this.wanted = false;
119
+ this.halt();
120
+ if (debug) this.dbgr.debug(this.processor.pc);
121
+ }
122
+
123
+ /**
124
+ * Holds the machine stopped until the returned function is called. Holds
125
+ * nest, and letting go twice counts once. `reason` names the holder when a
126
+ * go() has to wait, so a hold that is never released can be found.
127
+ */
128
+ pause(reason) {
129
+ if (this.holds.size === 0 && this.running) this.halt();
130
+ const hold = { reason };
131
+ this.holds.add(hold);
132
+ return () => {
133
+ if (!this.holds.delete(hold)) return;
134
+ if (this.holds.size === 0 && this.wanted) this.start();
135
+ };
136
+ }
137
+
138
+ start() {
139
+ if (this.running) return;
108
140
  this.audioHandler.unmute();
109
141
  this.running = true;
110
142
  this.dispatchEvent(new Event("running"));
111
143
  this.run();
112
144
  }
113
145
 
114
- stop(debug) {
146
+ halt() {
115
147
  this.running = false;
116
148
  this.dispatchEvent(new Event("running"));
117
149
  this.processor.stop();
118
- if (debug) this.dbgr.debug(this.processor.pc);
119
150
  this.audioHandler.mute();
120
151
  }
121
152
 
@@ -146,20 +177,15 @@ export class EmulationLoop extends EventTarget {
146
177
  const now = performance.now();
147
178
 
148
179
  const { processor, display, audioHandler } = this;
149
- const motorOn = processor.acia.motorOn;
180
+ const motorOn = processor.tapeInterface.motorOn;
150
181
  const speedy = this.fastAsPossible || (this.fastTape && motorOn);
151
182
 
152
- // In speedy mode, we still run all the state machines accurately
153
- // but we paint less often because painting is the most expensive
154
- // part of jsbeeb at this time.
155
- // We need need to paint per odd number of frames so that interlace
156
- // modes, i.e. MODE 7, still look ok.
157
- display.video.frameSkipCount = speedy ? 9 : 0;
183
+ display.setSpeedy(speedy);
158
184
 
159
185
  this.scheduleTick(speedy ? 0 : TickMs);
160
186
 
161
187
  this.gamepad.update(processor.sysvia);
162
- this.syncLights();
188
+ this.dispatchEvent(new Event("tick"));
163
189
  if (this.last !== 0) {
164
190
  let cycles;
165
191
  if (!speedy) {
@@ -182,8 +208,7 @@ export class EmulationLoop extends EventTarget {
182
208
  this.rewindCycleCounter += cycles;
183
209
  if (this.rewindCycleCounter >= this.rewindCaptureCycles) {
184
210
  this.rewindCycleCounter -= this.rewindCaptureCycles;
185
- this.rewindBuffer.push(processor.snapshotState());
186
- this.onRewindCaptured();
211
+ this.dispatchEvent(new Event("rewind-capture"));
187
212
  snapshotMs = performance.now() - end;
188
213
  }
189
214
  if (this.audioStatsNode)
@@ -197,7 +222,7 @@ export class EmulationLoop extends EventTarget {
197
222
  );
198
223
  } catch (e) {
199
224
  this.running = false;
200
- utils.noteEvent("exception", "thrown", e.stack);
225
+ noteEvent("exception", "thrown", e.stack);
201
226
  this.dbgr.debug(processor.pc);
202
227
  throw e;
203
228
  }
@@ -227,16 +252,12 @@ export class EmulationLoop extends EventTarget {
227
252
  handleVisibilityChange() {
228
253
  const { processor } = this;
229
254
  if (document.visibilityState === "hidden") {
230
- this.wasPreviouslyRunning = this.running;
231
255
  const keepRunningWhenHidden =
232
- processor.acia.motorOn || processor.fdc.motorOn[0] || processor.fdc.motorOn[1];
233
- if (this.running && !keepRunningWhenHidden) {
234
- this.stop(false);
235
- }
236
- } else {
237
- if (this.wasPreviouslyRunning) {
238
- this.go();
239
- }
256
+ processor.tapeInterface.motorOn || processor.fdc.motorOn[0] || processor.fdc.motorOn[1];
257
+ if (!keepRunningWhenHidden && !this.resumeOnVisible) this.resumeOnVisible = this.pause("the hidden tab");
258
+ } else if (this.resumeOnVisible) {
259
+ this.resumeOnVisible();
260
+ this.resumeOnVisible = null;
240
261
  }
241
262
  }
242
263
 
@@ -18,12 +18,20 @@ class Light {
18
18
  * controls, and the pop-up window the printer prints into.
19
19
  */
20
20
  export class FrontPanel {
21
- constructor({ processor, model, printer }) {
21
+ constructor({ processor, model, printer, loop }) {
22
22
  this.processor = processor;
23
23
  this.model = model;
24
24
  this.printer = printer;
25
25
  this.printerWindow = null;
26
26
  this.printerTextArea = null;
27
+ loop.addEventListener("tick", () => this.syncLights());
28
+ printer.addEventListener("output", (event) => this.printChar(event.detail));
29
+ printer.addEventListener("first-output", () =>
30
+ toast("Printer output is being kept. Press Ctrl-B to open the printer window.", {
31
+ title: "Printer",
32
+ quietKey: "quietPrinterOutput",
33
+ }),
34
+ );
27
35
 
28
36
  for (const link of document.querySelectorAll("#tape-menu a")) {
29
37
  link.addEventListener("click", (e) => {
@@ -32,13 +40,8 @@ export class FrontPanel {
32
40
 
33
41
  if (type === "rewind") {
34
42
  console.log("Rewinding tape to the start");
35
- if (model.isAtom) {
36
- processor.atomppia.stopTape();
37
- processor.atomppia.rewindTape();
38
- this.updateTapeButton();
39
- } else {
40
- processor.acia.rewindTape();
41
- }
43
+ processor.tapeInterface.rewindTape();
44
+ this.updateTapeButton();
42
45
  }
43
46
  });
44
47
  }
@@ -92,14 +95,12 @@ export class FrontPanel {
92
95
 
93
96
  syncLights() {
94
97
  const { processor } = this;
95
- if (this.model.isAtom) {
96
- this.cassette.update(processor.atomppia.motorOn);
97
- } else {
98
+ this.cassette.update(processor.tapeInterface.motorOn);
99
+ if (!this.model.isAtom) {
98
100
  this.caps.update(processor.sysvia.capsLockLight);
99
101
  this.shift.update(processor.sysvia.shiftLockLight);
100
102
  this.drive0.update(processor.fdc.motorOn[0]);
101
103
  this.drive1.update(processor.fdc.motorOn[1]);
102
- this.cassette.update(processor.acia.motorOn);
103
104
  if (processor.econet) {
104
105
  this.network.update(processor.econet.activityLight());
105
106
  }
@@ -1,7 +1,4 @@
1
- "use strict";
2
- import * as utils from "./utils.js";
3
-
4
- const BBC = utils.BBC;
1
+ import { BBC, isFirefox } from "../keymap.js";
5
2
 
6
3
  export class GamePad {
7
4
  constructor() {
@@ -201,8 +198,7 @@ export class GamePad {
201
198
  // process gamepad buttons
202
199
  if (this.gamepad0) {
203
200
  // these two lines needed in Chrome to update state, not Firefox
204
- // TODO: what about IE? (can't get Gamepads to work in IE11/IE12. Mike)
205
- if (!utils.isFirefox()) {
201
+ if (!isFirefox()) {
206
202
  this.gamepad0 = navigator.getGamepads()[0];
207
203
  }
208
204