jsbeeb 1.23.0 → 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.
- package/README.md +2 -2
- package/package.json +14 -10
- package/src/6502.js +39 -42
- package/src/6502.opcodes.js +7 -13
- package/src/6847.js +4 -5
- package/src/6847_fontdata.js +0 -2
- package/src/acia.js +2 -3
- package/src/app/app.js +0 -1
- package/src/app/args.js +0 -2
- package/src/app/electron.js +4 -6
- package/src/app-bench.js +1 -3
- package/src/archive.js +243 -0
- package/src/basic-loader.js +0 -7
- package/src/basic-tokenise.js +19 -6
- package/src/bbc-palette.js +0 -2
- package/src/bbcdiscs.js +0 -2
- package/src/bem-snapshot.js +1 -3
- package/src/binary.js +175 -0
- package/src/cmos.js +0 -2
- package/src/ddnoise.js +0 -1
- package/src/debounce.js +7 -0
- package/src/disc-surface.js +0 -2
- package/src/disc.js +10 -9
- package/src/fake6502.js +7 -23
- package/src/fdc.js +9 -51
- package/src/filestore.js +10 -17
- package/src/hex.js +38 -0
- package/src/index.js +11 -0
- package/src/intel-fdc.js +18 -20
- package/src/{utils_atom.js → keymap-atom.js} +2 -18
- package/src/{utils.js → keymap.js} +47 -610
- package/src/loader.js +63 -0
- package/src/machine-session.js +37 -177
- package/src/machine-spec.js +59 -0
- package/src/main.js +66 -180
- package/src/media-resolver.js +68 -0
- package/src/mmc.js +3 -2
- package/src/models.js +17 -3
- package/src/music5000.js +0 -2
- package/src/ppia.js +3 -4
- package/src/printer.js +7 -14
- package/src/relaynoise.js +0 -1
- package/src/rewind.js +0 -2
- package/src/sample-player.js +2 -3
- package/src/serial.js +0 -2
- package/src/snapshot-helpers.js +0 -2
- package/src/snapshot.js +0 -2
- package/src/soundchip.js +1 -1
- package/src/state-utils.js +0 -2
- package/src/sth.js +2 -4
- package/src/tapes.js +10 -4
- package/src/teletext.js +2 -3
- package/src/teletext_adaptor.js +2 -4
- package/src/teletext_data.js +0 -2
- package/src/test-machine.js +307 -0
- package/src/touchscreen.js +2 -4
- package/src/tube.js +5 -6
- package/src/typist.js +103 -0
- package/src/uef-snapshot.js +0 -2
- package/src/url-params.js +4 -5
- package/src/vdu-capture.js +113 -0
- package/src/via.js +3 -4
- package/src/video-filters/pal-composite.js +0 -2
- package/src/video-filters/passthrough-filter.js +0 -2
- package/src/video-filters/pixel-grid.js +0 -2
- package/src/video-filters/shader-program.js +0 -2
- package/src/video-filters/xbr-filter.js +0 -2
- package/src/video.js +12 -11
- package/src/wd-fdc.js +20 -20
- package/src/web/analogue-inputs.js +6 -8
- package/src/web/analytics.js +13 -0
- package/src/web/audio-handler.js +2 -2
- package/src/{audio-utils.js → web/audio-utils.js} +0 -2
- package/src/web/autoboot.js +10 -13
- package/src/{canvas.js → web/canvas.js} +4 -5
- package/src/web/config.js +41 -74
- package/src/web/console-surface.js +25 -0
- package/src/web/debug.js +6 -5
- package/src/web/disc-visualiser.js +0 -2
- package/src/web/display.js +21 -4
- package/src/{dom-utils.js → web/dom-utils.js} +1 -11
- package/src/web/drives.js +1 -1
- package/src/web/emulation-loop.js +54 -33
- package/src/web/front-panel.js +13 -12
- package/src/{gamepads.js → web/gamepads.js} +2 -6
- package/src/web/google-drive-picker.js +14 -9
- package/src/{google-drive.js → web/google-drive.js} +9 -10
- package/src/web/hfe-picker.js +5 -4
- package/src/web/keyboard-setup.js +18 -38
- package/src/{keyboard.js → web/keyboard.js} +18 -119
- package/src/web/layout.js +4 -0
- package/src/web/local-disc.js +44 -0
- package/src/web/machine.js +27 -20
- package/src/web/media-loader.js +38 -147
- package/src/{microphone-input.js → web/microphone-input.js} +1 -1
- package/src/web/modals.js +6 -9
- package/src/web/page-actions.js +88 -0
- package/src/web/quick-settings.js +16 -15
- package/src/web/reporting.js +0 -7
- package/src/{rewind-thumbnail.js → web/rewind-thumbnail.js} +0 -2
- package/src/web/rewind-ui.js +29 -29
- package/src/web/run-controls.js +39 -0
- package/src/web/settings.js +64 -135
- package/src/web/snapshot-ui.js +10 -16
- package/src/{speech-output.js → web/speech-output.js} +0 -2
- package/src/web/sth-picker.js +9 -7
- package/src/web/url-state.js +20 -6
- package/src/econet/L3FS.dat +0 -0
- package/src/econet/scsi.dat +0 -0
- 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 "
|
|
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.
|
|
63
|
-
this.onClose = onClose;
|
|
64
|
-
this.onRestartRequired = onRestartRequired;
|
|
60
|
+
this.settings = settings;
|
|
65
61
|
this.changed = {};
|
|
66
|
-
this
|
|
67
|
-
|
|
68
|
-
this.
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,12 +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
|
-
|
|
217
|
-
|
|
218
|
-
for (const el of document.querySelectorAll(".bbc-model")) el.textContent =
|
|
219
|
-
for (const el of document.querySelectorAll(".bbc-model-short")) el.textContent =
|
|
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;
|
|
220
216
|
const settingsLink = document.getElementById("model-settings");
|
|
221
|
-
if (settingsLink) settingsLink.title = `${
|
|
217
|
+
if (settingsLink) settingsLink.title = `${model.name}: emulation settings`;
|
|
222
218
|
}
|
|
223
219
|
|
|
224
220
|
setKeyLayout(keyLayout) {
|
|
@@ -227,12 +223,11 @@ export class Config extends EventTarget {
|
|
|
227
223
|
}
|
|
228
224
|
|
|
229
225
|
setTubeCpuMultiplier(value) {
|
|
230
|
-
this.tubeCpuMultiplier = value;
|
|
231
226
|
document.getElementById("tubeCpuMultiplier").value = value;
|
|
232
227
|
this.showTubeCpuMultiplier(value);
|
|
233
228
|
}
|
|
234
229
|
|
|
235
|
-
showTubeCpuMultiplier(value, model = this.model) {
|
|
230
|
+
showTubeCpuMultiplier(value, model = this.settings.model) {
|
|
236
231
|
document.getElementById("tubeCpuMultiplierValue").textContent = tubeCpuSpeedLabel(value, model);
|
|
237
232
|
}
|
|
238
233
|
|
|
@@ -240,32 +235,4 @@ export class Config extends EventTarget {
|
|
|
240
235
|
const el = document.querySelector("#bbc-model-dropdown .bbc-model");
|
|
241
236
|
if (el) el.textContent = modelName;
|
|
242
237
|
}
|
|
243
|
-
|
|
244
|
-
get extraRoms() {
|
|
245
|
-
return fittedRoms(this);
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
mapLegacyModels(parsedQuery) {
|
|
249
|
-
if (!parsedQuery.model) {
|
|
250
|
-
return;
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
// "MasterTurbo" = Master + 6502 second processor
|
|
254
|
-
if (parsedQuery.model.toLowerCase() === "masterturbo") {
|
|
255
|
-
parsedQuery.model = "Master";
|
|
256
|
-
parsedQuery.coProcessor = true;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
// "BMusic5000" = BBC DFS 1.2 + Music 5000
|
|
260
|
-
if (parsedQuery.model.toLowerCase() === "bmusic5000") {
|
|
261
|
-
parsedQuery.model = "B-DFS1.2";
|
|
262
|
-
parsedQuery.hasMusic5000 = true;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
// "BTeletext" = BBC DFS 1.2 + Teletext adaptor
|
|
266
|
-
if (parsedQuery.model.toLowerCase() === "bteletext") {
|
|
267
|
-
parsedQuery.model = "B-DFS1.2";
|
|
268
|
-
parsedQuery.hasTeletextAdaptor = true;
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
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
|
-
|
|
2
|
-
import { hexbyte, hexword,
|
|
3
|
-
|
|
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
|
-
//
|
|
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 = [];
|
|
@@ -394,7 +395,7 @@ export class Debugger {
|
|
|
394
395
|
}
|
|
395
396
|
|
|
396
397
|
stepUntil(f) {
|
|
397
|
-
this.cpu.targetCycles = this.cpu.currentCycles; // TODO
|
|
398
|
+
this.cpu.targetCycles = this.cpu.currentCycles; // TODO(#1062) this prevents the cpu from running any residual cycles. look into a better solution
|
|
398
399
|
for (let i = 0; i < 65536; i++) {
|
|
399
400
|
this.cpu.execute(1);
|
|
400
401
|
if (f()) break;
|
package/src/web/display.js
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
|
-
import * as canvasLib from "
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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 "
|
|
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
|
|
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
|
|
53
|
-
*
|
|
54
|
-
*
|
|
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.
|
|
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
|
-
|
|
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.
|
|
180
|
+
const motorOn = processor.tapeInterface.motorOn;
|
|
150
181
|
const speedy = this.fastAsPossible || (this.fastTape && motorOn);
|
|
151
182
|
|
|
152
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
233
|
-
if (
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
|
package/src/web/front-panel.js
CHANGED
|
@@ -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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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
|
-
|
|
205
|
-
if (!utils.isFirefox()) {
|
|
201
|
+
if (!isFirefox()) {
|
|
206
202
|
this.gamepad0 = navigator.getGamepads()[0];
|
|
207
203
|
}
|
|
208
204
|
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import * as utils from "../utils.js";
|
|
2
1
|
import * as bootstrap from "bootstrap";
|
|
3
2
|
import * as disc from "../fdc.js";
|
|
4
|
-
import { GoogleDriveLoader } from "
|
|
3
|
+
import { GoogleDriveLoader } from "./google-drive.js";
|
|
5
4
|
import { toast } from "./toast.js";
|
|
6
5
|
import { errorText } from "./reporting.js";
|
|
6
|
+
import { replaceOrAddExtension } from "../archive.js";
|
|
7
|
+
import { noteEvent } from "./analytics.js";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* The Google Drive picker: signing in, listing the user's discs, loading one
|
|
@@ -32,8 +33,12 @@ export class GoogleDrivePicker {
|
|
|
32
33
|
else this.authReject?.(new Error("Unable to authorize Google Drive"));
|
|
33
34
|
});
|
|
34
35
|
|
|
35
|
-
// Closing the loading dialog
|
|
36
|
-
|
|
36
|
+
// Closing the loading dialog puts the auth panel away; while the panel
|
|
37
|
+
// waits, that close is a cancellation, not an error.
|
|
38
|
+
document.getElementById("loading-dialog").addEventListener("hidden.bs.modal", () => {
|
|
39
|
+
this.authEl.style.display = "none";
|
|
40
|
+
this.authResolve?.(false);
|
|
41
|
+
});
|
|
37
42
|
|
|
38
43
|
// Loading the Google client holds the main thread for ~100ms, so it waits for
|
|
39
44
|
// someone to ask for Drive.
|
|
@@ -94,7 +99,7 @@ export class GoogleDrivePicker {
|
|
|
94
99
|
}
|
|
95
100
|
}
|
|
96
101
|
|
|
97
|
-
const ssd = await this.googleDrive.load(
|
|
102
|
+
const ssd = await this.googleDrive.load(cat.id, layout);
|
|
98
103
|
console.log("Google Drive loading finished");
|
|
99
104
|
this.modals.loadingFinished();
|
|
100
105
|
if (!ssd.savesChanges) {
|
|
@@ -133,7 +138,7 @@ export class GoogleDrivePicker {
|
|
|
133
138
|
dbList.appendChild(row);
|
|
134
139
|
row.querySelector(".name").textContent = item.name;
|
|
135
140
|
row.addEventListener("click", async () => {
|
|
136
|
-
|
|
141
|
+
noteEvent("google-drive", "click", item.name);
|
|
137
142
|
this.media.setDisc1Image(`gd:${item.id}/${item.name}`);
|
|
138
143
|
this.modal.hide();
|
|
139
144
|
try {
|
|
@@ -165,10 +170,10 @@ export class GoogleDrivePicker {
|
|
|
165
170
|
this.modals.loadingFinished(`Unable to create ${name} on Google Drive: ${errorText(e)}`);
|
|
166
171
|
return;
|
|
167
172
|
}
|
|
168
|
-
name =
|
|
173
|
+
name = replaceOrAddExtension(name, discType.extension);
|
|
169
174
|
console.log(`Saving existing disc: ${name}`);
|
|
170
175
|
} else {
|
|
171
|
-
// TODO
|
|
176
|
+
// TODO(#1070) blank HFE images have no fixed byteSize, so only SSD and DSD blanks can be made here.
|
|
172
177
|
const discType = disc.guessDiscTypeFromName(name);
|
|
173
178
|
if (!discType.byteSize) {
|
|
174
179
|
this.modals.loadingFinished(
|
|
@@ -184,7 +189,7 @@ export class GoogleDrivePicker {
|
|
|
184
189
|
}
|
|
185
190
|
|
|
186
191
|
try {
|
|
187
|
-
const result = await this.googleDrive.create(
|
|
192
|
+
const result = await this.googleDrive.create(name, data);
|
|
188
193
|
this.media.setDisc1Image("gd:" + result.fileId + "/" + name);
|
|
189
194
|
this.drives.putDiscIn(0, result.disc);
|
|
190
195
|
this.modals.loadingFinished();
|