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/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.col = this.prevCol = this.bg;
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
- if ((flashThisCell && this.flashOn) || (this.secondHalfOfDouble && !this.dbl)) {
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;
@@ -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
- if (this.teletextInts && this.teletextStatus & 0x80) {
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;
@@ -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 using native DecompressionStream for deflate.
3
- // Supports methods 0 (stored) and 8 (deflate); other methods (bzip2,
4
- // lzma, etc.) will throw an error with the method number.
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
- // Pipe data through a DecompressionStream and return the result.
39
- // Starts the read loop before writing to avoid backpressure deadlock.
40
- // On error, Node's DecompressionStream rejects multiple internal promises
41
- // (write, close, and closed); we catch the write side to prevent unhandled
42
- // rejections and let the error surface through the read side.
43
- export async function decompress(data, format) {
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
- return out;
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] = await decompress(raw, "deflate-raw");
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) reject(new Error("Unable to load " + url + ", http code " + request.status));
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
- try {
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.setca2(true);
177
+ this.ca2PulseTask.reschedule(PulseWidthCycles);
165
178
  }
166
- /* falls through */
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.setcb2(true);
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.task.scheduled() ? this.task.expireEpoch - this.scheduler.epoch : null,
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
- if (state.taskOffset !== null) {
466
- this.task.reschedule(state.taskOffset);
467
- } else {
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) {
@@ -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.noAudioWorklet = false;
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).then();
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.noAudioWorklet = true;
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.addModule(music5000WorkletUrl).then(() => {
70
- this._music5000workletnode = new AudioWorkletNode(this.audioContextM5000, "music5000", {
71
- outputChannelCount: [2],
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.noAudioWorklet) return;
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.addEventListener("hidden.bs.toast", () => element.remove());
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
  }
@@ -111,7 +111,9 @@ export class TestMachine {
111
111
  stopped = !this.processor.execute(todo);
112
112
  left -= todo;
113
113
  }
114
- if (left && !stopped) {
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);