jsbeeb 1.11.0 → 1.13.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 +17 -3
- package/package.json +8 -9
- package/public/roms/atom/ATMMC3E.rom +0 -0
- package/public/roms/atom/Atom_Basic.rom +0 -0
- package/public/roms/atom/Atom_DOS.rom +0 -0
- package/public/roms/atom/Atom_FloatingPoint.rom +0 -0
- package/public/roms/atom/Atom_Kernel.rom +0 -0
- package/public/roms/atom/Atom_Kernel_E.rom +0 -0
- package/public/roms/atom/PCHARME.ROM +0 -0
- package/public/roms/atom/gags.rom +0 -0
- package/public/roms/atom/werom.rom +0 -0
- package/src/6502.js +351 -44
- package/src/6847.js +724 -0
- package/src/6847_fontdata.js +124 -0
- package/src/app/app.js +1 -1
- package/src/app/electron.js +4 -4
- package/src/bem-snapshot.js +5 -184
- package/src/config.js +20 -9
- package/src/disc.js +2 -20
- package/src/fake6502.js +3 -2
- package/src/jsbeeb.css +23 -0
- package/src/keyboard.js +62 -37
- package/src/machine-session.js +85 -59
- package/src/main.js +188 -75
- package/src/mmc.js +1053 -0
- package/src/models.js +46 -5
- package/src/ppia.js +477 -0
- package/src/snapshot-helpers.js +208 -0
- package/src/snapshot.js +9 -18
- package/src/soundchip.js +99 -1
- package/src/tapes.js +73 -16
- package/src/uef-snapshot.js +402 -0
- package/src/url-params.js +7 -2
- package/src/utils.js +81 -2
- package/src/utils_atom.js +508 -0
- package/src/video.js +12 -1
- package/src/web/audio-handler.js +39 -17
- package/tests/test-machine.js +133 -8
package/src/main.js
CHANGED
|
@@ -7,12 +7,14 @@ 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 } from "./6502.js";
|
|
10
|
+
import { Cpu6502, AtomCpu6502 } from "./6502.js";
|
|
11
|
+
import * as utils_atom from "./utils_atom.js";
|
|
12
|
+
import { LoadSD } from "./mmc.js";
|
|
11
13
|
import { Cmos } from "./cmos.js";
|
|
12
14
|
import { StairwayToHell } from "./sth.js";
|
|
13
15
|
import { GamePad } from "./gamepads.js";
|
|
14
16
|
import * as disc from "./fdc.js";
|
|
15
|
-
import {
|
|
17
|
+
import { loadTapeFromData } from "./tapes.js";
|
|
16
18
|
import { GoogleDriveLoader } from "./google-drive.js";
|
|
17
19
|
import * as tokeniser from "./basic-tokenise.js";
|
|
18
20
|
import * as canvasLib from "./canvas.js";
|
|
@@ -28,8 +30,9 @@ import { MicrophoneInput } from "./microphone-input.js";
|
|
|
28
30
|
import { SpeechOutput } from "./speech-output.js";
|
|
29
31
|
import { MouseJoystickSource } from "./mouse-joystick-source.js";
|
|
30
32
|
import { getFilterForMode } from "./canvas.js";
|
|
31
|
-
import { createSnapshot, restoreSnapshot, snapshotToJSON, snapshotFromJSON,
|
|
33
|
+
import { createSnapshot, restoreSnapshot, snapshotToJSON, snapshotFromJSON, isSameModel } from "./snapshot.js";
|
|
32
34
|
import { isBemSnapshot, parseBemSnapshot } from "./bem-snapshot.js";
|
|
35
|
+
import { isUefSnapshot, parseUefSnapshot } from "./uef-snapshot.js";
|
|
33
36
|
import { RewindBuffer } from "./rewind.js";
|
|
34
37
|
import { RewindUI } from "./rewind-ui.js";
|
|
35
38
|
import {
|
|
@@ -53,6 +56,21 @@ let discSth;
|
|
|
53
56
|
let tapeSth;
|
|
54
57
|
let running;
|
|
55
58
|
let model;
|
|
59
|
+
|
|
60
|
+
// Route tape to the correct interface (ACIA for BBC, PPIA for Atom)
|
|
61
|
+
function setProcessorTape(tape) {
|
|
62
|
+
if (model.isAtom) {
|
|
63
|
+
processor.atomppia.setTape(tape);
|
|
64
|
+
} else {
|
|
65
|
+
processor.acia.setTape(tape);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Convert text to machine-appropriate key sequences (BBC or Atom)
|
|
70
|
+
function stringToMachineKeys(text) {
|
|
71
|
+
return model.isAtom ? utils_atom.stringToATOMKeys(text) : utils.stringToBBCKeys(text);
|
|
72
|
+
}
|
|
73
|
+
|
|
56
74
|
const gamepad = new GamePad();
|
|
57
75
|
const availableImages = [
|
|
58
76
|
{
|
|
@@ -102,6 +120,7 @@ const paramTypes = {
|
|
|
102
120
|
coProcessor: ParamTypes.BOOL,
|
|
103
121
|
mouseJoystickEnabled: ParamTypes.BOOL,
|
|
104
122
|
speechOutput: ParamTypes.BOOL,
|
|
123
|
+
audioDebug: ParamTypes.BOOL,
|
|
105
124
|
|
|
106
125
|
// Numeric parameters
|
|
107
126
|
speed: ParamTypes.INT,
|
|
@@ -119,6 +138,7 @@ const paramTypes = {
|
|
|
119
138
|
disc1: ParamTypes.STRING,
|
|
120
139
|
disc2: ParamTypes.STRING,
|
|
121
140
|
tape: ParamTypes.STRING,
|
|
141
|
+
mmc: ParamTypes.STRING,
|
|
122
142
|
keyLayout: ParamTypes.STRING,
|
|
123
143
|
autotype: ParamTypes.STRING,
|
|
124
144
|
displayMode: ParamTypes.STRING,
|
|
@@ -141,7 +161,7 @@ let stationId = 101;
|
|
|
141
161
|
let econet = null;
|
|
142
162
|
|
|
143
163
|
// Parse disc and tape images from query parameters
|
|
144
|
-
const { discImage: queryDiscImage, secondDiscImage: querySecondDisc } = parseMediaParams(parsedQuery);
|
|
164
|
+
const { discImage: queryDiscImage, secondDiscImage: querySecondDisc, mmcImage } = parseMediaParams(parsedQuery);
|
|
145
165
|
|
|
146
166
|
// Only assign if values are provided
|
|
147
167
|
if (queryDiscImage) discImage = queryDiscImage;
|
|
@@ -259,7 +279,7 @@ const config = new Config(
|
|
|
259
279
|
updateAdcSources(parsedQuery.mouseJoystickEnabled, parsedQuery.microphoneChannel);
|
|
260
280
|
|
|
261
281
|
if (changed.microphoneChannel !== undefined) {
|
|
262
|
-
setupMicrophone()
|
|
282
|
+
setupMicrophone();
|
|
263
283
|
}
|
|
264
284
|
}
|
|
265
285
|
if (changed.speechOutput !== undefined) {
|
|
@@ -319,7 +339,8 @@ if (parsedQuery.cpuMultiplier !== undefined) {
|
|
|
319
339
|
cpuMultiplier = parsedQuery.cpuMultiplier;
|
|
320
340
|
console.log("CPU multiplier set to " + cpuMultiplier);
|
|
321
341
|
}
|
|
322
|
-
const
|
|
342
|
+
const cpuSpeed = model.isAtom ? 1 * 1000 * 1000 : 2 * 1000 * 1000;
|
|
343
|
+
const clocksPerSecond = (cpuMultiplier * cpuSpeed) | 0;
|
|
323
344
|
const MaxCyclesPerFrame = clocksPerSecond / 10;
|
|
324
345
|
|
|
325
346
|
let tryGl = true;
|
|
@@ -375,23 +396,31 @@ function swapCanvas(newFilterClass) {
|
|
|
375
396
|
|
|
376
397
|
let canvas = createCanvasForFilter(displayModeFilter);
|
|
377
398
|
|
|
378
|
-
video = new Video(
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
399
|
+
video = new Video(
|
|
400
|
+
model.isMaster,
|
|
401
|
+
canvas.fb32,
|
|
402
|
+
function paint(minx, miny, maxx, maxy) {
|
|
403
|
+
frames++;
|
|
404
|
+
if (frames < frameSkip) return;
|
|
405
|
+
frames = 0;
|
|
406
|
+
canvas.paint(minx, miny, maxx, maxy, this.frameCount);
|
|
407
|
+
},
|
|
408
|
+
{ isAtom: model.isAtom },
|
|
409
|
+
);
|
|
384
410
|
if (parsedQuery.fakeVideo !== undefined) video = new FakeVideo();
|
|
385
411
|
|
|
386
|
-
const
|
|
412
|
+
const audioStatsEl = document.getElementById("audio-stats");
|
|
413
|
+
if (audioStatsEl) audioStatsEl.hidden = !parsedQuery.audioDebug;
|
|
414
|
+
const audioStatsNode = parsedQuery.audioDebug ? audioStatsEl : null;
|
|
387
415
|
const audioHandler = new AudioHandler({
|
|
388
416
|
warningNode: document.getElementById("audio-warning"),
|
|
389
417
|
statsNode: audioStatsNode,
|
|
390
418
|
audioFilterFreq,
|
|
391
419
|
audioFilterQ,
|
|
392
420
|
noSeek,
|
|
421
|
+
cpuSpeed,
|
|
422
|
+
isAtom: model.isAtom,
|
|
393
423
|
});
|
|
394
|
-
if (!parsedQuery.audioDebug) audioStatsNode.style.display = "none";
|
|
395
424
|
// Firefox will report that audio is suspended even when it will
|
|
396
425
|
// start playing without user interaction, so we need to delay a
|
|
397
426
|
// little to get a reliable indication.
|
|
@@ -482,7 +511,7 @@ const pastetext = document.getElementById("paste-text");
|
|
|
482
511
|
pastetext.closest("form").addEventListener("submit", (event) => event.preventDefault());
|
|
483
512
|
pastetext.addEventListener("paste", function (event) {
|
|
484
513
|
const text = event.clipboardData.getData("text/plain");
|
|
485
|
-
|
|
514
|
+
sendRawKeyboard(stringToMachineKeys(text), true);
|
|
486
515
|
});
|
|
487
516
|
pastetext.addEventListener("dragover", function (event) {
|
|
488
517
|
event.preventDefault();
|
|
@@ -492,8 +521,12 @@ pastetext.addEventListener("dragover", function (event) {
|
|
|
492
521
|
pastetext.addEventListener("drop", async function (event) {
|
|
493
522
|
utils.noteEvent("local", "drop");
|
|
494
523
|
const file = event.dataTransfer.files[0];
|
|
495
|
-
|
|
496
|
-
|
|
524
|
+
const arrayBuffer = await file.arrayBuffer();
|
|
525
|
+
if (isSnapshotFile(file.name, arrayBuffer)) {
|
|
526
|
+
await loadStateFromFile(file, arrayBuffer);
|
|
527
|
+
} else if (file.name.toLowerCase().endsWith(".uef")) {
|
|
528
|
+
// Regular UEF tape image (not a BeebEm save state)
|
|
529
|
+
setProcessorTape(await loadTapeFromData(file.name, new Uint8Array(arrayBuffer), model.isAtom));
|
|
497
530
|
} else {
|
|
498
531
|
await loadHTMLFile(file);
|
|
499
532
|
}
|
|
@@ -501,7 +534,7 @@ pastetext.addEventListener("drop", async function (event) {
|
|
|
501
534
|
|
|
502
535
|
const cubMonitor = document.getElementById("cub-monitor");
|
|
503
536
|
function onCubMouseEvent(evt) {
|
|
504
|
-
audioHandler.tryResume()
|
|
537
|
+
audioHandler.tryResume();
|
|
505
538
|
if (document.activeElement !== document.body) document.activeElement.blur();
|
|
506
539
|
const cubRect = cubMonitor.getBoundingClientRect();
|
|
507
540
|
const screenRect = screenCanvas.getBoundingClientRect();
|
|
@@ -617,7 +650,8 @@ function checkPrinterWindow() {
|
|
|
617
650
|
processor.uservia.setca1(true);
|
|
618
651
|
}
|
|
619
652
|
|
|
620
|
-
|
|
653
|
+
const CpuClass = model.isAtom ? AtomCpu6502 : Cpu6502;
|
|
654
|
+
processor = new CpuClass(model, {
|
|
621
655
|
dbgr,
|
|
622
656
|
video,
|
|
623
657
|
soundChip: audioHandler.soundChip,
|
|
@@ -737,12 +771,12 @@ keyboard = new Keyboard({
|
|
|
737
771
|
keyLayout,
|
|
738
772
|
dbgr,
|
|
739
773
|
});
|
|
740
|
-
keyboard.
|
|
741
|
-
keyboard.
|
|
742
|
-
keyboard.
|
|
743
|
-
keyboard.
|
|
774
|
+
keyboard.addEventListener("showError", (e) => showError(e.detail.context, e.detail.error));
|
|
775
|
+
keyboard.addEventListener("pause", () => stop(false));
|
|
776
|
+
keyboard.addEventListener("resume", () => go());
|
|
777
|
+
keyboard.addEventListener("break", (e) => {
|
|
744
778
|
// F12/Break: Reset processor
|
|
745
|
-
if (
|
|
779
|
+
if (e.detail) utils.noteEvent("keyboard", "press", "break");
|
|
746
780
|
});
|
|
747
781
|
|
|
748
782
|
// Register default key handlers
|
|
@@ -846,8 +880,8 @@ keyboard.registerKeyHandler(
|
|
|
846
880
|
|
|
847
881
|
// Setup key handlers
|
|
848
882
|
document.addEventListener("keydown", (evt) => {
|
|
849
|
-
audioHandler.tryResume()
|
|
850
|
-
ensureMicrophoneRunning()
|
|
883
|
+
audioHandler.tryResume();
|
|
884
|
+
ensureMicrophoneRunning();
|
|
851
885
|
keyboard.keyDown(evt);
|
|
852
886
|
});
|
|
853
887
|
document.addEventListener("keypress", (evt) => keyboard.keyPress(evt));
|
|
@@ -857,19 +891,19 @@ function setDisc1Image(name) {
|
|
|
857
891
|
delete parsedQuery.disc;
|
|
858
892
|
parsedQuery.disc1 = name;
|
|
859
893
|
updateUrl();
|
|
860
|
-
config.
|
|
894
|
+
config.dispatchEvent(new CustomEvent("media-changed", { detail: { disc1: name } }));
|
|
861
895
|
}
|
|
862
896
|
|
|
863
897
|
function setDisc2Image(name) {
|
|
864
898
|
parsedQuery.disc2 = name;
|
|
865
899
|
updateUrl();
|
|
866
|
-
config.
|
|
900
|
+
config.dispatchEvent(new CustomEvent("media-changed", { detail: { disc2: name } }));
|
|
867
901
|
}
|
|
868
902
|
|
|
869
903
|
function setTapeImage(name) {
|
|
870
904
|
parsedQuery.tape = name;
|
|
871
905
|
updateUrl();
|
|
872
|
-
config.
|
|
906
|
+
config.dispatchEvent(new CustomEvent("media-changed", { detail: { tape: name } }));
|
|
873
907
|
}
|
|
874
908
|
|
|
875
909
|
function sthClearList() {
|
|
@@ -913,7 +947,7 @@ async function tapeSthClick(item) {
|
|
|
913
947
|
popupLoading("Loading " + item);
|
|
914
948
|
try {
|
|
915
949
|
const tape = await loadTapeImage(parsedQuery.tape);
|
|
916
|
-
|
|
950
|
+
setProcessorTape(tape);
|
|
917
951
|
loadingFinished();
|
|
918
952
|
} catch (err) {
|
|
919
953
|
console.error("Error loading tape image:", err);
|
|
@@ -1001,9 +1035,9 @@ const sthFilter = document.getElementById("sth-filter");
|
|
|
1001
1035
|
sthFilter.addEventListener("change", () => setSthFilter(sthFilter.value));
|
|
1002
1036
|
sthFilter.addEventListener("keyup", () => setSthFilter(sthFilter.value));
|
|
1003
1037
|
|
|
1004
|
-
function
|
|
1038
|
+
function sendRawKeyboard(keysToSend, checkCapsAndShiftLocks) {
|
|
1005
1039
|
if (keyboard) {
|
|
1006
|
-
keyboard.
|
|
1040
|
+
keyboard.sendRawKeyboard(keysToSend, checkCapsAndShiftLocks);
|
|
1007
1041
|
} else {
|
|
1008
1042
|
console.warn("Tried to send keys before keyboard was initialised");
|
|
1009
1043
|
}
|
|
@@ -1016,39 +1050,39 @@ function autoboot(image) {
|
|
|
1016
1050
|
utils.noteEvent("init", "autoboot", image);
|
|
1017
1051
|
|
|
1018
1052
|
// Shift-break simulation, hold SHIFT for 1000ms.
|
|
1019
|
-
|
|
1053
|
+
sendRawKeyboard([BBC.SHIFT, 1000], false);
|
|
1020
1054
|
}
|
|
1021
1055
|
|
|
1022
1056
|
function autoBootType(keys) {
|
|
1023
1057
|
console.log("Auto typing '" + keys + "'");
|
|
1024
1058
|
utils.noteEvent("init", "autochain");
|
|
1025
1059
|
|
|
1026
|
-
const bbcKeys =
|
|
1027
|
-
|
|
1060
|
+
const bbcKeys = stringToMachineKeys(keys);
|
|
1061
|
+
sendRawKeyboard([1000].concat(bbcKeys), false);
|
|
1028
1062
|
}
|
|
1029
1063
|
|
|
1030
1064
|
function autoChainTape() {
|
|
1031
1065
|
console.log("Auto Chaining Tape");
|
|
1032
1066
|
utils.noteEvent("init", "autochain");
|
|
1033
1067
|
|
|
1034
|
-
const bbcKeys =
|
|
1035
|
-
|
|
1068
|
+
const bbcKeys = stringToMachineKeys('*TAPE\nCH.""\n');
|
|
1069
|
+
sendRawKeyboard([1000].concat(bbcKeys), false);
|
|
1036
1070
|
}
|
|
1037
1071
|
|
|
1038
1072
|
function autoRunTape() {
|
|
1039
1073
|
console.log("Auto Running Tape");
|
|
1040
1074
|
utils.noteEvent("init", "autorun");
|
|
1041
1075
|
|
|
1042
|
-
const bbcKeys =
|
|
1043
|
-
|
|
1076
|
+
const bbcKeys = stringToMachineKeys("*TAPE\n*/\n");
|
|
1077
|
+
sendRawKeyboard([1000].concat(bbcKeys), false);
|
|
1044
1078
|
}
|
|
1045
1079
|
|
|
1046
1080
|
function autoRunBasic() {
|
|
1047
1081
|
console.log("Auto Running basic");
|
|
1048
1082
|
utils.noteEvent("init", "autorunbasic");
|
|
1049
1083
|
|
|
1050
|
-
const bbcKeys =
|
|
1051
|
-
|
|
1084
|
+
const bbcKeys = stringToMachineKeys("RUN\n");
|
|
1085
|
+
sendRawKeyboard([1000].concat(bbcKeys), false);
|
|
1052
1086
|
}
|
|
1053
1087
|
|
|
1054
1088
|
function updateUrl() {
|
|
@@ -1168,16 +1202,17 @@ async function loadTapeImage(tapeImage) {
|
|
|
1168
1202
|
const split = splitImage(tapeImage);
|
|
1169
1203
|
tapeImage = split.image;
|
|
1170
1204
|
const schema = split.schema;
|
|
1205
|
+
const isAtom = model.isAtom;
|
|
1171
1206
|
|
|
1172
1207
|
switch (schema) {
|
|
1173
1208
|
case "|":
|
|
1174
1209
|
case "sth":
|
|
1175
|
-
return await loadTapeFromData(tapeImage, await tapeSth.fetch(tapeImage));
|
|
1210
|
+
return await loadTapeFromData(tapeImage, await tapeSth.fetch(tapeImage), isAtom);
|
|
1176
1211
|
|
|
1177
1212
|
case "data": {
|
|
1178
1213
|
const arr = Array.prototype.map.call(atob(tapeImage), (x) => x.charCodeAt(0));
|
|
1179
1214
|
const { name, data } = await utils.unzipDiscImage(arr);
|
|
1180
|
-
return await loadTapeFromData(name, data);
|
|
1215
|
+
return await loadTapeFromData(name, data, isAtom);
|
|
1181
1216
|
}
|
|
1182
1217
|
|
|
1183
1218
|
case "http":
|
|
@@ -1192,11 +1227,20 @@ async function loadTapeImage(tapeImage) {
|
|
|
1192
1227
|
tapeData = unzipped.data;
|
|
1193
1228
|
tapeImage = unzipped.name;
|
|
1194
1229
|
}
|
|
1195
|
-
return await loadTapeFromData(tapeImage, tapeData);
|
|
1230
|
+
return await loadTapeFromData(tapeImage, tapeData, isAtom);
|
|
1196
1231
|
}
|
|
1197
1232
|
|
|
1198
|
-
default:
|
|
1199
|
-
|
|
1233
|
+
default: {
|
|
1234
|
+
const tapePath = "tapes/" + tapeImage;
|
|
1235
|
+
let tapeData = await utils.loadData(tapePath);
|
|
1236
|
+
let tapeName = tapeImage;
|
|
1237
|
+
if (/\.zip/i.test(tapeName)) {
|
|
1238
|
+
const unzipped = await utils.unzipDiscImage(tapeData);
|
|
1239
|
+
tapeData = unzipped.data;
|
|
1240
|
+
tapeName = unzipped.name;
|
|
1241
|
+
}
|
|
1242
|
+
return await loadTapeFromData(tapeName, tapeData, isAtom);
|
|
1243
|
+
}
|
|
1200
1244
|
}
|
|
1201
1245
|
}
|
|
1202
1246
|
|
|
@@ -1221,8 +1265,14 @@ document.getElementById("tape_load").addEventListener("change", async function (
|
|
|
1221
1265
|
const file = evt.target.files[0];
|
|
1222
1266
|
utils.noteEvent("local", "clickTape"); // NB no filename here
|
|
1223
1267
|
|
|
1224
|
-
|
|
1225
|
-
|
|
1268
|
+
let tapeData = await readFileAsBinaryString(file);
|
|
1269
|
+
let tapeName = file.name;
|
|
1270
|
+
if (/\.zip/i.test(tapeName)) {
|
|
1271
|
+
const unzipped = await utils.unzipDiscImage(utils.stringToUint8Array(tapeData));
|
|
1272
|
+
tapeData = unzipped.data;
|
|
1273
|
+
tapeName = unzipped.name;
|
|
1274
|
+
}
|
|
1275
|
+
setProcessorTape(await loadTapeFromData(tapeName, tapeData, model.isAtom));
|
|
1226
1276
|
delete parsedQuery.tape;
|
|
1227
1277
|
updateUrl();
|
|
1228
1278
|
bootstrap.Modal.getInstance(document.getElementById("tapes"))?.hide();
|
|
@@ -1354,13 +1404,12 @@ googleDriveEl.addEventListener("show.bs.modal", async function () {
|
|
|
1354
1404
|
row.classList.remove("template");
|
|
1355
1405
|
dbList.appendChild(row);
|
|
1356
1406
|
row.querySelector(".name").textContent = item.name;
|
|
1357
|
-
row.addEventListener("click", function () {
|
|
1407
|
+
row.addEventListener("click", async function () {
|
|
1358
1408
|
utils.noteEvent("google-drive", "click", item.name);
|
|
1359
1409
|
setDisc1Image(`gd:${item.id}/${item.name}`);
|
|
1360
|
-
gdLoad(item).then(function (ssd) {
|
|
1361
|
-
processor.fdc.loadDisc(0, ssd);
|
|
1362
|
-
});
|
|
1363
1410
|
googleDriveModal.hide();
|
|
1411
|
+
const ssd = await gdLoad(item);
|
|
1412
|
+
if (ssd) processor.fdc.loadDisc(0, ssd);
|
|
1364
1413
|
});
|
|
1365
1414
|
}
|
|
1366
1415
|
});
|
|
@@ -1372,13 +1421,11 @@ for (const image of availableImages) {
|
|
|
1372
1421
|
discList.appendChild(elem);
|
|
1373
1422
|
elem.querySelector(".name").textContent = image.name;
|
|
1374
1423
|
elem.querySelector(".description").textContent = image.desc;
|
|
1375
|
-
elem.addEventListener("click", function () {
|
|
1424
|
+
elem.addEventListener("click", async function () {
|
|
1376
1425
|
utils.noteEvent("images", "click", image.file);
|
|
1377
1426
|
setDisc1Image(image.file);
|
|
1378
|
-
loadDiscImage(parsedQuery.disc1).then(function (disc) {
|
|
1379
|
-
processor.fdc.loadDisc(0, disc);
|
|
1380
|
-
});
|
|
1381
1427
|
$discsModal.hide();
|
|
1428
|
+
processor.fdc.loadDisc(0, await loadDiscImage(parsedQuery.disc1));
|
|
1382
1429
|
});
|
|
1383
1430
|
}
|
|
1384
1431
|
|
|
@@ -1501,14 +1548,16 @@ document.getElementById("save-state").addEventListener("click", async function (
|
|
|
1501
1548
|
if (wasRunning) go();
|
|
1502
1549
|
});
|
|
1503
1550
|
|
|
1504
|
-
async function loadStateFromFile(file) {
|
|
1551
|
+
async function loadStateFromFile(file, preReadBuffer) {
|
|
1505
1552
|
const wasRunning = running;
|
|
1506
1553
|
if (running) stop(false);
|
|
1507
1554
|
try {
|
|
1508
|
-
const arrayBuffer = await file.arrayBuffer();
|
|
1555
|
+
const arrayBuffer = preReadBuffer || (await file.arrayBuffer());
|
|
1509
1556
|
let snapshot;
|
|
1510
1557
|
if (isBemSnapshot(arrayBuffer)) {
|
|
1511
1558
|
snapshot = await parseBemSnapshot(arrayBuffer);
|
|
1559
|
+
} else if (isUefSnapshot(arrayBuffer)) {
|
|
1560
|
+
snapshot = parseUefSnapshot(arrayBuffer);
|
|
1512
1561
|
} else {
|
|
1513
1562
|
// Detect gzip (magic bytes 0x1f 0x8b) or plain JSON
|
|
1514
1563
|
const bytes = new Uint8Array(arrayBuffer);
|
|
@@ -1521,7 +1570,7 @@ async function loadStateFromFile(file) {
|
|
|
1521
1570
|
}
|
|
1522
1571
|
snapshot = snapshotFromJSON(text);
|
|
1523
1572
|
}
|
|
1524
|
-
if (!
|
|
1573
|
+
if (!isSameModel(snapshot.model, model.name)) {
|
|
1525
1574
|
// Model mismatch: stash state and reload with correct model
|
|
1526
1575
|
sessionStorage.setItem("jsbeeb-pending-state", snapshotToJSON(snapshot));
|
|
1527
1576
|
const newQuery = { ...parsedQuery, model: snapshot.model };
|
|
@@ -1541,9 +1590,13 @@ async function loadStateFromFile(file) {
|
|
|
1541
1590
|
if (wasRunning) go();
|
|
1542
1591
|
}
|
|
1543
1592
|
|
|
1544
|
-
function isSnapshotFile(filename) {
|
|
1593
|
+
function isSnapshotFile(filename, arrayBuffer) {
|
|
1545
1594
|
const lower = filename.toLowerCase();
|
|
1546
|
-
|
|
1595
|
+
if (lower.endsWith(".snp") || lower.endsWith(".json") || lower.endsWith(".json.gz") || lower.endsWith(".gz"))
|
|
1596
|
+
return true;
|
|
1597
|
+
// .uef can be either a BeebEm save state or a regular tape image - check content
|
|
1598
|
+
if (lower.endsWith(".uef") && arrayBuffer) return isUefSnapshot(arrayBuffer);
|
|
1599
|
+
return false;
|
|
1547
1600
|
}
|
|
1548
1601
|
|
|
1549
1602
|
document.getElementById("load-state").addEventListener("change", async function (event) {
|
|
@@ -1560,13 +1613,58 @@ for (const link of document.querySelectorAll("#tape-menu a")) {
|
|
|
1560
1613
|
|
|
1561
1614
|
if (type === "rewind") {
|
|
1562
1615
|
console.log("Rewinding tape to the start");
|
|
1563
|
-
|
|
1616
|
+
if (model.isAtom) {
|
|
1617
|
+
processor.atomppia.stopTape();
|
|
1618
|
+
processor.atomppia.rewindTape();
|
|
1619
|
+
updateTapeButton();
|
|
1620
|
+
} else {
|
|
1621
|
+
processor.acia.rewindTape();
|
|
1622
|
+
}
|
|
1564
1623
|
} else {
|
|
1565
1624
|
console.log("unknown type", type);
|
|
1566
1625
|
}
|
|
1567
1626
|
});
|
|
1568
1627
|
}
|
|
1569
1628
|
|
|
1629
|
+
const tapePlayStopBtn = document.getElementById("tape-play-stop");
|
|
1630
|
+
const tapeControlHeader = document.getElementById("tape-control-header");
|
|
1631
|
+
const tapeControlCell = document.getElementById("tape-control-cell");
|
|
1632
|
+
|
|
1633
|
+
function updateTapeButton() {
|
|
1634
|
+
if (!model.isAtom) return;
|
|
1635
|
+
const playing = processor.atomppia.motorOn;
|
|
1636
|
+
const label = playing ? "Stop cassette" : "Play cassette";
|
|
1637
|
+
tapePlayStopBtn.textContent = playing ? "\u25A0" : "\u25B6";
|
|
1638
|
+
tapePlayStopBtn.title = label;
|
|
1639
|
+
tapePlayStopBtn.setAttribute("aria-label", label);
|
|
1640
|
+
tapePlayStopBtn.classList.toggle("playing", playing);
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
function showTapeControl(visible) {
|
|
1644
|
+
const display = visible ? "" : "none";
|
|
1645
|
+
tapeControlHeader.style.display = display;
|
|
1646
|
+
tapeControlCell.style.display = display;
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
function updateLedVisibility() {
|
|
1650
|
+
const bbcDisplay = model.isAtom ? "none" : "";
|
|
1651
|
+
for (const el of document.querySelectorAll(".bbc-only")) {
|
|
1652
|
+
el.style.display = bbcDisplay;
|
|
1653
|
+
}
|
|
1654
|
+
showTapeControl(model.isAtom);
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
updateLedVisibility();
|
|
1658
|
+
|
|
1659
|
+
tapePlayStopBtn.addEventListener("click", () => {
|
|
1660
|
+
if (processor.atomppia.motorOn) {
|
|
1661
|
+
processor.atomppia.stopTape();
|
|
1662
|
+
} else {
|
|
1663
|
+
processor.atomppia.playTape();
|
|
1664
|
+
}
|
|
1665
|
+
updateTapeButton();
|
|
1666
|
+
});
|
|
1667
|
+
|
|
1570
1668
|
function Light(name) {
|
|
1571
1669
|
const dom = document.getElementById(name);
|
|
1572
1670
|
let on = false;
|
|
@@ -1585,13 +1683,17 @@ const drive1 = new Light("drive1");
|
|
|
1585
1683
|
const network = new Light("networklight");
|
|
1586
1684
|
|
|
1587
1685
|
syncLights = function () {
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1686
|
+
if (model.isAtom) {
|
|
1687
|
+
cassette.update(processor.atomppia.motorOn);
|
|
1688
|
+
} else {
|
|
1689
|
+
caps.update(processor.sysvia.capsLockLight);
|
|
1690
|
+
shift.update(processor.sysvia.shiftLockLight);
|
|
1691
|
+
drive0.update(processor.fdc.motorOn[0]);
|
|
1692
|
+
drive1.update(processor.fdc.motorOn[1]);
|
|
1693
|
+
cassette.update(processor.acia.motorOn);
|
|
1694
|
+
if (model.hasEconet) {
|
|
1695
|
+
network.update(processor.econet.activityLight());
|
|
1696
|
+
}
|
|
1595
1697
|
}
|
|
1596
1698
|
};
|
|
1597
1699
|
|
|
@@ -1626,7 +1728,16 @@ const startPromise = (async () => {
|
|
|
1626
1728
|
imageLoads.push(
|
|
1627
1729
|
(async () => {
|
|
1628
1730
|
const tape = await loadTapeImage(parsedQuery.tape);
|
|
1629
|
-
|
|
1731
|
+
setProcessorTape(tape);
|
|
1732
|
+
})(),
|
|
1733
|
+
);
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
if (mmcImage && model.isAtom) {
|
|
1737
|
+
imageLoads.push(
|
|
1738
|
+
(async () => {
|
|
1739
|
+
const files = await LoadSD(mmcImage);
|
|
1740
|
+
processor.atommc.SetMMCData(files);
|
|
1630
1741
|
})(),
|
|
1631
1742
|
);
|
|
1632
1743
|
}
|
|
@@ -1684,8 +1795,10 @@ const startPromise = (async () => {
|
|
|
1684
1795
|
return Promise.all(imageLoads);
|
|
1685
1796
|
})();
|
|
1686
1797
|
|
|
1687
|
-
|
|
1688
|
-
|
|
1798
|
+
(async () => {
|
|
1799
|
+
try {
|
|
1800
|
+
await startPromise;
|
|
1801
|
+
|
|
1689
1802
|
switch (needsAutoboot) {
|
|
1690
1803
|
case "boot":
|
|
1691
1804
|
sthAutoboot.checked = true;
|
|
@@ -1726,11 +1839,11 @@ startPromise
|
|
|
1726
1839
|
}
|
|
1727
1840
|
|
|
1728
1841
|
go();
|
|
1729
|
-
})
|
|
1730
|
-
.catch((error) => {
|
|
1842
|
+
} catch (error) {
|
|
1731
1843
|
console.error("Error initialising emulator:", error);
|
|
1732
1844
|
showError("initialising", error);
|
|
1733
|
-
}
|
|
1845
|
+
}
|
|
1846
|
+
})();
|
|
1734
1847
|
|
|
1735
1848
|
const aysEl = document.getElementById("are-you-sure");
|
|
1736
1849
|
const aysModal = new bootstrap.Modal(aysEl);
|