jsbeeb 1.17.1 → 1.19.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/main.js CHANGED
@@ -12,7 +12,7 @@ import * as utils_atom from "./utils_atom.js";
12
12
  import { LoadSD } from "./mmc.js";
13
13
  import { Cmos, localStoragePersistence } from "./cmos.js";
14
14
  import { StairwayToHell } from "./sth.js";
15
- import { BbcDiscArchive, describe as describeHfe } from "./bbcdiscs.js";
15
+ import { BbcDiscArchive, Provenance, describe as describeHfe, matches, provenancesIn } from "./bbcdiscs.js";
16
16
  import { GamePad } from "./gamepads.js";
17
17
  import * as disc from "./fdc.js";
18
18
  import { loadTapeFromData } from "./tapes.js";
@@ -374,6 +374,10 @@ let tryGl = true;
374
374
  if (parsedQuery.glEnabled !== undefined) {
375
375
  tryGl = parsedQuery.glEnabled === "true";
376
376
  }
377
+ let lowLatency = true;
378
+ if (parsedQuery.lowLatency !== undefined) {
379
+ lowLatency = parsedQuery.lowLatency === "true";
380
+ }
377
381
  const screenCanvas = document.getElementById("screen");
378
382
 
379
383
  const errorDialog = document.getElementById("error-dialog");
@@ -397,6 +401,24 @@ function showError(context, error) {
397
401
 
398
402
  const errorText = (error) => error?.message ?? `${error}`;
399
403
 
404
+ function reportLoadFailure(description, error) {
405
+ console.error(`Error loading ${description}:`, error);
406
+ toast(`Could not load ${description}: ${errorText(error)}`, { title: "Loading" });
407
+ }
408
+
409
+ function reportIgnoredFiles(name, ignored) {
410
+ if (!ignored.length) return;
411
+ toast(`Loaded ${name}. The archive also holds ${ignored.join(", ")}, and only one file is loaded from it.`, {
412
+ title: "Archive",
413
+ });
414
+ }
415
+
416
+ async function unzipAndReport(data) {
417
+ const unzipped = await utils.unzipDiscImage(data);
418
+ reportIgnoredFiles(unzipped.name, unzipped.ignored);
419
+ return unzipped;
420
+ }
421
+
400
422
  function showNotice(event) {
401
423
  const { message, title, quietKey } = event.detail;
402
424
  toast(message, { title, quietKey });
@@ -471,59 +493,59 @@ function noteDriveTracks(driveIndex, discName) {
471
493
  });
472
494
  }
473
495
 
