jsbeeb 1.14.0 → 1.16.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 +94 -0
- package/package.json +8 -6
- package/public/roms/tube/65C102Tube.rom +0 -0
- package/src/6502.js +22 -16
- package/src/6847.js +50 -8
- package/src/acia.js +31 -12
- package/src/canvas.js +77 -18
- package/src/config.js +16 -4
- package/src/disc-drive.js +9 -0
- package/src/disc-hfe.js +1 -2
- package/src/disc-surface.js +350 -0
- package/src/disc-visualiser.js +569 -0
- package/src/disc.js +143 -49
- package/src/dom-utils.js +16 -0
- package/src/econet.js +6 -2
- package/src/fake6502.js +2 -2
- package/src/gamepads.js +11 -4
- package/src/jsbeeb.css +126 -0
- package/src/main.js +112 -55
- package/src/models.js +47 -5
- package/src/serial.js +1 -0
- package/src/snapshot-helpers.js +1 -1
- package/src/sth.js +21 -18
- package/src/tapes.js +10 -12
- package/src/teletext_adaptor.js +38 -13
- package/src/touchscreen.js +6 -6
- package/src/tube.js +89 -37
- package/src/url-params.js +23 -19
- package/src/utils.js +10 -5
- package/src/utils_atom.js +9 -5
- package/src/video-filters/pal-composite.js +14 -48
- package/src/video-filters/passthrough-filter.js +11 -39
- package/src/video-filters/pixel-grid.js +55 -0
- package/src/video-filters/shader-program.js +53 -0
- package/src/video-filters/shaders/xbr.frag.glsl +278 -0
- package/src/video-filters/shaders/xbr.vert.glsl +7 -0
- package/src/video-filters/xbr-filter.js +107 -0
- package/src/video.js +56 -16
- package/src/wd-fdc.js +17 -7
- package/tests/test-machine.js +7 -5
package/src/main.js
CHANGED
|
@@ -7,7 +7,7 @@ import "./jsbeeb.css";
|
|
|
7
7
|
import * as utils from "./utils.js";
|
|
8
8
|
import { FakeVideo, Video } from "./video.js";
|
|
9
9
|
import { Debugger } from "./web/debug.js";
|
|
10
|
-
import { Cpu6502, AtomCpu6502
|
|
10
|
+
import { Cpu6502, AtomCpu6502 } from "./6502.js";
|
|
11
11
|
import * as utils_atom from "./utils_atom.js";
|
|
12
12
|
import { LoadSD } from "./mmc.js";
|
|
13
13
|
import { Cmos } from "./cmos.js";
|
|
@@ -19,7 +19,7 @@ import { GoogleDriveLoader } from "./google-drive.js";
|
|
|
19
19
|
import * as tokeniser from "./basic-tokenise.js";
|
|
20
20
|
import * as canvasLib from "./canvas.js";
|
|
21
21
|
import { Config } from "./config.js";
|
|
22
|
-
import {
|
|
22
|
+
import { tubeModelFor } from "./models.js";
|
|
23
23
|
import { initialise as electron } from "./app/electron.js";
|
|
24
24
|
import { AudioHandler } from "./web/audio-handler.js";
|
|
25
25
|
import { Econet } from "./econet.js";
|
|
@@ -44,6 +44,8 @@ import { isBemSnapshot, parseBemSnapshot } from "./bem-snapshot.js";
|
|
|
44
44
|
import { isUefSnapshot, parseUefSnapshot } from "./uef-snapshot.js";
|
|
45
45
|
import { RewindBuffer } from "./rewind.js";
|
|
46
46
|
import { RewindUI } from "./rewind-ui.js";
|
|
47
|
+
import { DiscVisualiser } from "./disc-visualiser.js";
|
|
48
|
+
import { downloadBlob } from "./dom-utils.js";
|
|
47
49
|
import {
|
|
48
50
|
buildUrlFromParams,
|
|
49
51
|
guessModelFromHostname,
|
|
@@ -51,7 +53,7 @@ import {
|
|
|
51
53
|
parseMediaParams,
|
|
52
54
|
parseQueryString,
|
|
53
55
|
processAutobootParams,
|
|
54
|
-
|
|
56
|
+
processInputParams,
|
|
55
57
|
} from "./url-params.js";
|
|
56
58
|
|
|
57
59
|
let processor;
|
|
@@ -138,7 +140,7 @@ const paramTypes = {
|
|
|
138
140
|
audiofilterfreq: ParamTypes.FLOAT,
|
|
139
141
|
audiofilterq: ParamTypes.FLOAT,
|
|
140
142
|
cpuMultiplier: ParamTypes.FLOAT,
|
|
141
|
-
tubeCpuMultiplier: ParamTypes.
|
|
143
|
+
tubeCpuMultiplier: ParamTypes.FLOAT,
|
|
142
144
|
microphoneChannel: ParamTypes.INT,
|
|
143
145
|
|
|
144
146
|
// String parameters (these are the default but listed for clarity)
|
|
@@ -176,9 +178,6 @@ const { discImage: queryDiscImage, secondDiscImage: querySecondDisc, mmcImage }
|
|
|
176
178
|
if (queryDiscImage) discImage = queryDiscImage;
|
|
177
179
|
if (querySecondDisc) secondDiscImage = querySecondDisc;
|
|
178
180
|
|
|
179
|
-
// Process keyboard mappings
|
|
180
|
-
parsedQuery = processKeyboardParams(parsedQuery, BBC, keyCodes, utils.userKeymap, gamepad);
|
|
181
|
-
|
|
182
181
|
// Handle specific query parameters
|
|
183
182
|
if (Array.isArray(parsedQuery.rom)) {
|
|
184
183
|
parsedQuery.rom.forEach((romPath) => {
|
|
@@ -234,9 +233,10 @@ speechOutput.enabled = !!parsedQuery.speechOutput;
|
|
|
234
233
|
const config = new Config(
|
|
235
234
|
function onChange(changed) {
|
|
236
235
|
if (changed.displayMode) {
|
|
237
|
-
displayModeFilter
|
|
236
|
+
// swapCanvas settles displayModeFilter on whatever was really
|
|
237
|
+
// built, so take the picture from that rather than the request.
|
|
238
|
+
swapCanvas(getFilterForMode(changed.displayMode));
|
|
238
239
|
setCrtPic(displayModeFilter);
|
|
239
|
-
swapCanvas(displayModeFilter);
|
|
240
240
|
// Trigger window resize to recalculate layout with new dimensions
|
|
241
241
|
window.dispatchEvent(new Event("resize"));
|
|
242
242
|
}
|
|
@@ -284,7 +284,7 @@ config.mapLegacyModels(parsedQuery);
|
|
|
284
284
|
|
|
285
285
|
config.setModel(parsedQuery.model || guessModelFromHostname(window.location.hostname));
|
|
286
286
|
config.setKeyLayout(keyLayout);
|
|
287
|
-
config.setTubeCpuMultiplier(parsedQuery.tubeCpuMultiplier ||
|
|
287
|
+
config.setTubeCpuMultiplier(parsedQuery.tubeCpuMultiplier || 1);
|
|
288
288
|
config.setMicrophoneChannel(parsedQuery.microphoneChannel);
|
|
289
289
|
config.setCheckboxes({
|
|
290
290
|
coProcessor: !!parsedQuery.coProcessor,
|
|
@@ -299,13 +299,22 @@ config.setDisplayMode(displayMode);
|
|
|
299
299
|
|
|
300
300
|
model = config.model;
|
|
301
301
|
|
|
302
|
+
// Must come after we know the model, to validate names against those of the hardware.
|
|
303
|
+
const keyMappingWarnings = processInputParams(
|
|
304
|
+
parsedQuery,
|
|
305
|
+
model.isAtom ? utils_atom.ATOM : BBC,
|
|
306
|
+
keyCodes,
|
|
307
|
+
utils.userKeymap,
|
|
308
|
+
gamepad,
|
|
309
|
+
);
|
|
310
|
+
|
|
302
311
|
// Depends on the config.setX calls above having applied the URL parameters.
|
|
303
312
|
const emulationConfig = {
|
|
304
313
|
keyLayout,
|
|
305
314
|
cpuMultiplier,
|
|
306
315
|
tubeCpuMultiplier: config.tubeCpuMultiplier,
|
|
307
316
|
videoCyclesBatch: parsedQuery.videoCyclesBatch,
|
|
308
|
-
tube: config.coProcessor ?
|
|
317
|
+
tube: config.coProcessor ? tubeModelFor(config.model) : null,
|
|
309
318
|
hasMusic5000: config.hasMusic5000,
|
|
310
319
|
hasTeletextAdaptor: config.hasTeletextAdaptor,
|
|
311
320
|
// ROM order determines sideways bank allocation, and the fittings' ROMs claim banks
|
|
@@ -345,7 +354,7 @@ sbBind(document.querySelector(".sidebar.bottom"), parsedQuery.sbBottom, function
|
|
|
345
354
|
});
|
|
346
355
|
|
|
347
356
|
if (cpuMultiplier !== 1) console.log(`CPU multiplier set to ${cpuMultiplier}`);
|
|
348
|
-
const cpuSpeed = model.
|
|
357
|
+
const cpuSpeed = model.cyclesPerSecond;
|
|
349
358
|
const clocksPerSecond = (cpuMultiplier * cpuSpeed) | 0;
|
|
350
359
|
const MaxCyclesPerFrame = clocksPerSecond / 10;
|
|
351
360
|
|
|
@@ -374,12 +383,29 @@ function showError(context, error) {
|
|
|
374
383
|
errorDialogModal.show();
|
|
375
384
|
}
|
|
376
385
|
|
|
386
|
+
if (keyMappingWarnings.length) {
|
|
387
|
+
showError("applying the key mappings in the URL", keyMappingWarnings.join(" "));
|
|
388
|
+
}
|
|
389
|
+
|
|
377
390
|
function createCanvasForFilter(filterClass) {
|
|
391
|
+
// Not `config`: that is the emulator's live configuration object, declared
|
|
392
|
+
// at module scope and used throughout this file.
|
|
393
|
+
const displayConfig = filterClass.getDisplayConfig();
|
|
394
|
+
// Each mode says how many pixels it wants to draw into. Set this before
|
|
395
|
+
// creating the context, which fixes its initial viewport.
|
|
396
|
+
screenCanvas.width = displayConfig.canvasWidth;
|
|
397
|
+
screenCanvas.height = displayConfig.canvasHeight;
|
|
398
|
+
|
|
378
399
|
const newCanvas = tryGl ? canvasLib.bestCanvas(screenCanvas, filterClass) : new canvasLib.Canvas(screenCanvas);
|
|
379
400
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
401
|
+
// Test which filter was actually built, not merely whether we got WebGL: a
|
|
402
|
+
// filter can decline a context that works perfectly well for other modes,
|
|
403
|
+
// in which case bestCanvas quietly gives us an unfiltered GL canvas.
|
|
404
|
+
if (newCanvas.filterClass !== filterClass) {
|
|
405
|
+
showError(
|
|
406
|
+
`enabling ${displayConfig.name} mode`,
|
|
407
|
+
`${displayConfig.name} is not available on this device. Using standard display instead.`,
|
|
408
|
+
);
|
|
383
409
|
}
|
|
384
410
|
|
|
385
411
|
return newCanvas;
|
|
@@ -387,20 +413,33 @@ function createCanvasForFilter(filterClass) {
|
|
|
387
413
|
|
|
388
414
|
let displayModeFilter = canvasLib.getFilterForMode(parsedQuery.displayMode || "rgb");
|
|
389
415
|
function swapCanvas(newFilterClass) {
|
|
416
|
+
const oldCanvas = canvas;
|
|
390
417
|
const newCanvas = createCanvasForFilter(newFilterClass);
|
|
418
|
+
// Carry the picture over; the buffers differ in height, so copy what fits.
|
|
419
|
+
newCanvas.fb32.set(oldCanvas.fb32.subarray(0, newCanvas.fb32.length));
|
|
420
|
+
// Only once the replacement exists, so a failure to build it leaves the
|
|
421
|
+
// display we already had. The two share a GL context but no GL objects.
|
|
422
|
+
oldCanvas.dispose();
|
|
391
423
|
video.fb32 = newCanvas.fb32;
|
|
392
424
|
video.paint_ext = function paint(minx, miny, maxx, maxy) {
|
|
393
425
|
frames++;
|
|
394
426
|
if (frames < frameSkip) return;
|
|
395
427
|
frames = 0;
|
|
396
|
-
newCanvas.paint(minx, miny, maxx, maxy, this.frameCount);
|
|
428
|
+
newCanvas.paint(minx, miny, maxx, maxy, { frameCount: this.frameCount, lineGrid: this.lineGrid });
|
|
397
429
|
};
|
|
398
430
|
canvas = newCanvas;
|
|
399
|
-
|
|
431
|
+
// Follow the filter we ended up with, not the one we asked for: everything
|
|
432
|
+
// downstream — the monitor picture, the canvas geometry, how large a
|
|
433
|
+
// drawing buffer to ask for — comes from its display config.
|
|
434
|
+
displayModeFilter = newCanvas.filterClass;
|
|
435
|
+
// Nothing else will redraw: the mode is changed from a modal, which stops
|
|
436
|
+
// the emulator.
|
|
437
|
+
video.paint();
|
|
400
438
|
window.setTimeout(() => window.dispatchEvent(new Event("resize")), 1);
|
|
401
439
|
}
|
|
402
440
|
|
|
403
441
|
let canvas = createCanvasForFilter(displayModeFilter);
|
|
442
|
+
displayModeFilter = canvas.filterClass;
|
|
404
443
|
|
|
405
444
|
video = new Video(
|
|
406
445
|
model.isMaster,
|
|
@@ -409,7 +448,7 @@ video = new Video(
|
|
|
409
448
|
frames++;
|
|
410
449
|
if (frames < frameSkip) return;
|
|
411
450
|
frames = 0;
|
|
412
|
-
canvas.paint(minx, miny, maxx, maxy, this.frameCount);
|
|
451
|
+
canvas.paint(minx, miny, maxx, maxy, { frameCount: this.frameCount, lineGrid: this.lineGrid });
|
|
413
452
|
},
|
|
414
453
|
{ isAtom: model.isAtom },
|
|
415
454
|
);
|
|
@@ -471,18 +510,8 @@ function replaceOrAddExtension(name, newExt) {
|
|
|
471
510
|
* @param {string} extension - The file extension to use
|
|
472
511
|
*/
|
|
473
512
|
function downloadDriveData(data, name, extension) {
|
|
474
|
-
const a = document.createElement("a");
|
|
475
|
-
document.body.appendChild(a);
|
|
476
|
-
a.style = "display: none";
|
|
477
|
-
|
|
478
|
-
const fileName = replaceOrAddExtension(name, extension);
|
|
479
513
|
const blob = new Blob([data], { type: "application/octet-stream" });
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
a.href = url;
|
|
483
|
-
a.download = fileName;
|
|
484
|
-
a.click();
|
|
485
|
-
window.URL.revokeObjectURL(url);
|
|
514
|
+
downloadBlob(blob, replaceOrAddExtension(name, extension));
|
|
486
515
|
}
|
|
487
516
|
|
|
488
517
|
async function loadHTMLFile(file) {
|
|
@@ -532,7 +561,7 @@ pastetext.addEventListener("drop", async function (event) {
|
|
|
532
561
|
await loadStateFromFile(file, arrayBuffer);
|
|
533
562
|
} else if (file.name.toLowerCase().endsWith(".uef")) {
|
|
534
563
|
// Regular UEF tape image (not a BeebEm save state)
|
|
535
|
-
setProcessorTape(await loadTapeFromData(file.name, new Uint8Array(arrayBuffer), model
|
|
564
|
+
setProcessorTape(await loadTapeFromData(file.name, new Uint8Array(arrayBuffer), model));
|
|
536
565
|
} else {
|
|
537
566
|
await loadHTMLFile(file);
|
|
538
567
|
}
|
|
@@ -618,7 +647,7 @@ window.addEventListener("beforeunload", function (event) {
|
|
|
618
647
|
});
|
|
619
648
|
|
|
620
649
|
if (config.hasEconet) {
|
|
621
|
-
econet = new Econet(stationId);
|
|
650
|
+
econet = new Econet(stationId, model.cyclesPerSecond);
|
|
622
651
|
} else {
|
|
623
652
|
document.getElementById("fsmenuitem").style.display = "none";
|
|
624
653
|
}
|
|
@@ -667,6 +696,8 @@ processor = new CpuClass(model, {
|
|
|
667
696
|
econet,
|
|
668
697
|
});
|
|
669
698
|
|
|
699
|
+
processor.teletextAdaptor?.addEventListener("showError", (e) => showError(e.detail.context, e.detail.error));
|
|
700
|
+
|
|
670
701
|
// Create input sources
|
|
671
702
|
const gamepadSource = new GamepadSource(emulationConfig.getGamepads);
|
|
672
703
|
// Create MicrophoneInput but don't enable by default
|
|
@@ -681,18 +712,16 @@ const mouseJoystickSource = new MouseJoystickSource(screenCanvas);
|
|
|
681
712
|
/**
|
|
682
713
|
* Attach an RS-423 composite handler to the ACIA that combines the touchscreen
|
|
683
714
|
* (which sends position data to the BBC) with the speech output (which speaks
|
|
684
|
-
* text the BBC sends out).
|
|
685
|
-
* again whenever speechOutput.enabled changes.
|
|
715
|
+
* text the BBC sends out).
|
|
686
716
|
*/
|
|
687
717
|
function setupRs423Handler() {
|
|
688
|
-
const touchScreen = processor.touchScreen;
|
|
689
718
|
processor.acia.setRs423Handler({
|
|
690
719
|
onTransmit(val) {
|
|
691
|
-
touchScreen.onTransmit(val);
|
|
720
|
+
processor.touchScreen.onTransmit(val);
|
|
692
721
|
speechOutput.onTransmit(val);
|
|
693
722
|
},
|
|
694
723
|
tryReceive(rts) {
|
|
695
|
-
return touchScreen.tryReceive(rts);
|
|
724
|
+
return processor.touchScreen.tryReceive(rts);
|
|
696
725
|
},
|
|
697
726
|
});
|
|
698
727
|
}
|
|
@@ -1206,17 +1235,16 @@ async function loadTapeImage(tapeImage) {
|
|
|
1206
1235
|
const split = splitImage(tapeImage);
|
|
1207
1236
|
tapeImage = split.image;
|
|
1208
1237
|
const schema = split.schema;
|
|
1209
|
-
const isAtom = model.isAtom;
|
|
1210
1238
|
|
|
1211
1239
|
switch (schema) {
|
|
1212
1240
|
case "|":
|
|
1213
1241
|
case "sth":
|
|
1214
|
-
return await loadTapeFromData(tapeImage, await tapeSth.fetch(tapeImage),
|
|
1242
|
+
return await loadTapeFromData(tapeImage, await tapeSth.fetch(tapeImage), model);
|
|
1215
1243
|
|
|
1216
1244
|
case "data": {
|
|
1217
1245
|
const arr = Array.prototype.map.call(atob(tapeImage), (x) => x.charCodeAt(0));
|
|
1218
1246
|
const { name, data } = await utils.unzipDiscImage(arr);
|
|
1219
|
-
return await loadTapeFromData(name, data,
|
|
1247
|
+
return await loadTapeFromData(name, data, model);
|
|
1220
1248
|
}
|
|
1221
1249
|
|
|
1222
1250
|
case "http":
|
|
@@ -1231,7 +1259,7 @@ async function loadTapeImage(tapeImage) {
|
|
|
1231
1259
|
tapeData = unzipped.data;
|
|
1232
1260
|
tapeImage = unzipped.name;
|
|
1233
1261
|
}
|
|
1234
|
-
return await loadTapeFromData(tapeImage, tapeData,
|
|
1262
|
+
return await loadTapeFromData(tapeImage, tapeData, model);
|
|
1235
1263
|
}
|
|
1236
1264
|
|
|
1237
1265
|
default: {
|
|
@@ -1243,7 +1271,7 @@ async function loadTapeImage(tapeImage) {
|
|
|
1243
1271
|
tapeData = unzipped.data;
|
|
1244
1272
|
tapeName = unzipped.name;
|
|
1245
1273
|
}
|
|
1246
|
-
return await loadTapeFromData(tapeName, tapeData,
|
|
1274
|
+
return await loadTapeFromData(tapeName, tapeData, model);
|
|
1247
1275
|
}
|
|
1248
1276
|
}
|
|
1249
1277
|
}
|
|
@@ -1276,7 +1304,7 @@ document.getElementById("tape_load").addEventListener("change", async function (
|
|
|
1276
1304
|
tapeData = unzipped.data;
|
|
1277
1305
|
tapeName = unzipped.name;
|
|
1278
1306
|
}
|
|
1279
|
-
setProcessorTape(await loadTapeFromData(tapeName, tapeData, model
|
|
1307
|
+
setProcessorTape(await loadTapeFromData(tapeName, tapeData, model));
|
|
1280
1308
|
delete parsedQuery.tape;
|
|
1281
1309
|
updateUrl();
|
|
1282
1310
|
bootstrap.Modal.getInstance(document.getElementById("tapes"))?.hide();
|
|
@@ -1445,7 +1473,12 @@ document.querySelector("#google-drive form").addEventListener("submit", async fu
|
|
|
1445
1473
|
let data;
|
|
1446
1474
|
if (document.querySelector("#google-drive .create-from-existing").checked) {
|
|
1447
1475
|
const discType = disc.guessDiscTypeFromName(name);
|
|
1448
|
-
|
|
1476
|
+
try {
|
|
1477
|
+
data = discType.saver(processor.fdc.drives[0].disc);
|
|
1478
|
+
} catch (e) {
|
|
1479
|
+
loadingFinished(`Create failed: ${e.message}`);
|
|
1480
|
+
return;
|
|
1481
|
+
}
|
|
1449
1482
|
name = replaceOrAddExtension(name, discType.extension);
|
|
1450
1483
|
console.log(`Saving existing disc: ${name}`);
|
|
1451
1484
|
} else {
|
|
@@ -1474,11 +1507,15 @@ document.querySelector("#google-drive form").addEventListener("submit", async fu
|
|
|
1474
1507
|
|
|
1475
1508
|
document.getElementById("download-drive-link").addEventListener("click", function () {
|
|
1476
1509
|
const disc = processor.fdc.drives[0].disc;
|
|
1477
|
-
const
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1510
|
+
const save = (options) =>
|
|
1511
|
+
downloadDriveData(toSsdOrDsd(disc, options), disc.name, disc.isDoubleSided ? ".dsd" : ".ssd");
|
|
1512
|
+
try {
|
|
1513
|
+
save();
|
|
1514
|
+
} catch (e) {
|
|
1515
|
+
areYouSure(`${e.message} Save anyway, losing what will not fit?`, "Save anyway", "Cancel", () =>
|
|
1516
|
+
save({ force: true }),
|
|
1517
|
+
);
|
|
1518
|
+
}
|
|
1482
1519
|
});
|
|
1483
1520
|
|
|
1484
1521
|
document.getElementById("download-drive-hfe-link").addEventListener("click", function () {
|
|
@@ -1539,13 +1576,8 @@ document.getElementById("save-state").addEventListener("click", async function (
|
|
|
1539
1576
|
const snapshot = createSnapshot(processor, model, Object.keys(media).length > 0 ? media : undefined);
|
|
1540
1577
|
const json = snapshotToJSON(snapshot);
|
|
1541
1578
|
const blob = await compressBlob(new Blob([json]));
|
|
1542
|
-
const url = URL.createObjectURL(blob);
|
|
1543
1579
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
1544
|
-
|
|
1545
|
-
a.href = url;
|
|
1546
|
-
a.download = `jsbeeb-${model.name}-${timestamp}.json.gz`;
|
|
1547
|
-
a.click();
|
|
1548
|
-
URL.revokeObjectURL(url);
|
|
1580
|
+
downloadBlob(blob, `jsbeeb-${model.name}-${timestamp}.json.gz`);
|
|
1549
1581
|
} catch (e) {
|
|
1550
1582
|
showError("saving state", e);
|
|
1551
1583
|
}
|
|
@@ -1935,7 +1967,7 @@ function VirtualSpeedUpdater() {
|
|
|
1935
1967
|
if (this.cycles) {
|
|
1936
1968
|
const thisMHz = this.cycles / this.time / 1000;
|
|
1937
1969
|
this.v.textContent = thisMHz.toFixed(1);
|
|
1938
|
-
if (this.cycles >= 10 *
|
|
1970
|
+
if (this.cycles >= 10 * cpuSpeed) {
|
|
1939
1971
|
this.cycles = this.time = 0;
|
|
1940
1972
|
}
|
|
1941
1973
|
this.header.style.color = this.speedy ? "red" : "white";
|
|
@@ -1963,6 +1995,9 @@ rewindUI = new RewindUI({
|
|
|
1963
1995
|
});
|
|
1964
1996
|
rewindUI.updateButtonState();
|
|
1965
1997
|
|
|
1998
|
+
if (processor.fdc) new DiscVisualiser({ fdc: processor.fdc });
|
|
1999
|
+
else document.getElementById("disc-visualiser-open").classList.add("disabled");
|
|
2000
|
+
|
|
1966
2001
|
function draw(now) {
|
|
1967
2002
|
if (!running) {
|
|
1968
2003
|
last = 0;
|
|
@@ -2078,6 +2113,9 @@ function stop(debug) {
|
|
|
2078
2113
|
updateDebugButtons();
|
|
2079
2114
|
}
|
|
2080
2115
|
|
|
2116
|
+
/** Steps the drawing buffer grows in, as a multiple of the base canvas size. */
|
|
2117
|
+
const CanvasScaleStep = 0.25;
|
|
2118
|
+
|
|
2081
2119
|
(function () {
|
|
2082
2120
|
const resizeCubMonitor = document.getElementById("cub-monitor");
|
|
2083
2121
|
const resizeCubMonitorPic = document.getElementById("cub-monitor-pic");
|
|
@@ -2130,6 +2168,25 @@ function stop(debug) {
|
|
|
2130
2168
|
resizeCubMonitor.style.width = width + "px";
|
|
2131
2169
|
resizeCubMonitorPic.style.height = height + "px";
|
|
2132
2170
|
resizeCubMonitorPic.style.width = width + "px";
|
|
2171
|
+
// A mode that reconstructs detail wants to draw at the size it will be
|
|
2172
|
+
// seen at, up to the limit it asks for. Drawing more than the display
|
|
2173
|
+
// can show costs fragments and buys nothing, and for an expensive
|
|
2174
|
+
// shader that is the difference between comfortable and not.
|
|
2175
|
+
if (displayConfig.maxCanvasScale) {
|
|
2176
|
+
const wanted = (finalCanvasWidth * (window.devicePixelRatio || 1)) / displayConfig.canvasWidth;
|
|
2177
|
+
// Quantised, because resize fires continuously while a window is
|
|
2178
|
+
// dragged and every distinct value reallocates the drawing buffer.
|
|
2179
|
+
const quantised = Math.round(wanted / CanvasScaleStep) * CanvasScaleStep;
|
|
2180
|
+
const scale = Math.min(displayConfig.maxCanvasScale, Math.max(1, quantised));
|
|
2181
|
+
const backingWidth = Math.round(displayConfig.canvasWidth * scale);
|
|
2182
|
+
if (screenCanvas.width !== backingWidth) {
|
|
2183
|
+
screenCanvas.width = backingWidth;
|
|
2184
|
+
screenCanvas.height = Math.round(displayConfig.canvasHeight * scale);
|
|
2185
|
+
// Resizing threw the drawing buffer away.
|
|
2186
|
+
video.paint();
|
|
2187
|
+
}
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2133
2190
|
screenCanvas.style.width = finalCanvasWidth + "px";
|
|
2134
2191
|
screenCanvas.style.height = finalCanvasHeight + "px";
|
|
2135
2192
|
screenCanvas.style.left = canvasOrigLeft * containerScale + "px";
|
package/src/models.js
CHANGED
|
@@ -19,10 +19,12 @@ const CpuModel = Object.freeze({
|
|
|
19
19
|
* to any number of machines without one session's settings leaking into the next.
|
|
20
20
|
*/
|
|
21
21
|
class Model {
|
|
22
|
-
constructor({ name, synonyms, os, cpuModel, isMaster, isAtom, swram, fdc, cmosOverride, banks } = {}) {
|
|
22
|
+
constructor({ name, synonyms, os, cpuModel, isMaster, isAtom, swram, fdc, cmosOverride, banks, clockMhz } = {}) {
|
|
23
|
+
if (!(clockMhz > 0)) throw new Error(`Model ${name} has no clock speed`);
|
|
23
24
|
this.name = name;
|
|
24
25
|
this.synonyms = synonyms;
|
|
25
26
|
this.os = os;
|
|
27
|
+
this.clockMhz = clockMhz;
|
|
26
28
|
this.banks = banks;
|
|
27
29
|
this._cpuModel = cpuModel;
|
|
28
30
|
this.isMaster = isMaster;
|
|
@@ -33,6 +35,15 @@ class Model {
|
|
|
33
35
|
this.cmosOverride = cmosOverride;
|
|
34
36
|
}
|
|
35
37
|
|
|
38
|
+
/**
|
|
39
|
+
* How many CPU cycles this machine runs in a second. Everything that
|
|
40
|
+
* converts between real time and emulated cycles should ask here rather
|
|
41
|
+
* than assuming a clock speed.
|
|
42
|
+
*/
|
|
43
|
+
get cyclesPerSecond() {
|
|
44
|
+
return this.clockMhz * 1000 * 1000;
|
|
45
|
+
}
|
|
46
|
+
|
|
36
47
|
get nmos() {
|
|
37
48
|
return this._cpuModel === CpuModel.MOS6502;
|
|
38
49
|
}
|
|
@@ -72,6 +83,7 @@ function atomModel({ name, synonyms, os, banks }) {
|
|
|
72
83
|
os,
|
|
73
84
|
cpuModel: CpuModel.MOS6502,
|
|
74
85
|
isMaster: false,
|
|
86
|
+
clockMhz: 1,
|
|
75
87
|
isAtom: true,
|
|
76
88
|
swram: beebSwram,
|
|
77
89
|
fdc: NoiseAwareIntelFdc,
|
|
@@ -124,6 +136,7 @@ export const allModels = [
|
|
|
124
136
|
os: ["os.rom", "BASIC.ROM", "b/DFS-1.2.rom"],
|
|
125
137
|
cpuModel: CpuModel.MOS6502,
|
|
126
138
|
isMaster: false,
|
|
139
|
+
clockMhz: 2,
|
|
127
140
|
swram: beebSwram,
|
|
128
141
|
fdc: NoiseAwareIntelFdc,
|
|
129
142
|
}),
|
|
@@ -133,6 +146,7 @@ export const allModels = [
|
|
|
133
146
|
os: ["os.rom", "BASIC.ROM", "b/DFS-0.9.rom"],
|
|
134
147
|
cpuModel: CpuModel.MOS6502,
|
|
135
148
|
isMaster: false,
|
|
149
|
+
clockMhz: 2,
|
|
136
150
|
swram: beebSwram,
|
|
137
151
|
fdc: NoiseAwareIntelFdc,
|
|
138
152
|
}),
|
|
@@ -142,6 +156,7 @@ export const allModels = [
|
|
|
142
156
|
os: ["os.rom", "BASIC.ROM", "b1770/dfs1770.rom", "b1770/zADFS.ROM"],
|
|
143
157
|
cpuModel: CpuModel.MOS6502,
|
|
144
158
|
isMaster: false,
|
|
159
|
+
clockMhz: 2,
|
|
145
160
|
swram: beebSwram,
|
|
146
161
|
fdc: NoiseAwareWdFdc,
|
|
147
162
|
}),
|
|
@@ -152,6 +167,7 @@ export const allModels = [
|
|
|
152
167
|
os: ["os.rom", "BASIC.ROM", "b1770/zADFS.ROM", "b1770/dfs1770.rom"],
|
|
153
168
|
cpuModel: CpuModel.MOS6502,
|
|
154
169
|
isMaster: false,
|
|
170
|
+
clockMhz: 2,
|
|
155
171
|
swram: beebSwram,
|
|
156
172
|
fdc: NoiseAwareWdFdc,
|
|
157
173
|
}),
|
|
@@ -161,6 +177,7 @@ export const allModels = [
|
|
|
161
177
|
os: ["master/mos3.20"],
|
|
162
178
|
cpuModel: CpuModel.CMOS65C12,
|
|
163
179
|
isMaster: true,
|
|
180
|
+
clockMhz: 2,
|
|
164
181
|
swram: masterSwram,
|
|
165
182
|
fdc: NoiseAwareWdFdc,
|
|
166
183
|
cmosOverride: pickDfs,
|
|
@@ -171,6 +188,7 @@ export const allModels = [
|
|
|
171
188
|
os: ["master/mos3.20"],
|
|
172
189
|
cpuModel: CpuModel.CMOS65C12,
|
|
173
190
|
isMaster: true,
|
|
191
|
+
clockMhz: 2,
|
|
174
192
|
swram: masterSwram,
|
|
175
193
|
fdc: NoiseAwareWdFdc,
|
|
176
194
|
cmosOverride: pickAdfs,
|
|
@@ -181,6 +199,7 @@ export const allModels = [
|
|
|
181
199
|
os: ["master/mos3.20"],
|
|
182
200
|
cpuModel: CpuModel.CMOS65C12,
|
|
183
201
|
isMaster: true,
|
|
202
|
+
clockMhz: 2,
|
|
184
203
|
swram: masterSwram,
|
|
185
204
|
fdc: NoiseAwareWdFdc,
|
|
186
205
|
cmosOverride: pickAnfs,
|
|
@@ -210,13 +229,25 @@ export const allModels = [
|
|
|
210
229
|
synonyms: ["Atom-DOS"],
|
|
211
230
|
os: ["atom/Atom_Kernel.rom", "atom/Atom_DOS.rom", "atom/Atom_FloatingPoint.rom", "atom/Atom_Basic.rom"],
|
|
212
231
|
}),
|
|
213
|
-
//
|
|
232
|
+
// Neither can be selected as a model: they are fitted to one, by the configuration builder later.
|
|
214
233
|
new Model({
|
|
215
234
|
name: "Tube65C02",
|
|
216
235
|
synonyms: [],
|
|
217
236
|
os: ["tube/6502Tube.rom"],
|
|
237
|
+
// The production wedge's GTE 65SC02 has no Rockwell bit instructions.
|
|
238
|
+
cpuModel: CpuModel.CMOS65C12,
|
|
239
|
+
isMaster: false,
|
|
240
|
+
clockMhz: 3,
|
|
241
|
+
}),
|
|
242
|
+
new Model({
|
|
243
|
+
name: "Tube65C102",
|
|
244
|
+
synonyms: [],
|
|
245
|
+
os: ["tube/65C102Tube.rom"],
|
|
246
|
+
// Boards are reported with both Rockwell and GTE parts, so keep the superset of the two
|
|
247
|
+
// until #756 lets the fitted co-processor be chosen.
|
|
218
248
|
cpuModel: CpuModel.CMOS65C02,
|
|
219
249
|
isMaster: false,
|
|
250
|
+
clockMhz: 4,
|
|
220
251
|
}),
|
|
221
252
|
];
|
|
222
253
|
|
|
@@ -236,6 +267,7 @@ export const TEST_6502 = new Model({
|
|
|
236
267
|
name: "TEST",
|
|
237
268
|
synonyms: ["TEST"],
|
|
238
269
|
os: [],
|
|
270
|
+
clockMhz: 2,
|
|
239
271
|
cpuModel: CpuModel.MOS6502,
|
|
240
272
|
isMaster: false,
|
|
241
273
|
swram: beebSwram,
|
|
@@ -246,6 +278,7 @@ export const TEST_65C02 = new Model({
|
|
|
246
278
|
name: "TEST",
|
|
247
279
|
synonyms: ["TEST"],
|
|
248
280
|
os: [],
|
|
281
|
+
clockMhz: 2,
|
|
249
282
|
cpuModel: CpuModel.CMOS65C02,
|
|
250
283
|
isMaster: false,
|
|
251
284
|
swram: masterSwram,
|
|
@@ -256,6 +289,7 @@ export const TEST_65C12 = new Model({
|
|
|
256
289
|
name: "TEST",
|
|
257
290
|
synonyms: ["TEST"],
|
|
258
291
|
os: [],
|
|
292
|
+
clockMhz: 2,
|
|
259
293
|
cpuModel: CpuModel.CMOS65C12,
|
|
260
294
|
isMaster: false,
|
|
261
295
|
swram: masterSwram,
|
|
@@ -265,6 +299,7 @@ TEST_65C12.isTest = true;
|
|
|
265
299
|
|
|
266
300
|
export const basicOnly = new Model({
|
|
267
301
|
name: "Basic only",
|
|
302
|
+
clockMhz: 2,
|
|
268
303
|
synonyms: ["Basic only"],
|
|
269
304
|
os: ["master/mos3.20"],
|
|
270
305
|
cpuModel: CpuModel.CMOS65C12,
|
|
@@ -274,11 +309,18 @@ export const basicOnly = new Model({
|
|
|
274
309
|
});
|
|
275
310
|
|
|
276
311
|
/**
|
|
277
|
-
* The
|
|
278
|
-
*
|
|
279
|
-
* import cycle via the FDC
|
|
312
|
+
* The second processors jsbeeb emulates: the external 3MHz box, and the Master Turbo's
|
|
313
|
+
* internal 4MHz board. Machine-building code passes one of these as the emulation config's
|
|
314
|
+
* `tube`, so that 6502.js needn't import this module and close an import cycle via the FDC
|
|
315
|
+
* modules.
|
|
280
316
|
*/
|
|
281
317
|
export const TubeModel = findModel("Tube65C02");
|
|
318
|
+
export const TurboTubeModel = findModel("Tube65C102");
|
|
319
|
+
|
|
320
|
+
/** @returns {Model} the second processor sold for this machine: the Turbo board for a Master. */
|
|
321
|
+
export function tubeModelFor(model) {
|
|
322
|
+
return model.isMaster ? TurboTubeModel : TubeModel;
|
|
323
|
+
}
|
|
282
324
|
|
|
283
325
|
// After the isTest assignments above, so those still apply.
|
|
284
326
|
for (const model of [...allModels, TEST_6502, TEST_65C02, TEST_65C12, basicOnly]) {
|
package/src/serial.js
CHANGED
|
@@ -20,6 +20,7 @@ export class Serial {
|
|
|
20
20
|
this.transmitRate = val & 0x07;
|
|
21
21
|
this.receiveRate = (val >>> 3) & 0x07;
|
|
22
22
|
this.acia.setSerialReceive(table[this.receiveRate]);
|
|
23
|
+
this.acia.setSerialTransmit(table[this.transmitRate]);
|
|
23
24
|
this.acia.setMotor(!!(val & 0x80));
|
|
24
25
|
this.acia.selectRs423(!!(val & 0x40));
|
|
25
26
|
}
|
package/src/snapshot-helpers.js
CHANGED
package/src/sth.js
CHANGED
|
@@ -2,30 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
import * as utils from "./utils.js";
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
// Always https, whatever the page was loaded over: the mirror redirects plain
|
|
6
|
+
// http, so following the page's protocol would cost a redirect on every request
|
|
7
|
+
// when developing over http, and Electron reports "file:" anyway.
|
|
8
|
+
const mirrorBase = "https://bbc.xania.org/archive/sth";
|
|
7
9
|
|
|
8
|
-
async function
|
|
10
|
+
async function _fetchManifest(url) {
|
|
9
11
|
const response = await fetch(url);
|
|
10
12
|
if (!response.ok) {
|
|
11
|
-
throw new Error(
|
|
13
|
+
throw new Error(`Network response was not ok (${response.status})`);
|
|
12
14
|
}
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
15
|
+
const data = await response.json();
|
|
16
|
+
if (!Array.isArray(data?.files)) {
|
|
17
|
+
throw new Error("Invalid manifest: missing files array");
|
|
18
|
+
}
|
|
19
|
+
return data.files.map((f) => f.path).sort();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Each path component is encoded individually so slashes survive but special
|
|
23
|
+
// characters in filenames (e.g. brackets in "Daxis[droids]-demo.zip") don't
|
|
24
|
+
// produce a malformed URL.
|
|
25
|
+
function encodePath(path) {
|
|
26
|
+
return path.split("/").map(encodeURIComponent).join("/");
|
|
22
27
|
}
|
|
23
28
|
|
|
24
29
|
export class StairwayToHell {
|
|
25
30
|
constructor(onStart, onCat, onError, tape) {
|
|
26
|
-
|
|
27
|
-
const protocol = document.location.protocol === "file:" ? "https:" : document.location.protocol;
|
|
28
|
-
this._baseUrl = `${protocol}//${sthArchive}/${tape ? "tape" : "disk"}images/`;
|
|
31
|
+
this._baseUrl = `${mirrorBase}/${tape ? "tape" : "disk"}images/`;
|
|
29
32
|
this._catalog = [];
|
|
30
33
|
this._onStart = onStart;
|
|
31
34
|
this._onCat = onCat;
|
|
@@ -36,7 +39,7 @@ export class StairwayToHell {
|
|
|
36
39
|
this._onStart();
|
|
37
40
|
if (this._catalog.length === 0) {
|
|
38
41
|
try {
|
|
39
|
-
this._catalog = await
|
|
42
|
+
this._catalog = await _fetchManifest(this._baseUrl + "manifest.json");
|
|
40
43
|
} catch (error) {
|
|
41
44
|
console.error("Failed to fetch catalog:", error);
|
|
42
45
|
if (this._onError) this._onError();
|
|
@@ -47,7 +50,7 @@ export class StairwayToHell {
|
|
|
47
50
|
}
|
|
48
51
|
|
|
49
52
|
async fetch(file) {
|
|
50
|
-
const name = this._baseUrl + file;
|
|
53
|
+
const name = this._baseUrl + encodePath(file);
|
|
51
54
|
console.log("Loading ZIP from " + name);
|
|
52
55
|
const response = await fetch(name);
|
|
53
56
|
if (!response.ok) throw new Error("Network response was not ok");
|