jsbeeb 1.22.2 → 1.22.4
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 +28 -32
- package/package.json +1 -1
- package/src/6502.js +6 -1
- package/src/app/electron.js +4 -2
- package/src/cmos.js +3 -3
- package/src/main.js +32 -29
- package/src/web/accessibility-switches.js +26 -0
- package/src/web/analogue-inputs.js +1 -2
- package/src/web/archive-list.js +1 -6
- package/src/web/debug.js +44 -37
- package/src/web/display.js +0 -3
- package/src/web/drives.js +4 -5
- package/src/web/google-drive-picker.js +31 -10
- package/src/web/hfe-picker.js +1 -0
- package/src/web/keyboard-setup.js +11 -46
- package/src/web/machine.js +8 -6
- package/src/web/media-loader.js +19 -22
- package/src/web/modals.js +20 -17
- package/src/web/settings.js +8 -9
- package/src/web/snapshot-ui.js +47 -14
- package/src/web/sth-picker.js +2 -0
- package/src/web/url-state.js +11 -1
- /package/{COPYING → LICENSE} +0 -0
package/README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
[](https://github.com/mattgodbolt/jsbeeb/actions/workflows/test-and-deploy.yml)
|
|
2
|
-
[](https://bbc.xania.org/coverage/index.html)
|
|
3
3
|
|
|
4
4
|
# jsbeeb - JavaScript BBC Micro Emulator
|
|
5
5
|
|
|
@@ -16,6 +16,7 @@ different peripherals.
|
|
|
16
16
|
- [Emulator Shortcuts](#emulator-shortcuts)
|
|
17
17
|
- [Printer Output](#printer-output)
|
|
18
18
|
- [Save State and Rewind](#save-state-and-rewind)
|
|
19
|
+
- [Joystick Support](#joystick-support)
|
|
19
20
|
- [Getting Set Up to Run Locally](#getting-set-up-to-run-locally)
|
|
20
21
|
- [Running as a Desktop Application](#running-as-a-desktop-application)
|
|
21
22
|
- [URL Parameters](#url-parameters)
|
|
@@ -120,10 +121,10 @@ Save and load full emulator state snapshots from the **State** menu (or `Ctrl+S`
|
|
|
120
121
|
|
|
121
122
|
The emulator continuously captures snapshots into a 30-slot rewind buffer (~1 per second). Open the rewind scrubber from **State > Rewind** or press **Alt+PageDown** to browse recent states as a visual filmstrip:
|
|
122
123
|
|
|
123
|
-
- **Left/Right arrows
|
|
124
|
+
- **Left/Right arrows**: navigate between snapshots (the main screen updates live)
|
|
124
125
|
- **Click** a thumbnail to jump to that point
|
|
125
|
-
- **Enter
|
|
126
|
-
- **Escape
|
|
126
|
+
- **Enter**: commit selection and close the panel
|
|
127
|
+
- **Escape**: cancel and restore the original state
|
|
127
128
|
|
|
128
129
|
### Joystick Support
|
|
129
130
|
|
|
@@ -146,13 +147,13 @@ presses `SPACE`. To play Superior's Space Invaders on a pad, where `COPY` fires:
|
|
|
146
147
|
|
|
147
148
|
The gamepad control names are:
|
|
148
149
|
|
|
149
|
-
- `FIRE
|
|
150
|
-
- `UP` `DOWN` `LEFT` `RIGHT
|
|
150
|
+
- `FIRE`: every button at once, which is usually what you want for a one-button game
|
|
151
|
+
- `UP` `DOWN` `LEFT` `RIGHT`: both analogue sticks at once, plus one face button each (`UP` is also `A`, `DOWN` is
|
|
151
152
|
`X`, `LEFT` is `Y`, `RIGHT` is `B`)
|
|
152
|
-
- `UP1` `DOWN1` `LEFT1` `RIGHT1
|
|
153
|
-
face buttons only
|
|
154
|
-
- `A` `B` `X` `Y` `START` `BACK` `LB` `RB` `LT` `RT
|
|
155
|
-
- `FIRE1` `FIRE2
|
|
153
|
+
- `UP1` `DOWN1` `LEFT1` `RIGHT1`: the left stick only; `UP2` `DOWN2` and so on: the right stick only; `UP3` `DOWN3`
|
|
154
|
+
and so on: the face buttons only
|
|
155
|
+
- `A` `B` `X` `Y` `START` `BACK` `LB` `RB` `LT` `RT`: individual buttons, by their Xbox 360 names
|
|
156
|
+
- `FIRE1` `FIRE2`: clicking the left and right sticks
|
|
156
157
|
|
|
157
158
|
The BBC key names are the same as for the keyboard, and digits may be written either way round: `GP.A=1` and `GP.A=K1`
|
|
158
159
|
both press `1`. Unlike `KEY.`, gamepad mappings are BBC-only: there's no Atom equivalent. The D-pad's default mapping
|
|
@@ -191,7 +192,9 @@ site to make it smaller and faster to load when it's deployed to [https://bbc.xa
|
|
|
191
192
|
|
|
192
193
|
## Running as a Desktop Application
|
|
193
194
|
|
|
194
|
-
jsbeeb can also run as a standalone desktop application using Electron
|
|
195
|
+
jsbeeb can also run as a standalone desktop application using Electron. Prebuilt packages (Debian/Ubuntu `.deb`,
|
|
196
|
+
Fedora/RHEL `.rpm` and a Windows installer) are attached to each
|
|
197
|
+
[GitHub release](https://github.com/mattgodbolt/jsbeeb/releases), or you can build your own:
|
|
195
198
|
|
|
196
199
|
### Running in Development
|
|
197
200
|
|
|
@@ -235,17 +238,15 @@ USE_SYSTEM_FPM=true npm run electron:build
|
|
|
235
238
|
**Debian/Ubuntu:**
|
|
236
239
|
|
|
237
240
|
```sh
|
|
238
|
-
sudo apt install ./out/dist/
|
|
241
|
+
sudo apt install ./out/dist/jsbeeb_<version>_amd64.deb
|
|
239
242
|
```
|
|
240
243
|
|
|
241
244
|
**Fedora/RHEL/CentOS:**
|
|
242
245
|
|
|
243
246
|
```sh
|
|
244
|
-
sudo rpm -i out/dist/jsbeeb
|
|
247
|
+
sudo rpm -i out/dist/jsbeeb-<version>.x86_64.rpm
|
|
245
248
|
```
|
|
246
249
|
|
|
247
|
-
**Note:** Electron support was re-enabled in November 2024 after being disabled during the ESM migration in 2021. It now works with Electron 28+ which added full ES Modules support.
|
|
248
|
-
|
|
249
250
|
## URL Parameters
|
|
250
251
|
|
|
251
252
|
- `autoboot` - fakes a shift break
|
|
@@ -335,19 +336,11 @@ Note that every update you make means you need to make a new raw link.
|
|
|
335
336
|
|
|
336
337
|
If you're looking to help:
|
|
337
338
|
|
|
338
|
-
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
dreadfully choppy on Chrome at least).
|
|
344
|
-
- Save disc support
|
|
345
|
-
- Local discs need to be made more workable and need an "export" feature
|
|
346
|
-
- Multiple discs need a UI
|
|
347
|
-
- `git grep -i todo`
|
|
348
|
-
- Optimisation
|
|
349
|
-
- While every attempt to make things fast has been made, I'm sure there's some more clever things that can be done
|
|
350
|
-
without compromising emulation accuracy
|
|
339
|
+
- Play lots of games and report anything that doesn't behave like the real machine, either on
|
|
340
|
+
[GitHub](https://github.com/mattgodbolt/jsbeeb/issues) or by email (matt@godbolt.org).
|
|
341
|
+
- Pick something from the [issue tracker](https://github.com/mattgodbolt/jsbeeb/issues): there's a mix of emulation
|
|
342
|
+
accuracy work, missing hardware and peripherals, user interface improvements and performance ideas in there, at all
|
|
343
|
+
levels of difficulty.
|
|
351
344
|
|
|
352
345
|
## Tests
|
|
353
346
|
|
|
@@ -370,12 +363,14 @@ For timing correctness, we have:
|
|
|
370
363
|
correctness-sensitive when it comes to the timers and interrupts of the BBC.
|
|
371
364
|
- Some 65C12-specific read-modify-write tests written by Ed Spittles.
|
|
372
365
|
|
|
373
|
-
Tests
|
|
374
|
-
|
|
366
|
+
Tests are run via npm scripts: `npm test` runs the whole suite. The vitest suites are `npm run test:unit`,
|
|
367
|
+
`npm run test:integration` and `npm run test:shader`; CPU tests are `npm run test:cpu`, and the smoke test is
|
|
368
|
+
`npm run test:smoke` (the shader and smoke tests need Chrome installed). Please note it can take a while to run the
|
|
369
|
+
whole test suite.
|
|
375
370
|
|
|
376
371
|
## Thanks
|
|
377
372
|
|
|
378
|
-
jsbeeb was heavily based on Sarah Walker's C [B-Em emulator](https://github.com/stardot/b-em)
|
|
373
|
+
jsbeeb was heavily based on Sarah Walker's C [B-Em emulator](https://github.com/stardot/b-em); thanks to her for her
|
|
379
374
|
hard work and for open sourcing her code. B-em is now being maintained by a group of enthusiasts - thanks to them too!
|
|
380
375
|
|
|
381
376
|
Huge thanks to Richard Talbot-Watkins for his advice and help along the way in fathoming out the instruction timings,
|
|
@@ -410,7 +405,8 @@ and posted the [video up on YouTube](https://www.youtube.com/watch?v=37jyHQT7fXQ
|
|
|
410
405
|
|
|
411
406
|
## License
|
|
412
407
|
|
|
413
|
-
This project is licensed under the
|
|
408
|
+
This project is licensed under the GNU General Public License v3.0 (or later); see the [LICENSE](LICENSE) file for
|
|
409
|
+
details.
|
|
414
410
|
|
|
415
411
|
## Contact
|
|
416
412
|
|
package/package.json
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"name": "jsbeeb",
|
|
8
8
|
"description": "Emulate a BBC Micro",
|
|
9
9
|
"repository": "git@github.com:mattgodbolt/jsbeeb.git",
|
|
10
|
-
"version": "1.22.
|
|
10
|
+
"version": "1.22.4",
|
|
11
11
|
"//engines": "If you change the version of Node, it must also be updated at the top of the Dockerfile.",
|
|
12
12
|
"engines": {
|
|
13
13
|
"node": ">=24.15.0"
|
package/src/6502.js
CHANGED
|
@@ -1195,6 +1195,7 @@ export class Cpu6502 extends Base6502 {
|
|
|
1195
1195
|
while (this.model.swram[romIndex]) {
|
|
1196
1196
|
romIndex--;
|
|
1197
1197
|
}
|
|
1198
|
+
if (romIndex < 0) throw new Error("Too many extra ROMs (no free sideways ROM banks)");
|
|
1198
1199
|
|
|
1199
1200
|
awaiting.push(this.loadRom(extraRoms[i_2], this.romOffset + romIndex * 16384));
|
|
1200
1201
|
}
|
|
@@ -1823,7 +1824,11 @@ export class AtomCpu6502 extends Cpu6502 {
|
|
|
1823
1824
|
|
|
1824
1825
|
// Override loadRom to accept 4KB ROMs
|
|
1825
1826
|
async loadRom(name, offset) {
|
|
1826
|
-
|
|
1827
|
+
if (name.indexOf("http") !== 0) name = "roms/" + name;
|
|
1828
|
+
let data = await utils.loadData(name);
|
|
1829
|
+
if (/\.zip/i.test(name)) {
|
|
1830
|
+
data = (await utils.unzipRomImage(data)).data;
|
|
1831
|
+
}
|
|
1827
1832
|
const len = data.length;
|
|
1828
1833
|
if (len !== 16384 && len !== 8192 && len !== 4096) {
|
|
1829
1834
|
throw new Error(`Broken ROM file ${name} (length=${len})`);
|
package/src/app/electron.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// Handles IPC communication for loading disc/tape images and showing modals from Electron's main process.
|
|
5
5
|
|
|
6
6
|
function init(args) {
|
|
7
|
-
const { loadDiscImage, loadTapeImage, loadStateFile, processor, modals, actions, config } = args;
|
|
7
|
+
const { loadDiscImage, loadTapeImage, loadStateFile, processor, modals, actions, config, media } = args;
|
|
8
8
|
const api = window.electronAPI;
|
|
9
9
|
|
|
10
10
|
api.onLoadDisc(async (message) => {
|
|
@@ -58,7 +58,9 @@ function init(args) {
|
|
|
58
58
|
config.addEventListener("settings-changed", (e) => {
|
|
59
59
|
api.saveSettings(e.detail);
|
|
60
60
|
});
|
|
61
|
-
|
|
61
|
+
}
|
|
62
|
+
if (media) {
|
|
63
|
+
media.addEventListener("media-changed", (e) => {
|
|
62
64
|
api.saveSettings(e.detail);
|
|
63
65
|
});
|
|
64
66
|
}
|
package/src/cmos.js
CHANGED
|
@@ -9,11 +9,11 @@
|
|
|
9
9
|
// STNICC-beeb to hang on the Master 128 because the WD1770 seek overran the
|
|
10
10
|
// vsync window. Confirmed by @tom-seddon (b2 emulator):
|
|
11
11
|
// https://github.com/mattgodbolt/jsbeeb/issues/576
|
|
12
|
-
const defaultCmos = [
|
|
12
|
+
const defaultCmos = Object.freeze([
|
|
13
13
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfe, 0x00, 0xeb, 0x00,
|
|
14
14
|
0xc9, 0xff, 0xff, 0x12, 0x00, 0x17, 0xc8, 0x1e, 0x05, 0x00, 0x35, 0xa6, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
|
15
15
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
|
16
|
-
];
|
|
16
|
+
]);
|
|
17
17
|
|
|
18
18
|
let timeOffset = 0;
|
|
19
19
|
|
|
@@ -82,7 +82,7 @@ export class Cmos {
|
|
|
82
82
|
this.cmosAddr = 0;
|
|
83
83
|
|
|
84
84
|
if (!this.store) {
|
|
85
|
-
this.store = defaultCmos;
|
|
85
|
+
this.store = [...defaultCmos];
|
|
86
86
|
}
|
|
87
87
|
if (cmosOverride) {
|
|
88
88
|
this.store = cmosOverride(this.store);
|
package/src/main.js
CHANGED
|
@@ -22,6 +22,7 @@ import { Display } from "./web/display.js";
|
|
|
22
22
|
import { Layout } from "./web/layout.js";
|
|
23
23
|
import { EmulationLoop, RewindCaptureInterval } from "./web/emulation-loop.js";
|
|
24
24
|
import { KeyboardSetup } from "./web/keyboard-setup.js";
|
|
25
|
+
import { AccessibilitySwitches } from "./web/accessibility-switches.js";
|
|
25
26
|
import { AnalogueInputs } from "./web/analogue-inputs.js";
|
|
26
27
|
import { FrontPanel } from "./web/front-panel.js";
|
|
27
28
|
import { Machine } from "./web/machine.js";
|
|
@@ -49,6 +50,7 @@ let secondDiscImage = null;
|
|
|
49
50
|
const { discImage: queryDiscImage, secondDiscImage: querySecondDisc, mmcImage } = parseMediaParams(parsedQuery);
|
|
50
51
|
if (queryDiscImage) discImage = queryDiscImage;
|
|
51
52
|
if (querySecondDisc) secondDiscImage = querySecondDisc;
|
|
53
|
+
const defaultBootDisc = queryDiscImage ? undefined : discImage;
|
|
52
54
|
const { settings: driveTracks, warnings: driveTrackWarnings } = processDriveTrackParams(parsedQuery);
|
|
53
55
|
|
|
54
56
|
const extraRoms = [];
|
|
@@ -92,19 +94,7 @@ const printer = new Printer({
|
|
|
92
94
|
}),
|
|
93
95
|
});
|
|
94
96
|
|
|
95
|
-
const
|
|
96
|
-
enterDebugger: () => loop.stop(true),
|
|
97
|
-
reload: () => window.location.reload(),
|
|
98
|
-
toggleFast: () => loop.toggleFastAsPossible(),
|
|
99
|
-
openRewind: () => rewindUI.open(),
|
|
100
|
-
openPrinter: () => frontPanel.checkPrinterWindow(),
|
|
101
|
-
pause: () => loop.stop(false),
|
|
102
|
-
resume: () => loop.go(),
|
|
103
|
-
onAnyKeyDown: () => {
|
|
104
|
-
audioHandler.tryResume();
|
|
105
|
-
inputs.ensureMicrophoneRunning();
|
|
106
|
-
},
|
|
107
|
-
});
|
|
97
|
+
const accessibilitySwitches = new AccessibilitySwitches();
|
|
108
98
|
|
|
109
99
|
const settings = new Settings({ urlState });
|
|
110
100
|
const { config, speechOutput, keyLayout, displayMode, audioOutput, speakerAmount } = settings;
|
|
@@ -188,7 +178,7 @@ const machine = new Machine({
|
|
|
188
178
|
cpuMultiplier,
|
|
189
179
|
extraRoms,
|
|
190
180
|
stationId,
|
|
191
|
-
userPort:
|
|
181
|
+
userPort: accessibilitySwitches.userPort,
|
|
192
182
|
printer,
|
|
193
183
|
speechOutput,
|
|
194
184
|
video,
|
|
@@ -197,6 +187,26 @@ const machine = new Machine({
|
|
|
197
187
|
});
|
|
198
188
|
const processor = machine.processor;
|
|
199
189
|
|
|
190
|
+
const keys = new KeyboardSetup({
|
|
191
|
+
actions: {
|
|
192
|
+
enterDebugger: () => loop.stop(true),
|
|
193
|
+
reload: () => window.location.reload(),
|
|
194
|
+
toggleFast: () => loop.toggleFastAsPossible(),
|
|
195
|
+
openRewind: () => rewindUI.open(),
|
|
196
|
+
openPrinter: () => frontPanel.checkPrinterWindow(),
|
|
197
|
+
pause: () => loop.stop(false),
|
|
198
|
+
resume: () => loop.go(),
|
|
199
|
+
onAnyKeyDown: () => {
|
|
200
|
+
audioHandler.tryResume();
|
|
201
|
+
inputs.ensureMicrophoneRunning();
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
accessibilitySwitches,
|
|
205
|
+
processor,
|
|
206
|
+
dbgr,
|
|
207
|
+
keyLayout,
|
|
208
|
+
});
|
|
209
|
+
|
|
200
210
|
const rewindBuffer = new RewindBuffer(30);
|
|
201
211
|
const loop = new EmulationLoop({
|
|
202
212
|
processor,
|
|
@@ -225,23 +235,15 @@ loop.addEventListener("running", () => {
|
|
|
225
235
|
|
|
226
236
|
const modals = new Modals({ loop });
|
|
227
237
|
|
|
228
|
-
const drives = new Drives({ fdc: processor.fdc, driveTracks,
|
|
238
|
+
const drives = new Drives({ fdc: processor.fdc, driveTracks, confirm: modals.confirm.bind(modals) });
|
|
229
239
|
const media = new MediaLoader({
|
|
230
240
|
processor,
|
|
231
241
|
model,
|
|
232
242
|
drives,
|
|
233
243
|
urlState,
|
|
234
|
-
config,
|
|
235
244
|
modals,
|
|
236
245
|
isSnapshotFile,
|
|
237
246
|
loadSnapshot: (file, buffer) => snapshots.loadStateFromFile(file, buffer),
|
|
238
|
-
// The archives are created further down; each source resolves when used.
|
|
239
|
-
sources: {
|
|
240
|
-
sth: (name) => sthPicker.discs.fetch(name),
|
|
241
|
-
tapeSth: (name) => sthPicker.tapes.fetch(name),
|
|
242
|
-
hfe: (path) => hfePicker.archive.fetch(path),
|
|
243
|
-
drive: (cat, layout) => drivePicker.load(cat, layout),
|
|
244
|
-
},
|
|
245
247
|
});
|
|
246
248
|
const autoBoot = new Autoboot({
|
|
247
249
|
model,
|
|
@@ -257,7 +259,7 @@ const sthPicker = new SthPicker({
|
|
|
257
259
|
processor,
|
|
258
260
|
autoboot: (image) => autoBoot.boot(image),
|
|
259
261
|
});
|
|
260
|
-
|
|
262
|
+
new HfePicker({
|
|
261
263
|
media,
|
|
262
264
|
drives,
|
|
263
265
|
modals,
|
|
@@ -265,7 +267,7 @@ const hfePicker = new HfePicker({
|
|
|
265
267
|
processor,
|
|
266
268
|
autoboot: (image) => autoBoot.boot(image),
|
|
267
269
|
});
|
|
268
|
-
|
|
270
|
+
new GoogleDrivePicker({ media, drives, modals, processor });
|
|
269
271
|
const snapshots = new SnapshotUI({
|
|
270
272
|
processor,
|
|
271
273
|
model,
|
|
@@ -275,6 +277,7 @@ const snapshots = new SnapshotUI({
|
|
|
275
277
|
urlState,
|
|
276
278
|
modals,
|
|
277
279
|
loop,
|
|
280
|
+
defaultBootDisc,
|
|
278
281
|
});
|
|
279
282
|
|
|
280
283
|
const inputs = new AnalogueInputs({
|
|
@@ -295,8 +298,6 @@ if (parsedQuery.microphoneChannel !== undefined) {
|
|
|
295
298
|
// Apply ADC source settings from URL parameters
|
|
296
299
|
inputs.updateAdcSources(parsedQuery.mouseJoystickEnabled, parsedQuery.microphoneChannel);
|
|
297
300
|
|
|
298
|
-
keys.attach({ processor, dbgr, keyLayout });
|
|
299
|
-
|
|
300
301
|
const frontPanel = new FrontPanel({ processor, model, printer });
|
|
301
302
|
|
|
302
303
|
// ------------------------------------------------------------------------
|
|
@@ -315,7 +316,7 @@ rewindUI.updateButtonState();
|
|
|
315
316
|
if (processor.fdc) new DiscVisualiser({ fdc: processor.fdc });
|
|
316
317
|
else document.getElementById("disc-visualiser-open").classList.add("disabled");
|
|
317
318
|
|
|
318
|
-
new Layout({
|
|
319
|
+
const layout = new Layout({
|
|
319
320
|
screenCanvas,
|
|
320
321
|
display,
|
|
321
322
|
embed: parsedQuery.embed !== undefined,
|
|
@@ -323,7 +324,7 @@ new Layout({
|
|
|
323
324
|
});
|
|
324
325
|
|
|
325
326
|
// Everything a setting can reach now exists.
|
|
326
|
-
settings.wire({ audioHandler, display, quickSettings, machine, keys, inputs, modals });
|
|
327
|
+
settings.wire({ audioHandler, display, layout, quickSettings, machine, keys, inputs, modals });
|
|
327
328
|
|
|
328
329
|
// ------------------------------------------------------------------------
|
|
329
330
|
// The page's own handlers.
|
|
@@ -393,6 +394,7 @@ document.getElementById("soft-reset").addEventListener("click", function (event)
|
|
|
393
394
|
});
|
|
394
395
|
|
|
395
396
|
document.getElementById("download-filestore-link").addEventListener("click", function () {
|
|
397
|
+
if (!processor.filestore) return;
|
|
396
398
|
downloadDriveData(processor.filestore.scsi, "scsi", ".dat");
|
|
397
399
|
});
|
|
398
400
|
|
|
@@ -500,6 +502,7 @@ electron({
|
|
|
500
502
|
loadTapeImage: media.loadTapeImage.bind(media),
|
|
501
503
|
processor,
|
|
502
504
|
config,
|
|
505
|
+
media,
|
|
503
506
|
modals: {
|
|
504
507
|
show: (modalId, sthType) => {
|
|
505
508
|
if (modalId === "sth" && sthType) {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The accessibility switches on the user port. Bits 0-7 correspond to switches
|
|
3
|
+
* 1-8, active low: 0xff = no switches pressed; a clear bit = that switch held.
|
|
4
|
+
*
|
|
5
|
+
* On real hardware, the Brilliant Computing switch interface box and special-ed
|
|
6
|
+
* joystick connect to the User Port only; they do not touch the analogue port
|
|
7
|
+
* or the System VIA fire buttons (PB4/PB5), which belong to the standard
|
|
8
|
+
* analogue joystick connector.
|
|
9
|
+
*/
|
|
10
|
+
export class AccessibilitySwitches {
|
|
11
|
+
constructor() {
|
|
12
|
+
this.switchState = 0xff;
|
|
13
|
+
const switches = this;
|
|
14
|
+
this.userPort = {
|
|
15
|
+
write() {},
|
|
16
|
+
read() {
|
|
17
|
+
return switches.switchState;
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
setSwitch(index, held) {
|
|
23
|
+
if (held) this.switchState &= ~(1 << index);
|
|
24
|
+
else this.switchState |= 1 << index;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -90,8 +90,7 @@ export class AnalogueInputs {
|
|
|
90
90
|
|
|
91
91
|
clearMicrophoneChannel() {
|
|
92
92
|
this.config.setMicrophoneChannel(undefined);
|
|
93
|
-
|
|
94
|
-
this.urlState.updateUrl();
|
|
93
|
+
this.urlState.set({ microphoneChannel: undefined });
|
|
95
94
|
}
|
|
96
95
|
|
|
97
96
|
async ensureMicrophoneRunning() {
|
package/src/web/archive-list.js
CHANGED
|
@@ -28,12 +28,7 @@ export class AutobootTicks {
|
|
|
28
28
|
for (const check of this.checks) {
|
|
29
29
|
check.addEventListener("click", () => {
|
|
30
30
|
this.show(check.checked);
|
|
31
|
-
|
|
32
|
-
urlState.params.autoboot = "";
|
|
33
|
-
} else {
|
|
34
|
-
delete urlState.params.autoboot;
|
|
35
|
-
}
|
|
36
|
-
urlState.updateUrl();
|
|
31
|
+
urlState.set({ autoboot: check.checked ? true : undefined });
|
|
37
32
|
});
|
|
38
33
|
}
|
|
39
34
|
}
|
package/src/web/debug.js
CHANGED
|
@@ -89,6 +89,48 @@ class MemoryView {
|
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Guesses the address of the instruction before the given one by scoring each
|
|
94
|
+
* possible run of instructions leading up to it: a point per "common"
|
|
95
|
+
* instruction (loads, stores, branches, compares, arithmetic and
|
|
96
|
+
* carry-set/clear that avoid unusual indexing modes) and a large boost for a
|
|
97
|
+
* run that passes through the current program counter. The highest-scoring
|
|
98
|
+
* run wins and its last instruction is the answer.
|
|
99
|
+
* Good test cases:
|
|
100
|
+
* Repton 2 @ 2cbb
|
|
101
|
+
* MOS @ cfc8
|
|
102
|
+
* also, just starting from the back of ROM and going up...
|
|
103
|
+
*/
|
|
104
|
+
export function prevInstruction(disassemble, pc, address) {
|
|
105
|
+
const commonInstructions =
|
|
106
|
+
/(RTS|B..|JMP|JSR|LD[AXY]|ST[AXY]|TA[XY]|T[XY]A|AD[DC]|SUB|SBC|CLC|SEC|CMP|EOR|ORR|AND|INC|DEC).*/;
|
|
107
|
+
const uncommonInstrucions = /.*,\s*([XY]|X\))$/;
|
|
108
|
+
|
|
109
|
+
address &= 0xffff;
|
|
110
|
+
let bestAddr = address - 1;
|
|
111
|
+
let bestScore = 0;
|
|
112
|
+
for (let startingPoint = address - 20; startingPoint !== address; startingPoint++) {
|
|
113
|
+
let score = 0;
|
|
114
|
+
let addr = startingPoint & 0xffff;
|
|
115
|
+
while (addr < address) {
|
|
116
|
+
const result = disassemble(addr);
|
|
117
|
+
if (addr === pc) score += 10;
|
|
118
|
+
if (result[0].match(commonInstructions) && !result[0].match(uncommonInstrucions)) {
|
|
119
|
+
score++;
|
|
120
|
+
}
|
|
121
|
+
if (result[1] === address) {
|
|
122
|
+
if (score > bestScore) {
|
|
123
|
+
bestScore = score;
|
|
124
|
+
bestAddr = addr;
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
addr = result[1];
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return bestAddr;
|
|
132
|
+
}
|
|
133
|
+
|
|
92
134
|
export class Debugger {
|
|
93
135
|
constructor() {
|
|
94
136
|
this.patchInstructions = new Map();
|
|
@@ -382,42 +424,7 @@ export class Debugger {
|
|
|
382
424
|
}
|
|
383
425
|
|
|
384
426
|
prevInstruction(address) {
|
|
385
|
-
|
|
386
|
-
// up to the target by counting all "common" instructions as a point. The highest-scoring run of
|
|
387
|
-
// instructions is picked as the most likely, and the previous from that is used. Common instructions
|
|
388
|
-
// here mean loads, stores, branches, compares, arithmetic and carry-set/clear that don't use "unusual"
|
|
389
|
-
// indexing modes like abs,X, abs,Y and (zp,X).
|
|
390
|
-
// Good test cases:
|
|
391
|
-
// Repton 2 @ 2cbb
|
|
392
|
-
// MOS @ cfc8
|
|
393
|
-
// also, just starting from the back of ROM and going up...
|
|
394
|
-
const commonInstructions =
|
|
395
|
-
/(RTS|B..|JMP|JSR|LD[AXY]|ST[AXY]|TA[XY]|T[XY]A|AD[DC]|SUB|SBC|CLC|SEC|CMP|EOR|ORR|AND|INC|DEC).*/;
|
|
396
|
-
const uncommonInstrucions = /.*,\s*([XY]|X\))$/;
|
|
397
|
-
|
|
398
|
-
address &= 0xffff;
|
|
399
|
-
let bestAddr = address - 1;
|
|
400
|
-
let bestScore = 0;
|
|
401
|
-
for (let startingPoint = address - 20; startingPoint !== address; startingPoint++) {
|
|
402
|
-
let score = 0;
|
|
403
|
-
let addr = startingPoint & 0xffff;
|
|
404
|
-
while (addr < address) {
|
|
405
|
-
const result = this.disassemble(addr);
|
|
406
|
-
if (result[0] === this.cpu.pc) score += 10; // huge boost if this instruction was executed
|
|
407
|
-
if (result[0].match(commonInstructions) && !result[0].match(uncommonInstrucions)) {
|
|
408
|
-
score++;
|
|
409
|
-
}
|
|
410
|
-
if (result[1] === address) {
|
|
411
|
-
if (score > bestScore) {
|
|
412
|
-
bestScore = score;
|
|
413
|
-
bestAddr = addr;
|
|
414
|
-
break;
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
addr = result[1];
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
return bestAddr;
|
|
427
|
+
return prevInstruction((addr) => this.disassemble(addr), this.cpu.pc, address);
|
|
421
428
|
}
|
|
422
429
|
|
|
423
430
|
updatePrevMem() {
|
|
@@ -544,7 +551,7 @@ export class Debugger {
|
|
|
544
551
|
case "N":
|
|
545
552
|
this.updatePrevMem();
|
|
546
553
|
this.cpu.execute(1);
|
|
547
|
-
|
|
554
|
+
this.debug(this.cpu.pc);
|
|
548
555
|
break;
|
|
549
556
|
case "m":
|
|
550
557
|
this.stepOver();
|
package/src/web/display.js
CHANGED
|
@@ -111,9 +111,6 @@ export class Display {
|
|
|
111
111
|
this.sizeCanvasFor(this.filterClass);
|
|
112
112
|
this.video.paint();
|
|
113
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
114
|
}
|
|
118
115
|
|
|
119
116
|
sizeCanvasFor(filterClass) {
|
package/src/web/drives.js
CHANGED
|
@@ -11,7 +11,7 @@ const tracksPerStepFor = (tracks) => (tracks === "40" ? 2 : 1);
|
|
|
11
11
|
* switches on the Discs menu, and downloading what is in drive 0.
|
|
12
12
|
*/
|
|
13
13
|
export class Drives {
|
|
14
|
-
constructor({ fdc, driveTracks,
|
|
14
|
+
constructor({ fdc, driveTracks, confirm }) {
|
|
15
15
|
this.fdc = fdc;
|
|
16
16
|
this.driveTracks = driveTracks;
|
|
17
17
|
this.saidWritesAreNotKept = false;
|
|
@@ -33,7 +33,7 @@ export class Drives {
|
|
|
33
33
|
if (drive) this.showDriveTracks(driveIndex);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
document.getElementById("download-drive-link").addEventListener("click", () => {
|
|
36
|
+
document.getElementById("download-drive-link").addEventListener("click", async () => {
|
|
37
37
|
const disc = this.discToDownload();
|
|
38
38
|
if (!disc) return;
|
|
39
39
|
const save = (options) =>
|
|
@@ -41,9 +41,8 @@ export class Drives {
|
|
|
41
41
|
try {
|
|
42
42
|
save();
|
|
43
43
|
} catch (e) {
|
|
44
|
-
|
|
45
|
-
save({ force: true })
|
|
46
|
-
);
|
|
44
|
+
if (await confirm(`${e.message} Save anyway, losing what will not fit?`, "Save anyway", "Cancel"))
|
|
45
|
+
save({ force: true });
|
|
47
46
|
}
|
|
48
47
|
});
|
|
49
48
|
|
|
@@ -16,6 +16,7 @@ export class GoogleDrivePicker {
|
|
|
16
16
|
this.modals = modals;
|
|
17
17
|
this.processor = processor;
|
|
18
18
|
this.googleDrive = loader;
|
|
19
|
+
media.addSource("drive", (cat, layout) => this.load(cat, layout));
|
|
19
20
|
|
|
20
21
|
this.authEl = document.getElementById("google-drive-auth");
|
|
21
22
|
this.el = document.getElementById("google-drive");
|
|
@@ -27,10 +28,13 @@ export class GoogleDrivePicker {
|
|
|
27
28
|
this.authEl.style.display = "none";
|
|
28
29
|
e.preventDefault();
|
|
29
30
|
const authed = await this.auth(false);
|
|
30
|
-
if (authed) this.authResolve();
|
|
31
|
-
else this.authReject(new Error("Unable to authorize Google Drive"));
|
|
31
|
+
if (authed) this.authResolve?.(true);
|
|
32
|
+
else this.authReject?.(new Error("Unable to authorize Google Drive"));
|
|
32
33
|
});
|
|
33
34
|
|
|
35
|
+
// Closing the loading dialog while the auth panel waits is a cancellation, not an error.
|
|
36
|
+
document.getElementById("loading-dialog").addEventListener("hidden.bs.modal", () => this.authResolve?.(false));
|
|
37
|
+
|
|
34
38
|
// Loading the Google client holds the main thread for ~100ms, so it waits for
|
|
35
39
|
// someone to ask for Drive.
|
|
36
40
|
document.getElementById("open-drive-link").addEventListener("click", async (e) => {
|
|
@@ -73,11 +77,21 @@ export class GoogleDrivePicker {
|
|
|
73
77
|
console.log("Google Drive authed=", authed);
|
|
74
78
|
|
|
75
79
|
if (!authed) {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
80
|
+
let granted;
|
|
81
|
+
try {
|
|
82
|
+
granted = await new Promise((resolve, reject) => {
|
|
83
|
+
this.authResolve = resolve;
|
|
84
|
+
this.authReject = reject;
|
|
85
|
+
this.authEl.style.display = "";
|
|
86
|
+
});
|
|
87
|
+
} finally {
|
|
88
|
+
this.authResolve = this.authReject = null;
|
|
89
|
+
}
|
|
90
|
+
if (!granted) {
|
|
91
|
+
console.log("Google Drive authorization dismissed");
|
|
92
|
+
this.modals.loadingFinished();
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
81
95
|
}
|
|
82
96
|
|
|
83
97
|
const ssd = await this.googleDrive.load(this.processor.fdc, cat.id, layout);
|
|
@@ -92,7 +106,8 @@ export class GoogleDrivePicker {
|
|
|
92
106
|
return ssd;
|
|
93
107
|
} catch (error) {
|
|
94
108
|
console.error("Google Drive loading error:", error);
|
|
95
|
-
this.modals.loadingFinished(
|
|
109
|
+
this.modals.loadingFinished();
|
|
110
|
+
throw error;
|
|
96
111
|
}
|
|
97
112
|
}
|
|
98
113
|
|
|
@@ -121,8 +136,14 @@ export class GoogleDrivePicker {
|
|
|
121
136
|
utils.noteEvent("google-drive", "click", item.name);
|
|
122
137
|
this.media.setDisc1Image(`gd:${item.id}/${item.name}`);
|
|
123
138
|
this.modal.hide();
|
|
124
|
-
|
|
125
|
-
|
|
139
|
+
try {
|
|
140
|
+
const ssd = await this.load(item, this.drives.layoutForDrive(0));
|
|
141
|
+
if (ssd) this.drives.putDiscIn(0, ssd);
|
|
142
|
+
} catch (error) {
|
|
143
|
+
toast(`Unable to load ${item.name} from Google Drive: ${errorText(error)}`, {
|
|
144
|
+
title: "Google Drive",
|
|
145
|
+
});
|
|
146
|
+
}
|
|
126
147
|
});
|
|
127
148
|
}
|
|
128
149
|
}
|
package/src/web/hfe-picker.js
CHANGED
|
@@ -41,6 +41,7 @@ export class HfePicker {
|
|
|
41
41
|
showArchiveMessage("hfe", "hfe-list", "There was an error accessing the HFE archive");
|
|
42
42
|
},
|
|
43
43
|
);
|
|
44
|
+
media.addSource("hfe", (path) => this.archive.fetch(path));
|
|
44
45
|
|
|
45
46
|
this.modal = new bootstrap.Modal(document.getElementById("hfe"));
|
|
46
47
|
document.getElementById("hfe").addEventListener("shown.bs.modal", () => {
|
|
@@ -2,36 +2,16 @@ import * as utils from "../utils.js";
|
|
|
2
2
|
import { Keyboard } from "../keyboard.js";
|
|
3
3
|
import { showNotice } from "./reporting.js";
|
|
4
4
|
|
|
5
|
-
/**
|
|
6
|
-
* The page's keyboard: the emulated one, the browser shortcuts around it, and
|
|
7
|
-
* the accessibility switches on the user port. Built in two steps because the
|
|
8
|
-
* user port has to exist before the processor does, and the keyboard after.
|
|
9
|
-
*/
|
|
5
|
+
/** The page's keyboard: the emulated one and the browser shortcuts around it. */
|
|
10
6
|
export class KeyboardSetup {
|
|
11
7
|
/**
|
|
12
|
-
* @param {object}
|
|
8
|
+
* @param {object} opts
|
|
9
|
+
* @param {object} opts.actions what each shortcut does, supplied late-bound:
|
|
13
10
|
* enterDebugger, reload, toggleFast, openRewind, openPrinter,
|
|
14
11
|
* pause, resume, onAnyKeyDown
|
|
12
|
+
* @param {import("./accessibility-switches.js").AccessibilitySwitches} opts.accessibilitySwitches
|
|
15
13
|
*/
|
|
16
|
-
constructor(actions) {
|
|
17
|
-
this.actions = actions;
|
|
18
|
-
this.keyboard = null;
|
|
19
|
-
|
|
20
|
-
// Accessibility switch state: bits 0-7 correspond to switches 1-8.
|
|
21
|
-
// Active low: 0xff = no switches pressed; clearing a bit = that switch is pressed.
|
|
22
|
-
this.switchState = 0xff;
|
|
23
|
-
const setup = this;
|
|
24
|
-
this.userPort = {
|
|
25
|
-
write() {},
|
|
26
|
-
read() {
|
|
27
|
-
return setup.switchState;
|
|
28
|
-
},
|
|
29
|
-
};
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** Initialise keyboard now that processor exists */
|
|
33
|
-
attach({ processor, dbgr, keyLayout }) {
|
|
34
|
-
const { actions } = this;
|
|
14
|
+
constructor({ actions, accessibilitySwitches, processor, dbgr, keyLayout }) {
|
|
35
15
|
const keyboard = (this.keyboard = new Keyboard({
|
|
36
16
|
processor,
|
|
37
17
|
inputEnabledFunction: () => document.activeElement && document.activeElement.id === "paste-text",
|
|
@@ -66,22 +46,11 @@ export class KeyboardSetup {
|
|
|
66
46
|
keyboard.registerKeyHandler(utils.keyCodes.PAGEDOWN, onDown("pagedown", actions.openRewind), alt);
|
|
67
47
|
keyboard.registerKeyHandler(utils.keyCodes.B, onDown(null, actions.openPrinter), ctrl);
|
|
68
48
|
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
// joystick connect to the User Port only; they do not touch the analogue port
|
|
75
|
-
// or the System VIA fire buttons (PB4/PB5), which belong to the standard
|
|
76
|
-
// analogue joystick connector. So we only update switchState here.
|
|
77
|
-
const handleSwitch = (bit) => (down) => {
|
|
78
|
-
if (down) this.switchState &= ~(1 << bit);
|
|
79
|
-
else this.switchState |= 1 << bit;
|
|
80
|
-
};
|
|
81
|
-
|
|
82
|
-
// Alt+1-8 and Alt+F1-F8 trigger the switches. Using Alt means the underlying
|
|
83
|
-
// key is never forwarded to the BBC Micro (keyboard.js bails out early when a
|
|
84
|
-
// handler fires), so typing numbers or using function keys works normally.
|
|
49
|
+
// Alt+1-8 and Alt+F1-F8 trigger the accessibility switches. Using Alt means
|
|
50
|
+
// the underlying key is never forwarded to the BBC Micro (keyboard.js bails
|
|
51
|
+
// out early when a handler fires), so typing numbers or using function keys
|
|
52
|
+
// works normally.
|
|
53
|
+
const handleSwitch = (index) => (down) => accessibilitySwitches.setSwitch(index, down);
|
|
85
54
|
for (let i = 0; i < 8; i++) {
|
|
86
55
|
keyboard.registerKeyHandler(utils.keyCodes.K1 + i, handleSwitch(i), alt);
|
|
87
56
|
keyboard.registerKeyHandler(utils.keyCodes.F1 + i, handleSwitch(i), alt);
|
|
@@ -96,11 +65,7 @@ export class KeyboardSetup {
|
|
|
96
65
|
}
|
|
97
66
|
|
|
98
67
|
sendRawKeyboard(keysToSend, checkCapsAndShiftLocks) {
|
|
99
|
-
|
|
100
|
-
this.keyboard.sendRawKeyboard(keysToSend, checkCapsAndShiftLocks);
|
|
101
|
-
} else {
|
|
102
|
-
console.warn("Tried to send keys before keyboard was initialised");
|
|
103
|
-
}
|
|
68
|
+
this.keyboard.sendRawKeyboard(keysToSend, checkCapsAndShiftLocks);
|
|
104
69
|
}
|
|
105
70
|
|
|
106
71
|
clearKeys() {
|
package/src/web/machine.js
CHANGED
|
@@ -162,15 +162,17 @@ export class Machine {
|
|
|
162
162
|
}
|
|
163
163
|
|
|
164
164
|
if (discImage) {
|
|
165
|
-
startImageLoad(`disc ${discImage}`, async () =>
|
|
166
|
-
|
|
167
|
-
|
|
165
|
+
startImageLoad(`disc ${discImage}`, async () => {
|
|
166
|
+
const loadedDisc = await media.loadDiscImage(discImage, drives.layoutForDrive(0));
|
|
167
|
+
if (loadedDisc) drives.putDiscIn(0, loadedDisc);
|
|
168
|
+
});
|
|
168
169
|
}
|
|
169
170
|
|
|
170
171
|
if (secondDiscImage) {
|
|
171
|
-
startImageLoad(`disc ${secondDiscImage}`, async () =>
|
|
172
|
-
|
|
173
|
-
|
|
172
|
+
startImageLoad(`disc ${secondDiscImage}`, async () => {
|
|
173
|
+
const loadedDisc = await media.loadDiscImage(secondDiscImage, drives.layoutForDrive(1));
|
|
174
|
+
if (loadedDisc) drives.putDiscIn(1, loadedDisc);
|
|
175
|
+
});
|
|
174
176
|
}
|
|
175
177
|
|
|
176
178
|
if (tape) {
|
package/src/web/media-loader.js
CHANGED
|
@@ -50,22 +50,20 @@ function readFileAsBinaryString(file) {
|
|
|
50
50
|
* URL schema can name, the local file inputs, the drop zone and the built-in
|
|
51
51
|
* list. Choosing what goes in a drive funnels through drives.putDiscIn.
|
|
52
52
|
*/
|
|
53
|
-
export class MediaLoader {
|
|
53
|
+
export class MediaLoader extends EventTarget {
|
|
54
54
|
/**
|
|
55
55
|
* @param {object} deps
|
|
56
|
-
* @param {object} deps.sources fetchers keyed by schema: sth, tapeSth and hfe
|
|
57
|
-
* resolve an archive name to image data; drive loads a Google Drive file
|
|
58
56
|
* @param {Function} deps.isSnapshotFile says whether a dropped file is a save state
|
|
59
57
|
* @param {Function} deps.loadSnapshot restores a dropped save state
|
|
60
58
|
*/
|
|
61
|
-
constructor({ processor, model, drives, urlState,
|
|
59
|
+
constructor({ processor, model, drives, urlState, modals, isSnapshotFile, loadSnapshot }) {
|
|
60
|
+
super();
|
|
62
61
|
this.processor = processor;
|
|
63
62
|
this.model = model;
|
|
64
63
|
this.drives = drives;
|
|
65
64
|
this.urlState = urlState;
|
|
66
|
-
this.config = config;
|
|
67
65
|
this.modals = modals;
|
|
68
|
-
this.sources =
|
|
66
|
+
this.sources = {};
|
|
69
67
|
|
|
70
68
|
document.getElementById("disc_load").addEventListener("change", async (evt) => {
|
|
71
69
|
if (evt.target.files.length === 0) return;
|
|
@@ -105,8 +103,7 @@ export class MediaLoader {
|
|
|
105
103
|
tapeName = unzipped.name;
|
|
106
104
|
}
|
|
107
105
|
this.setProcessorTape(await loadTapeFromData(tapeName, tapeData, model));
|
|
108
|
-
|
|
109
|
-
urlState.updateUrl();
|
|
106
|
+
urlState.set({ tape: undefined });
|
|
110
107
|
modals.hide("tapes");
|
|
111
108
|
} catch (error) {
|
|
112
109
|
reportLoadFailure(file.name, error);
|
|
@@ -167,6 +164,11 @@ export class MediaLoader {
|
|
|
167
164
|
return this.urlState.params;
|
|
168
165
|
}
|
|
169
166
|
|
|
167
|
+
/** Register the fetcher behind an image schema; each picker calls this as it is constructed. */
|
|
168
|
+
addSource(schema, fetcher) {
|
|
169
|
+
this.sources[schema] = fetcher;
|
|
170
|
+
}
|
|
171
|
+
|
|
170
172
|
/** Route tape to the correct interface (ACIA for BBC, PPIA for Atom) */
|
|
171
173
|
setProcessorTape(tape) {
|
|
172
174
|
if (this.model.isAtom) {
|
|
@@ -177,22 +179,18 @@ export class MediaLoader {
|
|
|
177
179
|
}
|
|
178
180
|
|
|
179
181
|
setDisc1Image(name) {
|
|
180
|
-
|
|
181
|
-
this.
|
|
182
|
-
this.urlState.updateUrl();
|
|
183
|
-
this.config.dispatchEvent(new CustomEvent("media-changed", { detail: { disc1: name } }));
|
|
182
|
+
this.urlState.set({ disc: undefined, disc1: name });
|
|
183
|
+
this.dispatchEvent(new CustomEvent("media-changed", { detail: { disc1: name } }));
|
|
184
184
|
}
|
|
185
185
|
|
|
186
186
|
setDisc2Image(name) {
|
|
187
|
-
this.
|
|
188
|
-
this.
|
|
189
|
-
this.config.dispatchEvent(new CustomEvent("media-changed", { detail: { disc2: name } }));
|
|
187
|
+
this.urlState.set({ disc2: name });
|
|
188
|
+
this.dispatchEvent(new CustomEvent("media-changed", { detail: { disc2: name } }));
|
|
190
189
|
}
|
|
191
190
|
|
|
192
191
|
setTapeImage(name) {
|
|
193
|
-
this.
|
|
194
|
-
this.
|
|
195
|
-
this.config.dispatchEvent(new CustomEvent("media-changed", { detail: { tape: name } }));
|
|
192
|
+
this.urlState.set({ tape: name });
|
|
193
|
+
this.dispatchEvent(new CustomEvent("media-changed", { detail: { tape: name } }));
|
|
196
194
|
}
|
|
197
195
|
|
|
198
196
|
async loadHTMLFile(file) {
|
|
@@ -207,15 +205,14 @@ export class MediaLoader {
|
|
|
207
205
|
// Local file: retain the image bytes for embedding in save-to-file snapshots.
|
|
208
206
|
loadedDisc.setOriginalImage(imageData);
|
|
209
207
|
this.drives.putDiscIn(0, loadedDisc);
|
|
210
|
-
|
|
211
|
-
delete this.params.disc1;
|
|
212
|
-
this.urlState.updateUrl();
|
|
208
|
+
this.urlState.set({ disc: undefined, disc1: undefined });
|
|
213
209
|
this.modals.hide("discs");
|
|
214
210
|
}
|
|
215
211
|
|
|
216
212
|
async loadSCSIFile(file) {
|
|
217
|
-
const binaryData = await readFileAsBinaryString(file);
|
|
218
213
|
const { processor } = this;
|
|
214
|
+
if (!processor.filestore) return;
|
|
215
|
+
const binaryData = await readFileAsBinaryString(file);
|
|
219
216
|
processor.filestore.scsi = utils.stringToUint8Array(binaryData);
|
|
220
217
|
|
|
221
218
|
processor.filestore.PC = 0x400;
|
package/src/web/modals.js
CHANGED
|
@@ -58,26 +58,29 @@ export class Modals {
|
|
|
58
58
|
if (message) toast(message);
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
/** @returns {Promise<boolean>} true for the yes button; false for any other way out of the dialog */
|
|
62
|
+
confirm(message, yesText, noText) {
|
|
62
63
|
const yesButton = this.aysEl.querySelector(".ays-yes");
|
|
63
64
|
this.aysEl.querySelector(".context").textContent = message;
|
|
64
65
|
this.aysEl.querySelector(".ays-no").textContent = noText;
|
|
65
66
|
yesButton.textContent = yesText;
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
67
|
+
return new Promise((resolve) => {
|
|
68
|
+
let confirmed = false;
|
|
69
|
+
const onYes = () => {
|
|
70
|
+
confirmed = true;
|
|
71
|
+
this.aysModal.hide();
|
|
72
|
+
};
|
|
73
|
+
yesButton.addEventListener("click", onYes, { once: true });
|
|
74
|
+
// The "no" button, Escape and a click outside raise no event of their own: they only hide the modal.
|
|
75
|
+
this.aysEl.addEventListener(
|
|
76
|
+
"hidden.bs.modal",
|
|
77
|
+
() => {
|
|
78
|
+
yesButton.removeEventListener("click", onYes);
|
|
79
|
+
resolve(confirmed);
|
|
80
|
+
},
|
|
81
|
+
{ once: true },
|
|
82
|
+
);
|
|
83
|
+
this.aysModal.show();
|
|
84
|
+
});
|
|
82
85
|
}
|
|
83
86
|
}
|
package/src/web/settings.js
CHANGED
|
@@ -39,13 +39,13 @@ export class Settings {
|
|
|
39
39
|
if (changed.displayMode) this.applyDisplayMode(changed.displayMode);
|
|
40
40
|
},
|
|
41
41
|
(changed) => this.onDialogClosed(changed),
|
|
42
|
-
() => {
|
|
43
|
-
this.targets.modals.
|
|
42
|
+
async () => {
|
|
43
|
+
const restart = await this.targets.modals.confirm(
|
|
44
44
|
"Your change is saved, but only takes effect when the emulator restarts. Restart now?",
|
|
45
45
|
"Restart now",
|
|
46
46
|
"Later",
|
|
47
|
-
() => window.location.reload(),
|
|
48
47
|
);
|
|
48
|
+
if (restart) window.location.reload();
|
|
49
49
|
},
|
|
50
50
|
);
|
|
51
51
|
|
|
@@ -106,8 +106,7 @@ export class Settings {
|
|
|
106
106
|
this.config.setAudioOutput(output);
|
|
107
107
|
this.targets.quickSettings?.showAudioOutput(output);
|
|
108
108
|
window.localStorage.audioOutput = output;
|
|
109
|
-
this.urlState.
|
|
110
|
-
this.urlState.updateUrl();
|
|
109
|
+
this.urlState.set({ audioOutput: output });
|
|
111
110
|
}
|
|
112
111
|
|
|
113
112
|
applySpeakerAmount(amount) {
|
|
@@ -123,18 +122,19 @@ export class Settings {
|
|
|
123
122
|
applyDisplayMode(mode) {
|
|
124
123
|
this.displayMode = mode;
|
|
125
124
|
this.targets.display.setMode(mode);
|
|
125
|
+
// The monitor picture may have changed shape.
|
|
126
|
+
this.targets.layout.resize();
|
|
126
127
|
this.config.setDisplayMode(mode);
|
|
127
128
|
this.targets.quickSettings?.showDisplayMode(mode);
|
|
128
129
|
window.localStorage.displayMode = mode;
|
|
129
|
-
this.urlState.
|
|
130
|
-
this.urlState.updateUrl();
|
|
130
|
+
this.urlState.set({ displayMode: mode });
|
|
131
131
|
}
|
|
132
132
|
|
|
133
133
|
onDialogClosed(changed) {
|
|
134
134
|
const { urlState } = this;
|
|
135
135
|
const { machine, keys, inputs } = this.targets;
|
|
136
136
|
const parsedQuery = urlState.params;
|
|
137
|
-
|
|
137
|
+
urlState.set(changed);
|
|
138
138
|
if (changed.keyLayout) {
|
|
139
139
|
window.localStorage.keyLayout = changed.keyLayout;
|
|
140
140
|
machine.emulationConfig.keyLayout = changed.keyLayout;
|
|
@@ -155,6 +155,5 @@ export class Settings {
|
|
|
155
155
|
machine.processor.tube.cpuMultiplier = changed.tubeCpuMultiplier;
|
|
156
156
|
}
|
|
157
157
|
}
|
|
158
|
-
urlState.updateUrl();
|
|
159
158
|
}
|
|
160
159
|
}
|
package/src/web/snapshot-ui.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import * as disc from "../fdc.js";
|
|
2
2
|
import { DiscLayout } from "../disc.js";
|
|
3
3
|
import { downloadBlob } from "../dom-utils.js";
|
|
4
|
-
import { toast } from "./toast.js";
|
|
5
4
|
import {
|
|
6
5
|
createSnapshot,
|
|
7
6
|
restoreSnapshot,
|
|
@@ -58,9 +57,14 @@ async function readSnapshot(arrayBuffer) {
|
|
|
58
57
|
* where they have one, the image bytes where they do not, and CRCs for
|
|
59
58
|
* saying when a source has changed underneath a state.
|
|
60
59
|
*/
|
|
61
|
-
export function snapshotMedia(fdcDrives, params) {
|
|
60
|
+
export function snapshotMedia(fdcDrives, params, defaultBootDisc) {
|
|
62
61
|
const manifest = {};
|
|
62
|
+
const drive0Disc = fdcDrives[0].disc;
|
|
63
63
|
if (params.disc1 || params.disc) manifest.disc1 = params.disc1 || params.disc;
|
|
64
|
+
// A default boot loads the built-in disc without naming it in the URL; record it by
|
|
65
|
+
// name so the state can reload it, but only while that disc is still in the drive.
|
|
66
|
+
else if (defaultBootDisc && drive0Disc && drive0Disc.name === defaultBootDisc && !drive0Disc.originalImageData)
|
|
67
|
+
manifest.disc1 = defaultBootDisc;
|
|
64
68
|
if (params.disc2) manifest.disc2 = params.disc2;
|
|
65
69
|
|
|
66
70
|
// For each drive with a disc loaded, include CRC32 for verification
|
|
@@ -84,7 +88,7 @@ export function snapshotMedia(fdcDrives, params) {
|
|
|
84
88
|
|
|
85
89
|
/** Saving and restoring states: the menu item, the file input and the reload across a model change. */
|
|
86
90
|
export class SnapshotUI {
|
|
87
|
-
constructor({ processor, model, video, media, drives, urlState, modals, loop }) {
|
|
91
|
+
constructor({ processor, model, video, media, drives, urlState, modals, loop, defaultBootDisc }) {
|
|
88
92
|
this.processor = processor;
|
|
89
93
|
this.model = model;
|
|
90
94
|
this.video = video;
|
|
@@ -93,6 +97,7 @@ export class SnapshotUI {
|
|
|
93
97
|
this.urlState = urlState;
|
|
94
98
|
this.modals = modals;
|
|
95
99
|
this.loop = loop;
|
|
100
|
+
this.defaultBootDisc = defaultBootDisc;
|
|
96
101
|
|
|
97
102
|
document.getElementById("save-state").addEventListener("click", async (event) => {
|
|
98
103
|
event.preventDefault();
|
|
@@ -110,22 +115,27 @@ export class SnapshotUI {
|
|
|
110
115
|
async saveState() {
|
|
111
116
|
const wasRunning = this.loop.isRunning();
|
|
112
117
|
if (wasRunning) this.loop.stop(false);
|
|
118
|
+
let failure = null;
|
|
113
119
|
try {
|
|
114
|
-
const manifest = snapshotMedia(this.processor.fdc.drives, this.urlState.params);
|
|
120
|
+
const manifest = snapshotMedia(this.processor.fdc.drives, this.urlState.params, this.defaultBootDisc);
|
|
115
121
|
const snapshot = createSnapshot(this.processor, this.model, manifest);
|
|
116
122
|
const json = snapshotToJSON(snapshot);
|
|
117
123
|
const blob = await compressBlob(new Blob([json]));
|
|
118
124
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
119
125
|
downloadBlob(blob, `jsbeeb-${this.model.name}-${timestamp}.json.gz`);
|
|
120
126
|
} catch (e) {
|
|
121
|
-
|
|
127
|
+
failure = e;
|
|
122
128
|
}
|
|
129
|
+
// Resume before reporting failure: the error dialog pauses a running
|
|
130
|
+
// loop itself, and resumes it on close.
|
|
123
131
|
if (wasRunning) this.loop.go();
|
|
132
|
+
if (failure) this.modals.showError("saving state", failure);
|
|
124
133
|
}
|
|
125
134
|
|
|
126
135
|
async loadStateFromFile(file, preReadBuffer) {
|
|
127
136
|
const wasRunning = this.loop.isRunning();
|
|
128
137
|
if (wasRunning) this.loop.stop(false);
|
|
138
|
+
let failure = null;
|
|
129
139
|
try {
|
|
130
140
|
const arrayBuffer = preReadBuffer || (await file.arrayBuffer());
|
|
131
141
|
const snapshot = await readSnapshot(arrayBuffer);
|
|
@@ -142,9 +152,10 @@ export class SnapshotUI {
|
|
|
142
152
|
// Force a repaint so the display updates even while paused
|
|
143
153
|
this.video.paint();
|
|
144
154
|
} catch (e) {
|
|
145
|
-
|
|
155
|
+
failure = e;
|
|
146
156
|
}
|
|
147
157
|
if (wasRunning) this.loop.go();
|
|
158
|
+
if (failure) this.modals.showError("loading state", failure);
|
|
148
159
|
}
|
|
149
160
|
|
|
150
161
|
/** Picks up the state a cross-model reload stashed, once the matching machine is up. */
|
|
@@ -192,16 +203,38 @@ export class SnapshotUI {
|
|
|
192
203
|
// Retain the image bytes so subsequent saves can re-embed them.
|
|
193
204
|
loadedDisc.setOriginalImage(imageData);
|
|
194
205
|
}
|
|
195
|
-
if (!loadedDisc)
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
206
|
+
if (!loadedDisc) {
|
|
207
|
+
if (savedMedia[crcKey] != null) {
|
|
208
|
+
// A state may name no source (older default-boot saves); the disc
|
|
209
|
+
// already in the drive can still satisfy the CRC, but only laid out
|
|
210
|
+
// the way the state's dirty tracks expect.
|
|
211
|
+
const currentDisc = this.processor.fdc.drives[driveIndex].disc;
|
|
212
|
+
const currentLayout = currentDisc?.is40Track ? DiscLayout.expanded40 : DiscLayout.contiguous;
|
|
213
|
+
if (
|
|
214
|
+
currentDisc &&
|
|
215
|
+
currentDisc.originalImageCrc32 === savedMedia[crcKey] &&
|
|
216
|
+
currentLayout === layout
|
|
217
|
+
)
|
|
218
|
+
continue;
|
|
219
|
+
const problem = savedMedia[discKey]
|
|
220
|
+
? `The disc for drive ${driveIndex} (${savedMedia[discKey]}) could not be reloaded`
|
|
221
|
+
: `This state does not record where the disc in drive ${driveIndex} came from`;
|
|
222
|
+
throw new Error(
|
|
223
|
+
`${problem}, and the drive does not hold a matching disc. ` +
|
|
224
|
+
`Load the right disc, then load the state again.`,
|
|
203
225
|
);
|
|
204
226
|
}
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (
|
|
231
|
+
savedMedia[crcKey] != null &&
|
|
232
|
+
loadedDisc.originalImageCrc32 != null &&
|
|
233
|
+
loadedDisc.originalImageCrc32 !== savedMedia[crcKey]
|
|
234
|
+
) {
|
|
235
|
+
throw new Error(
|
|
236
|
+
`${loadedDisc.name} has changed since this state was saved, so the state cannot be restored over it.`,
|
|
237
|
+
);
|
|
205
238
|
}
|
|
206
239
|
|
|
207
240
|
this.drives.putDiscIn(driveIndex, loadedDisc);
|
package/src/web/sth-picker.js
CHANGED
|
@@ -46,6 +46,8 @@ export class SthPicker {
|
|
|
46
46
|
onError,
|
|
47
47
|
true,
|
|
48
48
|
);
|
|
49
|
+
media.addSource("sth", (name) => this.discs.fetch(name));
|
|
50
|
+
media.addSource("tapeSth", (name) => this.tapes.fetch(name));
|
|
49
51
|
|
|
50
52
|
document.addEventListener("click", (e) => {
|
|
51
53
|
const target = e.target.closest("a.sth");
|
package/src/web/url-state.js
CHANGED
|
@@ -56,7 +56,8 @@ export const UrlParamTypes = {
|
|
|
56
56
|
|
|
57
57
|
/**
|
|
58
58
|
* The page's settings as carried in its URL. `params` is the one parsed
|
|
59
|
-
* object: whoever changes a setting
|
|
59
|
+
* object: whoever changes a setting hands the change to set(), which edits
|
|
60
|
+
* it in place and writes the URL in one step.
|
|
60
61
|
*/
|
|
61
62
|
export class UrlState {
|
|
62
63
|
constructor(location, history, paramTypes = UrlParamTypes) {
|
|
@@ -78,6 +79,15 @@ export class UrlState {
|
|
|
78
79
|
return buildUrlFromParams(this.baseUrl, { ...this.params, ...overrides }, this.paramTypes);
|
|
79
80
|
}
|
|
80
81
|
|
|
82
|
+
/** Apply `changes` to the parameters (undefined deletes a key) and push the new URL. */
|
|
83
|
+
set(changes) {
|
|
84
|
+
for (const [key, value] of Object.entries(changes)) {
|
|
85
|
+
if (value === undefined) delete this.params[key];
|
|
86
|
+
else this.params[key] = value;
|
|
87
|
+
}
|
|
88
|
+
this.updateUrl();
|
|
89
|
+
}
|
|
90
|
+
|
|
81
91
|
updateUrl() {
|
|
82
92
|
this.history.pushState(null, null, this.url());
|
|
83
93
|
}
|
/package/{COPYING → LICENSE}
RENAMED
|
File without changes
|