jsbeeb 1.22.0 → 1.22.2
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 +1 -0
- package/package.json +3 -2
- package/src/basic-loader.js +25 -0
- package/src/dom-utils.js +8 -0
- package/src/main.js +291 -2532
- package/src/utils.js +8 -0
- package/src/web/analogue-inputs.js +134 -0
- package/src/web/archive-list.js +44 -0
- package/src/web/audio-handler.js +2 -2
- package/src/web/autoboot.js +85 -0
- package/src/{config.js → web/config.js} +4 -4
- package/src/{disc-visualiser.js → web/disc-visualiser.js} +2 -2
- package/src/web/display.js +166 -0
- package/src/web/drives.js +116 -0
- package/src/web/emulation-loop.js +314 -0
- package/src/web/front-panel.js +133 -0
- package/src/web/google-drive-picker.js +175 -0
- package/src/web/hfe-picker.js +156 -0
- package/src/web/keyboard-setup.js +125 -0
- package/src/web/layout.js +156 -0
- package/src/web/machine.js +204 -0
- package/src/web/media-loader.js +359 -0
- package/src/web/modals.js +83 -0
- package/src/web/reporting.js +28 -0
- package/src/{rewind-ui.js → web/rewind-ui.js} +16 -13
- package/src/web/settings.js +160 -0
- package/src/web/snapshot-ui.js +217 -0
- package/src/web/sth-picker.js +137 -0
- package/src/web/url-state.js +84 -0
- package/tests/test-machine.js +6 -14
package/src/utils.js
CHANGED
|
@@ -924,6 +924,14 @@ export function getKeyMap(keyLayout) {
|
|
|
924
924
|
return keys2;
|
|
925
925
|
}
|
|
926
926
|
|
|
927
|
+
export function replaceOrAddExtension(name, newExt) {
|
|
928
|
+
const lastDot = name.lastIndexOf(".");
|
|
929
|
+
if (lastDot === -1) {
|
|
930
|
+
return name + newExt;
|
|
931
|
+
}
|
|
932
|
+
return name.substring(0, lastDot) + newExt;
|
|
933
|
+
}
|
|
934
|
+
|
|
927
935
|
export function hexbyte(value) {
|
|
928
936
|
return ((value >>> 4) & 0xf).toString(16) + (value & 0xf).toString(16);
|
|
929
937
|
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { GamepadSource } from "../gamepad-source.js";
|
|
2
|
+
import { MicrophoneInput } from "../microphone-input.js";
|
|
3
|
+
import { MouseJoystickSource } from "../mouse-joystick-source.js";
|
|
4
|
+
import { calculateMouseCoordinates } from "../mouse-coordinates.js";
|
|
5
|
+
import { toast } from "./toast.js";
|
|
6
|
+
|
|
7
|
+
const AdcChannelCount = 4;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* What feeds the analogue port and the touchscreen: the gamepad, the mouse
|
|
11
|
+
* acting as a joystick, and the microphone, with the mouse on the monitor
|
|
12
|
+
* routed to whichever of them wants it.
|
|
13
|
+
*/
|
|
14
|
+
export class AnalogueInputs {
|
|
15
|
+
constructor({ processor, screenCanvas, getGamepads, urlState, config, audioHandler }) {
|
|
16
|
+
this.processor = processor;
|
|
17
|
+
this.urlState = urlState;
|
|
18
|
+
this.config = config;
|
|
19
|
+
|
|
20
|
+
this.gamepadSource = new GamepadSource(getGamepads);
|
|
21
|
+
// Create MicrophoneInput but don't enable by default
|
|
22
|
+
this.microphoneInput = new MicrophoneInput();
|
|
23
|
+
this.microphoneInput.setErrorCallback((message) => {
|
|
24
|
+
toast(`${message} The microphone channel has been turned off.`, { title: "Microphone" });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// Create MouseJoystickSource but don't enable by default
|
|
28
|
+
this.mouseJoystickSource = new MouseJoystickSource(screenCanvas);
|
|
29
|
+
|
|
30
|
+
const cubMonitor = document.getElementById("cub-monitor");
|
|
31
|
+
const onCubMouseEvent = (evt) => {
|
|
32
|
+
audioHandler.tryResume();
|
|
33
|
+
if (document.activeElement !== document.body) document.activeElement.blur();
|
|
34
|
+
const screenRect = screenCanvas.getBoundingClientRect();
|
|
35
|
+
const { x, y } = calculateMouseCoordinates(evt, screenRect);
|
|
36
|
+
|
|
37
|
+
// Handle touchscreen
|
|
38
|
+
if (processor.touchScreen) processor.touchScreen.onMouse(x, y, evt.buttons);
|
|
39
|
+
|
|
40
|
+
// Handle mouse joystick if enabled
|
|
41
|
+
if (urlState.params.mouseJoystickEnabled && this.mouseJoystickSource.isEnabled()) {
|
|
42
|
+
// Use the API methods instead of direct manipulation
|
|
43
|
+
this.mouseJoystickSource.onMouseMove(x, y);
|
|
44
|
+
|
|
45
|
+
// Handle button events
|
|
46
|
+
if (evt.type === "mousedown" && evt.button === 0) {
|
|
47
|
+
this.mouseJoystickSource.onMouseDown(0);
|
|
48
|
+
} else if (evt.type === "mouseup" && evt.button === 0) {
|
|
49
|
+
this.mouseJoystickSource.onMouseUp(0);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
evt.preventDefault();
|
|
54
|
+
};
|
|
55
|
+
for (const eventType of ["mousemove", "mousedown", "mouseup"]) {
|
|
56
|
+
cubMonitor.addEventListener(eventType, onCubMouseEvent);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Helper to manage ADC source configuration */
|
|
61
|
+
updateAdcSources(mouseJoystickEnabled, microphoneChannel) {
|
|
62
|
+
const { processor } = this;
|
|
63
|
+
// Default all channels to the gamepad source.
|
|
64
|
+
for (let ch = 0; ch < AdcChannelCount; ch++) {
|
|
65
|
+
processor.adconverter.setChannelSource(ch, this.gamepadSource);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Apply mouse joystick if enabled (takes priority on channels 0 & 1)
|
|
69
|
+
if (mouseJoystickEnabled) {
|
|
70
|
+
processor.adconverter.setChannelSource(0, this.mouseJoystickSource);
|
|
71
|
+
processor.adconverter.setChannelSource(1, this.mouseJoystickSource);
|
|
72
|
+
this.mouseJoystickSource.setVia(processor.sysvia);
|
|
73
|
+
} else {
|
|
74
|
+
this.mouseJoystickSource.setVia(null);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Apply microphone if configured (can override any channel)
|
|
78
|
+
if (microphoneChannel === undefined) return;
|
|
79
|
+
if (Number.isInteger(microphoneChannel) && microphoneChannel >= 0 && microphoneChannel < AdcChannelCount) {
|
|
80
|
+
processor.adconverter.setChannelSource(microphoneChannel, this.microphoneInput);
|
|
81
|
+
} else {
|
|
82
|
+
toast(
|
|
83
|
+
`There is no analogue channel ${microphoneChannel}; channels are 0 to 3. ` +
|
|
84
|
+
`The microphone channel has been turned off.`,
|
|
85
|
+
{ title: "Microphone" },
|
|
86
|
+
);
|
|
87
|
+
this.clearMicrophoneChannel();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
clearMicrophoneChannel() {
|
|
92
|
+
this.config.setMicrophoneChannel(undefined);
|
|
93
|
+
delete this.urlState.params.microphoneChannel;
|
|
94
|
+
this.urlState.updateUrl();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async ensureMicrophoneRunning() {
|
|
98
|
+
const { microphoneInput } = this;
|
|
99
|
+
if (microphoneInput.audioContext && microphoneInput.audioContext.state !== "running") {
|
|
100
|
+
try {
|
|
101
|
+
await microphoneInput.audioContext.resume();
|
|
102
|
+
console.log("Microphone: Audio context resumed, new state:", microphoneInput.audioContext.state);
|
|
103
|
+
} catch (err) {
|
|
104
|
+
console.error("Microphone: Error resuming audio context:", err);
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async setupMicrophone() {
|
|
112
|
+
// The channel can have been turned off between the request and now.
|
|
113
|
+
if (this.urlState.params.microphoneChannel === undefined) return;
|
|
114
|
+
const micPermissionStatus = document.getElementById("micPermissionStatus");
|
|
115
|
+
micPermissionStatus.textContent = "Requesting microphone access...";
|
|
116
|
+
|
|
117
|
+
// Try to initialise the microphone
|
|
118
|
+
const success = await this.microphoneInput.initialise();
|
|
119
|
+
if (success) {
|
|
120
|
+
// Note: Channel assignment is handled by updateAdcSources()
|
|
121
|
+
micPermissionStatus.textContent = "Microphone connected successfully";
|
|
122
|
+
await this.ensureMicrophoneRunning();
|
|
123
|
+
|
|
124
|
+
// Try starting audio context from user gesture
|
|
125
|
+
const tryAgain = async () => {
|
|
126
|
+
if (await this.ensureMicrophoneRunning()) document.removeEventListener("click", tryAgain);
|
|
127
|
+
};
|
|
128
|
+
document.addEventListener("click", tryAgain);
|
|
129
|
+
} else {
|
|
130
|
+
micPermissionStatus.textContent = `Error: ${this.microphoneInput.getErrorMessage() || "Unknown error"}`;
|
|
131
|
+
this.clearMicrophoneChannel();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** List plumbing every archive picker shares: one modal, one list, one filter box. */
|
|
2
|
+
|
|
3
|
+
export function clearArchiveList(listId) {
|
|
4
|
+
for (const el of document.querySelectorAll(`#${listId} li:not(.template)`)) el.remove();
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function showArchiveMessage(modalId, listId, message) {
|
|
8
|
+
const loading = document.querySelector(`#${modalId} .loading`);
|
|
9
|
+
loading.textContent = message;
|
|
10
|
+
loading.style.display = "";
|
|
11
|
+
clearArchiveList(listId);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function filterArchiveList(listId, filter) {
|
|
15
|
+
filter = filter.toLowerCase();
|
|
16
|
+
for (const el of document.querySelectorAll(`#${listId} li:not(.template)`)) {
|
|
17
|
+
el.style.display = el.textContent.toLowerCase().includes(filter) ? "" : "none";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Every archive picker offers the same autoboot choice, and it is one setting,
|
|
23
|
+
* so ticking it in either has to show in both.
|
|
24
|
+
*/
|
|
25
|
+
export class AutobootTicks {
|
|
26
|
+
constructor({ urlState }) {
|
|
27
|
+
this.checks = document.querySelectorAll(".modal .autoboot");
|
|
28
|
+
for (const check of this.checks) {
|
|
29
|
+
check.addEventListener("click", () => {
|
|
30
|
+
this.show(check.checked);
|
|
31
|
+
if (check.checked) {
|
|
32
|
+
urlState.params.autoboot = "";
|
|
33
|
+
} else {
|
|
34
|
+
delete urlState.params.autoboot;
|
|
35
|
+
}
|
|
36
|
+
urlState.updateUrl();
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
show(checked) {
|
|
42
|
+
for (const check of this.checks) check.checked = checked;
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/web/audio-handler.js
CHANGED
|
@@ -14,8 +14,8 @@ const StallSpikeHeight = 20;
|
|
|
14
14
|
|
|
15
15
|
// Nobody is watching an unfocused window, so its sound can run far behind
|
|
16
16
|
// the picture, deep enough to ride out the browser starving the tab.
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
const UnfocusedLatencyMs = 200;
|
|
18
|
+
const DefaultLatencyMs = 20;
|
|
19
19
|
|
|
20
20
|
export class AudioHandler {
|
|
21
21
|
constructor({
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import * as utils from "../utils.js";
|
|
2
|
+
import * as utils_atom from "../utils_atom.js";
|
|
3
|
+
import * as tokeniser from "../basic-tokenise.js";
|
|
4
|
+
import { basicIdleAddr, installBasic } from "../basic-loader.js";
|
|
5
|
+
|
|
6
|
+
/** Booting and typing for the machine at startup: shift-break, *TAPE incantations and BASIC programs. */
|
|
7
|
+
export class Autoboot {
|
|
8
|
+
/** @param {Function} deps.sendKeys sends a raw key sequence once the keyboard exists */
|
|
9
|
+
constructor({ model, processor, sendKeys }) {
|
|
10
|
+
this.model = model;
|
|
11
|
+
this.processor = processor;
|
|
12
|
+
this.sendKeys = sendKeys;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Convert text to machine-appropriate key sequences (BBC or Atom) */
|
|
16
|
+
stringToMachineKeys(text) {
|
|
17
|
+
return this.model.isAtom ? utils_atom.stringToATOMKeys(text) : utils.stringToBBCKeys(text);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
boot(image) {
|
|
21
|
+
const BBC = utils.BBC;
|
|
22
|
+
|
|
23
|
+
console.log("Autobooting disc");
|
|
24
|
+
utils.noteEvent("init", "autoboot", image);
|
|
25
|
+
|
|
26
|
+
// Shift-break simulation, hold SHIFT for 1000ms.
|
|
27
|
+
this.sendKeys([BBC.SHIFT, 1000], false);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type(keys) {
|
|
31
|
+
console.log("Auto typing '" + keys + "'");
|
|
32
|
+
utils.noteEvent("init", "autochain");
|
|
33
|
+
|
|
34
|
+
const bbcKeys = this.stringToMachineKeys(keys);
|
|
35
|
+
this.sendKeys([1000].concat(bbcKeys), false);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
chainTape() {
|
|
39
|
+
console.log("Auto Chaining Tape");
|
|
40
|
+
utils.noteEvent("init", "autochain");
|
|
41
|
+
|
|
42
|
+
const bbcKeys = this.stringToMachineKeys('*TAPE\nCH.""\n');
|
|
43
|
+
this.sendKeys([1000].concat(bbcKeys), false);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
runTape() {
|
|
47
|
+
console.log("Auto Running Tape");
|
|
48
|
+
utils.noteEvent("init", "autorun");
|
|
49
|
+
|
|
50
|
+
const bbcKeys = this.stringToMachineKeys("*TAPE\n*/\n");
|
|
51
|
+
this.sendKeys([1000].concat(bbcKeys), false);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
runBasic() {
|
|
55
|
+
console.log("Auto Running basic");
|
|
56
|
+
utils.noteEvent("init", "autorunbasic");
|
|
57
|
+
|
|
58
|
+
const bbcKeys = this.stringToMachineKeys("RUN\n");
|
|
59
|
+
this.sendKeys([1000].concat(bbcKeys), false);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Tokenises a BASIC program and installs it once the OS reaches its idle
|
|
64
|
+
* loop, so it lands after the machine has finished starting up.
|
|
65
|
+
*/
|
|
66
|
+
async insertBasic(getBasicPromise, needsRun) {
|
|
67
|
+
const prog = await getBasicPromise;
|
|
68
|
+
const t = await tokeniser.create();
|
|
69
|
+
const tokenised = await t.tokenise(prog);
|
|
70
|
+
|
|
71
|
+
const { processor } = this;
|
|
72
|
+
const idleAddr = basicIdleAddr(processor.model);
|
|
73
|
+
const hook = processor.debugInstruction.add((addr) => {
|
|
74
|
+
if (addr !== idleAddr) return;
|
|
75
|
+
installBasic(tokenised, {
|
|
76
|
+
readByte: (a) => processor.readmem(a),
|
|
77
|
+
writeByte: (a, value) => processor.writemem(a, value),
|
|
78
|
+
});
|
|
79
|
+
hook.remove();
|
|
80
|
+
if (needsRun) {
|
|
81
|
+
this.runBasic();
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
import { allModels, findModel, tubeModelFor } from "
|
|
3
|
-
import { getFilterForMode } from "
|
|
4
|
-
import { AudioOutputs } from "
|
|
2
|
+
import { allModels, findModel, tubeModelFor } from "../models.js";
|
|
3
|
+
import { getFilterForMode } from "../canvas.js";
|
|
4
|
+
import { AudioOutputs } from "../audio-output.js";
|
|
5
5
|
|
|
6
6
|
const round = (value) => Number(value.toFixed(2));
|
|
7
7
|
|
|
@@ -25,7 +25,7 @@ export function fittedRoms({ model, hasEconet, hasMusic5000, hasTeletextAdaptor
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
/** The settings the dialog presents as checkboxes. `enables` names a control only usable while ticked. */
|
|
28
|
-
|
|
28
|
+
const CheckboxSettings = [
|
|
29
29
|
{ id: "65c02", field: "coProcessor", restartRequired: true, enables: "tubeCpuMultiplier" },
|
|
30
30
|
{ id: "hasTeletextAdaptor", field: "hasTeletextAdaptor", restartRequired: true },
|
|
31
31
|
{ id: "hasEconet", field: "hasEconet", restartRequired: true },
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
-
import { IbmDiscFormat } from "
|
|
3
|
+
import { IbmDiscFormat } from "../disc.js";
|
|
4
4
|
import {
|
|
5
5
|
DensityPalette,
|
|
6
6
|
DensityRampHex,
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
renderTracks,
|
|
16
16
|
trackPulseDensity,
|
|
17
17
|
trackRegions,
|
|
18
|
-
} from "
|
|
18
|
+
} from "../disc-surface.js";
|
|
19
19
|
|
|
20
20
|
/** 300 rpm. */
|
|
21
21
|
const RevolutionMs = 200;
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import * as canvasLib from "../canvas.js";
|
|
2
|
+
import { FakeVideo, Video } from "../video.js";
|
|
3
|
+
import { toast } from "./toast.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The picture: the canvas and its filter, the video chip that paints into a
|
|
7
|
+
* framebuffer of our own, and the animation frame that presents it. A stalled
|
|
8
|
+
* display holds up the picture and not the emulation (issue #885).
|
|
9
|
+
*/
|
|
10
|
+
export class Display {
|
|
11
|
+
constructor({
|
|
12
|
+
screenCanvas,
|
|
13
|
+
model,
|
|
14
|
+
mode,
|
|
15
|
+
tryGl = true,
|
|
16
|
+
lowLatency = true,
|
|
17
|
+
fakeVideo = false,
|
|
18
|
+
frameSkip = 0,
|
|
19
|
+
makeCanvas = (canvasEl, filterClass) =>
|
|
20
|
+
tryGl
|
|
21
|
+
? canvasLib.bestCanvas(canvasEl, filterClass, lowLatency)
|
|
22
|
+
: new canvasLib.Canvas(canvasEl, lowLatency),
|
|
23
|
+
}) {
|
|
24
|
+
this.screenCanvas = screenCanvas;
|
|
25
|
+
this.frames = 0;
|
|
26
|
+
this.frameSkip = frameSkip;
|
|
27
|
+
this.paintMsThisTick = 0;
|
|
28
|
+
this.presentMsMax = 0;
|
|
29
|
+
this.presentScheduled = false;
|
|
30
|
+
|
|
31
|
+
this.filterClass = canvasLib.getFilterForMode(mode);
|
|
32
|
+
// Each mode says how many pixels it wants to draw into. Set this before
|
|
33
|
+
// creating the context, which fixes its initial viewport.
|
|
34
|
+
this.sizeCanvasFor(this.filterClass);
|
|
35
|
+
this.canvas = makeCanvas(screenCanvas, this.filterClass);
|
|
36
|
+
this.reportAnyFallback(this.filterClass);
|
|
37
|
+
this.filterClass = this.canvas.filterClass;
|
|
38
|
+
|
|
39
|
+
// The emulator paints into its own framebuffer; flyback copies the
|
|
40
|
+
// finished frame into the canvas and an animation frame presents it.
|
|
41
|
+
this.videoFb32 = new Uint32Array(this.canvas.fb32.length);
|
|
42
|
+
this.pendingFrame = {
|
|
43
|
+
minx: 0,
|
|
44
|
+
miny: 0,
|
|
45
|
+
maxx: 0,
|
|
46
|
+
maxy: 0,
|
|
47
|
+
lineBaseEven: 0,
|
|
48
|
+
lineBaseOdd: 0,
|
|
49
|
+
lineGrid: new Uint8Array(0),
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const display = this;
|
|
53
|
+
this.video = fakeVideo
|
|
54
|
+
? new FakeVideo()
|
|
55
|
+
: new Video(
|
|
56
|
+
model.isMaster,
|
|
57
|
+
this.videoFb32,
|
|
58
|
+
function paint(minx, miny, maxx, maxy) {
|
|
59
|
+
display.onPaint(this, minx, miny, maxx, maxy);
|
|
60
|
+
},
|
|
61
|
+
{ isAtom: model.isAtom },
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
this.setCrtPic();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
onPaint(video, minx, miny, maxx, maxy) {
|
|
68
|
+
this.frames++;
|
|
69
|
+
if (this.frames < this.frameSkip) return;
|
|
70
|
+
this.frames = 0;
|
|
71
|
+
const start = performance.now();
|
|
72
|
+
this.canvas.fb32.set(this.videoFb32.subarray(miny * 1024, maxy * 1024), miny * 1024);
|
|
73
|
+
if (this.pendingFrame.lineGrid.length !== video.lineGrid.length)
|
|
74
|
+
this.pendingFrame.lineGrid = new Uint8Array(video.lineGrid.length);
|
|
75
|
+
this.pendingFrame.lineGrid.set(video.lineGrid);
|
|
76
|
+
Object.assign(this.pendingFrame, {
|
|
77
|
+
minx,
|
|
78
|
+
miny,
|
|
79
|
+
maxx,
|
|
80
|
+
maxy,
|
|
81
|
+
lineBaseEven: video.lineBaseEven,
|
|
82
|
+
lineBaseOdd: video.lineBaseOdd,
|
|
83
|
+
});
|
|
84
|
+
this.paintMsThisTick += performance.now() - start;
|
|
85
|
+
if (!this.presentScheduled) {
|
|
86
|
+
this.presentScheduled = true;
|
|
87
|
+
window.requestAnimationFrame(() => this.present());
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
present() {
|
|
92
|
+
this.presentScheduled = false;
|
|
93
|
+
const start = performance.now();
|
|
94
|
+
const { minx, miny, maxx, maxy } = this.pendingFrame;
|
|
95
|
+
this.canvas.paint(minx, miny, maxx, maxy, this.pendingFrame);
|
|
96
|
+
this.presentMsMax = Math.max(this.presentMsMax, performance.now() - start);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The mode is changed from a modal, which stops the emulator, so this repaints itself. */
|
|
100
|
+
setMode(mode) {
|
|
101
|
+
const newFilterClass = canvasLib.getFilterForMode(mode);
|
|
102
|
+
// Everything but the filter is the same whatever the mode: the framebuffer
|
|
103
|
+
// texture, the vertex buffers and fb32 all carry over untouched.
|
|
104
|
+
canvasLib.useBestFilter(this.canvas, newFilterClass);
|
|
105
|
+
this.reportAnyFallback(newFilterClass);
|
|
106
|
+
// Follow the filter we ended up with, not the one we asked for: everything
|
|
107
|
+
// downstream (the monitor picture, the canvas geometry, how large a drawing
|
|
108
|
+
// buffer to ask for) comes from its display config.
|
|
109
|
+
this.filterClass = this.canvas.filterClass;
|
|
110
|
+
// Back to the mode's own size, undoing any scaling the last one asked for.
|
|
111
|
+
this.sizeCanvasFor(this.filterClass);
|
|
112
|
+
this.video.paint();
|
|
113
|
+
this.setCrtPic();
|
|
114
|
+
window.setTimeout(() => window.dispatchEvent(new Event("resize")), 1);
|
|
115
|
+
// Relayout now as well: the monitor picture may have changed shape.
|
|
116
|
+
window.dispatchEvent(new Event("resize"));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
sizeCanvasFor(filterClass) {
|
|
120
|
+
const displayConfig = filterClass.getDisplayConfig();
|
|
121
|
+
if (
|
|
122
|
+
this.screenCanvas.width === displayConfig.canvasWidth &&
|
|
123
|
+
this.screenCanvas.height === displayConfig.canvasHeight
|
|
124
|
+
)
|
|
125
|
+
return;
|
|
126
|
+
this.screenCanvas.width = displayConfig.canvasWidth;
|
|
127
|
+
this.screenCanvas.height = displayConfig.canvasHeight;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Test which filter is actually in use, not merely whether we got WebGL: a
|
|
131
|
+
// filter can decline a context that works perfectly well for other modes, in
|
|
132
|
+
// which case we are quietly left with an unfiltered display.
|
|
133
|
+
reportAnyFallback(filterClass) {
|
|
134
|
+
if (this.canvas.filterClass === filterClass) return;
|
|
135
|
+
const reason = this.canvas.fallbackReason ? ` (${this.canvas.fallbackReason})` : "";
|
|
136
|
+
const { name } = filterClass.getDisplayConfig();
|
|
137
|
+
toast(`${name} is not available on this device, so the standard display is in use${reason}.`, {
|
|
138
|
+
title: "Display",
|
|
139
|
+
quietKey: "quietDisplayFallback",
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The monitor picture around the screen follows the filter in use. */
|
|
144
|
+
setCrtPic() {
|
|
145
|
+
const config = this.filterClass.getDisplayConfig();
|
|
146
|
+
const monitorPic = document.getElementById("cub-monitor-pic");
|
|
147
|
+
monitorPic.src = config.image;
|
|
148
|
+
monitorPic.alt = config.imageAlt;
|
|
149
|
+
monitorPic.width = config.imageWidth;
|
|
150
|
+
monitorPic.height = config.imageHeight;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** This tick's time spent copying frames out of the emulator, and start counting afresh. */
|
|
154
|
+
takePaintMs() {
|
|
155
|
+
const ms = this.paintMsThisTick;
|
|
156
|
+
this.paintMsThisTick = 0;
|
|
157
|
+
return ms;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The longest present since last asked, and start counting afresh. */
|
|
161
|
+
takePresentMs() {
|
|
162
|
+
const ms = this.presentMsMax;
|
|
163
|
+
this.presentMsMax = 0;
|
|
164
|
+
return ms;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { toast } from "./toast.js";
|
|
2
|
+
import { DiscLayout, toSsdOrDsd } from "../disc.js";
|
|
3
|
+
import { toHfe } from "../disc-hfe.js";
|
|
4
|
+
import { downloadDriveData } from "../dom-utils.js";
|
|
5
|
+
import { DriveTracks } from "../url-params.js";
|
|
6
|
+
|
|
7
|
+
const tracksPerStepFor = (tracks) => (tracks === "40" ? 2 : 1);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The disc drives as the page sees them: putting a disc in, the 40/80 track
|
|
11
|
+
* switches on the Discs menu, and downloading what is in drive 0.
|
|
12
|
+
*/
|
|
13
|
+
export class Drives {
|
|
14
|
+
constructor({ fdc, driveTracks, areYouSure }) {
|
|
15
|
+
this.fdc = fdc;
|
|
16
|
+
this.driveTracks = driveTracks;
|
|
17
|
+
this.saidWritesAreNotKept = false;
|
|
18
|
+
|
|
19
|
+
for (const item of document.querySelectorAll(".drive-tracks")) {
|
|
20
|
+
const driveIndex = Number(item.dataset.drive);
|
|
21
|
+
const drive = fdc?.drives[driveIndex];
|
|
22
|
+
const fixed = drive ? this.tracksPerStepForDrive(driveIndex) : undefined;
|
|
23
|
+
if (fixed !== undefined) drive.tracksPerStep = fixed;
|
|
24
|
+
for (const button of this.driveTracksButtons(driveIndex)) {
|
|
25
|
+
button.disabled = !drive;
|
|
26
|
+
button.addEventListener("click", (event) => {
|
|
27
|
+
// Setting a switch is not picking from a menu, so leave the menu where it is.
|
|
28
|
+
event.stopPropagation();
|
|
29
|
+
drive.tracksPerStep = tracksPerStepFor(button.dataset.tracks);
|
|
30
|
+
this.showDriveTracks(driveIndex);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
if (drive) this.showDriveTracks(driveIndex);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
document.getElementById("download-drive-link").addEventListener("click", () => {
|
|
37
|
+
const disc = this.discToDownload();
|
|
38
|
+
if (!disc) return;
|
|
39
|
+
const save = (options) =>
|
|
40
|
+
downloadDriveData(toSsdOrDsd(disc, options), disc.name, disc.isDoubleSided ? ".dsd" : ".ssd");
|
|
41
|
+
try {
|
|
42
|
+
save();
|
|
43
|
+
} catch (e) {
|
|
44
|
+
areYouSure(`${e.message} Save anyway, losing what will not fit?`, "Save anyway", "Cancel", () =>
|
|
45
|
+
save({ force: true }),
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
document.getElementById("download-drive-hfe-link").addEventListener("click", () => {
|
|
51
|
+
const disc = this.discToDownload();
|
|
52
|
+
if (!disc) return;
|
|
53
|
+
downloadDriveData(toHfe(disc), disc.name, ".hfe");
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** @returns {import("../disc.js").Disc|null} the disc in drive 0, saying so when there is nothing to download */
|
|
58
|
+
discToDownload() {
|
|
59
|
+
const disc = this.fdc?.drives[0].disc;
|
|
60
|
+
if (!disc) toast("There is no disc in drive 0 to download.", { title: "Disc" });
|
|
61
|
+
return disc ?? null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** @returns {string} the DiscLayout to load an image for this drive with */
|
|
65
|
+
layoutForDrive(driveIndex) {
|
|
66
|
+
return this.driveTracks[driveIndex] === DriveTracks.eighty ? DiscLayout.contiguous : DiscLayout.auto;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** @returns {Number|undefined} the tracksPerStep the user fixed this drive at, if they fixed one */
|
|
70
|
+
tracksPerStepForDrive(driveIndex) {
|
|
71
|
+
if (this.driveTracks[driveIndex] === DriveTracks.auto) return undefined;
|
|
72
|
+
return this.driveTracks[driveIndex] === DriveTracks.forty ? 2 : 1;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
putDiscIn(driveIndex, loadedDisc) {
|
|
76
|
+
const drive = this.fdc.drives[driveIndex];
|
|
77
|
+
const fixed = this.tracksPerStepForDrive(driveIndex);
|
|
78
|
+
const was = drive.tracksPerStep;
|
|
79
|
+
this.fdc.loadDisc(driveIndex, loadedDisc, fixed);
|
|
80
|
+
this.showDriveTracks(driveIndex);
|
|
81
|
+
this.noteUnsavedWrites(loadedDisc);
|
|
82
|
+
// A switch the user fixed does not move, so anything it does is not news.
|
|
83
|
+
if (fixed === undefined && drive.tracksPerStep !== was) this.noteDriveTracks(driveIndex, loadedDisc.name);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
noteUnsavedWrites(loadedDisc) {
|
|
87
|
+
if (loadedDisc.savesChanges || this.saidWritesAreNotKept) return;
|
|
88
|
+
loadedDisc.notifyOnFirstTrackWrite(() => {
|
|
89
|
+
if (this.saidWritesAreNotKept) return;
|
|
90
|
+
this.saidWritesAreNotKept = true;
|
|
91
|
+
toast(`Changes to ${loadedDisc.name} are not saved. Use Discs, Download to keep a copy.`, {
|
|
92
|
+
title: "Disc",
|
|
93
|
+
quietKey: "quietDiscNotSaved",
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
showDriveTracks(driveIndex) {
|
|
99
|
+
const drive = this.fdc?.drives[driveIndex];
|
|
100
|
+
if (!drive) return;
|
|
101
|
+
for (const button of this.driveTracksButtons(driveIndex))
|
|
102
|
+
button.classList.toggle("active", tracksPerStepFor(button.dataset.tracks) === drive.tracksPerStep);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
driveTracksButtons(driveIndex) {
|
|
106
|
+
return document.querySelectorAll(`.drive-tracks[data-drive="${driveIndex}"] [data-tracks]`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
noteDriveTracks(driveIndex, discName) {
|
|
110
|
+
const tracks = this.fdc.drives[driveIndex].tracksPerStep === 2 ? "40" : "80";
|
|
111
|
+
toast(`Drive ${driveIndex} switched to ${tracks} track for ${discName}.`, {
|
|
112
|
+
title: "Disc drive",
|
|
113
|
+
quietKey: "quietDriveTracks",
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|