jsbeeb 1.16.0 → 1.17.1

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/models.js CHANGED
@@ -263,6 +263,8 @@ export function findModel(name) {
263
263
  return null;
264
264
  }
265
265
 
266
+ export const DefaultModel = findModel("B-DFS1.2");
267
+
266
268
  export const TEST_6502 = new Model({
267
269
  name: "TEST",
268
270
  synonyms: ["TEST"],
package/src/printer.js ADDED
@@ -0,0 +1,59 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Centronics printer attached to the user VIA: acknowledges each character and keeps the most
5
+ * recent output so it can be shown whenever a printer window is opened.
6
+ */
7
+
8
+ export const MaxBufferedChars = 64 * 1024;
9
+
10
+ export class Printer {
11
+ /**
12
+ * @param {object} [handlers]
13
+ * @param {function(string): void} [handlers.onOutput] called with each character as it is printed
14
+ * @param {function(): void} [handlers.onFirstOutput] called once, when the machine first prints
15
+ */
16
+ constructor({ onOutput = () => {}, onFirstOutput = () => {} } = {}) {
17
+ this._onOutput = onOutput;
18
+ this._onFirstOutput = onFirstOutput;
19
+ this._uservia = null;
20
+ this._olderText = "";
21
+ this._newerText = "";
22
+ this._hasPrinted = false;
23
+ }
24
+
25
+ outputStrobe(level, output) {
26
+ if (!output || level) return;
27
+
28
+ const uservia = this._uservia;
29
+ // Ack the character by pulsing CA1 low.
30
+ uservia.setca1(false);
31
+ uservia.setca1(true);
32
+ this._append(String.fromCharCode(uservia.ora));
33
+ }
34
+
35
+ attach(uservia) {
36
+ this._uservia = uservia;
37
+ // The ack line idles high, so that each ack is a falling edge.
38
+ uservia.setca1(true);
39
+ }
40
+
41
+ /** The most recent output, at most MaxBufferedChars of it. */
42
+ get text() {
43
+ return (this._olderText + this._newerText).slice(-MaxBufferedChars);
44
+ }
45
+
46
+ _append(char) {
47
+ // Two chunks so the bound costs a trim when the text is read, not one per character.
48
+ this._newerText += char;
49
+ if (this._newerText.length >= MaxBufferedChars) {
50
+ this._olderText = this._newerText;
51
+ this._newerText = "";
52
+ }
53
+ if (!this._hasPrinted) {
54
+ this._hasPrinted = true;
55
+ this._onFirstOutput();
56
+ }
57
+ this._onOutput(char);
58
+ }
59
+ }
package/src/sth.js CHANGED
@@ -50,12 +50,12 @@ export class StairwayToHell {
50
50
  }
51
51
 
52
52
  async fetch(file) {
53
- const name = this._baseUrl + encodePath(file);
54
- console.log("Loading ZIP from " + name);
55
- const response = await fetch(name);
56
- if (!response.ok) throw new Error("Network response was not ok");
53
+ const url = this._baseUrl + encodePath(file);
54
+ console.log("Loading ZIP from " + url);
55
+ const response = await fetch(url);
56
+ if (!response.ok) throw new Error(`Unable to load ${url}, http code ${response.status}`);
57
57
  try {
58
- return (await utils.unzipDiscImage(new Uint8Array(await response.arrayBuffer()))).data;
58
+ return await utils.unzipDiscImage(new Uint8Array(await response.arrayBuffer()));
59
59
  } catch (error) {
60
60
  console.error("Failed to fetch file:", error);
61
61
  throw error;
@@ -35,8 +35,8 @@ Status register:
35
35
  */
36
36
 
37
37
  /**
38
- * Emulates the Acorn teletext adaptor. Dispatches a `showError` CustomEvent, carrying
39
- * `context` and `error` in its detail, when a channel's stream cannot be loaded.
38
+ * Emulates the Acorn teletext adaptor. Dispatches a `notice` CustomEvent, carrying a
39
+ * `message` in its detail, when a channel's stream cannot be loaded.
40
40
  */
41
41
  export class TeletextAdaptor extends EventTarget {
42
42
  constructor(cpu) {
@@ -79,8 +79,10 @@ export class TeletextAdaptor extends EventTarget {
79
79
  if (request !== this.streamRequest) return;
80
80
  console.error(`Teletext adaptor: failed to load channel ${channel}`, error);
81
81
  this.dispatchEvent(
82
- new CustomEvent("showError", {
83
- detail: { context: `loading teletext channel ${channel}`, error },
82
+ new CustomEvent("notice", {
83
+ detail: {
84
+ message: `Teletext channel ${channel} could not be loaded (${error?.message ?? error}). The adaptor carries on with nothing to show.`,
85
+ },
84
86
  }),
85
87
  );
86
88
  return;
package/src/url-params.js CHANGED
@@ -236,6 +236,34 @@ export function processAutobootParams(parsedQuery) {
236
236
  return { needsAutoboot, autoType };
237
237
  }
238
238
 
239
+ /** Where a drive's 40/80 switch is set, `auto` leaving it to whatever disc is loaded. */
240
+ export const DriveTracks = Object.freeze({ auto: "auto", forty: "40", eighty: "80" });
241
+
242
+ const NumDrives = 2;
243
+
244
+ /**
245
+ * Process the per-drive 40/80 track settings
246
+ * @param {Object} parsedQuery - The parsed query parameters
247
+ * @returns {{settings: string[], warnings: string[]}} One DriveTracks per drive, and what was
248
+ * unusable about anything asked for that is not in there
249
+ */
250
+ export function processDriveTrackParams(parsedQuery) {
251
+ const warnings = [];
252
+ const settings = [];
253
+ for (let driveIndex = 0; driveIndex < NumDrives; ++driveIndex) {
254
+ const name = `drive${driveIndex}Tracks`;
255
+ const asked = parsedQuery[name];
256
+ const setting = isDefined(asked) ? `${asked}`.toLowerCase() : DriveTracks.auto;
257
+ if (Object.values(DriveTracks).includes(setting)) {
258
+ settings.push(setting);
259
+ } else {
260
+ warnings.push(`${name}=${asked}: a drive is set to 40, 80 or auto.`);
261
+ settings.push(DriveTracks.auto);
262
+ }
263
+ }
264
+ return { settings, warnings };
265
+ }
266
+
239
267
  /**
240
268
  * Guess the appropriate model based on the hostname
241
269
  * @param {string} hostname - The hostname to check
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
 
@@ -1112,11 +1086,7 @@ export function readFloat32(data, offset) {
1112
1086
  }
1113
1087
 
1114
1088
  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
- }
1089
+ return inflateWith(pakoUngzip, data, "Unable to ungzip");
1120
1090
  }
1121
1091
 
1122
1092
  export class DataStream {
@@ -1216,7 +1186,10 @@ const knownDiscExtensions = {
1216
1186
  uef: true,
1217
1187
  ssd: true,
1218
1188
  dsd: true,
1189
+ adf: true,
1190
+ adm: true,
1219
1191
  adl: true,
1192
+ hfe: true,
1220
1193
  };
1221
1194
 
1222
1195
  const knownRomExtensions = {
package/src/via.js CHANGED
@@ -154,6 +154,11 @@ class Via {
154
154
  }
155
155
  this.updateIFR();
156
156
 
157
+ // The strobe is "data ready": the port settles before CA2 falls.
158
+ // http://archive.6502.org/datasheets/wdc_w65c22s_mar_2004.pdf figures 3-6 and 3-7.
159
+ this.ora = val;
160
+ this.recalculatePortAPins();
161
+
157
162
  mode = this.pcr & 0x0e;
158
163
  if (mode === 8) {
159
164
  // Handshake mode
@@ -163,7 +168,8 @@ class Via {
163
168
  this.setca2(false);
164
169
  this.setca2(true);
165
170
  }
166
- /* falls through */
171
+ break;
172
+
167
173
  case ORAnh:
168
174
  this.ora = val;
169
175
  this.recalculatePortAPins();
package/src/wd-fdc.js CHANGED
@@ -1414,8 +1414,15 @@ export class WdFdc {
1414
1414
  * @param {Number} drive
1415
1415
  * @param {Disc} disc
1416
1416
  */
1417
- loadDisc(drive, disc) {
1417
+ /**
1418
+ * @param {Number} drive
1419
+ * @param {Disc} disc
1420
+ * @param {Number} [tracksPerStep] where to leave the drive's 40/80 switch, which by default
1421
+ * follows the disc, since no drive can tell what pitch the disc in it was written at
1422
+ */
1423
+ loadDisc(drive, disc, tracksPerStep = disc?.is40Track ? 2 : 1) {
1418
1424
  this._drives[drive].setDisc(disc);
1425
+ this._drives[drive].tracksPerStep = tracksPerStep;
1419
1426
  }
1420
1427
 
1421
1428
  get motorOn() {
@@ -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,6 +16,7 @@ export class AudioHandler {
15
16
  this.cpuSpeed = cpuSpeed;
16
17
  this.isAtom = isAtom;
17
18
  this.warningNode = warningNode;
19
+ this.noAudio = false;
18
20
  toggle(this.warningNode, false);
19
21
  this.stats = {};
20
22
  if (statsNode) {
@@ -37,18 +39,19 @@ export class AudioHandler {
37
39
  this.masterGain.connect(this.audioContext.destination);
38
40
  this.ddNoise = noSeek ? new FakeDdNoise() : new DdNoise(this.audioContext, this.masterGain);
39
41
  this.relayNoise = new RelayNoise(this.audioContext, this.masterGain);
40
- this._setup(audioFilterFreq, audioFilterQ).then();
42
+ this._setup(audioFilterFreq, audioFilterQ).catch((error) => this._audioUnavailable(error));
41
43
  } else {
42
44
  if (this.audioContext && !this.audioContext.audioWorklet) {
43
45
  this.audioContext = null;
46
+ this.noAudio = true;
44
47
  console.log("Unable to initialise audio: no audio worklet API");
45
- toggle(this.warningNode, true);
46
48
  const localhost = new URL(window.location);
47
49
  localhost.hostname = "localhost";
48
50
  this.warningNode.innerHTML = `No audio worklet API was found - there will be no audio.
49
51
  If you are running a local jsbeeb, you must either use a host of
50
52
  <a href="${localhost}">localhost</a>,
51
53
  or serve the content over <em>https</em>.`;
54
+ toggle(this.warningNode, true);
52
55
  }
53
56
  this.soundChip = new FakeSoundChip();
54
57
  this.ddNoise = new FakeDdNoise();
@@ -56,7 +59,6 @@ export class AudioHandler {
56
59
  }
57
60
 
58
61
  this.warningNode.addEventListener("mousedown", () => this.tryResume());
59
- toggle(this.warningNode, false);
60
62
 
61
63
  // Initialise Music 5000 audio context
62
64
  this.audioContextM5000 = createAudioContext({ sampleRate: 46875 });
@@ -65,12 +67,21 @@ export class AudioHandler {
65
67
  this.audioContextM5000.onstatechange = () => this.checkStatus();
66
68
  this.music5000 = new Music5000((buffer) => this._onBufferMusic5000(buffer));
67
69
 
68
- this.audioContextM5000.audioWorklet.addModule(music5000WorkletUrl).then(() => {
69
- this._music5000workletnode = new AudioWorkletNode(this.audioContextM5000, "music5000", {
70
- 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
+ );
71
84
  });
72
- this._music5000workletnode.connect(this.audioContextM5000.destination);
73
- });
74
85
  } else {
75
86
  this.music5000 = new FakeMusic5000();
76
87
  }
@@ -115,6 +126,13 @@ export class AudioHandler {
115
126
  };
116
127
  }
117
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
+
118
136
  _addStat(stat, info) {
119
137
  const timeSeries = new this._TimeSeries();
120
138
  this.stats[stat] = timeSeries;
@@ -145,6 +163,7 @@ export class AudioHandler {
145
163
  }
146
164
 
147
165
  checkStatus() {
166
+ if (this.noAudio) return;
148
167
  if (!this.audioContext && !this.audioContextM5000) return;
149
168
  const suspended =
150
169
  (this.audioContext && this.audioContext.state === "suspended") ||
@@ -0,0 +1,83 @@
1
+ import * as bootstrap from "bootstrap";
2
+
3
+ /**
4
+ * Passing notices: something happened that is worth knowing and needs nothing doing about it.
5
+ * The error dialog is for the other kind.
6
+ */
7
+
8
+ let nextQuietId = 0;
9
+
10
+ function toastContainer() {
11
+ const existing = document.querySelector(".toast-container");
12
+ if (existing) return existing;
13
+ const container = document.createElement("div");
14
+ container.className = "toast-container position-fixed bottom-0 end-0 p-3";
15
+ document.body.appendChild(container);
16
+ return container;
17
+ }
18
+
19
+ // Storage can be unreachable or full, and a notice that nothing needs doing about is not worth
20
+ // failing over: forget the answer and say the thing again.
21
+ function remembered(key) {
22
+ try {
23
+ return !!window.localStorage.getItem(key);
24
+ } catch (e) {
25
+ console.log(`Unable to read ${key}: ${e}`);
26
+ return false;
27
+ }
28
+ }
29
+
30
+ function remember(key, wanted) {
31
+ try {
32
+ if (wanted) window.localStorage.setItem(key, "yes");
33
+ else window.localStorage.removeItem(key);
34
+ } catch (e) {
35
+ console.log(`Unable to remember ${key}: ${e}`);
36
+ }
37
+ }
38
+
39
+ /**
40
+ * @param {string} message
41
+ * @param {object} [options]
42
+ * @param {string} [options.title] a heading, for a notice whose message does not say where it came from
43
+ * @param {string} [options.quietKey] offer to stop showing this kind of notice, remembering the answer here
44
+ */
45
+ export function toast(message, { title = "", quietKey = "" } = {}) {
46
+ if (quietKey && remembered(quietKey)) return;
47
+
48
+ const element = document.createElement("div");
49
+ element.className = "toast text-bg-dark";
50
+ element.setAttribute("role", "status");
51
+ element.setAttribute("aria-live", "polite");
52
+ element.setAttribute("aria-atomic", "true");
53
+ const quietId = `toast-quiet-${nextQuietId++}`;
54
+ element.innerHTML = `
55
+ <div class="toast-header text-bg-dark">
56
+ <strong class="me-auto"></strong>
57
+ <button type="button" class="btn-close btn-close-white" data-bs-dismiss="toast" aria-label="Close"></button>
58
+ </div>
59
+ <div class="toast-body">
60
+ <div class="message"></div>
61
+ <div class="form-check mt-2">
62
+ <input class="form-check-input" type="checkbox" id="${quietId}" />
63
+ <label class="form-check-label small" for="${quietId}">Stop telling me this</label>
64
+ </div>
65
+ </div>`;
66
+ // Disc names and the like come from the outside world, so they are set as text, never as markup.
67
+ element.querySelector(".message").textContent = message;
68
+ element.querySelector(".toast-header strong").textContent = title;
69
+ element.querySelector(".toast-header").hidden = !title;
70
+
71
+ const quiet = element.querySelector(".form-check");
72
+ quiet.hidden = !quietKey;
73
+ quiet.querySelector("input").addEventListener("change", (event) => remember(quietKey, event.target.checked));
74
+
75
+ toastContainer().appendChild(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
+ });
81
+ bootstrap.Toast.getOrCreateInstance(element).show();
82
+ return element;
83
+ }
@@ -213,7 +213,7 @@ export class TestMachine {
213
213
 
214
214
  async loadDisc(image) {
215
215
  const data = await fdc.load(image);
216
- this.processor.fdc.loadDisc(0, fdc.discFor(this.processor.fdc, "", data));
216
+ this.processor.fdc.loadDisc(0, fdc.discFor(this.processor.fdc, image, data));
217
217
  }
218
218
 
219
219
  /**