jsbeeb 1.17.0 → 1.18.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 +8 -0
- package/package.json +5 -3
- package/src/6502.js +11 -2
- package/src/bbcdiscs.js +31 -1
- package/src/bem-snapshot.js +3 -4
- package/src/canvas.js +71 -29
- package/src/cmos.js +38 -0
- package/src/disc-hfe.js +1 -1
- package/src/disc.js +22 -2
- package/src/fdc.js +15 -2
- package/src/google-drive.js +2 -1
- package/src/jsbeeb.css +23 -1
- package/src/machine-session.js +57 -0
- package/src/main.js +227 -105
- package/src/printer.js +59 -0
- package/src/teletext.js +12 -2
- package/src/teletext_adaptor.js +40 -5
- package/src/touchscreen.js +24 -3
- package/src/utils.js +44 -57
- package/src/via.js +32 -9
- package/src/web/audio-handler.js +26 -9
- package/src/web/toast.js +5 -1
- package/tests/test-machine.js +3 -1
package/src/teletext.js
CHANGED
|
@@ -11,6 +11,7 @@ export class Teletext {
|
|
|
11
11
|
this.sep = false;
|
|
12
12
|
this.dbl = this.oldDbl = this.secondHalfOfDouble = this.wasDbl = false;
|
|
13
13
|
this.gfx = false;
|
|
14
|
+
this.conceal = false;
|
|
14
15
|
this.flash = this.flashOn = false;
|
|
15
16
|
this.flashTime = 0;
|
|
16
17
|
this.heldChar = 0;
|
|
@@ -172,6 +173,7 @@ export class Teletext {
|
|
|
172
173
|
secondHalfOfDouble: this.secondHalfOfDouble,
|
|
173
174
|
wasDbl: this.wasDbl,
|
|
174
175
|
gfx: this.gfx,
|
|
176
|
+
conceal: this.conceal,
|
|
175
177
|
flash: this.flash,
|
|
176
178
|
flashOn: this.flashOn,
|
|
177
179
|
flashTime: this.flashTime,
|
|
@@ -198,6 +200,7 @@ export class Teletext {
|
|
|
198
200
|
this.secondHalfOfDouble = state.secondHalfOfDouble;
|
|
199
201
|
this.wasDbl = state.wasDbl;
|
|
200
202
|
this.gfx = state.gfx;
|
|
203
|
+
this.conceal = state.conceal ?? false;
|
|
201
204
|
this.flash = state.flash;
|
|
202
205
|
this.flashOn = state.flashOn;
|
|
203
206
|
this.flashTime = state.flashTime;
|
|
@@ -245,6 +248,7 @@ export class Teletext {
|
|
|
245
248
|
case 7:
|
|
246
249
|
this.gfx = false;
|
|
247
250
|
this.col = data;
|
|
251
|
+
this.conceal = false;
|
|
248
252
|
this.setNextChars();
|
|
249
253
|
break;
|
|
250
254
|
case 8:
|
|
@@ -267,10 +271,11 @@ export class Teletext {
|
|
|
267
271
|
case 23:
|
|
268
272
|
this.gfx = true;
|
|
269
273
|
this.col = data & 7;
|
|
274
|
+
this.conceal = false;
|
|
270
275
|
this.setNextChars();
|
|
271
276
|
break;
|
|
272
277
|
case 24:
|
|
273
|
-
this.
|
|
278
|
+
this.conceal = true;
|
|
274
279
|
break;
|
|
275
280
|
case 25:
|
|
276
281
|
this.sep = false;
|
|
@@ -358,6 +363,7 @@ export class Teletext {
|
|
|
358
363
|
this.flash = false;
|
|
359
364
|
this.sep = false;
|
|
360
365
|
this.gfx = false;
|
|
366
|
+
this.conceal = false;
|
|
361
367
|
this.dbl = false;
|
|
362
368
|
|
|
363
369
|
this.scanlineCounter++;
|
|
@@ -396,6 +402,7 @@ export class Teletext {
|
|
|
396
402
|
this.curGlyphs = this.nextGlyphs;
|
|
397
403
|
|
|
398
404
|
let flashThisCell = this.flash;
|
|
405
|
+
let concealThisCell = this.conceal;
|
|
399
406
|
if (data < 0x20) {
|
|
400
407
|
data = this.handleControlCode(data);
|
|
401
408
|
} else if (this.gfx) {
|
|
@@ -419,7 +426,10 @@ export class Teletext {
|
|
|
419
426
|
// Steady (code 9) is "Set At" — update so this cell stops flashing immediately.
|
|
420
427
|
if (flashThisCell && !this.flash) flashThisCell = false;
|
|
421
428
|
|
|
422
|
-
|
|
429
|
+
// Conceal (code 24) is "Set At", and a colour code only reveals from the cell after itself.
|
|
430
|
+
if (this.conceal) concealThisCell = true;
|
|
431
|
+
|
|
432
|
+
if (concealThisCell || (flashThisCell && this.flashOn) || (this.secondHalfOfDouble && !this.dbl)) {
|
|
423
433
|
const backgroundColour = this.colour[(this.bg & 7) << 5];
|
|
424
434
|
for (let i = 0; i < 16; ++i) {
|
|
425
435
|
buf[offset++] = backgroundColour;
|
package/src/teletext_adaptor.js
CHANGED
|
@@ -94,6 +94,45 @@ export class TeletextAdaptor extends EventTarget {
|
|
|
94
94
|
this.currentFrame = 0;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
updateIrq() {
|
|
98
|
+
if (this.teletextInts && this.teletextStatus & 0x80) {
|
|
99
|
+
this.cpu.interrupt |= 1 << TELETEXT_IRQ;
|
|
100
|
+
} else {
|
|
101
|
+
this.cpu.interrupt &= ~(1 << TELETEXT_IRQ);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
snapshotState() {
|
|
106
|
+
return {
|
|
107
|
+
teletextStatus: this.teletextStatus,
|
|
108
|
+
teletextInts: this.teletextInts,
|
|
109
|
+
teletextEnable: this.teletextEnable,
|
|
110
|
+
channel: this.channel,
|
|
111
|
+
currentFrame: this.currentFrame,
|
|
112
|
+
rowPtr: this.rowPtr,
|
|
113
|
+
colPtr: this.colPtr,
|
|
114
|
+
pollCount: this.pollCount,
|
|
115
|
+
frameBuffer: this.frameBuffer.map((row) => row.slice()),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
restoreState(state) {
|
|
120
|
+
this.teletextStatus = state.teletextStatus;
|
|
121
|
+
this.teletextInts = state.teletextInts;
|
|
122
|
+
this.teletextEnable = state.teletextEnable;
|
|
123
|
+
this.currentFrame = state.currentFrame;
|
|
124
|
+
this.rowPtr = state.rowPtr;
|
|
125
|
+
this.colPtr = state.colPtr;
|
|
126
|
+
this.pollCount = state.pollCount;
|
|
127
|
+
this.frameBuffer = state.frameBuffer.map((row) => row.slice());
|
|
128
|
+
this.updateIrq();
|
|
129
|
+
// Refetching the multi-megabyte stream on every restore would be ruinous for rewind.
|
|
130
|
+
if (this.channel !== state.channel) {
|
|
131
|
+
this.channel = state.channel;
|
|
132
|
+
this.loadChannelStream(this.channel);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
97
136
|
read(addr) {
|
|
98
137
|
let data = 0x00;
|
|
99
138
|
|
|
@@ -120,11 +159,7 @@ export class TeletextAdaptor extends EventTarget {
|
|
|
120
159
|
case 0x00:
|
|
121
160
|
// Status register
|
|
122
161
|
this.teletextInts = (value & 0x08) === 0x08;
|
|
123
|
-
|
|
124
|
-
this.cpu.interrupt |= 1 << TELETEXT_IRQ; // Interrupt if INT and interrupts enabled
|
|
125
|
-
} else {
|
|
126
|
-
this.cpu.interrupt &= ~(1 << TELETEXT_IRQ); // Clear interrupt
|
|
127
|
-
}
|
|
162
|
+
this.updateIrq();
|
|
128
163
|
this.teletextEnable = (value & 0x04) === 0x04;
|
|
129
164
|
if ((value & 0x03) !== this.channel && this.teletextEnable) {
|
|
130
165
|
this.channel = value & 0x03;
|
package/src/touchscreen.js
CHANGED
|
@@ -13,11 +13,32 @@ export class TouchScreen {
|
|
|
13
13
|
constructor(scheduler, cyclesPerSecond) {
|
|
14
14
|
this.scheduler = scheduler;
|
|
15
15
|
this.pollCycles = cyclesPerSecond / PollHz;
|
|
16
|
-
this.mouse = { x: 0, y: 0, button: 0 };
|
|
17
16
|
this.outBuffer = new utils.Fifo(16);
|
|
18
|
-
this.delay = 0;
|
|
19
|
-
this.mode = 0;
|
|
20
17
|
this.pollTask = this.scheduler.newTask(() => this.poll());
|
|
18
|
+
this.reset();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
reset() {
|
|
22
|
+
this.mouse = { x: 0, y: 0, button: 0 };
|
|
23
|
+
this.mode = 0;
|
|
24
|
+
this.outBuffer.clear();
|
|
25
|
+
this.pollTask.cancel();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
snapshotState() {
|
|
29
|
+
return {
|
|
30
|
+
mode: this.mode,
|
|
31
|
+
outBuffer: this.outBuffer.toArray(),
|
|
32
|
+
pollTaskOffset: this.pollTask.scheduled() ? this.pollTask.expireEpoch - this.scheduler.epoch : null,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
restoreState(state) {
|
|
37
|
+
this.mode = state.mode;
|
|
38
|
+
this.outBuffer.clear();
|
|
39
|
+
for (const byte of state.outBuffer) this.store(byte);
|
|
40
|
+
this.pollTask.cancel();
|
|
41
|
+
if (state.pollTaskOffset !== null) this.pollTask.schedule(state.pollTaskOffset);
|
|
21
42
|
}
|
|
22
43
|
|
|
23
44
|
tryReceive(rts) {
|
package/src/utils.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
// Minimal ZIP extractor
|
|
3
|
-
//
|
|
4
|
-
|
|
2
|
+
// Minimal ZIP extractor. Supports methods 0 (stored) and 8 (deflate); other
|
|
3
|
+
// methods (bzip2, lzma, etc.) will throw an error with the method number.
|
|
4
|
+
|
|
5
|
+
import { inflate as pakoInflate, inflateRaw as pakoInflateRaw, ungzip as pakoUngzip } from "pako";
|
|
5
6
|
|
|
6
7
|
const ZipLocalHeaderSig = 0x04034b50;
|
|
7
8
|
const ZipCentralDirSig = 0x02014b50;
|
|
@@ -35,45 +36,17 @@ function findEocd(buf) {
|
|
|
35
36
|
throw new Error("Not a ZIP file: EOCD not found");
|
|
36
37
|
}
|
|
37
38
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const ds = new DecompressionStream(format);
|
|
45
|
-
const writer = ds.writable.getWriter();
|
|
46
|
-
const reader = ds.readable.getReader();
|
|
47
|
-
const chunks = [];
|
|
48
|
-
const readPromise = (async () => {
|
|
49
|
-
for (;;) {
|
|
50
|
-
const { done, value } = await reader.read();
|
|
51
|
-
if (done) break;
|
|
52
|
-
chunks.push(value);
|
|
53
|
-
}
|
|
54
|
-
})();
|
|
55
|
-
const writePromise = (async () => {
|
|
56
|
-
await writer.write(data);
|
|
57
|
-
await writer.close();
|
|
58
|
-
})().catch(() => {
|
|
59
|
-
// Intentionally empty.
|
|
60
|
-
});
|
|
61
|
-
// Intentionally empty: Node's DecompressionStream rejects multiple
|
|
62
|
-
// promises on error (write, close, closed). The read side surfaces
|
|
63
|
-
// the same error with proper context — catching these just prevents
|
|
64
|
-
// unhandled rejections from the write-side promises.
|
|
65
|
-
writer.closed.catch(() => {});
|
|
66
|
-
await readPromise;
|
|
67
|
-
await writePromise;
|
|
68
|
-
if (chunks.length === 1) return chunks[0];
|
|
69
|
-
const totalLen = chunks.reduce((s, c) => s + c.length, 0);
|
|
70
|
-
const out = new Uint8Array(totalLen);
|
|
71
|
-
let offset = 0;
|
|
72
|
-
for (const chunk of chunks) {
|
|
73
|
-
out.set(chunk, offset);
|
|
74
|
-
offset += chunk.length;
|
|
39
|
+
function inflateWith(inflater, data, context) {
|
|
40
|
+
if (!(data instanceof Uint8Array)) data = new Uint8Array(data);
|
|
41
|
+
try {
|
|
42
|
+
return inflater(data);
|
|
43
|
+
} catch (cause) {
|
|
44
|
+
throw new Error(`${context}: ${cause.message || cause}`, { cause });
|
|
75
45
|
}
|
|
76
|
-
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function inflate(data) {
|
|
49
|
+
return inflateWith(pakoInflate, data, "Unable to inflate");
|
|
77
50
|
}
|
|
78
51
|
|
|
79
52
|
// Extract all files from a ZIP archive. Returns {filename: Uint8Array}.
|
|
@@ -92,6 +65,7 @@ export async function unzip(buf) {
|
|
|
92
65
|
const flags = readU16(buf, pos + 8);
|
|
93
66
|
if (flags & 0x0001) throw new Error("Encrypted ZIP entries are not supported");
|
|
94
67
|
const method = readU16(buf, pos + 10);
|
|
68
|
+
const expectedCrc = readU32(buf, pos + 16);
|
|
95
69
|
const compressedSize = readU32(buf, pos + 20);
|
|
96
70
|
const nameLen = readU16(buf, pos + 28);
|
|
97
71
|
const extraLen = readU16(buf, pos + 30);
|
|
@@ -112,25 +86,25 @@ export async function unzip(buf) {
|
|
|
112
86
|
if (method === ZipMethodStored) {
|
|
113
87
|
files[name] = raw.slice();
|
|
114
88
|
} else if (method === ZipMethodDeflate) {
|
|
115
|
-
files[name] =
|
|
89
|
+
files[name] = inflateWith(pakoInflateRaw, raw, `Unable to inflate ZIP entry ${name}`);
|
|
116
90
|
} else {
|
|
117
91
|
throw new Error(`Unsupported ZIP compression method ${method} for ${name}`);
|
|
118
92
|
}
|
|
93
|
+
if (crc32(files[name]) >>> 0 !== expectedCrc) throw new Error(`Corrupt ZIP entry ${name}: CRC32 mismatch`);
|
|
119
94
|
}
|
|
120
95
|
return files;
|
|
121
96
|
}
|
|
122
97
|
|
|
123
98
|
// Standard CRC-32/ISO-HDLC.
|
|
99
|
+
const Crc32Table = new Uint32Array(256).map((_, index) => {
|
|
100
|
+
let crc = index;
|
|
101
|
+
for (let bit = 0; bit < 8; ++bit) crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
|
|
102
|
+
return crc;
|
|
103
|
+
});
|
|
104
|
+
|
|
124
105
|
export function crc32(data) {
|
|
125
106
|
let crc = 0xffffffff;
|
|
126
|
-
for (let i = 0; i < data.length; ++i)
|
|
127
|
-
crc ^= data[i];
|
|
128
|
-
for (let j = 0; j < 8; ++j) {
|
|
129
|
-
const doEor = crc & 1;
|
|
130
|
-
crc = crc >>> 1;
|
|
131
|
-
if (doEor) crc ^= 0xedb88320;
|
|
132
|
-
}
|
|
133
|
-
}
|
|
107
|
+
for (let i = 0; i < data.length; ++i) crc = (crc >>> 8) ^ Crc32Table[(crc ^ data[i]) & 0xff];
|
|
134
108
|
return ~crc;
|
|
135
109
|
}
|
|
136
110
|
|
|
@@ -1039,7 +1013,10 @@ function loadDataHttp(url) {
|
|
|
1039
1013
|
request.open("GET", baseUrl + url, true);
|
|
1040
1014
|
request.overrideMimeType("text/plain; charset=x-user-defined");
|
|
1041
1015
|
request.onload = function () {
|
|
1042
|
-
if (request.status !== 200)
|
|
1016
|
+
if (request.status !== 200) {
|
|
1017
|
+
reject(new Error("Unable to load " + url + ", http code " + request.status));
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1043
1020
|
if (typeof request.response !== "string") {
|
|
1044
1021
|
resolve(request.response);
|
|
1045
1022
|
} else {
|
|
@@ -1112,11 +1089,7 @@ export function readFloat32(data, offset) {
|
|
|
1112
1089
|
}
|
|
1113
1090
|
|
|
1114
1091
|
export async function ungzip(data) {
|
|
1115
|
-
|
|
1116
|
-
return await decompress(data, "gzip");
|
|
1117
|
-
} catch (cause) {
|
|
1118
|
-
throw new Error("Unable to ungzip: " + (cause.message || cause), { cause });
|
|
1119
|
-
}
|
|
1092
|
+
return inflateWith(pakoUngzip, data, "Unable to ungzip");
|
|
1120
1093
|
}
|
|
1121
1094
|
|
|
1122
1095
|
export class DataStream {
|
|
@@ -1238,6 +1211,7 @@ async function unzipImage(data, knownExtensions) {
|
|
|
1238
1211
|
|
|
1239
1212
|
let uncompressed = null;
|
|
1240
1213
|
let loadedFile;
|
|
1214
|
+
const ignored = [];
|
|
1241
1215
|
|
|
1242
1216
|
for (const [filename, fileData] of Object.entries(files)) {
|
|
1243
1217
|
const match = filename.match(/.*\.([a-z]+)/i);
|
|
@@ -1247,6 +1221,7 @@ async function unzipImage(data, knownExtensions) {
|
|
|
1247
1221
|
}
|
|
1248
1222
|
if (uncompressed) {
|
|
1249
1223
|
console.log("Ignoring", filename, "as already found a file");
|
|
1224
|
+
ignored.push(filename);
|
|
1250
1225
|
continue;
|
|
1251
1226
|
}
|
|
1252
1227
|
loadedFile = filename;
|
|
@@ -1258,9 +1233,14 @@ async function unzipImage(data, knownExtensions) {
|
|
|
1258
1233
|
}
|
|
1259
1234
|
|
|
1260
1235
|
console.log("Unzipped '" + loadedFile + "'");
|
|
1261
|
-
return { data: uncompressed, name: loadedFile };
|
|
1236
|
+
return { data: uncompressed, name: loadedFile, ignored };
|
|
1262
1237
|
}
|
|
1263
1238
|
|
|
1239
|
+
/**
|
|
1240
|
+
* @param {Uint8Array|number[]} data a ZIP archive
|
|
1241
|
+
* @returns {Promise<{data: Uint8Array, name: string, ignored: string[]}>} the one file loaded, and the
|
|
1242
|
+
* names of any other loadable files the archive held
|
|
1243
|
+
*/
|
|
1264
1244
|
export async function unzipDiscImage(data) {
|
|
1265
1245
|
return unzipImage(data, knownDiscExtensions);
|
|
1266
1246
|
}
|
|
@@ -1320,4 +1300,11 @@ export class Fifo {
|
|
|
1320
1300
|
this._size--;
|
|
1321
1301
|
return res;
|
|
1322
1302
|
}
|
|
1303
|
+
|
|
1304
|
+
/** @returns {number[]} pending bytes, oldest first */
|
|
1305
|
+
toArray() {
|
|
1306
|
+
const result = [];
|
|
1307
|
+
for (let i = 0; i < this._size; ++i) result.push(this._buffer[(this._rPtr + i) % this._buffer.length]);
|
|
1308
|
+
return result;
|
|
1309
|
+
}
|
|
1323
1310
|
}
|
package/src/via.js
CHANGED
|
@@ -24,6 +24,10 @@ const ORB = 0x0,
|
|
|
24
24
|
INT_CB1 = 0x10,
|
|
25
25
|
INT_CB2 = 0x08;
|
|
26
26
|
|
|
27
|
+
// Pulse mode holds CA2/CB2 low for one 1MHz VIA cycle, here in the scheduler's 2MHz ticks.
|
|
28
|
+
// Figures 3-4 and 3-6 of https://6502.org/documents/datasheets/wdc/wdc_w65c22s_mar_2004.pdf
|
|
29
|
+
const PulseWidthCycles = 2;
|
|
30
|
+
|
|
27
31
|
class Via {
|
|
28
32
|
constructor(cpu, scheduler, irq) {
|
|
29
33
|
this.cpu = cpu;
|
|
@@ -59,6 +63,8 @@ class Via {
|
|
|
59
63
|
this.t1_pb7 = 0;
|
|
60
64
|
|
|
61
65
|
this.task = this.scheduler.newTask(() => this._onTimeout());
|
|
66
|
+
this.ca2PulseTask = this.scheduler.newTask(() => this.setca2(true));
|
|
67
|
+
this.cb2PulseTask = this.scheduler.newTask(() => this.setcb2(true));
|
|
62
68
|
this.lastPolltime = 0;
|
|
63
69
|
}
|
|
64
70
|
|
|
@@ -72,6 +78,8 @@ class Via {
|
|
|
72
78
|
this.t1hit = this.t2hit = true;
|
|
73
79
|
this.acr = this.pcr = 0;
|
|
74
80
|
this.t1_pb7 = 1;
|
|
81
|
+
this.ca2PulseTask.cancel();
|
|
82
|
+
this.cb2PulseTask.cancel();
|
|
75
83
|
this.updateNextTime();
|
|
76
84
|
}
|
|
77
85
|
|
|
@@ -154,6 +162,11 @@ class Via {
|
|
|
154
162
|
}
|
|
155
163
|
this.updateIFR();
|
|
156
164
|
|
|
165
|
+
// The strobe is "data ready": the port settles before CA2 falls.
|
|
166
|
+
// http://archive.6502.org/datasheets/wdc_w65c22s_mar_2004.pdf figures 3-6 and 3-7.
|
|
167
|
+
this.ora = val;
|
|
168
|
+
this.recalculatePortAPins();
|
|
169
|
+
|
|
157
170
|
mode = this.pcr & 0x0e;
|
|
158
171
|
if (mode === 8) {
|
|
159
172
|
// Handshake mode
|
|
@@ -161,9 +174,10 @@ class Via {
|
|
|
161
174
|
} else if (mode === 0x0a) {
|
|
162
175
|
// Pulse mode
|
|
163
176
|
this.setca2(false);
|
|
164
|
-
this.
|
|
177
|
+
this.ca2PulseTask.reschedule(PulseWidthCycles);
|
|
165
178
|
}
|
|
166
|
-
|
|
179
|
+
break;
|
|
180
|
+
|
|
167
181
|
case ORAnh:
|
|
168
182
|
this.ora = val;
|
|
169
183
|
this.recalculatePortAPins();
|
|
@@ -187,7 +201,7 @@ class Via {
|
|
|
187
201
|
} else if (mode === 0x0a) {
|
|
188
202
|
// Pulse mode
|
|
189
203
|
this.setcb2(false);
|
|
190
|
-
this.
|
|
204
|
+
this.cb2PulseTask.reschedule(PulseWidthCycles);
|
|
191
205
|
}
|
|
192
206
|
break;
|
|
193
207
|
|
|
@@ -402,6 +416,15 @@ class Via {
|
|
|
402
416
|
this.portBUpdated();
|
|
403
417
|
}
|
|
404
418
|
|
|
419
|
+
_taskOffset(task) {
|
|
420
|
+
return task.scheduled() ? task.expireEpoch - this.scheduler.epoch : null;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
_restoreTask(task, offset) {
|
|
424
|
+
if (offset === null || offset === undefined) task.cancel();
|
|
425
|
+
else task.reschedule(offset);
|
|
426
|
+
}
|
|
427
|
+
|
|
405
428
|
snapshotState() {
|
|
406
429
|
return {
|
|
407
430
|
ora: this.ora,
|
|
@@ -430,7 +453,9 @@ class Via {
|
|
|
430
453
|
justhit: this.justhit,
|
|
431
454
|
t1_pb7: this.t1_pb7,
|
|
432
455
|
lastPolltime: this.lastPolltime,
|
|
433
|
-
taskOffset: this.
|
|
456
|
+
taskOffset: this._taskOffset(this.task),
|
|
457
|
+
ca2PulseTaskOffset: this._taskOffset(this.ca2PulseTask),
|
|
458
|
+
cb2PulseTaskOffset: this._taskOffset(this.cb2PulseTask),
|
|
434
459
|
};
|
|
435
460
|
}
|
|
436
461
|
|
|
@@ -462,11 +487,9 @@ class Via {
|
|
|
462
487
|
this.t1_pb7 = state.t1_pb7;
|
|
463
488
|
this.lastPolltime = state.lastPolltime;
|
|
464
489
|
this.updateIFR();
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
this.task.cancel();
|
|
469
|
-
}
|
|
490
|
+
this._restoreTask(this.task, state.taskOffset);
|
|
491
|
+
this._restoreTask(this.ca2PulseTask, state.ca2PulseTaskOffset);
|
|
492
|
+
this._restoreTask(this.cb2PulseTask, state.cb2PulseTaskOffset);
|
|
470
493
|
}
|
|
471
494
|
|
|
472
495
|
setca1(level) {
|
package/src/web/audio-handler.js
CHANGED
|
@@ -4,6 +4,7 @@ import { RelayNoise, FakeRelayNoise } from "../relaynoise.js";
|
|
|
4
4
|
import { Music5000, FakeMusic5000 } from "../music5000.js";
|
|
5
5
|
import { createAudioContext } from "../audio-utils.js";
|
|
6
6
|
import { toggle, fadeIn, fadeOut } from "../dom-utils.js";
|
|
7
|
+
import { toast } from "./toast.js";
|
|
7
8
|
|
|
8
9
|
// Using this approach means when jsbeeb is embedded in other projects, vite doesn't have a fit.
|
|
9
10
|
// See https://github.com/vitejs/vite/discussions/6459
|
|
@@ -15,7 +16,7 @@ export class AudioHandler {
|
|
|
15
16
|
this.cpuSpeed = cpuSpeed;
|
|
16
17
|
this.isAtom = isAtom;
|
|
17
18
|
this.warningNode = warningNode;
|
|
18
|
-
this.
|
|
19
|
+
this.noAudio = false;
|
|
19
20
|
toggle(this.warningNode, false);
|
|
20
21
|
this.stats = {};
|
|
21
22
|
if (statsNode) {
|
|
@@ -38,11 +39,11 @@ export class AudioHandler {
|
|
|
38
39
|
this.masterGain.connect(this.audioContext.destination);
|
|
39
40
|
this.ddNoise = noSeek ? new FakeDdNoise() : new DdNoise(this.audioContext, this.masterGain);
|
|
40
41
|
this.relayNoise = new RelayNoise(this.audioContext, this.masterGain);
|
|
41
|
-
this._setup(audioFilterFreq, audioFilterQ).
|
|
42
|
+
this._setup(audioFilterFreq, audioFilterQ).catch((error) => this._audioUnavailable(error));
|
|
42
43
|
} else {
|
|
43
44
|
if (this.audioContext && !this.audioContext.audioWorklet) {
|
|
44
45
|
this.audioContext = null;
|
|
45
|
-
this.
|
|
46
|
+
this.noAudio = true;
|
|
46
47
|
console.log("Unable to initialise audio: no audio worklet API");
|
|
47
48
|
const localhost = new URL(window.location);
|
|
48
49
|
localhost.hostname = "localhost";
|
|
@@ -66,12 +67,21 @@ export class AudioHandler {
|
|
|
66
67
|
this.audioContextM5000.onstatechange = () => this.checkStatus();
|
|
67
68
|
this.music5000 = new Music5000((buffer) => this._onBufferMusic5000(buffer));
|
|
68
69
|
|
|
69
|
-
this.audioContextM5000.audioWorklet
|
|
70
|
-
|
|
71
|
-
|
|
70
|
+
this.audioContextM5000.audioWorklet
|
|
71
|
+
.addModule(music5000WorkletUrl)
|
|
72
|
+
.then(() => {
|
|
73
|
+
this._music5000workletnode = new AudioWorkletNode(this.audioContextM5000, "music5000", {
|
|
74
|
+
outputChannelCount: [2],
|
|
75
|
+
});
|
|
76
|
+
this._music5000workletnode.connect(this.audioContextM5000.destination);
|
|
77
|
+
})
|
|
78
|
+
.catch((error) => {
|
|
79
|
+
console.error("Unable to initialise Music 5000 audio", error);
|
|
80
|
+
toast(
|
|
81
|
+
`The Music 5000 will be silent: its audio could not be started (${error?.message ?? error}). Reloading the page may help.`,
|
|
82
|
+
{ title: "Music 5000", quietKey: "quietMusic5000Audio" },
|
|
83
|
+
);
|
|
72
84
|
});
|
|
73
|
-
this._music5000workletnode.connect(this.audioContextM5000.destination);
|
|
74
|
-
});
|
|
75
85
|
} else {
|
|
76
86
|
this.music5000 = new FakeMusic5000();
|
|
77
87
|
}
|
|
@@ -116,6 +126,13 @@ export class AudioHandler {
|
|
|
116
126
|
};
|
|
117
127
|
}
|
|
118
128
|
|
|
129
|
+
_audioUnavailable(error) {
|
|
130
|
+
console.error("Unable to initialise audio", error);
|
|
131
|
+
this.noAudio = true;
|
|
132
|
+
this.warningNode.textContent = `There will be no sound: the audio system could not be started (${error?.message ?? error}). Reloading the page may help.`;
|
|
133
|
+
fadeIn(this.warningNode);
|
|
134
|
+
}
|
|
135
|
+
|
|
119
136
|
_addStat(stat, info) {
|
|
120
137
|
const timeSeries = new this._TimeSeries();
|
|
121
138
|
this.stats[stat] = timeSeries;
|
|
@@ -146,7 +163,7 @@ export class AudioHandler {
|
|
|
146
163
|
}
|
|
147
164
|
|
|
148
165
|
checkStatus() {
|
|
149
|
-
if (this.
|
|
166
|
+
if (this.noAudio) return;
|
|
150
167
|
if (!this.audioContext && !this.audioContextM5000) return;
|
|
151
168
|
const suspended =
|
|
152
169
|
(this.audioContext && this.audioContext.state === "suspended") ||
|
package/src/web/toast.js
CHANGED
|
@@ -73,7 +73,11 @@ export function toast(message, { title = "", quietKey = "" } = {}) {
|
|
|
73
73
|
quiet.querySelector("input").addEventListener("change", (event) => remember(quietKey, event.target.checked));
|
|
74
74
|
|
|
75
75
|
toastContainer().appendChild(element);
|
|
76
|
-
element
|
|
76
|
+
// Bootstrap keys its instances by element, so one only removed stays held.
|
|
77
|
+
element.addEventListener("hidden.bs.toast", () => {
|
|
78
|
+
bootstrap.Toast.getInstance(element)?.dispose();
|
|
79
|
+
element.remove();
|
|
80
|
+
});
|
|
77
81
|
bootstrap.Toast.getOrCreateInstance(element).show();
|
|
78
82
|
return element;
|
|
79
83
|
}
|
package/tests/test-machine.js
CHANGED
|
@@ -111,7 +111,9 @@ export class TestMachine {
|
|
|
111
111
|
stopped = !this.processor.execute(todo);
|
|
112
112
|
left -= todo;
|
|
113
113
|
}
|
|
114
|
-
|
|
114
|
+
// Not truthiness: a negative or NaN request clamps todo to zero,
|
|
115
|
+
// so left would never move and the loop never end.
|
|
116
|
+
if (left > 0 && !stopped) {
|
|
115
117
|
setTimeout(runAnIter, 0);
|
|
116
118
|
} else {
|
|
117
119
|
resolve(stopped);
|