jsbeeb 1.22.2 → 1.22.3
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/app/electron.js +4 -2
- package/src/main.js +29 -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 +1 -1
- package/src/web/display.js +0 -3
- package/src/web/drives.js +4 -5
- package/src/web/google-drive-picker.js +1 -0
- package/src/web/hfe-picker.js +1 -0
- package/src/web/keyboard-setup.js +11 -46
- package/src/web/media-loader.js +17 -21
- package/src/web/modals.js +20 -17
- package/src/web/settings.js +8 -9
- 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.3",
|
|
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/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/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";
|
|
@@ -92,19 +93,7 @@ const printer = new Printer({
|
|
|
92
93
|
}),
|
|
93
94
|
});
|
|
94
95
|
|
|
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
|
-
});
|
|
96
|
+
const accessibilitySwitches = new AccessibilitySwitches();
|
|
108
97
|
|
|
109
98
|
const settings = new Settings({ urlState });
|
|
110
99
|
const { config, speechOutput, keyLayout, displayMode, audioOutput, speakerAmount } = settings;
|
|
@@ -188,7 +177,7 @@ const machine = new Machine({
|
|
|
188
177
|
cpuMultiplier,
|
|
189
178
|
extraRoms,
|
|
190
179
|
stationId,
|
|
191
|
-
userPort:
|
|
180
|
+
userPort: accessibilitySwitches.userPort,
|
|
192
181
|
printer,
|
|
193
182
|
speechOutput,
|
|
194
183
|
video,
|
|
@@ -197,6 +186,26 @@ const machine = new Machine({
|
|
|
197
186
|
});
|
|
198
187
|
const processor = machine.processor;
|
|
199
188
|
|
|
189
|
+
const keys = new KeyboardSetup({
|
|
190
|
+
actions: {
|
|
191
|
+
enterDebugger: () => loop.stop(true),
|
|
192
|
+
reload: () => window.location.reload(),
|
|
193
|
+
toggleFast: () => loop.toggleFastAsPossible(),
|
|
194
|
+
openRewind: () => rewindUI.open(),
|
|
195
|
+
openPrinter: () => frontPanel.checkPrinterWindow(),
|
|
196
|
+
pause: () => loop.stop(false),
|
|
197
|
+
resume: () => loop.go(),
|
|
198
|
+
onAnyKeyDown: () => {
|
|
199
|
+
audioHandler.tryResume();
|
|
200
|
+
inputs.ensureMicrophoneRunning();
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
accessibilitySwitches,
|
|
204
|
+
processor,
|
|
205
|
+
dbgr,
|
|
206
|
+
keyLayout,
|
|
207
|
+
});
|
|
208
|
+
|
|
200
209
|
const rewindBuffer = new RewindBuffer(30);
|
|
201
210
|
const loop = new EmulationLoop({
|
|
202
211
|
processor,
|
|
@@ -225,23 +234,15 @@ loop.addEventListener("running", () => {
|
|
|
225
234
|
|
|
226
235
|
const modals = new Modals({ loop });
|
|
227
236
|
|
|
228
|
-
const drives = new Drives({ fdc: processor.fdc, driveTracks,
|
|
237
|
+
const drives = new Drives({ fdc: processor.fdc, driveTracks, confirm: modals.confirm.bind(modals) });
|
|
229
238
|
const media = new MediaLoader({
|
|
230
239
|
processor,
|
|
231
240
|
model,
|
|
232
241
|
drives,
|
|
233
242
|
urlState,
|
|
234
|
-
config,
|
|
235
243
|
modals,
|
|
236
244
|
isSnapshotFile,
|
|
237
245
|
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
246
|
});
|
|
246
247
|
const autoBoot = new Autoboot({
|
|
247
248
|
model,
|
|
@@ -257,7 +258,7 @@ const sthPicker = new SthPicker({
|
|
|
257
258
|
processor,
|
|
258
259
|
autoboot: (image) => autoBoot.boot(image),
|
|
259
260
|
});
|
|
260
|
-
|
|
261
|
+
new HfePicker({
|
|
261
262
|
media,
|
|
262
263
|
drives,
|
|
263
264
|
modals,
|
|
@@ -265,7 +266,7 @@ const hfePicker = new HfePicker({
|
|
|
265
266
|
processor,
|
|
266
267
|
autoboot: (image) => autoBoot.boot(image),
|
|
267
268
|
});
|
|
268
|
-
|
|
269
|
+
new GoogleDrivePicker({ media, drives, modals, processor });
|
|
269
270
|
const snapshots = new SnapshotUI({
|
|
270
271
|
processor,
|
|
271
272
|
model,
|
|
@@ -295,8 +296,6 @@ if (parsedQuery.microphoneChannel !== undefined) {
|
|
|
295
296
|
// Apply ADC source settings from URL parameters
|
|
296
297
|
inputs.updateAdcSources(parsedQuery.mouseJoystickEnabled, parsedQuery.microphoneChannel);
|
|
297
298
|
|
|
298
|
-
keys.attach({ processor, dbgr, keyLayout });
|
|
299
|
-
|
|
300
299
|
const frontPanel = new FrontPanel({ processor, model, printer });
|
|
301
300
|
|
|
302
301
|
// ------------------------------------------------------------------------
|
|
@@ -315,7 +314,7 @@ rewindUI.updateButtonState();
|
|
|
315
314
|
if (processor.fdc) new DiscVisualiser({ fdc: processor.fdc });
|
|
316
315
|
else document.getElementById("disc-visualiser-open").classList.add("disabled");
|
|
317
316
|
|
|
318
|
-
new Layout({
|
|
317
|
+
const layout = new Layout({
|
|
319
318
|
screenCanvas,
|
|
320
319
|
display,
|
|
321
320
|
embed: parsedQuery.embed !== undefined,
|
|
@@ -323,7 +322,7 @@ new Layout({
|
|
|
323
322
|
});
|
|
324
323
|
|
|
325
324
|
// Everything a setting can reach now exists.
|
|
326
|
-
settings.wire({ audioHandler, display, quickSettings, machine, keys, inputs, modals });
|
|
325
|
+
settings.wire({ audioHandler, display, layout, quickSettings, machine, keys, inputs, modals });
|
|
327
326
|
|
|
328
327
|
// ------------------------------------------------------------------------
|
|
329
328
|
// The page's own handlers.
|
|
@@ -500,6 +499,7 @@ electron({
|
|
|
500
499
|
loadTapeImage: media.loadTapeImage.bind(media),
|
|
501
500
|
processor,
|
|
502
501
|
config,
|
|
502
|
+
media,
|
|
503
503
|
modals: {
|
|
504
504
|
show: (modalId, sthType) => {
|
|
505
505
|
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
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");
|
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/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,9 +205,7 @@ 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
|
|
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/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
|