jsbeeb 1.15.0 → 1.17.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/models.js CHANGED
@@ -35,6 +35,15 @@ class Model {
35
35
  this.cmosOverride = cmosOverride;
36
36
  }
37
37
 
38
+ /**
39
+ * How many CPU cycles this machine runs in a second. Everything that
40
+ * converts between real time and emulated cycles should ask here rather
41
+ * than assuming a clock speed.
42
+ */
43
+ get cyclesPerSecond() {
44
+ return this.clockMhz * 1000 * 1000;
45
+ }
46
+
38
47
  get nmos() {
39
48
  return this._cpuModel === CpuModel.MOS6502;
40
49
  }
@@ -225,8 +234,8 @@ export const allModels = [
225
234
  name: "Tube65C02",
226
235
  synonyms: [],
227
236
  os: ["tube/6502Tube.rom"],
228
- // TODO(#746): the external second processor was an NMOS 6502A.
229
- cpuModel: CpuModel.CMOS65C02,
237
+ // The production wedge's GTE 65SC02 has no Rockwell bit instructions.
238
+ cpuModel: CpuModel.CMOS65C12,
230
239
  isMaster: false,
231
240
  clockMhz: 3,
232
241
  }),
@@ -234,7 +243,8 @@ export const allModels = [
234
243
  name: "Tube65C102",
235
244
  synonyms: [],
236
245
  os: ["tube/65C102Tube.rom"],
237
- // TODO(#746): Acorn's 65C102 has no Rockwell bit instructions.
246
+ // Boards are reported with both Rockwell and GTE parts, so keep the superset of the two
247
+ // until #756 lets the fitted co-processor be chosen.
238
248
  cpuModel: CpuModel.CMOS65C02,
239
249
  isMaster: false,
240
250
  clockMhz: 4,
@@ -253,6 +263,8 @@ export function findModel(name) {
253
263
  return null;
254
264
  }
255
265
 
266
+ export const DefaultModel = findModel("B-DFS1.2");
267
+
256
268
  export const TEST_6502 = new Model({
257
269
  name: "TEST",
258
270
  synonyms: ["TEST"],
package/src/sth.js CHANGED
@@ -2,30 +2,33 @@
2
2
 
3
3
  import * as utils from "./utils.js";
4
4
 
5
- const catalogUrl = "reclist.php?sort=name&filter=.zip";
6
- const sthArchive = "www.stairwaytohell.com/bbc/archive";
5
+ // Always https, whatever the page was loaded over: the mirror redirects plain
6
+ // http, so following the page's protocol would cost a redirect on every request
7
+ // when developing over http, and Electron reports "file:" anyway.
8
+ const mirrorBase = "https://bbc.xania.org/archive/sth";
7
9
 
8
- async function _fetchAndParseCatalog(url) {
10
+ async function _fetchManifest(url) {
9
11
  const response = await fetch(url);
10
12
  if (!response.ok) {
11
- throw new Error("Network response was not ok");
13
+ throw new Error(`Network response was not ok (${response.status})`);
12
14
  }
13
- const parser = new DOMParser();
14
- const doc = parser.parseFromString(await response.text(), "text/html");
15
- const result = [];
16
- doc.querySelectorAll("tr td:nth-child(3) a").forEach((link) => {
17
- const href = link.getAttribute("href");
18
- if (href.indexOf(".zip") > 0) result.push(href);
19
- });
20
- result.sort();
21
- return result;
15
+ const data = await response.json();
16
+ if (!Array.isArray(data?.files)) {
17
+ throw new Error("Invalid manifest: missing files array");
18
+ }
19
+ return data.files.map((f) => f.path).sort();
20
+ }
21
+
22
+ // Each path component is encoded individually so slashes survive but special
23
+ // characters in filenames (e.g. brackets in "Daxis[droids]-demo.zip") don't
24
+ // produce a malformed URL.
25
+ function encodePath(path) {
26
+ return path.split("/").map(encodeURIComponent).join("/");
22
27
  }
23
28
 
24
29
  export class StairwayToHell {
25
30
  constructor(onStart, onCat, onError, tape) {
26
- // Use https explicitly - document.location.protocol is 'file:' in Electron
27
- const protocol = document.location.protocol === "file:" ? "https:" : document.location.protocol;
28
- this._baseUrl = `${protocol}//${sthArchive}/${tape ? "tape" : "disk"}images/`;
31
+ this._baseUrl = `${mirrorBase}/${tape ? "tape" : "disk"}images/`;
29
32
  this._catalog = [];
30
33
  this._onStart = onStart;
31
34
  this._onCat = onCat;
@@ -36,7 +39,7 @@ export class StairwayToHell {
36
39
  this._onStart();
37
40
  if (this._catalog.length === 0) {
38
41
  try {
39
- this._catalog = await _fetchAndParseCatalog(this._baseUrl + catalogUrl);
42
+ this._catalog = await _fetchManifest(this._baseUrl + "manifest.json");
40
43
  } catch (error) {
41
44
  console.error("Failed to fetch catalog:", error);
42
45
  if (this._onError) this._onError();
@@ -47,12 +50,12 @@ export class StairwayToHell {
47
50
  }
48
51
 
49
52
  async fetch(file) {
50
- const name = this._baseUrl + file;
51
- console.log("Loading ZIP from " + name);
52
- const response = await fetch(name);
53
- 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}`);
54
57
  try {
55
- return (await utils.unzipDiscImage(new Uint8Array(await response.arrayBuffer()))).data;
58
+ return await utils.unzipDiscImage(new Uint8Array(await response.arrayBuffer()));
56
59
  } catch (error) {
57
60
  console.error("Failed to fetch file:", error);
58
61
  throw error;
package/src/tapes.js CHANGED
@@ -1,9 +1,6 @@
1
1
  "use strict";
2
2
  import * as utils from "./utils.js";
3
3
 
4
- const BbcCpuSpeed = 2 * 1000 * 1000;
5
- const AtomCpuSpeed = 1 * 1000 * 1000;
6
-
7
4
  function secsToClocks(secs, cpuSpeed) {
8
5
  return (cpuSpeed * secs) | 0;
9
6
  }
@@ -25,11 +22,11 @@ function parityOf(curByte) {
25
22
  const ParityN = "N".charCodeAt(0);
26
23
 
27
24
  class UefTape {
28
- constructor(stream, isAtom = false) {
25
+ constructor(stream, model) {
29
26
  this.stream = stream;
30
27
  this.baseFrequency = 1200;
31
- this.isAtom = isAtom;
32
- this.cpuSpeed = isAtom ? AtomCpuSpeed : BbcCpuSpeed;
28
+ this.isAtom = model.isAtom;
29
+ this.cpuSpeed = model.cyclesPerSecond;
33
30
  this.rewind();
34
31
 
35
32
  this.curChunk = this.readChunk();
@@ -265,9 +262,10 @@ class UefTape {
265
262
  const dividerTable = [1, 16, 64, -1];
266
263
 
267
264
  class TapefileTape {
268
- constructor(stream) {
265
+ constructor(stream, model) {
269
266
  this.count = 0;
270
267
  this.stream = stream;
268
+ this.cpuSpeed = model.cyclesPerSecond;
271
269
  }
272
270
 
273
271
  rate(acia) {
@@ -278,7 +276,7 @@ class TapefileTape {
278
276
  const divider = dividerTable[acia.cr & 0x03];
279
277
  // http://beebwiki.mdfs.net/index.php/Serial_ULA says the serial rate is ignored
280
278
  // for cassette mode.
281
- const cpp = (2 * 1000 * 1000) / (19200 / divider);
279
+ const cpp = this.cpuSpeed / (19200 / divider);
282
280
  return Math.floor(bitsPerByte * cpp);
283
281
  }
284
282
 
@@ -297,7 +295,7 @@ class TapefileTape {
297
295
  } else if (byte === 0x04) {
298
296
  acia.setTapeCarrier(true);
299
297
  // Simulate 5 seconds of carrier.
300
- return 5 * 2 * 1000 * 1000;
298
+ return secsToClocks(5, this.cpuSpeed);
301
299
  } else if (byte !== 0xff) {
302
300
  throw "Got a weird byte in the tape";
303
301
  }
@@ -307,15 +305,15 @@ class TapefileTape {
307
305
  }
308
306
  }
309
307
 
310
- export async function loadTapeFromData(name, data, isAtom = false) {
308
+ export async function loadTapeFromData(name, data, model) {
311
309
  const stream = await utils.DataStream.create(name, data);
312
310
  if (stream.readByte(0) === 0xff && stream.readByte(1) === 0x04) {
313
311
  console.log("Detected a 'tapefile' tape");
314
- return new TapefileTape(stream);
312
+ return new TapefileTape(stream, model);
315
313
  }
316
314
  if (stream.readNulString(0) === "UEF File!") {
317
315
  console.log("Detected a UEF tape");
318
- return new UefTape(stream, isAtom);
316
+ return new UefTape(stream, model);
319
317
  }
320
318
  console.log("Unknown tape format");
321
319
  return null;
@@ -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;
@@ -3,7 +3,6 @@
3
3
  import * as utils from "./utils.js";
4
4
 
5
5
  const PollHz = 8; // Made up
6
- const PollCycles = (2 * 1000 * 1000) / PollHz;
7
6
 
8
7
  function doScale(val, scale, margin) {
9
8
  val = (val - margin) / (1 - 2 * margin);
@@ -11,13 +10,14 @@ function doScale(val, scale, margin) {
11
10
  }
12
11
 
13
12
  export class TouchScreen {
14
- constructor(scheduler) {
13
+ constructor(scheduler, cyclesPerSecond) {
15
14
  this.scheduler = scheduler;
16
- this.mouse = [];
15
+ this.pollCycles = cyclesPerSecond / PollHz;
16
+ this.mouse = { x: 0, y: 0, button: 0 };
17
17
  this.outBuffer = new utils.Fifo(16);
18
18
  this.delay = 0;
19
19
  this.mode = 0;
20
- this.pollTask = this.scheduler.newTask(this.poll);
20
+ this.pollTask = this.scheduler.newTask(() => this.poll());
21
21
  }
22
22
 
23
23
  tryReceive(rts) {
@@ -47,7 +47,7 @@ export class TouchScreen {
47
47
 
48
48
  poll() {
49
49
  this.doRead();
50
- this.pollTask.reschedule(PollCycles);
50
+ this.pollTask.reschedule(this.pollCycles);
51
51
  }
52
52
 
53
53
  store(byte) {
@@ -81,6 +81,6 @@ export class TouchScreen {
81
81
  if (this.mode === 1) this.doRead();
82
82
  break;
83
83
  }
84
- this.pollTask.ensureScheduled(this.mode === 129 || this.mode === 130, PollCycles);
84
+ this.pollTask.ensureScheduled(this.mode === 129 || this.mode === 130, this.pollCycles);
85
85
  }
86
86
  }
package/src/tube.js CHANGED
@@ -36,6 +36,11 @@ const TUBE_ULA_FLAG_STATUS_PARASITE_RESET_ACTIVE_LOW = TUBE_ULA_FLAG_STATUS_P;
36
36
  const TUBE_ULA_FLAG_STATUS_CLEAR_ALL_TUBE_REGISTERS = TUBE_ULA_FLAG_STATUS_T;
37
37
  const TUBE_ULA_FLAG_STATUS_SET_CONTROL_FLAGS = TUBE_ULA_FLAG_STATUS_S;
38
38
  const TUBE_ULA_R1_PARASITE_BYTE_COUNT = 24;
39
+ // the control flags live in the bottom six bits of the register 1 status registers, which both
40
+ // sides see merged with their own flow control bits
41
+ const TUBE_ULA_CONTROL_FLAG_MASK = 0x3f;
42
+ // every status register except register 1's reads its unused bits as ones
43
+ const TUBE_ULA_STATUS_UNUSED_BITS = 0x3f;
39
44
 
40
45
  export class Tube {
41
46
  constructor(hostCpu, parasiteCpu) {
@@ -54,6 +59,7 @@ export class Tube {
54
59
  this.parasiteToHostFifoByteCount1 = 0;
55
60
  this.parasiteToHostFifoByteCount3 = 0;
56
61
  this.hostToParasiteFifoByteCount3 = 0;
62
+ this.parasiteNmi = false;
57
63
  this.debug = false;
58
64
  }
59
65
  reset(updateInternalStatusRegister = true) {
@@ -61,8 +67,10 @@ export class Tube {
61
67
  this.internalStatusRegister = 0;
62
68
  }
63
69
  for (let i = 0; i < 4; i++) {
64
- this.hostStatus[i] = TUBE_ULA_FLAG_DATA_REGISTER_NOT_FULL;
65
- this.parasiteStatus[i] = TUBE_ULA_FLAG_DATA_REGISTER_NOT_FULL;
70
+ // register 1's spare bits carry the control flags instead, merged in when either side reads it
71
+ const unused = i === TUBE_ULA_R1 ? 0 : TUBE_ULA_STATUS_UNUSED_BITS;
72
+ this.hostStatus[i] = TUBE_ULA_FLAG_DATA_REGISTER_NOT_FULL | unused;
73
+ this.parasiteStatus[i] = TUBE_ULA_FLAG_DATA_REGISTER_NOT_FULL | unused;
66
74
  if (i === TUBE_ULA_R3) {
67
75
  // register 3 has one valid but insignificant byte in the parasite to host FIFO (this is to prevent an immediate PNMI state after PRST)
68
76
  this.hostStatus[i] |= TUBE_ULA_FLAG_DATA_AVAILABLE;
@@ -73,8 +81,39 @@ export class Tube {
73
81
  // see info in the loop above from Tube Application Note about R3
74
82
  this.parasiteToHostFifoByteCount3 = 1;
75
83
  this.hostToParasiteFifoByteCount3 = 0;
84
+ this.parasiteNmi = false;
76
85
  this.updateInterrupts();
77
86
  }
87
+
88
+ /**
89
+ * The register 3 condition the ULA raises an NMI from, before the M flag gates it: the host has
90
+ * queued a whole transfer for the parasite, or the parasite has nothing queued back to the host.
91
+ *
92
+ * @returns {boolean} whether the condition currently holds
93
+ */
94
+ r3NmiCondition() {
95
+ const bytesPerTransfer = this.internalStatusRegister & TUBE_ULA_FLAG_STATUS_ENABLE_2_BYTE_R3_DATA ? 2 : 1;
96
+ return this.hostToParasiteFifoByteCount3 >= bytesPerTransfer || this.parasiteToHostFifoByteCount3 === 0;
97
+ }
98
+
99
+ /** @returns {boolean} whether that condition is currently allowed to reach the parasite's NMI pin. */
100
+ parasiteNmiEnabled() {
101
+ return !!(this.internalStatusRegister & TUBE_ULA_FLAG_STATUS_ENABLE_PARASITE_NMI_FROM_R3_DATA);
102
+ }
103
+
104
+ /** Drops the NMI request once the parasite has vectored through it. */
105
+ acknowledgeNmi() {
106
+ this.parasiteNmi = false;
107
+ this.updateInterrupts();
108
+ }
109
+
110
+ /**
111
+ * Withdraws a pending NMI whose register 3 condition has since gone away. Only the parasite's own
112
+ * accesses can do this: a transfer it has satisfied no longer needs servicing.
113
+ */
114
+ clearNmiIfSatisfied() {
115
+ if (!this.r3NmiCondition()) this.parasiteNmi = false;
116
+ }
78
117
  updateInterrupts() {
79
118
  // host IRQ
80
119
  if (
@@ -97,21 +136,11 @@ export class Tube {
97
136
  this.parasiteCpu.interrupt = false;
98
137
  }
99
138
  // parasite NMI
100
- // (from Tube Application Note)
101
- // either: M = 1, V = 0, 1 or 2 bytes in host to parasite register 3 FIFO or 0 bytes in parasite
102
- // to host register 3 FIFO (this allows single byte transfers across
103
- // register 3)
104
- // or: M = 1, V = 1, 2 bytes in host to parasite register 3 FIFO or 0 bytes in parasite to host
105
- // register 3 FIFO. (this allows two byte transfers across register 3)
106
- const r3Size = this.internalStatusRegister & TUBE_ULA_FLAG_STATUS_ENABLE_2_BYTE_R3_DATA ? 2 : 1;
107
- if (
108
- this.internalStatusRegister & TUBE_ULA_FLAG_STATUS_ENABLE_PARASITE_NMI_FROM_R3_DATA &&
109
- (this.hostToParasiteFifoByteCount3 >= r3Size || this.parasiteToHostFifoByteCount3 === 0)
110
- ) {
111
- this.parasiteCpu.NMI(true);
112
- } else {
113
- this.parasiteCpu.NMI(false);
114
- }
139
+ // The ULA latches its NMI request rather than presenting the register 3 condition as a level:
140
+ // each host access to register 3 that gives the parasite something to do raises a request, and
141
+ // only the parasite can retire one, by servicing the transfer or by taking the NMI. Recomputing
142
+ // the condition here instead would lose every request raised while an earlier one still stood.
143
+ this.parasiteCpu.NMI(this.parasiteNmi && this.parasiteNmiEnabled());
115
144
  // parasite CPU RESET held low - not implemented in the CPU - the CPU should be frozen until this signal is released
116
145
  this.parasiteCpu.resetHeldLow = this.internalStatusRegister & TUBE_ULA_FLAG_STATUS_PARASITE_RESET_ACTIVE_LOW;
117
146
  }
@@ -122,8 +151,7 @@ export class Tube {
122
151
  result =
123
152
  (this.hostStatus[TUBE_ULA_R1] &
124
153
  (TUBE_ULA_FLAG_DATA_AVAILABLE | TUBE_ULA_FLAG_DATA_REGISTER_NOT_FULL)) |
125
- (this.internalStatusRegister &
126
- ~(TUBE_ULA_FLAG_DATA_AVAILABLE | TUBE_ULA_FLAG_DATA_REGISTER_NOT_FULL));
154
+ (this.internalStatusRegister & TUBE_ULA_CONTROL_FLAG_MASK);
127
155
  break;
128
156
  case TUBE_ULA_R1_DATA_ADDRESS:
129
157
  result = this.parasiteToHostData[TUBE_ULA_R1][0];
@@ -143,8 +171,10 @@ export class Tube {
143
171
  break;
144
172
  case TUBE_ULA_R2_DATA_ADDRESS:
145
173
  result = this.parasiteToHostData[TUBE_ULA_R2][0];
146
- this.parasiteStatus[TUBE_ULA_R2] |= TUBE_ULA_FLAG_DATA_REGISTER_NOT_FULL;
147
- this.hostStatus[TUBE_ULA_R2] &= ~TUBE_ULA_FLAG_DATA_AVAILABLE;
174
+ if (this.hostStatus[TUBE_ULA_R2] & TUBE_ULA_FLAG_DATA_AVAILABLE) {
175
+ this.parasiteStatus[TUBE_ULA_R2] |= TUBE_ULA_FLAG_DATA_REGISTER_NOT_FULL;
176
+ this.hostStatus[TUBE_ULA_R2] &= ~TUBE_ULA_FLAG_DATA_AVAILABLE;
177
+ }
148
178
  break;
149
179
  case TUBE_ULA_R3_STATUS_ADDRESS:
150
180
  result = this.hostStatus[TUBE_ULA_R3];
@@ -157,6 +187,8 @@ export class Tube {
157
187
  this.parasiteToHostFifoByteCount3--;
158
188
  if (this.parasiteToHostFifoByteCount3 === 0) {
159
189
  this.hostStatus[TUBE_ULA_R3] &= ~TUBE_ULA_FLAG_DATA_AVAILABLE;
190
+ // the parasite owes the host a byte, so ask it for one
191
+ this.parasiteNmi = true;
160
192
  }
161
193
  }
162
194
  break;
@@ -165,8 +197,10 @@ export class Tube {
165
197
  break;
166
198
  case TUBE_ULA_R4_DATA_ADDRESS:
167
199
  result = this.parasiteToHostData[TUBE_ULA_R4][0];
168
- this.parasiteStatus[TUBE_ULA_R4] |= TUBE_ULA_FLAG_DATA_REGISTER_NOT_FULL;
169
- this.hostStatus[TUBE_ULA_R4] &= ~TUBE_ULA_FLAG_DATA_AVAILABLE;
200
+ if (this.hostStatus[TUBE_ULA_R4] & TUBE_ULA_FLAG_DATA_AVAILABLE) {
201
+ this.parasiteStatus[TUBE_ULA_R4] |= TUBE_ULA_FLAG_DATA_REGISTER_NOT_FULL;
202
+ this.hostStatus[TUBE_ULA_R4] &= ~TUBE_ULA_FLAG_DATA_AVAILABLE;
203
+ }
170
204
  break;
171
205
  }
172
206
  this.updateInterrupts();
@@ -180,14 +214,13 @@ export class Tube {
180
214
  console.log("TUBE ULA: host write " + utils.hexword(address) + " = " + utils.hexbyte(value));
181
215
  }
182
216
  switch (address & 7) {
183
- case TUBE_ULA_R1_STATUS_ADDRESS:
217
+ case TUBE_ULA_R1_STATUS_ADDRESS: {
218
+ // M and V both feed the NMI, so a write here can raise or drop the request on its own
219
+ const nmiBefore = this.parasiteNmiEnabled() && this.r3NmiCondition();
184
220
  if (value & TUBE_ULA_FLAG_STATUS_SET_CONTROL_FLAGS) {
185
- this.internalStatusRegister |=
186
- value & ~(TUBE_ULA_FLAG_DATA_AVAILABLE | TUBE_ULA_FLAG_DATA_REGISTER_NOT_FULL);
221
+ this.internalStatusRegister |= value & TUBE_ULA_CONTROL_FLAG_MASK;
187
222
  } else {
188
- this.internalStatusRegister &= ~(
189
- value & ~(TUBE_ULA_FLAG_DATA_AVAILABLE | TUBE_ULA_FLAG_DATA_REGISTER_NOT_FULL)
190
- );
223
+ this.internalStatusRegister &= ~(value & TUBE_ULA_CONTROL_FLAG_MASK);
191
224
  }
192
225
  if (value & TUBE_ULA_FLAG_STATUS_CLEAR_ALL_TUBE_REGISTERS) {
193
226
  this.reset(false);
@@ -200,7 +233,11 @@ export class Tube {
200
233
  this.parasiteCpu.reset(true); // this in turn calls our this.reset(true)
201
234
  }
202
235
  }
236
+ const nmiAfter = this.parasiteNmiEnabled() && this.r3NmiCondition();
237
+ if (!nmiBefore && nmiAfter) this.parasiteNmi = true;
238
+ if (!nmiAfter) this.parasiteNmi = false;
203
239
  break;
240
+ }
204
241
  case TUBE_ULA_R1_DATA_ADDRESS:
205
242
  if (this.hostStatus[TUBE_ULA_R1] & TUBE_ULA_FLAG_DATA_REGISTER_NOT_FULL) {
206
243
  this.hostToParasiteData[TUBE_ULA_R1][0] = value;
@@ -224,12 +261,15 @@ export class Tube {
224
261
  if (this.hostToParasiteFifoByteCount3 === 2) {
225
262
  this.parasiteStatus[TUBE_ULA_R3] |= TUBE_ULA_FLAG_DATA_AVAILABLE;
226
263
  this.hostStatus[TUBE_ULA_R3] &= ~TUBE_ULA_FLAG_DATA_REGISTER_NOT_FULL;
264
+ // a whole transfer is waiting, so ask the parasite to take it
265
+ this.parasiteNmi = true;
227
266
  }
228
267
  } else {
229
268
  this.hostToParasiteData[TUBE_ULA_R3][0] = value;
230
269
  this.hostToParasiteFifoByteCount3 = 1;
231
270
  this.parasiteStatus[TUBE_ULA_R3] |= TUBE_ULA_FLAG_DATA_AVAILABLE;
232
271
  this.hostStatus[TUBE_ULA_R3] &= ~TUBE_ULA_FLAG_DATA_REGISTER_NOT_FULL;
272
+ this.parasiteNmi = true;
233
273
  }
234
274
  }
235
275
  break;
@@ -250,20 +290,24 @@ export class Tube {
250
290
  let result = 0;
251
291
  switch (address & 7) {
252
292
  case TUBE_ULA_R1_STATUS_ADDRESS:
253
- result = this.parasiteStatus[TUBE_ULA_R1];
293
+ result = this.parasiteStatus[TUBE_ULA_R1] | (this.internalStatusRegister & TUBE_ULA_CONTROL_FLAG_MASK);
254
294
  break;
255
295
  case TUBE_ULA_R1_DATA_ADDRESS:
256
296
  result = this.hostToParasiteData[TUBE_ULA_R1][0];
257
- this.hostStatus[TUBE_ULA_R1] |= TUBE_ULA_FLAG_DATA_REGISTER_NOT_FULL;
258
- this.parasiteStatus[TUBE_ULA_R1] &= ~TUBE_ULA_FLAG_DATA_AVAILABLE;
297
+ if (this.parasiteStatus[TUBE_ULA_R1] & TUBE_ULA_FLAG_DATA_AVAILABLE) {
298
+ this.hostStatus[TUBE_ULA_R1] |= TUBE_ULA_FLAG_DATA_REGISTER_NOT_FULL;
299
+ this.parasiteStatus[TUBE_ULA_R1] &= ~TUBE_ULA_FLAG_DATA_AVAILABLE;
300
+ }
259
301
  break;
260
302
  case TUBE_ULA_R2_STATUS_ADDRESS:
261
303
  result = this.parasiteStatus[TUBE_ULA_R2];
262
304
  break;
263
305
  case TUBE_ULA_R2_DATA_ADDRESS:
264
306
  result = this.hostToParasiteData[TUBE_ULA_R2][0];
265
- this.hostStatus[TUBE_ULA_R2] |= TUBE_ULA_FLAG_DATA_REGISTER_NOT_FULL;
266
- this.parasiteStatus[TUBE_ULA_R2] &= ~TUBE_ULA_FLAG_DATA_AVAILABLE;
307
+ if (this.parasiteStatus[TUBE_ULA_R2] & TUBE_ULA_FLAG_DATA_AVAILABLE) {
308
+ this.hostStatus[TUBE_ULA_R2] |= TUBE_ULA_FLAG_DATA_REGISTER_NOT_FULL;
309
+ this.parasiteStatus[TUBE_ULA_R2] &= ~TUBE_ULA_FLAG_DATA_AVAILABLE;
310
+ }
267
311
  break;
268
312
  case TUBE_ULA_R3_STATUS_ADDRESS:
269
313
  result = this.parasiteStatus[TUBE_ULA_R3];
@@ -277,6 +321,7 @@ export class Tube {
277
321
  this.hostStatus[TUBE_ULA_R3] |= TUBE_ULA_FLAG_DATA_REGISTER_NOT_FULL;
278
322
  this.parasiteStatus[TUBE_ULA_R3] &= ~TUBE_ULA_FLAG_DATA_AVAILABLE;
279
323
  }
324
+ this.clearNmiIfSatisfied();
280
325
  }
281
326
  break;
282
327
  case TUBE_ULA_R4_STATUS_ADDRESS:
@@ -284,8 +329,10 @@ export class Tube {
284
329
  break;
285
330
  case TUBE_ULA_R4_DATA_ADDRESS:
286
331
  result = this.hostToParasiteData[TUBE_ULA_R4][0];
287
- this.hostStatus[TUBE_ULA_R4] |= TUBE_ULA_FLAG_DATA_REGISTER_NOT_FULL;
288
- this.parasiteStatus[TUBE_ULA_R4] &= ~TUBE_ULA_FLAG_DATA_AVAILABLE;
332
+ if (this.parasiteStatus[TUBE_ULA_R4] & TUBE_ULA_FLAG_DATA_AVAILABLE) {
333
+ this.hostStatus[TUBE_ULA_R4] |= TUBE_ULA_FLAG_DATA_REGISTER_NOT_FULL;
334
+ this.parasiteStatus[TUBE_ULA_R4] &= ~TUBE_ULA_FLAG_DATA_AVAILABLE;
335
+ }
289
336
  break;
290
337
  }
291
338
  this.updateInterrupts();
@@ -304,13 +351,15 @@ export class Tube {
304
351
  parasiteToHostFifoByteCount1: this.parasiteToHostFifoByteCount1,
305
352
  parasiteToHostFifoByteCount3: this.parasiteToHostFifoByteCount3,
306
353
  hostToParasiteFifoByteCount3: this.hostToParasiteFifoByteCount3,
354
+ parasiteNmi: this.parasiteNmi,
307
355
  };
308
356
  }
309
357
 
310
358
  /**
311
359
  * The interrupt and reset lines are not saved: they are derived from the status registers
312
360
  * and FIFO counts, in the same way the host's `interrupt` is rebuilt by the VIA and ACIA
313
- * restores.
361
+ * restores. The latched NMI request is saved, as nothing else records whether the parasite
362
+ * still owes the host a transfer.
314
363
  */
315
364
  restoreState(state) {
316
365
  this.internalStatusRegister = state.internalStatusRegister;
@@ -323,6 +372,8 @@ export class Tube {
323
372
  this.parasiteToHostFifoByteCount1 = state.parasiteToHostFifoByteCount1;
324
373
  this.parasiteToHostFifoByteCount3 = state.parasiteToHostFifoByteCount3;
325
374
  this.hostToParasiteFifoByteCount3 = state.hostToParasiteFifoByteCount3;
375
+ // snapshots taken before the ULA latched its NMI have to fall back on the condition itself
376
+ this.parasiteNmi = state.parasiteNmi ?? this.r3NmiCondition();
326
377
  this.updateInterrupts();
327
378
  }
328
379
 
@@ -366,6 +417,7 @@ export class Tube {
366
417
  this.hostStatus[TUBE_ULA_R3] |= TUBE_ULA_FLAG_DATA_AVAILABLE;
367
418
  this.parasiteStatus[TUBE_ULA_R3] &= ~TUBE_ULA_FLAG_DATA_REGISTER_NOT_FULL;
368
419
  }
420
+ this.clearNmiIfSatisfied();
369
421
  }
370
422
  break;
371
423
  case TUBE_ULA_R4_DATA_ADDRESS:
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
@@ -1216,7 +1216,10 @@ const knownDiscExtensions = {
1216
1216
  uef: true,
1217
1217
  ssd: true,
1218
1218
  dsd: true,
1219
+ adf: true,
1220
+ adm: true,
1219
1221
  adl: true,
1222
+ hfe: true,
1220
1223
  };
1221
1224
 
1222
1225
  const knownRomExtensions = {