474
- function createCanvasForFilter(filterClass) {
496
+ // Test which filter is actually in use, not merely whether we got WebGL: a
497
+ // filter can decline a context that works perfectly well for other modes, in
498
+ // which case we are quietly left with an unfiltered display.
499
+ function reportAnyFallback(displayCanvas, filterClass) {
500
+ if (displayCanvas.filterClass === filterClass) return;
501
+ const reason = displayCanvas.fallbackReason ? ` (${displayCanvas.fallbackReason})` : "";
502
+ const { name } = filterClass.getDisplayConfig();
503
+ toast(`${name} is not available on this device, so the standard display is in use${reason}.`, {
504
+ title: "Display",
505
+ quietKey: "quietDisplayFallback",
506
+ });
507
+ }
508
+
509
+ function sizeCanvasFor(filterClass) {
475
510
  // Not `config`: that is the emulator's live configuration object, declared
476
511
  // at module scope and used throughout this file.
477
512
  const displayConfig = filterClass.getDisplayConfig();
478
- // Each mode says how many pixels it wants to draw into. Set this before
479
- // creating the context, which fixes its initial viewport.
513
+ if (screenCanvas.width === displayConfig.canvasWidth && screenCanvas.height === displayConfig.canvasHeight) return;
480
514
  screenCanvas.width = displayConfig.canvasWidth;
481
515
  screenCanvas.height = displayConfig.canvasHeight;
516
+ }
482
517
 
483
- const newCanvas = tryGl ? canvasLib.bestCanvas(screenCanvas, filterClass) : new canvasLib.Canvas(screenCanvas);
484
-
485
- // Test which filter was actually built, not merely whether we got WebGL: a
486
- // filter can decline a context that works perfectly well for other modes,
487
- // in which case bestCanvas quietly gives us an unfiltered GL canvas.
488
- if (newCanvas.filterClass !== filterClass) {
489
- const reason = newCanvas.fallbackReason ? ` (${newCanvas.fallbackReason})` : "";
490
- toast(`${displayConfig.name} is not available on this device, so the standard display is in use${reason}.`, {
491
- title: "Display",
492
- quietKey: "quietDisplayFallback",
493
- });
494
- }
518
+ function createCanvasForFilter(filterClass) {
519
+ // Each mode says how many pixels it wants to draw into. Set this before
520
+ // creating the context, which fixes its initial viewport.
521
+ sizeCanvasFor(filterClass);
495
522
 
523
+ const newCanvas = tryGl
524
+ ? canvasLib.bestCanvas(screenCanvas, filterClass, lowLatency)
525
+ : new canvasLib.Canvas(screenCanvas, lowLatency);
526
+ reportAnyFallback(newCanvas, filterClass);
496
527
  return newCanvas;
497
528
  }
498
529
 
499
530
  let displayModeFilter = canvasLib.getFilterForMode(parsedQuery.displayMode || "rgb");
500
531
  function swapCanvas(newFilterClass) {
501
- const oldCanvas = canvas;
502
- const newCanvas = createCanvasForFilter(newFilterClass);
503
- // Carry the picture over; the buffers differ in height, so copy what fits.
504
- newCanvas.fb32.set(oldCanvas.fb32.subarray(0, newCanvas.fb32.length));
505
- // Only once the replacement exists, so a failure to build it leaves the
506
- // display we already had. The two share a GL context but no GL objects.
507
- oldCanvas.dispose();
508
- video.fb32 = newCanvas.fb32;
509
- video.paint_ext = function paint(minx, miny, maxx, maxy) {
510
- frames++;
511
- if (frames < frameSkip) return;
512
- frames = 0;
513
- newCanvas.paint(minx, miny, maxx, maxy, { frameCount: this.frameCount, lineGrid: this.lineGrid });
514
- };
515
- canvas = newCanvas;
532
+ // Everything but the filter is the same whatever the mode: the framebuffer
533
+ // texture, the vertex buffers and fb32 all carry over untouched.
534
+ canvasLib.useBestFilter(canvas, newFilterClass);
535
+ reportAnyFallback(canvas, newFilterClass);
516
536
  // Follow the filter we ended up with, not the one we asked for: everything
517
- // downstream the monitor picture, the canvas geometry, how large a
518
- // drawing buffer to ask for comes from its display config.
519
- displayModeFilter = newCanvas.filterClass;
537
+ // downstream (the monitor picture, the canvas geometry, how large a drawing
538
+ // buffer to ask for) comes from its display config.
539
+ displayModeFilter = canvas.filterClass;
540
+ // Back to the mode's own size, undoing any scaling the last one asked for.
541
+ sizeCanvasFor(displayModeFilter);
520
542
  // Nothing else will redraw: the mode is changed from a modal, which stops
521
543
  // the emulator.
522
544
  video.paint();
523
545
  window.setTimeout(() => window.dispatchEvent(new Event("resize")), 1);
524
546
  }
525
547
 
526
- let canvas = createCanvasForFilter(displayModeFilter);
548
+ const canvas = createCanvasForFilter(displayModeFilter);
527
549
  displayModeFilter = canvas.filterClass;
528
550
 
529
551
  video = new Video(
@@ -641,14 +663,19 @@ pastetext.addEventListener("dragover", function (event) {
641
663
  pastetext.addEventListener("drop", async function (event) {
642
664
  utils.noteEvent("local", "drop");
643
665
  const file = event.dataTransfer.files[0];
644
- const arrayBuffer = await file.arrayBuffer();
645
- if (isSnapshotFile(file.name, arrayBuffer)) {
646
- await loadStateFromFile(file, arrayBuffer);
647
- } else if (file.name.toLowerCase().endsWith(".uef")) {
648
- // Regular UEF tape image (not a BeebEm save state)
649
- setProcessorTape(await loadTapeFromData(file.name, new Uint8Array(arrayBuffer), model));
650
- } else {
651
- await loadHTMLFile(file);
666
+ if (!file) return;
667
+ try {
668
+ const arrayBuffer = await file.arrayBuffer();
669
+ if (isSnapshotFile(file.name, arrayBuffer)) {
670
+ await loadStateFromFile(file, arrayBuffer);
671
+ } else if (file.name.toLowerCase().endsWith(".uef")) {
672
+ // Regular UEF tape image (not a BeebEm save state)
673
+ setProcessorTape(await loadTapeFromData(file.name, new Uint8Array(arrayBuffer), model));
674
+ } else {
675
+ await loadHTMLFile(file);
676
+ }
677
+ } catch (error) {
678
+ reportLoadFailure(file.name, error);
652
679
  }
653
680
  });
654
681
 
@@ -1211,13 +1238,15 @@ function hfeOnCat(catalogue) {
1211
1238
  const list = document.getElementById("hfe-list");
1212
1239
  document.querySelector("#hfe .loading").style.display = "none";
1213
1240
  const template = list.querySelector(".template");
1241
+ showProvenanceChoices(catalogue, onHfeFilter);
1214
1242
 
1215
1243
  const addSome = (remaining) => {
1216
1244
  if (ticket !== hfeRender) return;
1217
1245
  const MaxAtATime = 100;
1218
1246
  const Delay = 30;
1219
- // Read per batch: the filter can be typed into while this is still going.
1247
+ // Read per batch: both can be changed while this is still going.
1220
1248
  const filter = document.getElementById("hfe-filter").value.toLowerCase();
1249
+ const shown = shownProvenances();
1221
1250
  for (const file of remaining.slice(0, MaxAtATime)) {
1222
1251
  const { title, publisher, detail } = describeHfe(file);
1223
1252
  const row = template.cloneNode(true);
@@ -1225,6 +1254,8 @@ function hfeOnCat(catalogue) {
1225
1254
  row.querySelector(".name").textContent = title;
1226
1255
  row.querySelector(".publisher").textContent = publisher;
1227
1256
  row.querySelector(".detail").textContent = detail;
1257
+ row.querySelector(".provenance").textContent =
1258
+ file.provenance === Provenance.Reconstructed ? "reconstructed" : "";
1228
1259
  if (file.notes) row.title = file.notes;
1229
1260
  // The row is an anchor, and letting it navigate to "#" would push a
1230
1261
  // history entry of its own on top of the one updateUrl pushes.
@@ -1233,8 +1264,9 @@ function hfeOnCat(catalogue) {
1233
1264
  hfeClick(file);
1234
1265
  $hfeModal.hide();
1235
1266
  });
1236
- row.style.display = row.textContent.toLowerCase().includes(filter) ? "" : "none";
1267
+ row.hfeFile = file;
1237
1268
  list.appendChild(row);
1269
+ showHfeRow(row, file, filter, shown);
1238
1270
  }
1239
1271
  if (remaining.length > MaxAtATime) setTimeout(() => addSome(remaining.slice(MaxAtATime)), Delay);
1240
1272
  };
@@ -1248,7 +1280,53 @@ document.getElementById("hfe").addEventListener("shown.bs.modal", () => {
1248
1280
  document.getElementById("hfe").addEventListener("show.bs.modal", () => hfeArchive.populate());
1249
1281
 
1250
1282
  const hfeFilter = document.getElementById("hfe-filter");
1251
- const onHfeFilter = () => filterArchiveList("hfe-list", hfeFilter.value);
1283
+ const hfeProvenance = document.getElementById("hfe-provenance");
1284
+
1285
+ const HfeProvenanceLabels = {
1286
+ [Provenance.Captured]: ["Captured", "Direct from disc"],
1287
+ [Provenance.Reconstructed]: ["Reconstructed", "Rebuilt from a sector dump"],
1288
+ };
1289
+
1290
+ /** Which provenances the picker is showing, or null when it is not offering the choice. */
1291
+ const shownProvenances = () => {
1292
+ const boxes = [...hfeProvenance.querySelectorAll("input")];
1293
+ return boxes.length ? new Set(boxes.filter((box) => box.checked).map((box) => box.value)) : null;
1294
+ };
1295
+
1296
+ // Offer one tick per provenance the archive actually holds, rather than naming
1297
+ // them here: a source added later should appear without this having to change.
1298
+ function showProvenanceChoices(catalogue, onChange) {
1299
+ const present = provenancesIn(catalogue);
1300
+ // Nothing to choose between: no ticks, and shownProvenances says "all".
1301
+ if (present.length < 2) {
1302
+ hfeProvenance.replaceChildren();
1303
+ return;
1304
+ }
1305
+ const wasShown = shownProvenances();
1306
+ hfeProvenance.replaceChildren(
1307
+ ...present.map((provenance) => {
1308
+ const [text, why] = HfeProvenanceLabels[provenance] ?? [provenance, ""];
1309
+ const label = document.createElement("label");
1310
+ label.title = why;
1311
+ const box = document.createElement("input");
1312
+ box.type = "checkbox";
1313
+ box.value = provenance;
1314
+ box.checked = !wasShown || wasShown.has(provenance);
1315
+ box.addEventListener("change", onChange);
1316
+ label.append(box, text);
1317
+ return label;
1318
+ }),
1319
+ );
1320
+ }
1321
+
1322
+ const showHfeRow = (row, file, filter, shown) => (row.style.display = matches(file, filter, shown) ? "" : "none");
1323
+
1324
+ const onHfeFilter = () => {
1325
+ const filter = hfeFilter.value.toLowerCase();
1326
+ const shown = shownProvenances();
1327
+ for (const row of document.querySelectorAll("#hfe-list li:not(.template)"))
1328
+ showHfeRow(row, row.hfeFile, filter, shown);
1329
+ };
1252
1330
  hfeFilter.addEventListener("change", onHfeFilter);
1253
1331
  hfeFilter.addEventListener("keyup", onHfeFilter);
1254
1332
 
@@ -1385,7 +1463,8 @@ async function loadDiscImage(discImage, layout = DiscLayout.auto) {
1385
1463
  switch (schema) {
1386
1464
  case "|":
1387
1465
  case "sth": {
1388
- const { name, data } = await discSth.fetch(discImage);
1466
+ const { name, data, ignored } = await discSth.fetch(discImage);
1467
+ reportIgnoredFiles(name, ignored);
1389
1468
  return disc.discFor(processor.fdc, name, data, undefined, layout);
1390
1469
  }
1391
1470
 
@@ -1406,7 +1485,7 @@ async function loadDiscImage(discImage, layout = DiscLayout.auto) {
1406
1485
 
1407
1486
  case "data": {
1408
1487
  const arr = Array.prototype.map.call(atob(discImage), (x) => x.charCodeAt(0));
1409
- const { name, data } = await utils.unzipDiscImage(arr);
1488
+ const { name, data } = await unzipAndReport(arr);
1410
1489
  return disc.discFor(processor.fdc, name, data, undefined, layout);
1411
1490
  }
1412
1491
  case "http":
@@ -1417,7 +1496,7 @@ async function loadDiscImage(discImage, layout = DiscLayout.auto) {
1417
1496
  discImage = new URL(asUrl).pathname;
1418
1497
  let discData = await utils.loadData(asUrl);
1419
1498
  if (/\.zip/i.test(discImage)) {
1420
- const unzipped = await utils.unzipDiscImage(discData);
1499
+ const unzipped = await unzipAndReport(discData);
1421
1500
  discData = unzipped.data;
1422
1501
  discImage = unzipped.name;
1423
1502
  }
@@ -1436,13 +1515,14 @@ async function loadTapeImage(tapeImage) {
1436
1515
  switch (schema) {
1437
1516
  case "|":
1438
1517
  case "sth": {
1439
- const { name, data } = await tapeSth.fetch(tapeImage);
1518
+ const { name, data, ignored } = await tapeSth.fetch(tapeImage);
1519
+ reportIgnoredFiles(name, ignored);
1440
1520
  return await loadTapeFromData(name, data, model);
1441
1521
  }
1442
1522
 
1443
1523
  case "data": {
1444
1524
  const arr = Array.prototype.map.call(atob(tapeImage), (x) => x.charCodeAt(0));
1445
- const { name, data } = await utils.unzipDiscImage(arr);
1525
+ const { name, data } = await unzipAndReport(arr);
1446
1526
  return await loadTapeFromData(name, data, model);
1447
1527
  }
1448
1528
 
@@ -1454,7 +1534,7 @@ async function loadTapeImage(tapeImage) {
1454
1534
  tapeImage = new URL(asUrl).pathname;
1455
1535
  let tapeData = await utils.loadData(asUrl);
1456
1536
  if (/\.zip/i.test(tapeImage)) {
1457
- const unzipped = await utils.unzipDiscImage(tapeData);
1537
+ const unzipped = await unzipAndReport(tapeData);
1458
1538
  tapeData = unzipped.data;
1459
1539
  tapeImage = unzipped.name;
1460
1540
  }
@@ -1466,7 +1546,7 @@ async function loadTapeImage(tapeImage) {
1466
1546
  let tapeData = await utils.loadData(tapePath);
1467
1547
  let tapeName = tapeImage;
1468
1548
  if (/\.zip/i.test(tapeName)) {
1469
- const unzipped = await utils.unzipDiscImage(tapeData);
1549
+ const unzipped = await unzipAndReport(tapeData);
1470
1550
  tapeData = unzipped.data;
1471
1551
  tapeName = unzipped.name;
1472
1552
  }
@@ -1479,7 +1559,11 @@ document.getElementById("disc_load").addEventListener("change", async function (
1479
1559
  if (evt.target.files.length === 0) return;
1480
1560
  utils.noteEvent("local", "click"); // NB no filename here
1481
1561
  const file = evt.target.files[0];
1482
- await loadHTMLFile(file);
1562
+ try {
1563
+ await loadHTMLFile(file);
1564
+ } catch (error) {
1565
+ reportLoadFailure(file.name, error);
1566
+ }
1483
1567
  evt.target.value = ""; // clear so if the user picks the same file again after a reset we get a "change"
1484
1568
  });
1485
1569
 
@@ -1487,7 +1571,11 @@ document.getElementById("fs_load").addEventListener("change", async function (ev
1487
1571
  if (evt.target.files.length === 0) return;
1488
1572
  utils.noteEvent("local", "click"); // NB no filename here
1489
1573
  const file = evt.target.files[0];
1490
- await loadSCSIFile(file);
1574
+ try {
1575
+ await loadSCSIFile(file);
1576
+ } catch (error) {
1577
+ reportLoadFailure(file.name, error);
1578
+ }
1491
1579
  evt.target.value = ""; // clear so if the user picks the same file again after a reset we get a "change"
1492
1580
  });
1493
1581
 
@@ -1496,17 +1584,21 @@ document.getElementById("tape_load").addEventListener("change", async function (
1496
1584
  const file = evt.target.files[0];
1497
1585
  utils.noteEvent("local", "clickTape"); // NB no filename here
1498
1586
 
1499
- let tapeData = await readFileAsBinaryString(file);
1500
- let tapeName = file.name;
1501
- if (/\.zip/i.test(tapeName)) {
1502
- const unzipped = await utils.unzipDiscImage(utils.stringToUint8Array(tapeData));
1503
- tapeData = unzipped.data;
1504
- tapeName = unzipped.name;
1587
+ try {
1588
+ let tapeData = await readFileAsBinaryString(file);
1589
+ let tapeName = file.name;
1590
+ if (/\.zip/i.test(tapeName)) {
1591
+ const unzipped = await unzipAndReport(utils.stringToUint8Array(tapeData));
1592
+ tapeData = unzipped.data;
1593
+ tapeName = unzipped.name;
1594
+ }
1595
+ setProcessorTape(await loadTapeFromData(tapeName, tapeData, model));
1596
+ delete parsedQuery.tape;
1597
+ updateUrl();
1598
+ bootstrap.Modal.getInstance(document.getElementById("tapes"))?.hide();
1599
+ } catch (error) {
1600
+ reportLoadFailure(file.name, error);
1505
1601
  }
1506
- setProcessorTape(await loadTapeFromData(tapeName, tapeData, model));
1507
- delete parsedQuery.tape;
1508
- updateUrl();
1509
- bootstrap.Modal.getInstance(document.getElementById("tapes"))?.hide();
1510
1602
 
1511
1603
  evt.target.value = ""; // clear so if the user picks the same file again after a reset we get a "change"
1512
1604
  });
@@ -1623,7 +1715,14 @@ googleDriveEl.addEventListener("show.bs.modal", async function () {
1623
1715
  gdLoading.textContent = "Loading...";
1624
1716
  gdLoading.style.display = "";
1625
1717
  for (const el of googleDriveEl.querySelectorAll("li:not(.template)")) el.remove();
1626
- const cat = await googleDrive.listFiles();
1718
+ let cat;
1719
+ try {
1720
+ cat = await googleDrive.listFiles();
1721
+ } catch (error) {
1722
+ console.error("Error listing Google Drive files:", error);
1723
+ gdLoading.textContent = `Unable to list your Google Drive files: ${errorText(error)}`;
1724
+ return;
1725
+ }
1627
1726
  const dbList = googleDriveEl.querySelector(".list");
1628
1727
  gdLoading.style.display = "none";
1629
1728
  const template = dbList.querySelector(".template");
@@ -1653,7 +1752,11 @@ for (const image of availableImages) {
1653
1752
  utils.noteEvent("images", "click", image.file);
1654
1753
  setDisc1Image(image.file);
1655
1754
  $discsModal.hide();
1656
- putDiscIn(0, await loadDiscImage(parsedQuery.disc1, layoutForDrive(0)));
1755
+ try {
1756
+ putDiscIn(0, await loadDiscImage(parsedQuery.disc1, layoutForDrive(0)));
1757
+ } catch (error) {
1758
+ reportLoadFailure(`${image.name} (${image.file})`, error);
1759
+ }
1657
1760
  });
1658
1761
  }
1659
1762
 
@@ -1946,8 +2049,7 @@ const startPromise = (async () => {
1946
2049
  try {
1947
2050
  await load();
1948
2051
  } catch (error) {
1949
- console.error(`Error loading ${description}:`, error);
1950
- toast(`Could not load ${description}: ${error?.message ?? error}`, { title: "Loading" });
2052
+ reportLoadFailure(description, error);
1951
2053
  }
1952
2054
  })();
1953
2055
  imageLoads.push(loading);
@@ -139,7 +139,6 @@ export function buildVideoState(ulaControl, ulaPalette, crtcRegs, nulaCollook, c
139
139
  wasDbl: false,
140
140
  gfx: false,
141
141
  flash: false,
142
- flashOn: false,
143
142
  flashTime: 0,
144
143
  heldChar: 0,
145
144
  holdChar: false,
package/src/teletext.js CHANGED
@@ -11,7 +11,8 @@ 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.flash = this.flashOn = false;
14
+ this.conceal = false;
15
+ this.flash = false;
15
16
  this.flashTime = 0;
16
17
  this.heldChar = 0;
17
18
  this.holdChar = false;
@@ -172,8 +173,8 @@ 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
- flashOn: this.flashOn,
177
178
  flashTime: this.flashTime,
178
179
  heldChar: this.heldChar,
179
180
  holdChar: this.holdChar,
@@ -198,8 +199,8 @@ export class Teletext {
198
199
  this.secondHalfOfDouble = state.secondHalfOfDouble;
199
200
  this.wasDbl = state.wasDbl;
200
201
  this.gfx = state.gfx;
202
+ this.conceal = state.conceal ?? false;
201
203
  this.flash = state.flash;
202
- this.flashOn = state.flashOn;
203
204
  this.flashTime = state.flashTime;
204
205
  this.heldChar = state.heldChar;
205
206
  this.holdChar = state.holdChar;
@@ -245,6 +246,7 @@ export class Teletext {
245
246
  case 7:
246
247
  this.gfx = false;
247
248
  this.col = data;
249
+ this.conceal = false;
248
250
  this.setNextChars();
249
251
  break;
250
252
  case 8:
@@ -267,10 +269,11 @@ export class Teletext {
267
269
  case 23:
268
270
  this.gfx = true;
269
271
  this.col = data & 7;
272
+ this.conceal = false;
270
273
  this.setNextChars();
271
274
  break;
272
275
  case 24:
273
- this.col = this.prevCol = this.bg;
276
+ this.conceal = true;
274
277
  break;
275
278
  case 25:
276
279
  this.sep = false;
@@ -295,7 +298,6 @@ export class Teletext {
295
298
  }
296
299
  if (wasGfx && (wasHoldChar || this.holdChar) && this.dbl === this.oldDbl) {
297
300
  data = this.heldChar;
298
- if (data >= 0x40 && data < 0x60) data = 0x20;
299
301
  this.curGlyphs = this.heldGlyphs;
300
302
  } else {
301
303
  this.heldChar = 0x20;
@@ -328,13 +330,14 @@ export class Teletext {
328
330
 
329
331
  // 3:1 flash ratio.
330
332
  if (++this.flashTime === 64) this.flashTime = 0;
331
- // Flashing text starts off in sync with a slow cursor, extinguished
332
- // together. Multiple MODE changes gradually desynchronise the
333
- // frame counters.
334
- // TODO: this point is being reached a MOS-dependent number of times
335
- // before Video.frameCount rises. The next line achieves initial
336
- // sync under MOS 1.20 only.
337
- this.flashOn = this.flashTime < 16;
333
+ }
334
+
335
+ // Flashing text starts off in sync with a slow cursor, extinguished together. Multiple MODE
336
+ // changes gradually desynchronise the frame counters.
337
+ // TODO: setDEW is reached a MOS-dependent number of times before Video.frameCount rises, so
338
+ // the initial sync here holds under MOS 1.20 only.
339
+ get hideFlashing() {
340
+ return this.flashTime < 16;
338
341
  }
339
342
 
340
343
  setDISPTMG(level) {
@@ -358,6 +361,7 @@ export class Teletext {
358
361
  this.flash = false;
359
362
  this.sep = false;
360
363
  this.gfx = false;
364
+ this.conceal = false;
361
365
  this.dbl = false;
362
366
 
363
367
  this.scanlineCounter++;
@@ -396,6 +400,7 @@ export class Teletext {
396
400
  this.curGlyphs = this.nextGlyphs;
397
401
 
398
402
  let flashThisCell = this.flash;
403
+ let concealThisCell = this.conceal;
399
404
  if (data < 0x20) {
400
405
  data = this.handleControlCode(data);
401
406
  } else if (this.gfx) {
@@ -419,7 +424,10 @@ export class Teletext {
419
424
  // Steady (code 9) is "Set At" — update so this cell stops flashing immediately.
420
425
  if (flashThisCell && !this.flash) flashThisCell = false;
421
426
 
422
- if ((flashThisCell && this.flashOn) || (this.secondHalfOfDouble && !this.dbl)) {
427
+ // Conceal (code 24) is "Set At", and a colour code only reveals from the cell after itself.
428
+ if (this.conceal) concealThisCell = true;
429
+
430
+ if (concealThisCell || (flashThisCell && this.hideFlashing) || (this.secondHalfOfDouble && !this.dbl)) {
423
431
  const backgroundColour = this.colour[(this.bg & 7) << 5];
424
432
  for (let i = 0; i < 16; ++i) {
425
433
  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) {