jsbeeb 1.15.0 → 1.16.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/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,7 +50,7 @@ export class StairwayToHell {
47
50
  }
48
51
 
49
52
  async fetch(file) {
50
- const name = this._baseUrl + file;
53
+ const name = this._baseUrl + encodePath(file);
51
54
  console.log("Loading ZIP from " + name);
52
55
  const response = await fetch(name);
53
56
  if (!response.ok) throw new Error("Network response was not ok");
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;
@@ -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:
@@ -14,12 +14,9 @@
14
14
 
15
15
  import VERT_SHADER from "./shaders/pal-composite.vert.glsl?raw";
16
16
  import FRAG_SHADER from "./shaders/pal-composite.frag.glsl?raw";
17
+ import { compileProgram } from "./shader-program.js";
17
18
 
18
19
  export class PALCompositeFilter {
19
- static requiresGl() {
20
- return true;
21
- }
22
-
23
20
  static getDisplayConfig() {
24
21
  return {
25
22
  name: "PAL TV",
@@ -31,57 +28,26 @@ export class PALCompositeFilter {
31
28
  canvasTop: 70,
32
29
  visibleWidth: 800,
33
30
  visibleHeight: 600,
31
+ canvasWidth: 896,
32
+ canvasHeight: 600,
34
33
  };
35
34
  }
36
35
 
37
36
  constructor(gl) {
38
37
  this.gl = gl;
39
- this.program = null;
40
- this.locations = {};
41
-
42
- this._init();
43
- }
44
-
45
- _init() {
46
- const gl = this.gl;
47
-
48
- // Compile shaders
49
- const vertShader = this._compileShader(gl.VERTEX_SHADER, VERT_SHADER);
50
- const fragShader = this._compileShader(gl.FRAGMENT_SHADER, FRAG_SHADER);
51
-
52
- // Link program
53
- this.program = gl.createProgram();
54
- gl.attachShader(this.program, vertShader);
55
- gl.attachShader(this.program, fragShader);
56
- gl.linkProgram(this.program);
57
-
58
- if (!gl.getProgramParameter(this.program, gl.LINK_STATUS)) {
59
- const info = gl.getProgramInfoLog(this.program);
60
- throw new Error("Failed to link PAL shader program: " + info);
61
- }
62
-
63
- // Get uniform locations
64
- this.locations.uFramebuffer = gl.getUniformLocation(this.program, "uFramebuffer");
65
- this.locations.uResolution = gl.getUniformLocation(this.program, "uResolution");
66
- this.locations.uTexelSize = gl.getUniformLocation(this.program, "uTexelSize");
67
- this.locations.uFrameCount = gl.getUniformLocation(this.program, "uFrameCount");
68
-
69
- console.log("PAL composite filter initialized");
38
+ this.program = compileProgram(gl, VERT_SHADER, FRAG_SHADER, "PAL composite");
39
+ this.locations = {
40
+ uFramebuffer: gl.getUniformLocation(this.program, "uFramebuffer"),
41
+ uResolution: gl.getUniformLocation(this.program, "uResolution"),
42
+ uTexelSize: gl.getUniformLocation(this.program, "uTexelSize"),
43
+ uFrameCount: gl.getUniformLocation(this.program, "uFrameCount"),
44
+ };
70
45
  }
71
46
 
72
- _compileShader(type, source) {
73
- const gl = this.gl;
74
- const shader = gl.createShader(type);
75
- gl.shaderSource(shader, source);
76
- gl.compileShader(shader);
77
-
78
- if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
79
- const info = gl.getShaderInfoLog(shader);
80
- const typeName = type === gl.VERTEX_SHADER ? "vertex" : "fragment";
81
- throw new Error(`Failed to compile ${typeName} shader: ${info}`);
82
- }
83
-
84
- return shader;
47
+ /** Release the GL objects this filter owns. */
48
+ dispose() {
49
+ this.gl.deleteProgram(this.program);
50
+ this.program = null;
85
51
  }
86
52
 
87
53
  setUniforms(params) {
@@ -2,12 +2,9 @@
2
2
 
3
3
  import VERT_SHADER from "./shaders/passthrough.vert.glsl?raw";
4
4
  import FRAG_SHADER from "./shaders/passthrough.frag.glsl?raw";
5
+ import { compileProgram } from "./shader-program.js";
5
6
 
6
7
  export class PassthroughFilter {
7
- static requiresGl() {
8
- return false;
9
- }
10
-
11
8
  static getDisplayConfig() {
12
9
  return {
13
10
  name: "RGB Monitor",
@@ -19,48 +16,23 @@ export class PassthroughFilter {
19
16
  canvasTop: 8,
20
17
  visibleWidth: 896,
21
18
  visibleHeight: 600,
19
+ canvasWidth: 896,
20
+ canvasHeight: 600,
22
21
  };
23
22
  }
24
23
 
25
24
  constructor(gl) {
26
25
  this.gl = gl;
27
- this.program = null;
28
- this.locations = {};
29
-
30
- this._init();
31
- }
32
-
33
- _init() {
34
- const gl = this.gl;
35
-
36
- const vertexShader = this._compileShader(gl.VERTEX_SHADER, VERT_SHADER);
37
- const fragmentShader = this._compileShader(gl.FRAGMENT_SHADER, FRAG_SHADER);
38
-
39
- this.program = gl.createProgram();
40
- gl.attachShader(this.program, vertexShader);
41
- gl.attachShader(this.program, fragmentShader);
42
- gl.linkProgram(this.program);
43
-
44
- if (!gl.getProgramParameter(this.program, gl.LINK_STATUS)) {
45
- throw new Error("Failed to link passthrough shader program: " + gl.getProgramInfoLog(this.program));
46
- }
47
-
48
- this.locations.tex = gl.getUniformLocation(this.program, "tex");
26
+ this.program = compileProgram(gl, VERT_SHADER, FRAG_SHADER, "passthrough");
27
+ this.locations = {
28
+ tex: gl.getUniformLocation(this.program, "tex"),
29
+ };
49
30
  }
50
31
 
51
- _compileShader(type, source) {
52
- const gl = this.gl;
53
- const shader = gl.createShader(type);
54
- gl.shaderSource(shader, source);
55
- gl.compileShader(shader);
56
-
57
- if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
58
- const error = gl.getShaderInfoLog(shader);
59
- gl.deleteShader(shader);
60
- throw new Error("Shader compilation failed: " + error);
61
- }
62
-
63
- return shader;
32
+ /** Release the GL objects this filter owns. */
33
+ dispose() {
34
+ this.gl.deleteProgram(this.program);
35
+ this.program = null;
64
36
  }
65
37
 
66
38
  setUniforms(_params) {
@@ -0,0 +1,55 @@
1
+ "use strict";
2
+
3
+ // Describing the logical pixel grid of jsbeeb's framebuffer, which is a
4
+ // 1024-wide *raster* rather than a grid of BBC pixels: one logical pixel covers
5
+ // up to eight texels across and two down. Filters need to know that, and only
6
+ // the video chips do, so they record one descriptor byte per row and this
7
+ // module owns the encoding.
8
+ //
9
+ // See docs/xbr-display-mode.md for why an upscaler that samples raw texels
10
+ // achieves nothing at all.
11
+
12
+ /**
13
+ * Rows in the line grid. The framebuffer is 625 rows, but the GL texture it is
14
+ * uploaded into is 1024 square, and the shader indexes the grid with the same
15
+ * coordinate it uses for the picture — so the two must agree.
16
+ */
17
+ export const LineGridRows = 1024;
18
+
19
+ // Row descriptor bits, as stored in `Video.lineGrid`. The width is held less
20
+ // one, so it needs no logarithm to write and no exponential to read, and any
21
+ // width from one to eight can be described — the 6847 uses widths the BBC's ULA
22
+ // never selects.
23
+ const LineGridRendered = 0x80;
24
+ const LineGridVerticalDouble = 0x08;
25
+ const LineGridWidthMask = 0x07;
26
+
27
+ /**
28
+ * How many framebuffer texels wide one logical pixel is, given the ULA's
29
+ * colour-select bits. The ULA writes 8 pixels per byte at `ulaMode` 3 and
30
+ * halves the count for each step down, which `Video.table4bpp` implements by
31
+ * indexing its 1bpp entries with `i >> (3 - ulaMode)`. This holds for the 1MHz
32
+ * modes too: `pixelsPerChar` is 16 there rather than 8, and the same shift over
33
+ * twice as many texels gives the same width — MODE 4 is two, MODE 5 is four.
34
+ *
35
+ * @param {number} ulaMode 0..3, the ULA control register's colour bits
36
+ * @returns {number} 1, 2, 4 or 8; the standard modes use 1, 2 and 4
37
+ */
38
+ export function texelsPerPixel(ulaMode) {
39
+ return 8 >> ulaMode;
40
+ }
41
+
42
+ /**
43
+ * Pack a row's grid description into the byte the video chips store.
44
+ *
45
+ * @param {number} texelsWide 1 to 8 (see {@link texelsPerPixel})
46
+ * @param {boolean} doubledLines whether this scanline was written to two rows
47
+ */
48
+ export function encodeLineGrid(texelsWide, doubledLines) {
49
+ // A width of 9 would set the doubling bit and read back as a doubled width
50
+ // of 1 — a silent lie rather than an error. The widths come from mode-table
51
+ // arithmetic, so check.
52
+ if (texelsWide < 1 || texelsWide > LineGridWidthMask + 1)
53
+ throw new Error(`Logical pixel width ${texelsWide} cannot be described in a line grid descriptor`);
54
+ return LineGridRendered | (texelsWide - 1) | (doubledLines ? LineGridVerticalDouble : 0);
55
+ }