jsbeeb 1.10.0 → 1.11.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/package.json +2 -6
- package/src/6502.js +1 -1
- package/src/app/electron.js +4 -4
- package/src/bem-snapshot.js +2 -34
- package/src/config.js +65 -59
- package/src/dom-utils.js +32 -0
- package/src/google-drive.js +3 -4
- package/src/main.js +223 -203
- package/src/sth.js +1 -1
- package/src/tapes.js +2 -2
- package/src/utils.js +140 -15
- package/src/web/audio-handler.js +10 -11
- package/src/web/debug.js +100 -71
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.
|
|
10
|
+
"version": "1.11.0",
|
|
11
11
|
"//": "If you change the version of Node, it must also be updated at the top of the Dockerfile.",
|
|
12
12
|
"engines": {
|
|
13
13
|
"node": ">=22.12.0"
|
|
@@ -31,12 +31,8 @@
|
|
|
31
31
|
"bootstrap": "^5.3.8",
|
|
32
32
|
"bootswatch": "^5.3.8",
|
|
33
33
|
"event-emitter-es6": "^1.1.5",
|
|
34
|
-
"fflate": "^0.8.2",
|
|
35
|
-
"jquery": "^4.0.0",
|
|
36
|
-
"pako": "^2.1.0",
|
|
37
34
|
"sharp": "^0.34.5",
|
|
38
|
-
"smoothie": "^1.36.1"
|
|
39
|
-
"underscore": "^1.13.7"
|
|
35
|
+
"smoothie": "^1.36.1"
|
|
40
36
|
},
|
|
41
37
|
"devDependencies": {
|
|
42
38
|
"@eslint/js": "^10.0.1",
|
package/src/6502.js
CHANGED
|
@@ -1106,7 +1106,7 @@ export class Cpu6502 extends Base6502 {
|
|
|
1106
1106
|
const ramRomOs = this.ramRomOs;
|
|
1107
1107
|
let data = await utils.loadData(name);
|
|
1108
1108
|
if (/\.zip/i.test(name)) {
|
|
1109
|
-
data = utils.unzipRomImage(data).data;
|
|
1109
|
+
data = (await utils.unzipRomImage(data)).data;
|
|
1110
1110
|
}
|
|
1111
1111
|
ramRomOs.set(data, offset);
|
|
1112
1112
|
}
|
package/src/app/electron.js
CHANGED
|
@@ -3,8 +3,6 @@
|
|
|
3
3
|
// Electron integration for jsbeeb desktop application.
|
|
4
4
|
// Handles IPC communication for loading disc/tape images and showing modals from Electron's main process.
|
|
5
5
|
|
|
6
|
-
export let initialise = function () {};
|
|
7
|
-
|
|
8
6
|
function init(args) {
|
|
9
7
|
const { loadDiscImage, loadTapeImage, loadStateFile, processor, modals, actions, config } = args;
|
|
10
8
|
const api = window.electronAPI;
|
|
@@ -66,6 +64,8 @@ function init(args) {
|
|
|
66
64
|
}
|
|
67
65
|
}
|
|
68
66
|
|
|
69
|
-
|
|
70
|
-
|
|
67
|
+
export function initialise(args) {
|
|
68
|
+
if (typeof window.electronAPI !== "undefined") {
|
|
69
|
+
init(args);
|
|
70
|
+
}
|
|
71
71
|
}
|
package/src/bem-snapshot.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
import { decompress } from "./utils.js";
|
|
2
3
|
|
|
3
4
|
// B-em snapshot format parser (versions 1 and 3).
|
|
4
5
|
// v1 (BEMSNAP1): Fixed-size 327,885 byte packed struct. Reference: beebjit state.c
|
|
@@ -434,39 +435,6 @@ function readString(data, pos) {
|
|
|
434
435
|
return str;
|
|
435
436
|
}
|
|
436
437
|
|
|
437
|
-
async function inflateRaw(compressedData) {
|
|
438
|
-
// Use DecompressionStream (available in modern browsers and Node 18+)
|
|
439
|
-
if (typeof DecompressionStream !== "undefined") {
|
|
440
|
-
const ds = new DecompressionStream("deflate");
|
|
441
|
-
const writer = ds.writable.getWriter();
|
|
442
|
-
const reader = ds.readable.getReader();
|
|
443
|
-
const chunks = [];
|
|
444
|
-
|
|
445
|
-
// Start reading before writing to avoid backpressure deadlock
|
|
446
|
-
const readPromise = (async () => {
|
|
447
|
-
for (;;) {
|
|
448
|
-
const { done, value } = await reader.read();
|
|
449
|
-
if (done) break;
|
|
450
|
-
chunks.push(value);
|
|
451
|
-
}
|
|
452
|
-
})();
|
|
453
|
-
|
|
454
|
-
await writer.write(compressedData);
|
|
455
|
-
await writer.close();
|
|
456
|
-
await readPromise;
|
|
457
|
-
|
|
458
|
-
const totalLength = chunks.reduce((sum, c) => sum + c.length, 0);
|
|
459
|
-
const result = new Uint8Array(totalLength);
|
|
460
|
-
let offset = 0;
|
|
461
|
-
for (const chunk of chunks) {
|
|
462
|
-
result.set(chunk, offset);
|
|
463
|
-
offset += chunk.length;
|
|
464
|
-
}
|
|
465
|
-
return result;
|
|
466
|
-
}
|
|
467
|
-
throw new Error("Zlib decompression not available (need DecompressionStream API)");
|
|
468
|
-
}
|
|
469
|
-
|
|
470
438
|
function parseBemV3(buffer) {
|
|
471
439
|
const bytes = new Uint8Array(buffer);
|
|
472
440
|
let offset = 8; // Skip "BEMSNAP3" signature
|
|
@@ -523,7 +491,7 @@ function parseBemV3(buffer) {
|
|
|
523
491
|
if (memSection) {
|
|
524
492
|
// Memory is zlib-compressed; decompression is async.
|
|
525
493
|
// Decompressed layout: 2 bytes (fe30, fe34) + 64KB RAM + 256KB ROM
|
|
526
|
-
return
|
|
494
|
+
return decompress(memSection.data, "deflate").then((memData) => {
|
|
527
495
|
cpuState.fe30 = memData[0];
|
|
528
496
|
cpuState.fe34 = memData[1];
|
|
529
497
|
const ramStart = 2;
|
package/src/config.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
import $ from "jquery";
|
|
3
2
|
import EventEmitter from "event-emitter-es6";
|
|
4
3
|
import { findModel } from "./models.js";
|
|
5
4
|
import { getFilterForMode } from "./canvas.js";
|
|
@@ -12,8 +11,8 @@ export class Config extends EventEmitter {
|
|
|
12
11
|
this.changed = {};
|
|
13
12
|
this.model = null;
|
|
14
13
|
this.coProcessor = null;
|
|
15
|
-
const
|
|
16
|
-
|
|
14
|
+
const configuration = document.getElementById("configuration");
|
|
15
|
+
configuration.addEventListener("show.bs.modal", () => {
|
|
17
16
|
this.changed = {};
|
|
18
17
|
this.setDropdownText(this.model.name);
|
|
19
18
|
this.set65c02(this.model.tube);
|
|
@@ -23,116 +22,122 @@ export class Config extends EventEmitter {
|
|
|
23
22
|
this.setEconet(this.model.hasEconet);
|
|
24
23
|
});
|
|
25
24
|
|
|
26
|
-
|
|
25
|
+
configuration.addEventListener("hide.bs.modal", () => {
|
|
27
26
|
this.onClose(this.changed);
|
|
28
27
|
if (Object.keys(this.changed).length > 0) {
|
|
29
28
|
this.emit("settings-changed", this.changed);
|
|
30
29
|
}
|
|
31
30
|
});
|
|
32
31
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
32
|
+
for (const link of document.querySelectorAll(".model-menu a")) {
|
|
33
|
+
link.addEventListener("click", (e) => {
|
|
34
|
+
this.changed.model = e.target.dataset.target;
|
|
35
|
+
this.setDropdownText(e.target.textContent);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
37
38
|
|
|
38
|
-
|
|
39
|
-
this.changed.coProcessor =
|
|
40
|
-
|
|
39
|
+
document.getElementById("65c02").addEventListener("click", () => {
|
|
40
|
+
this.changed.coProcessor = document.getElementById("65c02").checked;
|
|
41
|
+
document.getElementById("tubeCpuMultiplier").disabled = !document.getElementById("65c02").checked;
|
|
41
42
|
});
|
|
42
43
|
|
|
43
|
-
|
|
44
|
-
const val = parseInt(
|
|
45
|
-
|
|
44
|
+
document.getElementById("tubeCpuMultiplier").addEventListener("input", () => {
|
|
45
|
+
const val = parseInt(document.getElementById("tubeCpuMultiplier").value, 10);
|
|
46
|
+
document.getElementById("tubeCpuMultiplierValue").textContent = val;
|
|
46
47
|
this.changed.tubeCpuMultiplier = val;
|
|
47
48
|
});
|
|
48
49
|
|
|
49
|
-
|
|
50
|
-
this.changed.hasTeletextAdaptor =
|
|
50
|
+
document.getElementById("hasTeletextAdaptor").addEventListener("click", () => {
|
|
51
|
+
this.changed.hasTeletextAdaptor = document.getElementById("hasTeletextAdaptor").checked;
|
|
51
52
|
});
|
|
52
53
|
|
|
53
|
-
|
|
54
|
-
this.changed.hasEconet =
|
|
54
|
+
document.getElementById("hasEconet").addEventListener("click", () => {
|
|
55
|
+
this.changed.hasEconet = document.getElementById("hasEconet").checked;
|
|
55
56
|
});
|
|
56
57
|
|
|
57
|
-
|
|
58
|
-
this.changed.hasMusic5000 =
|
|
58
|
+
document.getElementById("hasMusic5000").addEventListener("click", () => {
|
|
59
|
+
this.changed.hasMusic5000 = document.getElementById("hasMusic5000").checked;
|
|
59
60
|
});
|
|
60
61
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
62
|
+
for (const link of document.querySelectorAll(".keyboard-menu a")) {
|
|
63
|
+
link.addEventListener("click", (e) => {
|
|
64
|
+
const keyLayout = e.target.dataset.target;
|
|
65
|
+
this.changed.keyLayout = keyLayout;
|
|
66
|
+
this.setKeyLayout(keyLayout);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
66
69
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
70
|
+
for (const option of document.querySelectorAll(".mic-channel-option")) {
|
|
71
|
+
option.addEventListener("click", (e) => {
|
|
72
|
+
const channelString = e.target.dataset.channel;
|
|
73
|
+
const channel = channelString === "" ? undefined : parseInt(channelString, 10);
|
|
74
|
+
this.changed.microphoneChannel = channel;
|
|
75
|
+
this.setMicrophoneChannel(channel);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
73
78
|
|
|
74
|
-
|
|
75
|
-
this.changed.mouseJoystickEnabled =
|
|
79
|
+
document.getElementById("mouseJoystickEnabled").addEventListener("click", () => {
|
|
80
|
+
this.changed.mouseJoystickEnabled = document.getElementById("mouseJoystickEnabled").checked;
|
|
76
81
|
});
|
|
77
82
|
|
|
78
|
-
|
|
79
|
-
this.changed.speechOutput =
|
|
83
|
+
document.getElementById("speechOutput").addEventListener("click", () => {
|
|
84
|
+
this.changed.speechOutput = document.getElementById("speechOutput").checked;
|
|
80
85
|
});
|
|
81
86
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
87
|
+
for (const option of document.querySelectorAll(".display-mode-option")) {
|
|
88
|
+
option.addEventListener("click", (e) => {
|
|
89
|
+
const mode = e.target.dataset.mode;
|
|
90
|
+
this.changed.displayMode = mode;
|
|
91
|
+
this.setDisplayMode(mode);
|
|
92
|
+
this.onChange({ displayMode: mode });
|
|
93
|
+
});
|
|
94
|
+
}
|
|
88
95
|
}
|
|
89
96
|
|
|
90
97
|
setMicrophoneChannel(channel) {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
} else {
|
|
94
|
-
$(".mic-channel-text").text("Disabled");
|
|
95
|
-
}
|
|
98
|
+
const text = channel !== undefined ? `Channel ${channel}` : "Disabled";
|
|
99
|
+
for (const el of document.querySelectorAll(".mic-channel-text")) el.textContent = text;
|
|
96
100
|
}
|
|
97
101
|
|
|
98
102
|
setMouseJoystickEnabled(enabled) {
|
|
99
|
-
|
|
103
|
+
document.getElementById("mouseJoystickEnabled").checked = !!enabled;
|
|
100
104
|
}
|
|
101
105
|
|
|
102
106
|
setSpeechOutput(enabled) {
|
|
103
|
-
|
|
107
|
+
document.getElementById("speechOutput").checked = !!enabled;
|
|
104
108
|
}
|
|
105
109
|
|
|
106
110
|
setDisplayMode(mode) {
|
|
107
111
|
const config = getFilterForMode(mode).getDisplayConfig();
|
|
108
|
-
|
|
112
|
+
for (const el of document.querySelectorAll(".display-mode-text")) el.textContent = config.name;
|
|
109
113
|
}
|
|
110
114
|
|
|
111
115
|
setModel(modelName) {
|
|
112
116
|
this.model = findModel(modelName);
|
|
113
|
-
|
|
117
|
+
for (const el of document.querySelectorAll(".bbc-model")) el.textContent = this.model.name;
|
|
114
118
|
}
|
|
115
119
|
|
|
116
120
|
setKeyLayout(keyLayout) {
|
|
117
|
-
|
|
121
|
+
const text = keyLayout[0].toUpperCase() + keyLayout.substring(1);
|
|
122
|
+
for (const el of document.querySelectorAll(".keyboard-layout")) el.textContent = text;
|
|
118
123
|
}
|
|
119
124
|
|
|
120
125
|
set65c02(enabled) {
|
|
121
126
|
enabled = !!enabled;
|
|
122
|
-
|
|
127
|
+
document.getElementById("65c02").checked = enabled;
|
|
123
128
|
this.model.tube = enabled ? findModel("Tube65c02") : null;
|
|
124
|
-
|
|
129
|
+
document.getElementById("tubeCpuMultiplier").disabled = !enabled;
|
|
125
130
|
}
|
|
126
131
|
|
|
127
132
|
setTubeCpuMultiplier(value) {
|
|
128
133
|
this.tubeCpuMultiplier = value;
|
|
129
|
-
|
|
130
|
-
|
|
134
|
+
document.getElementById("tubeCpuMultiplier").value = value;
|
|
135
|
+
document.getElementById("tubeCpuMultiplierValue").textContent = value;
|
|
131
136
|
}
|
|
132
137
|
|
|
133
138
|
setEconet(enabled) {
|
|
134
139
|
enabled = !!enabled;
|
|
135
|
-
|
|
140
|
+
document.getElementById("hasEconet").checked = enabled;
|
|
136
141
|
this.model.hasEconet = enabled;
|
|
137
142
|
|
|
138
143
|
if (enabled && this.model.isMaster) {
|
|
@@ -142,20 +147,21 @@ export class Config extends EventEmitter {
|
|
|
142
147
|
|
|
143
148
|
setMusic5000(enabled) {
|
|
144
149
|
enabled = !!enabled;
|
|
145
|
-
|
|
150
|
+
document.getElementById("hasMusic5000").checked = enabled;
|
|
146
151
|
this.model.hasMusic5000 = enabled;
|
|
147
152
|
this.addRemoveROM("ample.rom", enabled);
|
|
148
153
|
}
|
|
149
154
|
|
|
150
155
|
setTeletext(enabled) {
|
|
151
156
|
enabled = !!enabled;
|
|
152
|
-
|
|
157
|
+
document.getElementById("hasTeletextAdaptor").checked = enabled;
|
|
153
158
|
this.model.hasTeletextAdaptor = enabled;
|
|
154
159
|
this.addRemoveROM("ats-3.0.rom", enabled);
|
|
155
160
|
}
|
|
156
161
|
|
|
157
162
|
setDropdownText(modelName) {
|
|
158
|
-
|
|
163
|
+
const el = document.querySelector("#bbc-model-dropdown .bbc-model");
|
|
164
|
+
if (el) el.textContent = modelName;
|
|
159
165
|
}
|
|
160
166
|
|
|
161
167
|
addRemoveROM(romName, required) {
|
package/src/dom-utils.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// Minimal DOM helpers to replace jQuery usage.
|
|
4
|
+
|
|
5
|
+
export function show(el) {
|
|
6
|
+
el.style.display = "";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function hide(el) {
|
|
10
|
+
el.style.display = "none";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function toggle(el, visible) {
|
|
14
|
+
el.style.display = visible ? "" : "none";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function fadeIn(el, duration = 400) {
|
|
18
|
+
el.style.display = "";
|
|
19
|
+
el.style.transition = `opacity ${duration}ms`;
|
|
20
|
+
el.style.opacity = "0";
|
|
21
|
+
// Force reflow so the transition triggers.
|
|
22
|
+
void el.offsetHeight;
|
|
23
|
+
el.style.opacity = "1";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function fadeOut(el, duration = 400) {
|
|
27
|
+
el.style.transition = `opacity ${duration}ms`;
|
|
28
|
+
el.style.opacity = "0";
|
|
29
|
+
setTimeout(() => {
|
|
30
|
+
if (el.style.opacity === "0") el.style.display = "none";
|
|
31
|
+
}, duration);
|
|
32
|
+
}
|
package/src/google-drive.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
-
import
|
|
4
|
-
import * as utils from "./utils.js";
|
|
3
|
+
import { debounce, uint8ArrayToString } from "./utils.js";
|
|
5
4
|
import { discFor } from "./fdc.js";
|
|
6
5
|
|
|
7
6
|
const MIME_TYPE = "application/vnd.jsbeeb.disc-image";
|
|
@@ -112,7 +111,7 @@ export class GoogleDriveLoader {
|
|
|
112
111
|
const metadata = { name, mimeType: MIME_TYPE };
|
|
113
112
|
if (!idOrNone) metadata.parents = [this.parentFolderId];
|
|
114
113
|
|
|
115
|
-
const base64Data = btoa(
|
|
114
|
+
const base64Data = btoa(uint8ArrayToString(data));
|
|
116
115
|
const multipartRequestBody =
|
|
117
116
|
`${delimiter}Content-Type: application/json\r\n\r\n` +
|
|
118
117
|
`${JSON.stringify(metadata)}${delimiter}` +
|
|
@@ -141,7 +140,7 @@ export class GoogleDriveLoader {
|
|
|
141
140
|
const id = meta.id;
|
|
142
141
|
if (meta.capabilities.canEdit) {
|
|
143
142
|
console.log("Making editable disc");
|
|
144
|
-
flusher =
|
|
143
|
+
flusher = debounce(async (changedData) => {
|
|
145
144
|
console.log("Data changed...");
|
|
146
145
|
await this.saveFile(name, changedData, id);
|
|
147
146
|
console.log("Saved ok");
|