jsbeeb 1.13.1 → 1.14.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 +3 -2
- package/package.json +23 -16
- package/src/6502.js +74 -16
- package/src/bem-snapshot.js +154 -15
- package/src/config.js +100 -81
- package/src/fake6502.js +7 -2
- package/src/keyboard.js +3 -2
- package/src/machine-session.js +11 -2
- package/src/main.js +79 -67
- package/src/models.js +24 -4
- package/src/mouse-coordinates.js +16 -0
- package/src/snapshot-helpers.js +16 -2
- package/src/snapshot.js +19 -1
- package/src/soundchip.js +23 -5
- package/src/teletext_adaptor.js +2 -1
- package/src/tube.js +33 -1
- package/src/web/audio-handler.js +4 -1
- package/tests/test-machine.js +7 -5
package/src/config.js
CHANGED
|
@@ -2,30 +2,85 @@
|
|
|
2
2
|
import { allModels, findModel } from "./models.js";
|
|
3
3
|
import { getFilterForMode } from "./canvas.js";
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* The sideways ROMs the optional fittings need, in the order they claim banks.
|
|
7
|
+
*
|
|
8
|
+
* @param {{model: object, hasEconet: boolean, hasMusic5000: boolean, hasTeletextAdaptor: boolean}} settings
|
|
9
|
+
* @returns {string[]}
|
|
10
|
+
*/
|
|
11
|
+
export function fittedRoms({ model, hasEconet, hasMusic5000, hasTeletextAdaptor }) {
|
|
12
|
+
return [
|
|
13
|
+
...(hasEconet && model.isMaster ? ["master/anfs-4.25.rom"] : []),
|
|
14
|
+
...(hasMusic5000 ? ["ample.rom"] : []),
|
|
15
|
+
...(hasTeletextAdaptor ? ["ats-3.0.rom"] : []),
|
|
16
|
+
];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The settings the dialog presents as checkboxes. `enables` names a control only usable while ticked. */
|
|
20
|
+
export const CheckboxSettings = [
|
|
21
|
+
{ id: "65c02", field: "coProcessor", restartRequired: true, enables: "tubeCpuMultiplier" },
|
|
22
|
+
{ id: "hasTeletextAdaptor", field: "hasTeletextAdaptor", restartRequired: true },
|
|
23
|
+
{ id: "hasEconet", field: "hasEconet", restartRequired: true },
|
|
24
|
+
{ id: "hasMusic5000", field: "hasMusic5000", restartRequired: true },
|
|
25
|
+
{ id: "mouseJoystickEnabled", field: "mouseJoystickEnabled" },
|
|
26
|
+
{ id: "speechOutput", field: "speechOutput" },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
/** The model is not a checkbox, but changing it needs a restart just the same. */
|
|
30
|
+
const RestartRequiredFields = [
|
|
31
|
+
"model",
|
|
32
|
+
...CheckboxSettings.filter((setting) => setting.restartRequired).map((setting) => setting.field),
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
/** @returns {boolean} whether any of the changed settings only take effect on a freshly built machine. */
|
|
36
|
+
export function needsRestart(changed) {
|
|
37
|
+
return RestartRequiredFields.some((field) => field in changed);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** @returns {boolean} whether the saved settings differ from those the running machine was built with. */
|
|
41
|
+
export function restartPending(settings, running) {
|
|
42
|
+
return RestartRequiredFields.some((field) => settings[field] !== running[field]);
|
|
43
|
+
}
|
|
44
|
+
|
|
5
45
|
export class Config extends EventTarget {
|
|
6
|
-
|
|
46
|
+
/**
|
|
47
|
+
* @param {function(object)} onChange called as soon as a setting the emulator can follow live changes
|
|
48
|
+
* @param {function(object)} onClose called with the settings to apply and persist
|
|
49
|
+
* @param {function()} onRestartRequired called after the settings have been saved, when some of them
|
|
50
|
+
* will only take effect once the machine is rebuilt
|
|
51
|
+
*/
|
|
52
|
+
constructor(onChange, onClose, onRestartRequired) {
|
|
7
53
|
super();
|
|
8
54
|
this.onChange = onChange;
|
|
9
55
|
this.onClose = onClose;
|
|
56
|
+
this.onRestartRequired = onRestartRequired;
|
|
10
57
|
this.changed = {};
|
|
11
58
|
this.model = null;
|
|
12
|
-
this
|
|
59
|
+
for (const { field } of CheckboxSettings) this[field] = false;
|
|
60
|
+
this.runningSettings = null;
|
|
13
61
|
const configuration = document.getElementById("configuration");
|
|
14
62
|
configuration.addEventListener("show.bs.modal", () => {
|
|
15
63
|
this.changed = {};
|
|
64
|
+
// The startup settings are pushed in after construction, so what the running machine was
|
|
65
|
+
// built with is only knowable from the first time the dialog is opened.
|
|
66
|
+
if (!this.runningSettings) this.runningSettings = this.proposedSettings();
|
|
16
67
|
this.setDropdownText(this.model.name);
|
|
17
|
-
this.set65c02(this.model.tube);
|
|
18
68
|
this.setTubeCpuMultiplier(this.tubeCpuMultiplier);
|
|
19
|
-
this.
|
|
20
|
-
this.
|
|
21
|
-
this.setEconet(this.model.hasEconet);
|
|
69
|
+
this.setCheckboxes(this);
|
|
70
|
+
this.showRestartPending();
|
|
22
71
|
});
|
|
23
72
|
|
|
24
73
|
configuration.addEventListener("hide.bs.modal", () => {
|
|
25
|
-
this.
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
74
|
+
const changed = this.changed;
|
|
75
|
+
// Not setModel: that also renames the machine in the title bar, which has not changed yet.
|
|
76
|
+
if (changed.model !== undefined) this.model = findModel(changed.model);
|
|
77
|
+
this.setCheckboxes(changed);
|
|
78
|
+
this.onClose(changed);
|
|
79
|
+
if (Object.keys(changed).length === 0) return;
|
|
80
|
+
this.dispatchEvent(new CustomEvent("settings-changed", { detail: changed }));
|
|
81
|
+
// changed records which controls were touched, so a value in it can be what is already running.
|
|
82
|
+
if (needsRestart(changed) && restartPending(this.proposedSettings(), this.runningSettings))
|
|
83
|
+
this.onRestartRequired();
|
|
29
84
|
});
|
|
30
85
|
|
|
31
86
|
const modelMenu = document.querySelector(".model-menu");
|
|
@@ -45,12 +100,17 @@ export class Config extends EventTarget {
|
|
|
45
100
|
if (!link) return;
|
|
46
101
|
this.changed.model = link.dataset.target;
|
|
47
102
|
this.setDropdownText(link.textContent);
|
|
103
|
+
this.showRestartPending();
|
|
48
104
|
});
|
|
49
105
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
106
|
+
for (const { id, field, enables } of CheckboxSettings) {
|
|
107
|
+
document.getElementById(id).addEventListener("click", () => {
|
|
108
|
+
const checked = document.getElementById(id).checked;
|
|
109
|
+
this.changed[field] = checked;
|
|
110
|
+
if (enables) document.getElementById(enables).disabled = !checked;
|
|
111
|
+
this.showRestartPending();
|
|
112
|
+
});
|
|
113
|
+
}
|
|
54
114
|
|
|
55
115
|
document.getElementById("tubeCpuMultiplier").addEventListener("input", () => {
|
|
56
116
|
const val = parseInt(document.getElementById("tubeCpuMultiplier").value, 10);
|
|
@@ -58,18 +118,6 @@ export class Config extends EventTarget {
|
|
|
58
118
|
this.changed.tubeCpuMultiplier = val;
|
|
59
119
|
});
|
|
60
120
|
|
|
61
|
-
document.getElementById("hasTeletextAdaptor").addEventListener("click", () => {
|
|
62
|
-
this.changed.hasTeletextAdaptor = document.getElementById("hasTeletextAdaptor").checked;
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
document.getElementById("hasEconet").addEventListener("click", () => {
|
|
66
|
-
this.changed.hasEconet = document.getElementById("hasEconet").checked;
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
document.getElementById("hasMusic5000").addEventListener("click", () => {
|
|
70
|
-
this.changed.hasMusic5000 = document.getElementById("hasMusic5000").checked;
|
|
71
|
-
});
|
|
72
|
-
|
|
73
121
|
for (const link of document.querySelectorAll(".keyboard-menu a")) {
|
|
74
122
|
link.addEventListener("click", (e) => {
|
|
75
123
|
const keyLayout = e.target.dataset.target;
|
|
@@ -87,14 +135,6 @@ export class Config extends EventTarget {
|
|
|
87
135
|
});
|
|
88
136
|
}
|
|
89
137
|
|
|
90
|
-
document.getElementById("mouseJoystickEnabled").addEventListener("click", () => {
|
|
91
|
-
this.changed.mouseJoystickEnabled = document.getElementById("mouseJoystickEnabled").checked;
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
document.getElementById("speechOutput").addEventListener("click", () => {
|
|
95
|
-
this.changed.speechOutput = document.getElementById("speechOutput").checked;
|
|
96
|
-
});
|
|
97
|
-
|
|
98
138
|
for (const option of document.querySelectorAll(".display-mode-option")) {
|
|
99
139
|
option.addEventListener("click", (e) => {
|
|
100
140
|
const mode = e.target.dataset.mode;
|
|
@@ -105,17 +145,34 @@ export class Config extends EventTarget {
|
|
|
105
145
|
}
|
|
106
146
|
}
|
|
107
147
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
148
|
+
/**
|
|
149
|
+
* The restart-required settings as they would be saved if the dialog were closed now, in the form the
|
|
150
|
+
* menu and the URL use: the model by synonym rather than resolved, the fittings as booleans.
|
|
151
|
+
*/
|
|
152
|
+
proposedSettings() {
|
|
153
|
+
const saved = (field) => (field === "model" ? this.model.synonyms[0] : this[field]);
|
|
154
|
+
return Object.fromEntries(RestartRequiredFields.map((field) => [field, this.changed[field] ?? saved(field)]));
|
|
111
155
|
}
|
|
112
156
|
|
|
113
|
-
|
|
114
|
-
|
|
157
|
+
showRestartPending() {
|
|
158
|
+
const pending = restartPending(this.proposedSettings(), this.runningSettings);
|
|
159
|
+
document.getElementById("restart-pending").classList.toggle("d-none", !pending);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Ticks the boxes named in `values` and adopts them, leaving any the object does not mention alone. */
|
|
163
|
+
setCheckboxes(values) {
|
|
164
|
+
for (const { id, field, enables } of CheckboxSettings) {
|
|
165
|
+
if (values[field] === undefined) continue;
|
|
166
|
+
const checked = !!values[field];
|
|
167
|
+
document.getElementById(id).checked = checked;
|
|
168
|
+
this[field] = checked;
|
|
169
|
+
if (enables) document.getElementById(enables).disabled = !checked;
|
|
170
|
+
}
|
|
115
171
|
}
|
|
116
172
|
|
|
117
|
-
|
|
118
|
-
|
|
173
|
+
setMicrophoneChannel(channel) {
|
|
174
|
+
const text = channel !== undefined ? `Channel ${channel}` : "Disabled";
|
|
175
|
+
for (const el of document.querySelectorAll(".mic-channel-text")) el.textContent = text;
|
|
119
176
|
}
|
|
120
177
|
|
|
121
178
|
setDisplayMode(mode) {
|
|
@@ -133,57 +190,19 @@ export class Config extends EventTarget {
|
|
|
133
190
|
for (const el of document.querySelectorAll(".keyboard-layout")) el.textContent = text;
|
|
134
191
|
}
|
|
135
192
|
|
|
136
|
-
set65c02(enabled) {
|
|
137
|
-
enabled = !!enabled;
|
|
138
|
-
document.getElementById("65c02").checked = enabled;
|
|
139
|
-
this.model.tube = enabled ? findModel("Tube65c02") : null;
|
|
140
|
-
document.getElementById("tubeCpuMultiplier").disabled = !enabled;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
193
|
setTubeCpuMultiplier(value) {
|
|
144
194
|
this.tubeCpuMultiplier = value;
|
|
145
195
|
document.getElementById("tubeCpuMultiplier").value = value;
|
|
146
196
|
document.getElementById("tubeCpuMultiplierValue").textContent = value;
|
|
147
197
|
}
|
|
148
198
|
|
|
149
|
-
setEconet(enabled) {
|
|
150
|
-
enabled = !!enabled;
|
|
151
|
-
document.getElementById("hasEconet").checked = enabled;
|
|
152
|
-
this.model.hasEconet = enabled;
|
|
153
|
-
|
|
154
|
-
if (enabled && this.model.isMaster) {
|
|
155
|
-
this.addRemoveROM("master/anfs-4.25.rom", true);
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
setMusic5000(enabled) {
|
|
160
|
-
enabled = !!enabled;
|
|
161
|
-
document.getElementById("hasMusic5000").checked = enabled;
|
|
162
|
-
this.model.hasMusic5000 = enabled;
|
|
163
|
-
this.addRemoveROM("ample.rom", enabled);
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
setTeletext(enabled) {
|
|
167
|
-
enabled = !!enabled;
|
|
168
|
-
document.getElementById("hasTeletextAdaptor").checked = enabled;
|
|
169
|
-
this.model.hasTeletextAdaptor = enabled;
|
|
170
|
-
this.addRemoveROM("ats-3.0.rom", enabled);
|
|
171
|
-
}
|
|
172
|
-
|
|
173
199
|
setDropdownText(modelName) {
|
|
174
200
|
const el = document.querySelector("#bbc-model-dropdown .bbc-model");
|
|
175
201
|
if (el) el.textContent = modelName;
|
|
176
202
|
}
|
|
177
203
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
this.model.os.push(romName);
|
|
181
|
-
} else {
|
|
182
|
-
let pos = this.model.os.indexOf(romName);
|
|
183
|
-
if (pos !== -1) {
|
|
184
|
-
this.model.os.splice(pos, 1);
|
|
185
|
-
}
|
|
186
|
-
}
|
|
204
|
+
get extraRoms() {
|
|
205
|
+
return fittedRoms(this);
|
|
187
206
|
}
|
|
188
207
|
|
|
189
208
|
mapLegacyModels(parsedQuery) {
|
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 {
|
|
6
|
+
import { TEST_6502, TEST_65C02, TEST_65C12, TubeModel } 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";
|
|
@@ -19,7 +19,6 @@ const dbgr = {
|
|
|
19
19
|
export function fake6502(model, opts) {
|
|
20
20
|
opts = opts || {};
|
|
21
21
|
model = model || TEST_6502;
|
|
22
|
-
if (opts.tube) model.tube = findModel("Tube65c02");
|
|
23
22
|
const CpuClass = model.isAtom ? AtomCpu6502 : Cpu6502;
|
|
24
23
|
return new CpuClass(model, {
|
|
25
24
|
dbgr,
|
|
@@ -30,6 +29,12 @@ export function fake6502(model, opts) {
|
|
|
30
29
|
music5000: new FakeMusic5000(),
|
|
31
30
|
cmos: new Cmos(),
|
|
32
31
|
cycleAccurate: opts.cycleAccurate,
|
|
32
|
+
config: {
|
|
33
|
+
tube: opts.tube ? TubeModel : null,
|
|
34
|
+
tubeCpuMultiplier: opts.tubeCpuMultiplier,
|
|
35
|
+
cpuMultiplier: opts.cpuMultiplier,
|
|
36
|
+
hasTeletextAdaptor: opts.hasTeletextAdaptor,
|
|
37
|
+
},
|
|
33
38
|
});
|
|
34
39
|
}
|
|
35
40
|
|
package/src/keyboard.js
CHANGED
|
@@ -341,8 +341,9 @@ export class Keyboard extends EventTarget {
|
|
|
341
341
|
if (this.isPasting) this.cancelPaste();
|
|
342
342
|
|
|
343
343
|
this.keyInterface.disableKeyboard();
|
|
344
|
-
|
|
345
|
-
|
|
344
|
+
// The paste task lives on the processor's scheduler, which is polled with peripheral
|
|
345
|
+
// cycles, so paste delays stay in real time whatever the CPU multiplier is.
|
|
346
|
+
this._pasteClocksPerMs = this.processor.peripheralCyclesPerSecond / 1000;
|
|
346
347
|
|
|
347
348
|
if (checkCapsAndShiftLocks) {
|
|
348
349
|
let toggleKey = null;
|
package/src/machine-session.js
CHANGED
|
@@ -30,6 +30,9 @@ export class MachineSession {
|
|
|
30
30
|
* @param {string} modelName - e.g. "B-DFS1.2", "Master"
|
|
31
31
|
* @param {Object} [opts]
|
|
32
32
|
* @param {string} [opts.discImage] - path to an .ssd or .dsd disc image to load on boot
|
|
33
|
+
* @param {boolean} [opts.tube] - attach a 65C02 second processor (Tube co-processor)
|
|
34
|
+
* @param {number} [opts.cpuMultiplier] - run the CPU this many times faster than the peripherals
|
|
35
|
+
* @param {boolean} [opts.hasTeletextAdaptor] - fit the Acorn teletext adaptor
|
|
33
36
|
*/
|
|
34
37
|
constructor(modelName = "B-DFS1.2", opts = {}) {
|
|
35
38
|
this.modelName = modelName;
|
|
@@ -65,8 +68,14 @@ export class MachineSession {
|
|
|
65
68
|
// toneGenerator); FakeSoundChip provides compatible no-op stubs for headless mode.
|
|
66
69
|
this._soundChip = modelObj.isAtom ? new FakeSoundChip() : new InstrumentedSoundChip();
|
|
67
70
|
|
|
68
|
-
// TestMachine forwards
|
|
69
|
-
this._machine = new TestMachine(modelName, {
|
|
71
|
+
// TestMachine forwards these to fake6502
|
|
72
|
+
this._machine = new TestMachine(modelName, {
|
|
73
|
+
video: this._video,
|
|
74
|
+
soundChip: this._soundChip,
|
|
75
|
+
tube: opts.tube,
|
|
76
|
+
cpuMultiplier: opts.cpuMultiplier,
|
|
77
|
+
hasTeletextAdaptor: opts.hasTeletextAdaptor,
|
|
78
|
+
});
|
|
70
79
|
|
|
71
80
|
// Accumulated VDU text output — drained by callers
|
|
72
81
|
this._pendingOutput = [];
|
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 } from "./6502.js";
|
|
10
|
+
import { Cpu6502, AtomCpu6502, DefaultTubeCpuMultiplier } 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,6 +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
23
|
import { initialise as electron } from "./app/electron.js";
|
|
23
24
|
import { AudioHandler } from "./web/audio-handler.js";
|
|
24
25
|
import { Econet } from "./econet.js";
|
|
@@ -29,8 +30,16 @@ import { GamepadSource } from "./gamepad-source.js";
|
|
|
29
30
|
import { MicrophoneInput } from "./microphone-input.js";
|
|
30
31
|
import { SpeechOutput } from "./speech-output.js";
|
|
31
32
|
import { MouseJoystickSource } from "./mouse-joystick-source.js";
|
|
33
|
+
import { calculateMouseCoordinates } from "./mouse-coordinates.js";
|
|
32
34
|
import { getFilterForMode } from "./canvas.js";
|
|
33
|
-
import {
|
|
35
|
+
import {
|
|
36
|
+
createSnapshot,
|
|
37
|
+
restoreSnapshot,
|
|
38
|
+
snapshotToJSON,
|
|
39
|
+
snapshotFromJSON,
|
|
40
|
+
isSameModel,
|
|
41
|
+
hasCoProcessor,
|
|
42
|
+
} from "./snapshot.js";
|
|
34
43
|
import { isBemSnapshot, parseBemSnapshot } from "./bem-snapshot.js";
|
|
35
44
|
import { isUefSnapshot, parseUefSnapshot } from "./uef-snapshot.js";
|
|
36
45
|
import { RewindBuffer } from "./rewind.js";
|
|
@@ -151,7 +160,7 @@ let keyLayout = window.localStorage.keyLayout || "physical";
|
|
|
151
160
|
|
|
152
161
|
const BBC = utils.BBC;
|
|
153
162
|
const keyCodes = utils.keyCodes;
|
|
154
|
-
|
|
163
|
+
const cpuMultiplier = parsedQuery.cpuMultiplier ?? 1;
|
|
155
164
|
let fastAsPossible = false;
|
|
156
165
|
let fastTape = false;
|
|
157
166
|
let noSeek;
|
|
@@ -217,27 +226,8 @@ const userPort = {
|
|
|
217
226
|
},
|
|
218
227
|
};
|
|
219
228
|
|
|
220
|
-
const emulationConfig = {
|
|
221
|
-
keyLayout: keyLayout,
|
|
222
|
-
coProcessor: parsedQuery.coProcessor,
|
|
223
|
-
cpuMultiplier: cpuMultiplier,
|
|
224
|
-
tubeCpuMultiplier: parsedQuery.tubeCpuMultiplier || 2,
|
|
225
|
-
videoCyclesBatch: parsedQuery.videoCyclesBatch,
|
|
226
|
-
extraRoms: extraRoms,
|
|
227
|
-
userPort: userPort,
|
|
228
|
-
printerPort: printerPort,
|
|
229
|
-
getGamepads: function () {
|
|
230
|
-
// Gamepads are only available in secure contexts. If e.g. loading from http:// urls they aren't there.
|
|
231
|
-
return navigator.getGamepads ? navigator.getGamepads() : [];
|
|
232
|
-
},
|
|
233
|
-
debugFlags: {
|
|
234
|
-
logFdcCommands: parsedQuery.logFdcCommands !== undefined,
|
|
235
|
-
logFdcStateChanges: parsedQuery.logFdcStateChanges !== undefined,
|
|
236
|
-
},
|
|
237
|
-
};
|
|
238
|
-
|
|
239
229
|
// Speech output: initialised from URL param; can be toggled at runtime via the Settings panel.
|
|
240
|
-
// Must be created before Config so the onClose callback and
|
|
230
|
+
// Must be created before Config so the onClose callback and the initial checkbox state can reference it.
|
|
241
231
|
const speechOutput = new SpeechOutput();
|
|
242
232
|
speechOutput.enabled = !!parsedQuery.speechOutput;
|
|
243
233
|
|
|
@@ -253,23 +243,6 @@ const config = new Config(
|
|
|
253
243
|
},
|
|
254
244
|
function onClose(changed) {
|
|
255
245
|
parsedQuery = Object.assign(parsedQuery, changed);
|
|
256
|
-
if (
|
|
257
|
-
changed.model ||
|
|
258
|
-
changed.coProcessor !== undefined ||
|
|
259
|
-
changed.hasMusic5000 !== undefined ||
|
|
260
|
-
changed.hasTeletextAdaptor !== undefined ||
|
|
261
|
-
changed.hasEconet !== undefined
|
|
262
|
-
) {
|
|
263
|
-
areYouSure(
|
|
264
|
-
"Changing model requires a restart of the emulator. Restart now?",
|
|
265
|
-
"Yes, restart now",
|
|
266
|
-
"No, thanks",
|
|
267
|
-
function () {
|
|
268
|
-
updateUrl();
|
|
269
|
-
window.location.reload();
|
|
270
|
-
},
|
|
271
|
-
);
|
|
272
|
-
}
|
|
273
246
|
if (changed.keyLayout) {
|
|
274
247
|
window.localStorage.keyLayout = changed.keyLayout;
|
|
275
248
|
emulationConfig.keyLayout = changed.keyLayout;
|
|
@@ -288,12 +261,22 @@ const config = new Config(
|
|
|
288
261
|
if (changed.tubeCpuMultiplier !== undefined) {
|
|
289
262
|
emulationConfig.tubeCpuMultiplier = changed.tubeCpuMultiplier;
|
|
290
263
|
config.setTubeCpuMultiplier(changed.tubeCpuMultiplier);
|
|
291
|
-
if (processor.
|
|
264
|
+
if (processor.hasTube) {
|
|
292
265
|
processor.tube.cpuMultiplier = changed.tubeCpuMultiplier;
|
|
293
266
|
}
|
|
294
267
|
}
|
|
295
268
|
updateUrl();
|
|
296
269
|
},
|
|
270
|
+
function onRestartRequired() {
|
|
271
|
+
areYouSure(
|
|
272
|
+
"Your change is saved, but only takes effect when the emulator restarts. Restart now?",
|
|
273
|
+
"Restart now",
|
|
274
|
+
"Later",
|
|
275
|
+
function () {
|
|
276
|
+
window.location.reload();
|
|
277
|
+
},
|
|
278
|
+
);
|
|
279
|
+
},
|
|
297
280
|
);
|
|
298
281
|
|
|
299
282
|
// Perform mapping of legacy models to the new format
|
|
@@ -301,19 +284,45 @@ config.mapLegacyModels(parsedQuery);
|
|
|
301
284
|
|
|
302
285
|
config.setModel(parsedQuery.model || guessModelFromHostname(window.location.hostname));
|
|
303
286
|
config.setKeyLayout(keyLayout);
|
|
304
|
-
config.
|
|
305
|
-
config.setTubeCpuMultiplier(parsedQuery.tubeCpuMultiplier || 2);
|
|
306
|
-
config.setEconet(parsedQuery.hasEconet);
|
|
307
|
-
config.setMusic5000(parsedQuery.hasMusic5000);
|
|
308
|
-
config.setTeletext(parsedQuery.hasTeletextAdaptor);
|
|
287
|
+
config.setTubeCpuMultiplier(parsedQuery.tubeCpuMultiplier || DefaultTubeCpuMultiplier);
|
|
309
288
|
config.setMicrophoneChannel(parsedQuery.microphoneChannel);
|
|
310
|
-
config.
|
|
311
|
-
|
|
289
|
+
config.setCheckboxes({
|
|
290
|
+
coProcessor: !!parsedQuery.coProcessor,
|
|
291
|
+
hasEconet: !!parsedQuery.hasEconet,
|
|
292
|
+
hasMusic5000: !!parsedQuery.hasMusic5000,
|
|
293
|
+
hasTeletextAdaptor: !!parsedQuery.hasTeletextAdaptor,
|
|
294
|
+
mouseJoystickEnabled: !!parsedQuery.mouseJoystickEnabled,
|
|
295
|
+
speechOutput: speechOutput.enabled,
|
|
296
|
+
});
|
|
312
297
|
let displayMode = parsedQuery.displayMode || "rgb";
|
|
313
298
|
config.setDisplayMode(displayMode);
|
|
314
299
|
|
|
315
300
|
model = config.model;
|
|
316
301
|
|
|
302
|
+
// Depends on the config.setX calls above having applied the URL parameters.
|
|
303
|
+
const emulationConfig = {
|
|
304
|
+
keyLayout,
|
|
305
|
+
cpuMultiplier,
|
|
306
|
+
tubeCpuMultiplier: config.tubeCpuMultiplier,
|
|
307
|
+
videoCyclesBatch: parsedQuery.videoCyclesBatch,
|
|
308
|
+
tube: config.coProcessor ? TubeModel : null,
|
|
309
|
+
hasMusic5000: config.hasMusic5000,
|
|
310
|
+
hasTeletextAdaptor: config.hasTeletextAdaptor,
|
|
311
|
+
// ROM order determines sideways bank allocation, and the fittings' ROMs claim banks
|
|
312
|
+
// before any the user asked for with ?rom=.
|
|
313
|
+
extraRoms: [...config.extraRoms, ...extraRoms],
|
|
314
|
+
userPort,
|
|
315
|
+
printerPort,
|
|
316
|
+
getGamepads: function () {
|
|
317
|
+
// Gamepads are only available in secure contexts. If e.g. loading from http:// urls they aren't there.
|
|
318
|
+
return navigator.getGamepads ? navigator.getGamepads() : [];
|
|
319
|
+
},
|
|
320
|
+
debugFlags: {
|
|
321
|
+
logFdcCommands: parsedQuery.logFdcCommands !== undefined,
|
|
322
|
+
logFdcStateChanges: parsedQuery.logFdcStateChanges !== undefined,
|
|
323
|
+
},
|
|
324
|
+
};
|
|
325
|
+
|
|
317
326
|
function sbBind(div, url, onload) {
|
|
318
327
|
const img = div.querySelector("img");
|
|
319
328
|
img.style.display = "none";
|
|
@@ -335,10 +344,7 @@ sbBind(document.querySelector(".sidebar.bottom"), parsedQuery.sbBottom, function
|
|
|
335
344
|
div.style.bottom = -img.naturalHeight + "px";
|
|
336
345
|
});
|
|
337
346
|
|
|
338
|
-
if (
|
|
339
|
-
cpuMultiplier = parsedQuery.cpuMultiplier;
|
|
340
|
-
console.log("CPU multiplier set to " + cpuMultiplier);
|
|
341
|
-
}
|
|
347
|
+
if (cpuMultiplier !== 1) console.log(`CPU multiplier set to ${cpuMultiplier}`);
|
|
342
348
|
const cpuSpeed = model.isAtom ? 1 * 1000 * 1000 : 2 * 1000 * 1000;
|
|
343
349
|
const clocksPerSecond = (cpuMultiplier * cpuSpeed) | 0;
|
|
344
350
|
const MaxCyclesPerFrame = clocksPerSecond / 10;
|
|
@@ -536,10 +542,8 @@ const cubMonitor = document.getElementById("cub-monitor");
|
|
|
536
542
|
function onCubMouseEvent(evt) {
|
|
537
543
|
audioHandler.tryResume();
|
|
538
544
|
if (document.activeElement !== document.body) document.activeElement.blur();
|
|
539
|
-
const cubRect = cubMonitor.getBoundingClientRect();
|
|
540
545
|
const screenRect = screenCanvas.getBoundingClientRect();
|
|
541
|
-
const x = (evt
|
|
542
|
-
const y = (evt.offsetY - cubRect.top + screenRect.top) / screenCanvas.offsetHeight;
|
|
546
|
+
const { x, y } = calculateMouseCoordinates(evt, screenRect);
|
|
543
547
|
|
|
544
548
|
// Handle touchscreen
|
|
545
549
|
if (processor.touchScreen) processor.touchScreen.onMouse(x, y, evt.buttons);
|
|
@@ -613,7 +617,7 @@ window.addEventListener("beforeunload", function (event) {
|
|
|
613
617
|
}
|
|
614
618
|
});
|
|
615
619
|
|
|
616
|
-
if (
|
|
620
|
+
if (config.hasEconet) {
|
|
617
621
|
econet = new Econet(stationId);
|
|
618
622
|
} else {
|
|
619
623
|
document.getElementById("fsmenuitem").style.display = "none";
|
|
@@ -657,7 +661,7 @@ processor = new CpuClass(model, {
|
|
|
657
661
|
soundChip: audioHandler.soundChip,
|
|
658
662
|
ddNoise: audioHandler.ddNoise,
|
|
659
663
|
relayNoise: audioHandler.relayNoise,
|
|
660
|
-
music5000:
|
|
664
|
+
music5000: config.hasMusic5000 ? audioHandler.music5000 : null,
|
|
661
665
|
cmos,
|
|
662
666
|
config: emulationConfig,
|
|
663
667
|
econet,
|
|
@@ -1570,10 +1574,10 @@ async function loadStateFromFile(file, preReadBuffer) {
|
|
|
1570
1574
|
}
|
|
1571
1575
|
snapshot = snapshotFromJSON(text);
|
|
1572
1576
|
}
|
|
1573
|
-
if (!isSameModel(snapshot.model, model.name)) {
|
|
1574
|
-
// Model mismatch: stash state and reload with
|
|
1577
|
+
if (!isSameModel(snapshot.model, model.name) || hasCoProcessor(snapshot) !== processor.hasTube) {
|
|
1578
|
+
// Model or co-processor mismatch: stash state and reload with a matching machine
|
|
1575
1579
|
sessionStorage.setItem("jsbeeb-pending-state", snapshotToJSON(snapshot));
|
|
1576
|
-
const newQuery = { ...parsedQuery, model: snapshot.model };
|
|
1580
|
+
const newQuery = { ...parsedQuery, model: snapshot.model, coProcessor: hasCoProcessor(snapshot) };
|
|
1577
1581
|
const baseUrl = window.location.origin + window.location.pathname;
|
|
1578
1582
|
window.location.href = buildUrlFromParams(baseUrl, newQuery, paramTypes);
|
|
1579
1583
|
return;
|
|
@@ -1691,7 +1695,7 @@ syncLights = function () {
|
|
|
1691
1695
|
drive0.update(processor.fdc.motorOn[0]);
|
|
1692
1696
|
drive1.update(processor.fdc.motorOn[1]);
|
|
1693
1697
|
cassette.update(processor.acia.motorOn);
|
|
1694
|
-
if (
|
|
1698
|
+
if (processor.econet) {
|
|
1695
1699
|
network.update(processor.econet.activityLight());
|
|
1696
1700
|
}
|
|
1697
1701
|
}
|
|
@@ -1849,14 +1853,22 @@ const aysEl = document.getElementById("are-you-sure");
|
|
|
1849
1853
|
const aysModal = new bootstrap.Modal(aysEl);
|
|
1850
1854
|
|
|
1851
1855
|
function areYouSure(message, yesText, noText, yesFunc) {
|
|
1856
|
+
const yesButton = aysEl.querySelector(".ays-yes");
|
|
1852
1857
|
aysEl.querySelector(".context").textContent = message;
|
|
1853
|
-
aysEl.querySelector(".ays-yes").textContent = yesText;
|
|
1854
1858
|
aysEl.querySelector(".ays-no").textContent = noText;
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1859
|
+
yesButton.textContent = yesText;
|
|
1860
|
+
let confirmed = false;
|
|
1861
|
+
const onYes = () => {
|
|
1862
|
+
confirmed = true;
|
|
1863
|
+
aysModal.hide();
|
|
1864
|
+
};
|
|
1865
|
+
yesButton.addEventListener("click", onYes, { once: true });
|
|
1866
|
+
// The "no" button, Escape and a click outside raise no event of their own: they only hide the modal.
|
|
1867
|
+
aysEl.addEventListener(
|
|
1868
|
+
"hidden.bs.modal",
|
|
1869
|
+
() => {
|
|
1870
|
+
yesButton.removeEventListener("click", onYes);
|
|
1871
|
+
if (confirmed) yesFunc();
|
|
1860
1872
|
},
|
|
1861
1873
|
{ once: true },
|
|
1862
1874
|
);
|
package/src/models.js
CHANGED
|
@@ -10,8 +10,16 @@ const CpuModel = Object.freeze({
|
|
|
10
10
|
CMOS65C12: 2,
|
|
11
11
|
});
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Describes what a machine *is*: which CPU, which ROMs, which disc controller.
|
|
15
|
+
* Anything a user can turn on and off for a session (second processor, Econet,
|
|
16
|
+
* Music 5000, teletext adaptor) belongs in the emulation config passed to Cpu6502.
|
|
17
|
+
*
|
|
18
|
+
* Instances are shared process-wide via `allModels` and frozen, so they can be handed
|
|
19
|
+
* to any number of machines without one session's settings leaking into the next.
|
|
20
|
+
*/
|
|
13
21
|
class Model {
|
|
14
|
-
constructor({ name, synonyms, os, cpuModel, isMaster, isAtom, swram, fdc,
|
|
22
|
+
constructor({ name, synonyms, os, cpuModel, isMaster, isAtom, swram, fdc, cmosOverride, banks } = {}) {
|
|
15
23
|
this.name = name;
|
|
16
24
|
this.synonyms = synonyms;
|
|
17
25
|
this.os = os;
|
|
@@ -22,10 +30,7 @@ class Model {
|
|
|
22
30
|
this.Fdc = fdc;
|
|
23
31
|
this.swram = swram;
|
|
24
32
|
this.isTest = false;
|
|
25
|
-
this.tube = tube;
|
|
26
33
|
this.cmosOverride = cmosOverride;
|
|
27
|
-
this.hasEconet = false;
|
|
28
|
-
this.hasMusic5000 = false;
|
|
29
34
|
}
|
|
30
35
|
|
|
31
36
|
get nmos() {
|
|
@@ -267,3 +272,18 @@ export const basicOnly = new Model({
|
|
|
267
272
|
swram: masterSwram,
|
|
268
273
|
fdc: NoiseAwareWdFdc,
|
|
269
274
|
});
|
|
275
|
+
|
|
276
|
+
/**
|
|
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.
|
|
280
|
+
*/
|
|
281
|
+
export const TubeModel = findModel("Tube65C02");
|
|
282
|
+
|
|
283
|
+
// After the isTest assignments above, so those still apply.
|
|
284
|
+
for (const model of [...allModels, TEST_6502, TEST_65C02, TEST_65C12, basicOnly]) {
|
|
285
|
+
Object.freeze(model.os);
|
|
286
|
+
Object.freeze(model.synonyms);
|
|
287
|
+
Object.freeze(model);
|
|
288
|
+
}
|
|
289
|
+
Object.freeze(allModels);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Calculate normalized mouse coordinates relative to the screen canvas.
|
|
3
|
+
*
|
|
4
|
+
* Uses clientX/clientY against the canvas bounding rect so that coordinates
|
|
5
|
+
* are correct even when the canvas is inset within a monitor image (e.g.
|
|
6
|
+
* display filters with non-zero canvasLeft/canvasTop).
|
|
7
|
+
*
|
|
8
|
+
* @param {MouseEvent} evt - The mouse event
|
|
9
|
+
* @param {DOMRect} screenRect - Bounding client rect of the screen canvas
|
|
10
|
+
* @returns {{x: number, y: number}} Normalized coordinates in [0, 1]
|
|
11
|
+
*/
|
|
12
|
+
export function calculateMouseCoordinates(evt, screenRect) {
|
|
13
|
+
const x = (evt.clientX - screenRect.left) / screenRect.width;
|
|
14
|
+
const y = (evt.clientY - screenRect.top) / screenRect.height;
|
|
15
|
+
return { x, y };
|
|
16
|
+
}
|