jsbeeb 1.13.1 → 1.15.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 +97 -2
- package/package.json +23 -16
- package/public/roms/tube/65C102Tube.rom +0 -0
- package/src/6502.js +74 -17
- package/src/acia.js +29 -11
- package/src/bem-snapshot.js +154 -15
- package/src/config.js +114 -83
- package/src/disc.js +47 -5
- package/src/dom-utils.js +16 -0
- package/src/fake6502.js +7 -2
- package/src/gamepads.js +11 -4
- package/src/keyboard.js +3 -2
- package/src/machine-session.js +11 -2
- package/src/main.js +117 -100
- package/src/models.js +57 -5
- package/src/mouse-coordinates.js +16 -0
- package/src/serial.js +1 -0
- package/src/snapshot-helpers.js +17 -3
- package/src/snapshot.js +19 -1
- package/src/soundchip.js +23 -5
- package/src/teletext_adaptor.js +40 -14
- package/src/tube.js +33 -1
- package/src/url-params.js +23 -19
- package/src/utils.js +10 -5
- package/src/utils_atom.js +9 -5
- package/src/web/audio-handler.js +4 -1
- package/tests/test-machine.js +7 -5
package/src/bem-snapshot.js
CHANGED
|
@@ -255,7 +255,145 @@ function readString(data, pos) {
|
|
|
255
255
|
return str;
|
|
256
256
|
}
|
|
257
257
|
|
|
258
|
-
|
|
258
|
+
// b-em writes the tube ULA as a raw struct dump. Version 2 is all single-byte fields, so it has
|
|
259
|
+
// no padding and can be read directly; version 1 used native ints and is not supported here.
|
|
260
|
+
const BemTubeUlaVersion = 2;
|
|
261
|
+
const BemTubeUlaOffsets = {
|
|
262
|
+
ph1: 0,
|
|
263
|
+
ph2: 24,
|
|
264
|
+
ph3: 25,
|
|
265
|
+
ph4: 27,
|
|
266
|
+
hp1: 29, // phl (byte 28) is a latch jsbeeb does not model
|
|
267
|
+
hp2: 30,
|
|
268
|
+
hp3: 31,
|
|
269
|
+
hp4: 33,
|
|
270
|
+
hstat: 35, // hpl (byte 34) likewise unmodelled
|
|
271
|
+
pstat: 39,
|
|
272
|
+
r1stat: 43,
|
|
273
|
+
ph1head: 45, // ph1tail (byte 44) matters only to b-em, which appends there
|
|
274
|
+
ph1count: 46,
|
|
275
|
+
ph3pos: 47,
|
|
276
|
+
hp3pos: 48,
|
|
277
|
+
};
|
|
278
|
+
const BemTubeUlaSize = 49;
|
|
279
|
+
const BemPh1Size = 24;
|
|
280
|
+
// 9 bytes of registers, then RAM, then ROM.
|
|
281
|
+
const BemTubeRegisterBytes = 9;
|
|
282
|
+
const BemTubeRamSize = 65536;
|
|
283
|
+
// b-em's other co-processors are different CPUs, and its "6502 Turbo" has 16MB of RAM.
|
|
284
|
+
const BemSupportedTubes = ["6502 Internal", "6502 External"];
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Convert b-em's tube sections into jsbeeb tube state.
|
|
288
|
+
* @param {object} sections - parsed v3 sections
|
|
289
|
+
* @param {string|null} tubeName - co-processor name from the model section
|
|
290
|
+
* @returns {Promise<object|null>} jsbeeb tube state, or null if no usable co-processor
|
|
291
|
+
*/
|
|
292
|
+
async function parseBemTube(sections, tubeName) {
|
|
293
|
+
// b-em names the model's co-processor even when it failed to start one, so naming a tube but
|
|
294
|
+
// carrying no state for it is valid. Carrying only one of the two sections is not.
|
|
295
|
+
if (!sections["T"] && !sections["P"]) return null;
|
|
296
|
+
if (!sections["T"] || !sections["P"]) {
|
|
297
|
+
throw new Error("Truncated b-em snapshot: it holds only one of the two co-processor sections");
|
|
298
|
+
}
|
|
299
|
+
// b-em only writes these sections for a co-processor it started, which it always names.
|
|
300
|
+
if (!tubeName) {
|
|
301
|
+
throw new Error("Corrupt b-em snapshot: it holds co-processor state but names no co-processor");
|
|
302
|
+
}
|
|
303
|
+
if (!BemSupportedTubes.includes(tubeName)) {
|
|
304
|
+
throw new Error(
|
|
305
|
+
`Unsupported b-em co-processor "${tubeName}": jsbeeb only emulates the 64K 65C02 second processor`,
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const ulaSection = sections["T"].data;
|
|
310
|
+
if (ulaSection[0] !== BemTubeUlaVersion) {
|
|
311
|
+
throw new Error(`Unsupported b-em tube ULA state version ${ulaSection[0]}`);
|
|
312
|
+
}
|
|
313
|
+
if (ulaSection.length < 2 + BemTubeUlaSize) {
|
|
314
|
+
throw new Error(
|
|
315
|
+
`Truncated b-em tube ULA state: expected ${2 + BemTubeUlaSize} bytes, got ${ulaSection.length}`,
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
const romPaged = !!ulaSection[1];
|
|
319
|
+
const ula = ulaSection.slice(2, 2 + BemTubeUlaSize);
|
|
320
|
+
const at = (field) => ula[BemTubeUlaOffsets[field]];
|
|
321
|
+
|
|
322
|
+
// b-em keeps the R1 FIFO as a circular buffer; jsbeeb shifts its contents down on read, so
|
|
323
|
+
// the bytes have to be linearised into the order jsbeeb expects to read them.
|
|
324
|
+
const ph1Count = at("ph1count");
|
|
325
|
+
const ph1Head = at("ph1head");
|
|
326
|
+
// Out-of-range counts would restore a FIFO jsbeeb's own reads could never have produced.
|
|
327
|
+
if (ph1Count > BemPh1Size || ph1Head >= BemPh1Size || at("ph3pos") > 2 || at("hp3pos") > 2) {
|
|
328
|
+
throw new Error("Corrupt b-em tube ULA state: FIFO counts out of range");
|
|
329
|
+
}
|
|
330
|
+
const ph1 = new Uint8Array(BemPh1Size);
|
|
331
|
+
for (let i = 0; i < ph1Count; i++) {
|
|
332
|
+
ph1[i] = ula[BemTubeUlaOffsets.ph1 + ((ph1Head + i) % BemPh1Size)];
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Those names come from a config file, so size is the real check. It cannot be exact: the
|
|
336
|
+
// trailing ROM's size comes from that same config, and b-em records it as zero if unset.
|
|
337
|
+
const parasite = await decompress(sections["P"].data, "deflate");
|
|
338
|
+
const romBytes = parasite.length - BemTubeRegisterBytes - BemTubeRamSize;
|
|
339
|
+
if (romBytes < 0 || romBytes > BemTubeRamSize) {
|
|
340
|
+
throw new Error(
|
|
341
|
+
`Unsupported b-em co-processor: expected ${BemTubeRamSize} bytes of parasite RAM, ` +
|
|
342
|
+
`but the state section holds ${parasite.length - BemTubeRegisterBytes}`,
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return {
|
|
347
|
+
a: parasite[2],
|
|
348
|
+
x: parasite[3],
|
|
349
|
+
y: parasite[4],
|
|
350
|
+
p: parasite[5] | 0x30,
|
|
351
|
+
s: parasite[6],
|
|
352
|
+
pc: parasite[7] | (parasite[8] << 8),
|
|
353
|
+
// b-em's oldnmi is the NMI level as of the parasite's last instruction, so it may lag the
|
|
354
|
+
// line its ULA state implies. b-em's skipint has no jsbeeb equivalent and is dropped.
|
|
355
|
+
nmiLevel: !!parasite[1],
|
|
356
|
+
nmiEdge: false,
|
|
357
|
+
takeInt: false,
|
|
358
|
+
cycles: 0,
|
|
359
|
+
romPaged,
|
|
360
|
+
memory: parasite.slice(BemTubeRegisterBytes, BemTubeRegisterBytes + BemTubeRamSize),
|
|
361
|
+
// The parasite ROM is loaded from file at boot, so b-em's copy is not needed.
|
|
362
|
+
ula: {
|
|
363
|
+
internalStatusRegister: at("r1stat"),
|
|
364
|
+
hostStatus: ula.slice(BemTubeUlaOffsets.hstat, BemTubeUlaOffsets.hstat + 4),
|
|
365
|
+
parasiteStatus: ula.slice(BemTubeUlaOffsets.pstat, BemTubeUlaOffsets.pstat + 4),
|
|
366
|
+
parasiteToHostData: [
|
|
367
|
+
ph1,
|
|
368
|
+
Uint8Array.of(at("ph2")),
|
|
369
|
+
ula.slice(BemTubeUlaOffsets.ph3, BemTubeUlaOffsets.ph3 + 2),
|
|
370
|
+
Uint8Array.of(at("ph4")),
|
|
371
|
+
],
|
|
372
|
+
hostToParasiteData: [
|
|
373
|
+
Uint8Array.of(at("hp1")),
|
|
374
|
+
Uint8Array.of(at("hp2")),
|
|
375
|
+
ula.slice(BemTubeUlaOffsets.hp3, BemTubeUlaOffsets.hp3 + 2),
|
|
376
|
+
Uint8Array.of(at("hp4")),
|
|
377
|
+
],
|
|
378
|
+
parasiteToHostFifoByteCount1: ph1Count,
|
|
379
|
+
parasiteToHostFifoByteCount3: at("ph3pos"),
|
|
380
|
+
hostToParasiteFifoByteCount3: at("hp3pos"),
|
|
381
|
+
},
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Read the co-processor name from b-em's model section, if one was fitted.
|
|
387
|
+
* @returns {string|null}
|
|
388
|
+
*/
|
|
389
|
+
function readBemTubeName(data, pos) {
|
|
390
|
+
for (let i = 0; i < 4; i++) readString(data, pos); // os, cmos, rom setup, fdc names
|
|
391
|
+
pos.offset += 6; // model flag bytes
|
|
392
|
+
const fitted = data[pos.offset++];
|
|
393
|
+
return fitted ? readString(data, pos) : null;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async function parseBemV3(buffer) {
|
|
259
397
|
const bytes = new Uint8Array(buffer);
|
|
260
398
|
let offset = 8; // Skip "BEMSNAP3" signature
|
|
261
399
|
|
|
@@ -281,11 +419,13 @@ function parseBemV3(buffer) {
|
|
|
281
419
|
// Parse model section to determine jsbeeb model name.
|
|
282
420
|
// Use jsbeeb synonyms (from models.js) so findModel() resolves them.
|
|
283
421
|
let modelName = "B";
|
|
422
|
+
let tubeName = null;
|
|
284
423
|
if (sections["m"]) {
|
|
285
424
|
const pos = { offset: 0 };
|
|
286
425
|
const data = sections["m"].data;
|
|
287
426
|
readVar(data, pos); // curmodel index (skip)
|
|
288
427
|
const name = readString(data, pos);
|
|
428
|
+
tubeName = readBemTubeName(data, pos);
|
|
289
429
|
if (name.includes("Master")) {
|
|
290
430
|
if (name.includes("ADFS")) modelName = "MasterADFS";
|
|
291
431
|
else if (name.includes("ANFS")) modelName = "MasterANFS";
|
|
@@ -311,23 +451,21 @@ function parseBemV3(buffer) {
|
|
|
311
451
|
if (memSection) {
|
|
312
452
|
// Memory is zlib-compressed; decompression is async.
|
|
313
453
|
// Decompressed layout: 2 bytes (fe30, fe34) + 64KB RAM + 256KB ROM
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
return finishV3Parse(modelName, cpuState, ram, roms, sections);
|
|
325
|
-
});
|
|
454
|
+
const memData = await decompress(memSection.data, "deflate");
|
|
455
|
+
cpuState.fe30 = memData[0];
|
|
456
|
+
cpuState.fe34 = memData[1];
|
|
457
|
+
const ramStart = 2;
|
|
458
|
+
const ramSize = 64 * 1024;
|
|
459
|
+
ram.set(memData.slice(ramStart, ramStart + ramSize));
|
|
460
|
+
const romStart = ramStart + ramSize;
|
|
461
|
+
if (memData.length > romStart) {
|
|
462
|
+
roms = memData.slice(romStart, romStart + 262144);
|
|
463
|
+
}
|
|
326
464
|
}
|
|
327
|
-
return finishV3Parse(modelName, cpuState, ram, roms, sections);
|
|
465
|
+
return finishV3Parse(modelName, cpuState, ram, roms, sections, await parseBemTube(sections, tubeName));
|
|
328
466
|
}
|
|
329
467
|
|
|
330
|
-
function finishV3Parse(modelName, cpuState, ram, roms, sections) {
|
|
468
|
+
function finishV3Parse(modelName, cpuState, ram, roms, sections, tube) {
|
|
331
469
|
// Parse system VIA
|
|
332
470
|
let sysvia = convertViaState(
|
|
333
471
|
{
|
|
@@ -466,5 +604,6 @@ function finishV3Parse(modelName, cpuState, ram, roms, sections) {
|
|
|
466
604
|
uservia,
|
|
467
605
|
buildVideoState(ulaControl, ulaPalette, crtcRegs, nulaCollook, crtcCounters),
|
|
468
606
|
soundChip,
|
|
607
|
+
tube,
|
|
469
608
|
);
|
|
470
609
|
}
|
package/src/config.js
CHANGED
|
@@ -1,31 +1,93 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
import { allModels, findModel } from "./models.js";
|
|
2
|
+
import { allModels, findModel, tubeModelFor } from "./models.js";
|
|
3
3
|
import { getFilterForMode } from "./canvas.js";
|
|
4
4
|
|
|
5
|
+
const round = (value) => Number(value.toFixed(2));
|
|
6
|
+
|
|
7
|
+
/** @returns {string} the speed a multiplier gives this machine's co-processor, e.g. "1.6x (4.8MHz)". */
|
|
8
|
+
export function tubeCpuSpeedLabel(multiplier, model) {
|
|
9
|
+
return `${round(multiplier)}x (${round(multiplier * tubeModelFor(model).clockMhz)}MHz)`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The sideways ROMs the optional fittings need, in the order they claim banks.
|
|
14
|
+
*
|
|
15
|
+
* @param {{model: object, hasEconet: boolean, hasMusic5000: boolean, hasTeletextAdaptor: boolean}} settings
|
|
16
|
+
* @returns {string[]}
|
|
17
|
+
*/
|
|
18
|
+
export function fittedRoms({ model, hasEconet, hasMusic5000, hasTeletextAdaptor }) {
|
|
19
|
+
return [
|
|
20
|
+
...(hasEconet && model.isMaster ? ["master/anfs-4.25.rom"] : []),
|
|
21
|
+
...(hasMusic5000 ? ["ample.rom"] : []),
|
|
22
|
+
...(hasTeletextAdaptor ? ["ats-3.0.rom"] : []),
|
|
23
|
+
];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The settings the dialog presents as checkboxes. `enables` names a control only usable while ticked. */
|
|
27
|
+
export const CheckboxSettings = [
|
|
28
|
+
{ id: "65c02", field: "coProcessor", restartRequired: true, enables: "tubeCpuMultiplier" },
|
|
29
|
+
{ id: "hasTeletextAdaptor", field: "hasTeletextAdaptor", restartRequired: true },
|
|
30
|
+
{ id: "hasEconet", field: "hasEconet", restartRequired: true },
|
|
31
|
+
{ id: "hasMusic5000", field: "hasMusic5000", restartRequired: true },
|
|
32
|
+
{ id: "mouseJoystickEnabled", field: "mouseJoystickEnabled" },
|
|
33
|
+
{ id: "speechOutput", field: "speechOutput" },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
/** The model is not a checkbox, but changing it needs a restart just the same. */
|
|
37
|
+
const RestartRequiredFields = [
|
|
38
|
+
"model",
|
|
39
|
+
...CheckboxSettings.filter((setting) => setting.restartRequired).map((setting) => setting.field),
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
/** @returns {boolean} whether any of the changed settings only take effect on a freshly built machine. */
|
|
43
|
+
export function needsRestart(changed) {
|
|
44
|
+
return RestartRequiredFields.some((field) => field in changed);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** @returns {boolean} whether the saved settings differ from those the running machine was built with. */
|
|
48
|
+
export function restartPending(settings, running) {
|
|
49
|
+
return RestartRequiredFields.some((field) => settings[field] !== running[field]);
|
|
50
|
+
}
|
|
51
|
+
|
|
5
52
|
export class Config extends EventTarget {
|
|
6
|
-
|
|
53
|
+
/**
|
|
54
|
+
* @param {function(object)} onChange called as soon as a setting the emulator can follow live changes
|
|
55
|
+
* @param {function(object)} onClose called with the settings to apply and persist
|
|
56
|
+
* @param {function()} onRestartRequired called after the settings have been saved, when some of them
|
|
57
|
+
* will only take effect once the machine is rebuilt
|
|
58
|
+
*/
|
|
59
|
+
constructor(onChange, onClose, onRestartRequired) {
|
|
7
60
|
super();
|
|
8
61
|
this.onChange = onChange;
|
|
9
62
|
this.onClose = onClose;
|
|
63
|
+
this.onRestartRequired = onRestartRequired;
|
|
10
64
|
this.changed = {};
|
|
11
65
|
this.model = null;
|
|
12
|
-
this
|
|
66
|
+
for (const { field } of CheckboxSettings) this[field] = false;
|
|
67
|
+
this.runningSettings = null;
|
|
13
68
|
const configuration = document.getElementById("configuration");
|
|
14
69
|
configuration.addEventListener("show.bs.modal", () => {
|
|
15
70
|
this.changed = {};
|
|
71
|
+
// The startup settings are pushed in after construction, so what the running machine was
|
|
72
|
+
// built with is only knowable from the first time the dialog is opened.
|
|
73
|
+
if (!this.runningSettings) this.runningSettings = this.proposedSettings();
|
|
16
74
|
this.setDropdownText(this.model.name);
|
|
17
|
-
this.set65c02(this.model.tube);
|
|
18
75
|
this.setTubeCpuMultiplier(this.tubeCpuMultiplier);
|
|
19
|
-
this.
|
|
20
|
-
this.
|
|
21
|
-
this.setEconet(this.model.hasEconet);
|
|
76
|
+
this.setCheckboxes(this);
|
|
77
|
+
this.showRestartPending();
|
|
22
78
|
});
|
|
23
79
|
|
|
24
80
|
configuration.addEventListener("hide.bs.modal", () => {
|
|
25
|
-
this.
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
81
|
+
const changed = this.changed;
|
|
82
|
+
// Not setModel: that also renames the machine in the title bar, which has not changed yet.
|
|
83
|
+
if (changed.model !== undefined) this.model = findModel(changed.model);
|
|
84
|
+
this.setCheckboxes(changed);
|
|
85
|
+
this.onClose(changed);
|
|
86
|
+
if (Object.keys(changed).length === 0) return;
|
|
87
|
+
this.dispatchEvent(new CustomEvent("settings-changed", { detail: changed }));
|
|
88
|
+
// changed records which controls were touched, so a value in it can be what is already running.
|
|
89
|
+
if (needsRestart(changed) && restartPending(this.proposedSettings(), this.runningSettings))
|
|
90
|
+
this.onRestartRequired();
|
|
29
91
|
});
|
|
30
92
|
|
|
31
93
|
const modelMenu = document.querySelector(".model-menu");
|
|
@@ -45,31 +107,25 @@ export class Config extends EventTarget {
|
|
|
45
107
|
if (!link) return;
|
|
46
108
|
this.changed.model = link.dataset.target;
|
|
47
109
|
this.setDropdownText(link.textContent);
|
|
110
|
+
this.showTubeCpuMultiplier(this.tubeCpuMultiplier, findModel(link.dataset.target));
|
|
111
|
+
this.showRestartPending();
|
|
48
112
|
});
|
|
49
113
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
114
|
+
for (const { id, field, enables } of CheckboxSettings) {
|
|
115
|
+
document.getElementById(id).addEventListener("click", () => {
|
|
116
|
+
const checked = document.getElementById(id).checked;
|
|
117
|
+
this.changed[field] = checked;
|
|
118
|
+
if (enables) document.getElementById(enables).disabled = !checked;
|
|
119
|
+
this.showRestartPending();
|
|
120
|
+
});
|
|
121
|
+
}
|
|
54
122
|
|
|
55
123
|
document.getElementById("tubeCpuMultiplier").addEventListener("input", () => {
|
|
56
|
-
const val =
|
|
57
|
-
|
|
124
|
+
const val = parseFloat(document.getElementById("tubeCpuMultiplier").value);
|
|
125
|
+
this.showTubeCpuMultiplier(val);
|
|
58
126
|
this.changed.tubeCpuMultiplier = val;
|
|
59
127
|
});
|
|
60
128
|
|
|
61
|
-
document.getElementById("hasTeletextAdaptor").addEventListener("click", () => {
|
|
62
|
-
this.changed.hasTeletextAdaptor = document.getElementById("hasTeletextAdaptor").checked;
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
document.getElementById("hasEconet").addEventListener("click", () => {
|
|
66
|
-
this.changed.hasEconet = document.getElementById("hasEconet").checked;
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
document.getElementById("hasMusic5000").addEventListener("click", () => {
|
|
70
|
-
this.changed.hasMusic5000 = document.getElementById("hasMusic5000").checked;
|
|
71
|
-
});
|
|
72
|
-
|
|
73
129
|
for (const link of document.querySelectorAll(".keyboard-menu a")) {
|
|
74
130
|
link.addEventListener("click", (e) => {
|
|
75
131
|
const keyLayout = e.target.dataset.target;
|
|
@@ -87,14 +143,6 @@ export class Config extends EventTarget {
|
|
|
87
143
|
});
|
|
88
144
|
}
|
|
89
145
|
|
|
90
|
-
document.getElementById("mouseJoystickEnabled").addEventListener("click", () => {
|
|
91
|
-
this.changed.mouseJoystickEnabled = document.getElementById("mouseJoystickEnabled").checked;
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
document.getElementById("speechOutput").addEventListener("click", () => {
|
|
95
|
-
this.changed.speechOutput = document.getElementById("speechOutput").checked;
|
|
96
|
-
});
|
|
97
|
-
|
|
98
146
|
for (const option of document.querySelectorAll(".display-mode-option")) {
|
|
99
147
|
option.addEventListener("click", (e) => {
|
|
100
148
|
const mode = e.target.dataset.mode;
|
|
@@ -105,17 +153,34 @@ export class Config extends EventTarget {
|
|
|
105
153
|
}
|
|
106
154
|
}
|
|
107
155
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
156
|
+
/**
|
|
157
|
+
* The restart-required settings as they would be saved if the dialog were closed now, in the form the
|
|
158
|
+
* menu and the URL use: the model by synonym rather than resolved, the fittings as booleans.
|
|
159
|
+
*/
|
|
160
|
+
proposedSettings() {
|
|
161
|
+
const saved = (field) => (field === "model" ? this.model.synonyms[0] : this[field]);
|
|
162
|
+
return Object.fromEntries(RestartRequiredFields.map((field) => [field, this.changed[field] ?? saved(field)]));
|
|
111
163
|
}
|
|
112
164
|
|
|
113
|
-
|
|
114
|
-
|
|
165
|
+
showRestartPending() {
|
|
166
|
+
const pending = restartPending(this.proposedSettings(), this.runningSettings);
|
|
167
|
+
document.getElementById("restart-pending").classList.toggle("d-none", !pending);
|
|
115
168
|
}
|
|
116
169
|
|
|
117
|
-
|
|
118
|
-
|
|
170
|
+
/** Ticks the boxes named in `values` and adopts them, leaving any the object does not mention alone. */
|
|
171
|
+
setCheckboxes(values) {
|
|
172
|
+
for (const { id, field, enables } of CheckboxSettings) {
|
|
173
|
+
if (values[field] === undefined) continue;
|
|
174
|
+
const checked = !!values[field];
|
|
175
|
+
document.getElementById(id).checked = checked;
|
|
176
|
+
this[field] = checked;
|
|
177
|
+
if (enables) document.getElementById(enables).disabled = !checked;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
setMicrophoneChannel(channel) {
|
|
182
|
+
const text = channel !== undefined ? `Channel ${channel}` : "Disabled";
|
|
183
|
+
for (const el of document.querySelectorAll(".mic-channel-text")) el.textContent = text;
|
|
119
184
|
}
|
|
120
185
|
|
|
121
186
|
setDisplayMode(mode) {
|
|
@@ -133,41 +198,14 @@ export class Config extends EventTarget {
|
|
|
133
198
|
for (const el of document.querySelectorAll(".keyboard-layout")) el.textContent = text;
|
|
134
199
|
}
|
|
135
200
|
|
|
136
|
-
set65c02(enabled) {
|
|
137
|
-
enabled = !!enabled;
|
|
138
|
-
document.getElementById("65c02").checked = enabled;
|
|
139
|
-
this.model.tube = enabled ? findModel("Tube65c02") : null;
|
|
140
|
-
document.getElementById("tubeCpuMultiplier").disabled = !enabled;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
201
|
setTubeCpuMultiplier(value) {
|
|
144
202
|
this.tubeCpuMultiplier = value;
|
|
145
203
|
document.getElementById("tubeCpuMultiplier").value = value;
|
|
146
|
-
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
setEconet(enabled) {
|
|
150
|
-
enabled = !!enabled;
|
|
151
|
-
document.getElementById("hasEconet").checked = enabled;
|
|
152
|
-
this.model.hasEconet = enabled;
|
|
153
|
-
|
|
154
|
-
if (enabled && this.model.isMaster) {
|
|
155
|
-
this.addRemoveROM("master/anfs-4.25.rom", true);
|
|
156
|
-
}
|
|
204
|
+
this.showTubeCpuMultiplier(value);
|
|
157
205
|
}
|
|
158
206
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
document.getElementById("hasMusic5000").checked = enabled;
|
|
162
|
-
this.model.hasMusic5000 = enabled;
|
|
163
|
-
this.addRemoveROM("ample.rom", enabled);
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
setTeletext(enabled) {
|
|
167
|
-
enabled = !!enabled;
|
|
168
|
-
document.getElementById("hasTeletextAdaptor").checked = enabled;
|
|
169
|
-
this.model.hasTeletextAdaptor = enabled;
|
|
170
|
-
this.addRemoveROM("ats-3.0.rom", enabled);
|
|
207
|
+
showTubeCpuMultiplier(value, model = this.model) {
|
|
208
|
+
document.getElementById("tubeCpuMultiplierValue").textContent = tubeCpuSpeedLabel(value, model);
|
|
171
209
|
}
|
|
172
210
|
|
|
173
211
|
setDropdownText(modelName) {
|
|
@@ -175,15 +213,8 @@ export class Config extends EventTarget {
|
|
|
175
213
|
if (el) el.textContent = modelName;
|
|
176
214
|
}
|
|
177
215
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
this.model.os.push(romName);
|
|
181
|
-
} else {
|
|
182
|
-
let pos = this.model.os.indexOf(romName);
|
|
183
|
-
if (pos !== -1) {
|
|
184
|
-
this.model.os.splice(pos, 1);
|
|
185
|
-
}
|
|
186
|
-
}
|
|
216
|
+
get extraRoms() {
|
|
217
|
+
return fittedRoms(this);
|
|
187
218
|
}
|
|
188
219
|
|
|
189
220
|
mapLegacyModels(parsedQuery) {
|
package/src/disc.js
CHANGED
|
@@ -672,11 +672,56 @@ export function loadAdf(disc, data, isDsd) {
|
|
|
672
672
|
return disc;
|
|
673
673
|
}
|
|
674
674
|
|
|
675
|
+
/** Why a sector will not fit in an SSD or DSD image, or null if it will. */
|
|
676
|
+
function sectorShortfall(sector, trackNum) {
|
|
677
|
+
if (sector.hasDataCrcError || sector.hasHeaderCrcError) return "with a CRC error";
|
|
678
|
+
// A header whose data mark never arrives leaves the sector with nothing to write.
|
|
679
|
+
if (!sector.sectorData) return "with no data";
|
|
680
|
+
if (sector.sectorNumber >= SsdFormat.sectorsPerTrack)
|
|
681
|
+
return `numbered past the ${SsdFormat.sectorsPerTrack} a track holds`;
|
|
682
|
+
if (sector.sectorData.length !== SsdFormat.sectorSize) return `not ${SsdFormat.sectorSize} bytes`;
|
|
683
|
+
if (trackNum >= SsdFormat.tracksPerDisc) return `past track ${SsdFormat.tracksPerDisc}`;
|
|
684
|
+
return null;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* SSD and DSD images hold sector contents and nothing else, so anything a DFS sector could not
|
|
689
|
+
* have held is lost. Copy protection usually shows up as one of these.
|
|
690
|
+
*
|
|
691
|
+
* @returns {string[]} what `disc` holds that an SSD or DSD cannot, worst first
|
|
692
|
+
* @param {Disc} disc
|
|
693
|
+
*/
|
|
694
|
+
export function ssdOrDsdShortfalls(disc) {
|
|
695
|
+
const counts = new Map();
|
|
696
|
+
for (let trackNum = 0; trackNum < disc.tracksUsed; ++trackNum) {
|
|
697
|
+
for (const upper of disc.isDoubleSided ? [false, true] : [false]) {
|
|
698
|
+
for (const sector of disc.getTrack(upper, trackNum).findSectors()) {
|
|
699
|
+
const shortfall = sectorShortfall(sector, trackNum);
|
|
700
|
+
if (shortfall) counts.set(shortfall, (counts.get(shortfall) ?? 0) + 1);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
return [...counts]
|
|
705
|
+
.sort(([, a], [, b]) => b - a)
|
|
706
|
+
.map(([shortfall, count]) => `${count} sector${count === 1 ? "" : "s"} ${shortfall}`);
|
|
707
|
+
}
|
|
708
|
+
|
|
675
709
|
/**
|
|
676
710
|
* @returns {Uint8Array}
|
|
677
711
|
* @param {Disc} disc
|
|
712
|
+
* @param {object} [options]
|
|
713
|
+
* @param {boolean} [options.force] save what fits instead of refusing a disc that will not fit
|
|
714
|
+
* @throws if the disc holds anything an SSD or DSD cannot, and `force` is not set
|
|
678
715
|
*/
|
|
679
|
-
export function toSsdOrDsd(disc) {
|
|
716
|
+
export function toSsdOrDsd(disc, { force = false } = {}) {
|
|
717
|
+
if (!force) {
|
|
718
|
+
const shortfalls = ssdOrDsdShortfalls(disc);
|
|
719
|
+
if (shortfalls.length)
|
|
720
|
+
throw new Error(
|
|
721
|
+
`This disc cannot be saved as SSD or DSD: it has ${shortfalls.join(", ")}. ` +
|
|
722
|
+
`Save it as HFE to keep everything.`,
|
|
723
|
+
);
|
|
724
|
+
}
|
|
680
725
|
const numSides = disc.isDoubleSided ? 2 : 1;
|
|
681
726
|
const result = new Uint8Array(
|
|
682
727
|
numSides * SsdFormat.tracksPerDisc * SsdFormat.sectorsPerTrack * SsdFormat.sectorSize,
|
|
@@ -686,11 +731,8 @@ export function toSsdOrDsd(disc) {
|
|
|
686
731
|
for (let side = 0; side < numSides; ++side) {
|
|
687
732
|
const trackObj = disc.getTrack(side === 1, trackNum);
|
|
688
733
|
for (const sector of trackObj.findSectors()) {
|
|
734
|
+
if (sectorShortfall(sector, trackNum)) continue;
|
|
689
735
|
const sectorOffset = offset + sector.sectorNumber * SsdFormat.sectorSize;
|
|
690
|
-
if (sector.hasDataCrcError || sector.hasHeaderCrcError) {
|
|
691
|
-
console.log(`Skipping sector ${sector.description} with bad CRC`);
|
|
692
|
-
continue;
|
|
693
|
-
}
|
|
694
736
|
for (let x = 0; x < SsdFormat.sectorSize; ++x) result[sectorOffset + x] = sector.sectorData[x];
|
|
695
737
|
}
|
|
696
738
|
offset += SsdFormat.sectorsPerTrack * SsdFormat.sectorSize;
|
package/src/dom-utils.js
CHANGED
|
@@ -30,3 +30,19 @@ export function fadeOut(el, duration = 400) {
|
|
|
30
30
|
if (el.style.opacity === "0") el.style.display = "none";
|
|
31
31
|
}, duration);
|
|
32
32
|
}
|
|
33
|
+
|
|
34
|
+
// Safari fetches the blob a task or more after the click, so the URL must outlive it.
|
|
35
|
+
// 40s matches FileSaver.js.
|
|
36
|
+
const BlobUrlLifetimeMs = 40000;
|
|
37
|
+
|
|
38
|
+
/** Save a blob to the user's downloads under the given file name. */
|
|
39
|
+
export function downloadBlob(blob, fileName) {
|
|
40
|
+
const url = URL.createObjectURL(blob);
|
|
41
|
+
const a = document.createElement("a");
|
|
42
|
+
a.href = url;
|
|
43
|
+
a.download = fileName;
|
|
44
|
+
document.body.appendChild(a);
|
|
45
|
+
a.click();
|
|
46
|
+
a.remove();
|
|
47
|
+
setTimeout(() => URL.revokeObjectURL(url), BlobUrlLifetimeMs);
|
|
48
|
+
}
|
package/src/fake6502.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import { FakeVideo } from "./video.js";
|
|
5
5
|
import { FakeSoundChip } from "./soundchip.js";
|
|
6
|
-
import {
|
|
6
|
+
import { TEST_6502, TEST_65C02, TEST_65C12, tubeModelFor } from "./models.js";
|
|
7
7
|
import { FakeDdNoise } from "./ddnoise.js";
|
|
8
8
|
import { FakeRelayNoise } from "./relaynoise.js";
|
|
9
9
|
import { Cpu6502, AtomCpu6502 } from "./6502.js";
|
|
@@ -19,7 +19,6 @@ const dbgr = {
|
|
|
19
19
|
export function fake6502(model, opts) {
|
|
20
20
|
opts = opts || {};
|
|
21
21
|
model = model || TEST_6502;
|
|
22
|
-
if (opts.tube) model.tube = findModel("Tube65c02");
|
|
23
22
|
const CpuClass = model.isAtom ? AtomCpu6502 : Cpu6502;
|
|
24
23
|
return new CpuClass(model, {
|
|
25
24
|
dbgr,
|
|
@@ -30,6 +29,12 @@ export function fake6502(model, opts) {
|
|
|
30
29
|
music5000: new FakeMusic5000(),
|
|
31
30
|
cmos: new Cmos(),
|
|
32
31
|
cycleAccurate: opts.cycleAccurate,
|
|
32
|
+
config: {
|
|
33
|
+
tube: opts.tube ? tubeModelFor(model) : null,
|
|
34
|
+
tubeCpuMultiplier: opts.tubeCpuMultiplier,
|
|
35
|
+
cpuMultiplier: opts.cpuMultiplier,
|
|
36
|
+
hasTeletextAdaptor: opts.hasTeletextAdaptor,
|
|
37
|
+
},
|
|
33
38
|
});
|
|
34
39
|
}
|
|
35
40
|
|
package/src/gamepads.js
CHANGED
|
@@ -39,16 +39,21 @@ export class GamePad {
|
|
|
39
39
|
this.gamepadAxisMapping[3][-1] = BBC.COLON_STAR; // up
|
|
40
40
|
this.gamepadAxisMapping[3][1] = BBC.SLASH; // down
|
|
41
41
|
*/
|
|
42
|
+
/**
|
|
43
|
+
* Maps a gamepad button or stick direction to a BBC key.
|
|
44
|
+
* @param {string} gamepadKey - the gamepad control, eg `FIRE2`
|
|
45
|
+
* @param {string} bbcKey - the BBC key to press, eg `RETURN`
|
|
46
|
+
* @returns {?string} a description of the problem, or null if the mapping was applied
|
|
47
|
+
*/
|
|
42
48
|
remap(gamepadKey, bbcKey) {
|
|
43
49
|
// convert "1" into "K1"
|
|
44
|
-
if ("
|
|
50
|
+
if (bbcKey.length === 1 && bbcKey >= "0" && bbcKey <= "9") {
|
|
45
51
|
bbcKey = "K" + bbcKey;
|
|
46
52
|
}
|
|
47
53
|
|
|
48
54
|
const mappedBbcKey = BBC[bbcKey];
|
|
49
55
|
if (!mappedBbcKey) {
|
|
50
|
-
|
|
51
|
-
return;
|
|
56
|
+
return `unknown BBC key "${bbcKey}".`;
|
|
52
57
|
}
|
|
53
58
|
|
|
54
59
|
switch (gamepadKey) {
|
|
@@ -152,8 +157,10 @@ export class GamePad {
|
|
|
152
157
|
this.gamepadMapping[6] = mappedBbcKey;
|
|
153
158
|
break;
|
|
154
159
|
default:
|
|
155
|
-
|
|
160
|
+
return `unknown gamepad control "${gamepadKey}".`;
|
|
156
161
|
}
|
|
162
|
+
|
|
163
|
+
return null;
|
|
157
164
|
}
|
|
158
165
|
|
|
159
166
|
update(sysvia) {
|
package/src/keyboard.js
CHANGED
|
@@ -341,8 +341,9 @@ export class Keyboard extends EventTarget {
|
|
|
341
341
|
if (this.isPasting) this.cancelPaste();
|
|
342
342
|
|
|
343
343
|
this.keyInterface.disableKeyboard();
|
|
344
|
-
|
|
345
|
-
|
|
344
|
+
// The paste task lives on the processor's scheduler, which is polled with peripheral
|
|
345
|
+
// cycles, so paste delays stay in real time whatever the CPU multiplier is.
|
|
346
|
+
this._pasteClocksPerMs = this.processor.peripheralCyclesPerSecond / 1000;
|
|
346
347
|
|
|
347
348
|
if (checkCapsAndShiftLocks) {
|
|
348
349
|
let toggleKey = null;
|
package/src/machine-session.js
CHANGED
|
@@ -30,6 +30,9 @@ export class MachineSession {
|
|
|
30
30
|
* @param {string} modelName - e.g. "B-DFS1.2", "Master"
|
|
31
31
|
* @param {Object} [opts]
|
|
32
32
|
* @param {string} [opts.discImage] - path to an .ssd or .dsd disc image to load on boot
|
|
33
|
+
* @param {boolean} [opts.tube] - attach a 65C02 second processor (Tube co-processor)
|
|
34
|
+
* @param {number} [opts.cpuMultiplier] - run the CPU this many times faster than the peripherals
|
|
35
|
+
* @param {boolean} [opts.hasTeletextAdaptor] - fit the Acorn teletext adaptor
|
|
33
36
|
*/
|
|
34
37
|
constructor(modelName = "B-DFS1.2", opts = {}) {
|
|
35
38
|
this.modelName = modelName;
|
|
@@ -65,8 +68,14 @@ export class MachineSession {
|
|
|
65
68
|
// toneGenerator); FakeSoundChip provides compatible no-op stubs for headless mode.
|
|
66
69
|
this._soundChip = modelObj.isAtom ? new FakeSoundChip() : new InstrumentedSoundChip();
|
|
67
70
|
|
|
68
|
-
// TestMachine forwards
|
|
69
|
-
this._machine = new TestMachine(modelName, {
|
|
71
|
+
// TestMachine forwards these to fake6502
|
|
72
|
+
this._machine = new TestMachine(modelName, {
|
|
73
|
+
video: this._video,
|
|
74
|
+
soundChip: this._soundChip,
|
|
75
|
+
tube: opts.tube,
|
|
76
|
+
cpuMultiplier: opts.cpuMultiplier,
|
|
77
|
+
hasTeletextAdaptor: opts.hasTeletextAdaptor,
|
|
78
|
+
});
|
|
70
79
|
|
|
71
80
|
// Accumulated VDU text output — drained by callers
|
|
72
81
|
this._pendingOutput = [];
|