jsbeeb 1.22.3 → 1.23.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/package.json CHANGED
@@ -7,7 +7,7 @@
7
7
  "name": "jsbeeb",
8
8
  "description": "Emulate a BBC Micro",
9
9
  "repository": "git@github.com:mattgodbolt/jsbeeb.git",
10
- "version": "1.22.3",
10
+ "version": "1.23.0",
11
11
  "//engines": "If you change the version of Node, it must also be updated at the top of the Dockerfile.",
12
12
  "engines": {
13
13
  "node": ">=24.15.0"
@@ -29,6 +29,7 @@
29
29
  "@popperjs/core": "^2.11.8",
30
30
  "argparse": "^3.0.0",
31
31
  "bootstrap": "^5.3.8",
32
+ "bootstrap-icons": "^1.13.1",
32
33
  "bootswatch": "^5.3.8",
33
34
  "pako": "^3.0.1",
34
35
  "sharp": "^0.35.3",
@@ -52,7 +53,7 @@
52
53
  "vitest": "^4.0.10"
53
54
  },
54
55
  "optionalDependencies": {
55
- "electron": "^43.2.0",
56
+ "electron": "^44.0.0",
56
57
  "electron-builder": "^26.15.3",
57
58
  "electron-store": "^11.0.2"
58
59
  },
package/src/6502.js CHANGED
@@ -1195,6 +1195,7 @@ export class Cpu6502 extends Base6502 {
1195
1195
  while (this.model.swram[romIndex]) {
1196
1196
  romIndex--;
1197
1197
  }
1198
+ if (romIndex < 0) throw new Error("Too many extra ROMs (no free sideways ROM banks)");
1198
1199
 
1199
1200
  awaiting.push(this.loadRom(extraRoms[i_2], this.romOffset + romIndex * 16384));
1200
1201
  }
@@ -1823,7 +1824,11 @@ export class AtomCpu6502 extends Cpu6502 {
1823
1824
 
1824
1825
  // Override loadRom to accept 4KB ROMs
1825
1826
  async loadRom(name, offset) {
1826
- const data = await utils.loadData("roms/" + name);
1827
+ if (name.indexOf("http") !== 0) name = "roms/" + name;
1828
+ let data = await utils.loadData(name);
1829
+ if (/\.zip/i.test(name)) {
1830
+ data = (await utils.unzipRomImage(data)).data;
1831
+ }
1827
1832
  const len = data.length;
1828
1833
  if (len !== 16384 && len !== 8192 && len !== 4096) {
1829
1834
  throw new Error(`Broken ROM file ${name} (length=${len})`);
@@ -525,254 +525,269 @@ function getOp(op, arg) {
525
525
  throw new Error(`Unrecognised operation '${op}'`);
526
526
  }
527
527
 
528
- const opcodes6502 = {
528
+ // Undocumented opcodes carry a leading "*", as 6502 listings conventionally show them.
529
+ function splitUndocumented(markedOpcodes) {
530
+ const opcodes = {};
531
+ const undocumented = new Set();
532
+ for (const [op, text] of Object.entries(markedOpcodes)) {
533
+ if (text.startsWith("*")) {
534
+ undocumented.add(Number(op));
535
+ opcodes[op] = text.slice(1);
536
+ } else {
537
+ opcodes[op] = text;
538
+ }
539
+ }
540
+ return { opcodes, undocumented };
541
+ }
542
+
543
+ const { opcodes: opcodes6502, undocumented: undocumented6502 } = splitUndocumented({
529
544
  0x00: "BRK",
530
545
  0x01: "ORA (,x)",
531
- 0x03: "SLO (,x)",
532
- 0x04: "NOP zp",
546
+ 0x03: "*SLO (,x)",
547
+ 0x04: "*NOP zp",
533
548
  0x05: "ORA zp",
534
549
  0x06: "ASL zp",
535
- 0x07: "SLO zp",
550
+ 0x07: "*SLO zp",
536
551
  0x08: "PHP",
537
552
  0x09: "ORA imm",
538
553
  0x0a: "ASL A",
539
- 0x0b: "ANC imm",
540
- 0x0c: "NOP abs",
554
+ 0x0b: "*ANC imm",
555
+ 0x0c: "*NOP abs",
541
556
  0x0d: "ORA abs",
542
557
  0x0e: "ASL abs",
543
- 0x0f: "SLO abs",
558
+ 0x0f: "*SLO abs",
544
559
  0x10: "BPL branch",
545
560
  0x11: "ORA (),y",
546
- 0x13: "SLO (),y",
547
- 0x14: "NOP zp,x",
561
+ 0x13: "*SLO (),y",
562
+ 0x14: "*NOP zp,x",
548
563
  0x15: "ORA zp,x",
549
564
  0x16: "ASL zp,x",
550
- 0x17: "SLO zp,x",
565
+ 0x17: "*SLO zp,x",
551
566
  0x18: "CLC",
552
567
  0x19: "ORA abs,y",
553
- 0x1a: "NOP",
554
- 0x1b: "SLO abs,y",
555
- 0x1c: "NOP abs,x",
568
+ 0x1a: "*NOP",
569
+ 0x1b: "*SLO abs,y",
570
+ 0x1c: "*NOP abs,x",
556
571
  0x1d: "ORA abs,x",
557
572
  0x1e: "ASL abs,x",
558
- 0x1f: "SLO abs,x",
573
+ 0x1f: "*SLO abs,x",
559
574
  0x20: "JSR abs",
560
575
  0x21: "AND (,x)",
561
- 0x23: "RLA (,x)",
576
+ 0x23: "*RLA (,x)",
562
577
  0x24: "BIT zp",
563
578
  0x25: "AND zp",
564
579
  0x26: "ROL zp",
565
- 0x27: "RLA zp",
580
+ 0x27: "*RLA zp",
566
581
  0x28: "PLP",
567
582
  0x29: "AND imm",
568
583
  0x2a: "ROL A",
569
- 0x2b: "ANC imm",
584
+ 0x2b: "*ANC imm",
570
585
  0x2c: "BIT abs",
571
586
  0x2d: "AND abs",
572
587
  0x2e: "ROL abs",
573
- 0x2f: "RLA abs",
588
+ 0x2f: "*RLA abs",
574
589
  0x30: "BMI branch",
575
590
  0x31: "AND (),y",
576
- 0x33: "RLA (),y",
577
- 0x34: "NOP zp,x",
591
+ 0x33: "*RLA (),y",
592
+ 0x34: "*NOP zp,x",
578
593
  0x35: "AND zp,x",
579
594
  0x36: "ROL zp,x",
580
- 0x37: "RLA zp,x",
595
+ 0x37: "*RLA zp,x",
581
596
  0x38: "SEC",
582
597
  0x39: "AND abs,y",
583
- 0x3a: "NOP",
584
- 0x3b: "RLA abs,y",
585
- 0x3c: "NOP abs,x",
598
+ 0x3a: "*NOP",
599
+ 0x3b: "*RLA abs,y",
600
+ 0x3c: "*NOP abs,x",
586
601
  0x3d: "AND abs,x",
587
602
  0x3e: "ROL abs,x",
588
- 0x3f: "RLA abs,x",
603
+ 0x3f: "*RLA abs,x",
589
604
  0x40: "RTI",
590
605
  0x41: "EOR (,x)",
591
- 0x43: "SRE (,x)",
592
- 0x44: "NOP zp",
606
+ 0x43: "*SRE (,x)",
607
+ 0x44: "*NOP zp",
593
608
  0x45: "EOR zp",
594
609
  0x46: "LSR zp",
595
- 0x47: "SRE zp",
610
+ 0x47: "*SRE zp",
596
611
  0x48: "PHA",
597
612
  0x49: "EOR imm",
598
613
  0x4a: "LSR A",
599
- 0x4b: "ASR imm",
614
+ 0x4b: "*ASR imm",
600
615
  0x4c: "JMP abs",
601
616
  0x4d: "EOR abs",
602
617
  0x4e: "LSR abs",
603
- 0x4f: "SRE abs",
618
+ 0x4f: "*SRE abs",
604
619
  0x50: "BVC branch",
605
620
  0x51: "EOR (),y",
606
- 0x53: "SRE (),y",
607
- 0x54: "NOP zp,x",
621
+ 0x53: "*SRE (),y",
622
+ 0x54: "*NOP zp,x",
608
623
  0x55: "EOR zp,x",
609
624
  0x56: "LSR zp,x",
610
- 0x57: "SRE zp,x",
625
+ 0x57: "*SRE zp,x",
611
626
  0x58: "CLI",
612
627
  0x59: "EOR abs,y",
613
- 0x5a: "NOP",
614
- 0x5b: "SRE abs,y",
615
- 0x5c: "NOP abs,x",
628
+ 0x5a: "*NOP",
629
+ 0x5b: "*SRE abs,y",
630
+ 0x5c: "*NOP abs,x",
616
631
  0x5d: "EOR abs,x",
617
632
  0x5e: "LSR abs,x",
618
- 0x5f: "SRE abs,x",
633
+ 0x5f: "*SRE abs,x",
619
634
  0x60: "RTS",
620
635
  0x61: "ADC (,x)",
621
- 0x63: "RRA (,x)",
622
- 0x64: "NOP zp",
636
+ 0x63: "*RRA (,x)",
637
+ 0x64: "*NOP zp",
623
638
  0x65: "ADC zp",
624
639
  0x66: "ROR zp",
625
- 0x67: "RRA zp",
640
+ 0x67: "*RRA zp",
626
641
  0x68: "PLA",
627
642
  0x69: "ADC imm",
628
643
  0x6a: "ROR A",
629
- 0x6b: "ARR imm",
644
+ 0x6b: "*ARR imm",
630
645
  0x6c: "JMP (abs)",
631
646
  0x6d: "ADC abs",
632
647
  0x6e: "ROR abs",
633
- 0x6f: "RRA abs",
648
+ 0x6f: "*RRA abs",
634
649
  0x70: "BVS branch",
635
650
  0x71: "ADC (),y",
636
- 0x73: "RRA (),y",
637
- 0x74: "NOP zp,x",
651
+ 0x73: "*RRA (),y",
652
+ 0x74: "*NOP zp,x",
638
653
  0x75: "ADC zp,x",
639
654
  0x76: "ROR zp,x",
640
- 0x77: "RRA zp,x",
655
+ 0x77: "*RRA zp,x",
641
656
  0x78: "SEI",
642
657
  0x79: "ADC abs,y",
643
- 0x7a: "NOP",
644
- 0x7b: "RRA abs,y",
645
- 0x7c: "NOP abs,x",
658
+ 0x7a: "*NOP",
659
+ 0x7b: "*RRA abs,y",
660
+ 0x7c: "*NOP abs,x",
646
661
  0x7d: "ADC abs,x",
647
662
  0x7e: "ROR abs,x",
648
- 0x7f: "RRA abs,x",
649
- 0x80: "NOP imm",
663
+ 0x7f: "*RRA abs,x",
664
+ 0x80: "*NOP imm",
650
665
  0x81: "STA (,x)",
651
- 0x82: "NOP imm",
652
- 0x83: "SAX (,x)",
666
+ 0x82: "*NOP imm",
667
+ 0x83: "*SAX (,x)",
653
668
  0x84: "STY zp",
654
669
  0x85: "STA zp",
655
670
  0x86: "STX zp",
656
- 0x87: "SAX zp",
671
+ 0x87: "*SAX zp",
657
672
  0x88: "DEY",
658
- 0x89: "NOP imm",
673
+ 0x89: "*NOP imm",
659
674
  0x8a: "TXA",
660
- 0x8b: "ANE imm",
675
+ 0x8b: "*ANE imm",
661
676
  0x8c: "STY abs",
662
677
  0x8d: "STA abs",
663
678
  0x8e: "STX abs",
664
- 0x8f: "SAX abs",
679
+ 0x8f: "*SAX abs",
665
680
  0x90: "BCC branch",
666
681
  0x91: "STA (),y",
667
- 0x93: "SHA (),y",
682
+ 0x93: "*SHA (),y",
668
683
  0x94: "STY zp,x",
669
684
  0x95: "STA zp,x",
670
685
  0x96: "STX zp,y",
671
- 0x97: "SAX zp,y",
686
+ 0x97: "*SAX zp,y",
672
687
  0x98: "TYA",
673
688
  0x99: "STA abs,y",
674
689
  0x9a: "TXS",
675
- 0x9b: "SHS abs,y",
676
- 0x9c: "SHY abs,x",
690
+ 0x9b: "*SHS abs,y",
691
+ 0x9c: "*SHY abs,x",
677
692
  0x9d: "STA abs,x",
678
- 0x9e: "SHX abs,y",
679
- 0x9f: "SHA abs,y",
693
+ 0x9e: "*SHX abs,y",
694
+ 0x9f: "*SHA abs,y",
680
695
  0xa0: "LDY imm",
681
696
  0xa1: "LDA (,x)",
682
697
  0xa2: "LDX imm",
683
- 0xa3: "LAX (,x)",
698
+ 0xa3: "*LAX (,x)",
684
699
  0xa4: "LDY zp",
685
700
  0xa5: "LDA zp",
686
701
  0xa6: "LDX zp",
687
- 0xa7: "LAX zp",
702
+ 0xa7: "*LAX zp",
688
703
  0xa8: "TAY",
689
704
  0xa9: "LDA imm",
690
705
  0xaa: "TAX",
691
- 0xab: "LXA imm",
706
+ 0xab: "*LXA imm",
692
707
  0xac: "LDY abs",
693
708
  0xad: "LDA abs",
694
709
  0xae: "LDX abs",
695
- 0xaf: "LAX abs",
710
+ 0xaf: "*LAX abs",
696
711
  0xb0: "BCS branch",
697
712
  0xb1: "LDA (),y",
698
- 0xb3: "LAX (),y",
713
+ 0xb3: "*LAX (),y",
699
714
  0xb4: "LDY zp,x",
700
715
  0xb5: "LDA zp,x",
701
716
  0xb6: "LDX zp,y",
702
- 0xb7: "LAX zp,y",
717
+ 0xb7: "*LAX zp,y",
703
718
  0xb8: "CLV",
704
719
  0xb9: "LDA abs,y",
705
720
  0xba: "TSX",
706
- 0xbb: "LAS abs,y",
721
+ 0xbb: "*LAS abs,y",
707
722
  0xbc: "LDY abs,x",
708
723
  0xbd: "LDA abs,x",
709
724
  0xbe: "LDX abs,y",
710
- 0xbf: "LAX abs,y",
725
+ 0xbf: "*LAX abs,y",
711
726
  0xc0: "CPY imm",
712
727
  0xc1: "CMP (,x)",
713
- 0xc2: "NOP imm",
714
- 0xc3: "DCP (,x)",
728
+ 0xc2: "*NOP imm",
729
+ 0xc3: "*DCP (,x)",
715
730
  0xc4: "CPY zp",
716
731
  0xc5: "CMP zp",
717
732
  0xc6: "DEC zp",
718
- 0xc7: "DCP zp",
733
+ 0xc7: "*DCP zp",
719
734
  0xc8: "INY",
720
735
  0xc9: "CMP imm",
721
736
  0xca: "DEX",
722
- 0xcb: "SBX imm",
737
+ 0xcb: "*SBX imm",
723
738
  0xcc: "CPY abs",
724
739
  0xcd: "CMP abs",
725
740
  0xce: "DEC abs",
726
- 0xcf: "DCP abs",
741
+ 0xcf: "*DCP abs",
727
742
  0xd0: "BNE branch",
728
743
  0xd1: "CMP (),y",
729
- 0xd3: "DCP (),y",
730
- 0xd4: "NOP zp,x",
744
+ 0xd3: "*DCP (),y",
745
+ 0xd4: "*NOP zp,x",
731
746
  0xd5: "CMP zp,x",
732
747
  0xd6: "DEC zp,x",
733
- 0xd7: "DCP zp,x",
748
+ 0xd7: "*DCP zp,x",
734
749
  0xd8: "CLD",
735
750
  0xd9: "CMP abs,y",
736
- 0xda: "NOP",
737
- 0xdb: "DCP abs,y",
738
- 0xdc: "NOP abs,x",
751
+ 0xda: "*NOP",
752
+ 0xdb: "*DCP abs,y",
753
+ 0xdc: "*NOP abs,x",
739
754
  0xdd: "CMP abs,x",
740
755
  0xde: "DEC abs,x",
741
- 0xdf: "DCP abs,x",
756
+ 0xdf: "*DCP abs,x",
742
757
  0xe0: "CPX imm",
743
758
  0xe1: "SBC (,x)",
744
- 0xe2: "NOP imm",
745
- 0xe3: "ISB (,x)",
759
+ 0xe2: "*NOP imm",
760
+ 0xe3: "*ISB (,x)",
746
761
  0xe4: "CPX zp",
747
762
  0xe5: "SBC zp",
748
763
  0xe6: "INC zp",
749
- 0xe7: "ISB zp",
764
+ 0xe7: "*ISB zp",
750
765
  0xe8: "INX",
751
766
  0xe9: "SBC imm",
752
767
  0xea: "NOP",
753
- 0xeb: "SBC imm",
768
+ 0xeb: "*SBC imm",
754
769
  0xec: "CPX abs",
755
770
  0xed: "SBC abs",
756
771
  0xee: "INC abs",
757
- 0xef: "ISB abs",
772
+ 0xef: "*ISB abs",
758
773
  0xf0: "BEQ branch",
759
774
  0xf1: "SBC (),y",
760
- 0xf3: "ISB (),y",
761
- 0xf4: "NOP zpx",
775
+ 0xf3: "*ISB (),y",
776
+ 0xf4: "*NOP zp,x",
762
777
  0xf5: "SBC zp,x",
763
778
  0xf6: "INC zp,x",
764
- 0xf7: "ISB zp,x",
779
+ 0xf7: "*ISB zp,x",
765
780
  0xf8: "SED",
766
781
  0xf9: "SBC abs,y",
767
- 0xfa: "NOP",
768
- 0xfb: "ISB abs,y",
769
- 0xfc: "NOP abs,x",
782
+ 0xfa: "*NOP",
783
+ 0xfb: "*ISB abs,y",
784
+ 0xfc: "*NOP abs,x",
770
785
  0xfd: "SBC abs,x",
771
786
  0xfe: "INC abs,x",
772
- 0xff: "ISB abs,x",
773
- };
787
+ 0xff: "*ISB abs,x",
788
+ });
774
789
 
775
- const opcodes65c12 = {
790
+ const { opcodes: opcodes65c12, undocumented: undocumented65c12 } = splitUndocumented({
776
791
  0x00: "BRK",
777
792
  0x01: "ORA (,x)",
778
793
  0x04: "TSB zp",
@@ -952,9 +967,9 @@ const opcodes65c12 = {
952
967
  0xfa: "PLX",
953
968
  0xfd: "SBC abs,x",
954
969
  0xfe: "INC abs,x",
955
- };
970
+ });
956
971
 
957
- const opcodes65c02 = {
972
+ const { opcodes: opcodes65c02, undocumented: undocumented65c02 } = splitUndocumented({
958
973
  ...opcodes65c12,
959
974
  0x07: "RMB0 zp",
960
975
  0x17: "RMB1 zp",
@@ -988,12 +1003,20 @@ const opcodes65c02 = {
988
1003
  0xdf: "BBS5 zp,branch",
989
1004
  0xef: "BBS6 zp,branch",
990
1005
  0xff: "BBS7 zp,branch",
991
- };
1006
+ });
1007
+
1008
+ const PrevInstructionWindow = 64;
992
1009
 
993
1010
  class Disassemble6502 {
994
- constructor(cpu, opcodes) {
1011
+ constructor(cpu, opcodes, undocumented) {
995
1012
  this.cpu = cpu;
996
1013
  this.opcodes = opcodes;
1014
+ this.undocumented = undocumented;
1015
+ }
1016
+
1017
+ isDocumented(addr) {
1018
+ const op = this.cpu.peekmem(addr);
1019
+ return op in this.opcodes && !this.undocumented.has(op);
997
1020
  }
998
1021
 
999
1022
  disassemble(addr, plain) {
@@ -1003,13 +1026,15 @@ class Disassemble6502 {
1003
1026
  formatAddr = hexword;
1004
1027
  formatJumpAddr = hexword;
1005
1028
  }
1006
- const opcode = this.opcodes[this.cpu.peekmem(addr)];
1029
+ const opcodeByte = this.cpu.peekmem(addr);
1030
+ const opcode = this.opcodes[opcodeByte];
1007
1031
  if (!opcode) {
1008
1032
  return ["???", addr + 1];
1009
1033
  }
1010
- const split = opcode.split(" ");
1034
+ const [mnemonic, ...rest] = opcode.split(" ");
1035
+ const split = [(this.undocumented.has(opcodeByte) ? "*" : "") + mnemonic, ...rest];
1011
1036
  if (!split[1]) {
1012
- return [opcode, addr + 1];
1037
+ return [split[0], addr + 1];
1013
1038
  }
1014
1039
  let param = split[1] || "";
1015
1040
  let suffix = "";
@@ -1034,6 +1059,14 @@ class Disassemble6502 {
1034
1059
  }
1035
1060
  case "zp":
1036
1061
  return [`${split[0]} $${hexbyte(this.cpu.peekmem(addr + 1))}${suffix}`, addr + 2];
1062
+ case "zp,branch": {
1063
+ const destAddr = addr + signExtend(this.cpu.peekmem(addr + 2)) + 3;
1064
+ return [
1065
+ `${split[0]} $${hexbyte(this.cpu.peekmem(addr + 1))}, $${formatJumpAddr(destAddr)}`,
1066
+ addr + 3,
1067
+ destAddr,
1068
+ ];
1069
+ }
1037
1070
  case "(,x)":
1038
1071
  return [`${split[0]} ($${hexbyte(this.cpu.peekmem(addr + 1))}, X)${suffix}`, addr + 2];
1039
1072
  case "()": {
@@ -1056,11 +1089,60 @@ class Disassemble6502 {
1056
1089
  return [`${split[0]} ($${formatJumpAddr(destAddr)},x)${suffix}`, addr + 3, indDest];
1057
1090
  }
1058
1091
  }
1059
- return [opcode, addr + 1];
1092
+ return [split.join(" "), addr + 1];
1093
+ }
1094
+
1095
+ nextInstruction(address) {
1096
+ return this.disassemble(address)[1] & 0xffff;
1097
+ }
1098
+
1099
+ /**
1100
+ * Guesses the address of the instruction before the given one by scoring
1101
+ * each run of instructions in the preceding window that lands exactly on
1102
+ * it: a point per "common" instruction (loads, stores, branches, compares,
1103
+ * arithmetic and carry-set/clear that avoid unusual indexing modes) and a
1104
+ * large boost for a run that passes through pc. Runs through undocumented
1105
+ * opcodes are discarded. The highest-scoring run's last instruction wins;
1106
+ * any run beats none, since a wrong parse resynchronises within a few
1107
+ * instructions and so nearly always shares the true stream's last one.
1108
+ * Good test cases:
1109
+ * Repton 2 @ 2cbb
1110
+ * MOS @ cfc8, and the unrolled STA abs,X screen clear @ cc63
1111
+ * also, just starting from the back of ROM and going up...
1112
+ */
1113
+ prevInstruction(address, pc) {
1114
+ const commonInstructions =
1115
+ /(RTS|B..|JMP|JSR|LD[AXY]|ST[AXY]|TA[XY]|T[XY]A|AD[DC]|SUB|SBC|CLC|SEC|CMP|EOR|ORA|AND|INC|DEC).*/;
1116
+ const uncommonInstructions = /.*,\s*([XY]|X\))$/;
1117
+
1118
+ address &= 0xffff;
1119
+ let bestAddr = address - 1;
1120
+ let bestScore = -1;
1121
+ for (let startingPoint = address - PrevInstructionWindow; startingPoint !== address; startingPoint++) {
1122
+ let score = 0;
1123
+ let addr = startingPoint & 0xffff;
1124
+ while (addr < address) {
1125
+ if (!this.isDocumented(addr)) break;
1126
+ const result = this.disassemble(addr);
1127
+ if (addr === pc) score += 10;
1128
+ if (result[0].match(commonInstructions) && !result[0].match(uncommonInstructions)) {
1129
+ score++;
1130
+ }
1131
+ if (result[1] === address) {
1132
+ if (score > bestScore) {
1133
+ bestScore = score;
1134
+ bestAddr = addr;
1135
+ }
1136
+ break;
1137
+ }
1138
+ addr = result[1];
1139
+ }
1140
+ }
1141
+ return bestAddr & 0xffff;
1060
1142
  }
1061
1143
  }
1062
1144
 
1063
- function makeCpuFunctions(cpu, opcodes, is65c12, cycleAccurate = true) {
1145
+ function makeCpuFunctions(cpu, opcodes, undocumented, is65c12, cycleAccurate = true) {
1064
1146
  function getInstruction(opcodeString, needsReg) {
1065
1147
  const split = opcodeString.split(" ");
1066
1148
  const opcode = split[0];
@@ -1097,7 +1179,6 @@ function makeCpuFunctions(cpu, opcodes, is65c12, cycleAccurate = true) {
1097
1179
  return ig.render();
1098
1180
 
1099
1181
  case "zp":
1100
- case "zpx": // Seems to be enough to keep tests happy, but needs investigation.
1101
1182
  case "zp,x":
1102
1183
  case "zp,y":
1103
1184
  if (arg === "zp") {
@@ -1409,7 +1490,7 @@ ${indent}`)
1409
1490
  Runner.prototype.run = generate6502JumpTable();
1410
1491
 
1411
1492
  return {
1412
- disassembler: new Disassemble6502(cpu, opcodes),
1493
+ disassembler: new Disassemble6502(cpu, opcodes, undocumented),
1413
1494
  runInstruction: new Runner(),
1414
1495
  opcodes: opcodes,
1415
1496
  getInstruction: getInstruction,
@@ -1417,13 +1498,13 @@ ${indent}`)
1417
1498
  }
1418
1499
 
1419
1500
  export function Cpu6502(cpu, { cycleAccurate = true } = {}) {
1420
- return makeCpuFunctions(cpu, opcodes6502, false, cycleAccurate);
1501
+ return makeCpuFunctions(cpu, opcodes6502, undocumented6502, false, cycleAccurate);
1421
1502
  }
1422
1503
 
1423
1504
  export function Cpu65c12(cpu, { cycleAccurate = true } = {}) {
1424
- return makeCpuFunctions(cpu, opcodes65c12, true, cycleAccurate);
1505
+ return makeCpuFunctions(cpu, opcodes65c12, undocumented65c12, true, cycleAccurate);
1425
1506
  }
1426
1507
 
1427
1508
  export function Cpu65c02(cpu, { cycleAccurate = true } = {}) {
1428
- return makeCpuFunctions(cpu, opcodes65c02, true, cycleAccurate);
1509
+ return makeCpuFunctions(cpu, opcodes65c02, undocumented65c02, true, cycleAccurate);
1429
1510
  }
package/src/cmos.js CHANGED
@@ -9,11 +9,11 @@
9
9
  // STNICC-beeb to hang on the Master 128 because the WD1770 seek overran the
10
10
  // vsync window. Confirmed by @tom-seddon (b2 emulator):
11
11
  // https://github.com/mattgodbolt/jsbeeb/issues/576
12
- const defaultCmos = [
12
+ const defaultCmos = Object.freeze([
13
13
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfe, 0x00, 0xeb, 0x00,
14
14
  0xc9, 0xff, 0xff, 0x12, 0x00, 0x17, 0xc8, 0x1e, 0x05, 0x00, 0x35, 0xa6, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
15
15
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
16
- ];
16
+ ]);
17
17
 
18
18
  let timeOffset = 0;
19
19
 
@@ -82,7 +82,7 @@ export class Cmos {
82
82
  this.cmosAddr = 0;
83
83
 
84
84
  if (!this.store) {
85
- this.store = defaultCmos;
85
+ this.store = [...defaultCmos];
86
86
  }
87
87
  if (cmosOverride) {
88
88
  this.store = cmosOverride(this.store);
package/src/jsbeeb.css CHANGED
@@ -560,6 +560,39 @@ small {
560
560
  flex: none;
561
561
  }
562
562
 
563
+ #header-bar .navbar-brand img {
564
+ display: block;
565
+ }
566
+
567
+ #header-bar [data-icon] > svg {
568
+ width: 1.15em;
569
+ height: 1.15em;
570
+ vertical-align: -0.2em;
571
+ }
572
+
573
+ /* Shrunk, the menu list wraps onto a second row; the promo yields instead, by truncating. */
574
+ #header-bar .navbar-nav {
575
+ flex-shrink: 0;
576
+ }
577
+
578
+ #header-bar .navbar-text {
579
+ flex: 0 1 auto;
580
+ min-width: 0;
581
+ white-space: nowrap;
582
+ overflow: hidden;
583
+ text-overflow: ellipsis;
584
+ }
585
+
586
+ #paste-form {
587
+ flex: 1 0 6rem;
588
+ min-width: 0;
589
+ max-width: 14rem;
590
+ }
591
+
592
+ #paste-text {
593
+ min-width: 0;
594
+ }
595
+
563
596
  /* The only item in the header with no bound on its width, so it is the one that yields. */
564
597
  #disc-name {
565
598
  flex: 1;
package/src/main.js CHANGED
@@ -9,6 +9,7 @@ import * as utils_atom from "./utils_atom.js";
9
9
  import { GamePad } from "./gamepads.js";
10
10
  import { initialise as electron } from "./app/electron.js";
11
11
  import { AudioHandler } from "./web/audio-handler.js";
12
+ import { installIcons } from "./web/icons.js";
12
13
  import { QuickSettings } from "./web/quick-settings.js";
13
14
  import { toast } from "./web/toast.js";
14
15
  import { BuiltInImages, MediaLoader } from "./web/media-loader.js";
@@ -37,6 +38,8 @@ import { DiscVisualiser } from "./web/disc-visualiser.js";
37
38
  import { downloadDriveData } from "./dom-utils.js";
38
39
  import { parseMediaParams, processAutobootParams, processDriveTrackParams, processInputParams } from "./url-params.js";
39
40
 
41
+ installIcons();
42
+
40
43
  // ------------------------------------------------------------------------
41
44
  // What the URL asked for.
42
45
  // ------------------------------------------------------------------------
@@ -50,6 +53,7 @@ let secondDiscImage = null;
50
53
  const { discImage: queryDiscImage, secondDiscImage: querySecondDisc, mmcImage } = parseMediaParams(parsedQuery);
51
54
  if (queryDiscImage) discImage = queryDiscImage;
52
55
  if (querySecondDisc) secondDiscImage = querySecondDisc;
56
+ const defaultBootDisc = queryDiscImage ? undefined : discImage;
53
57
  const { settings: driveTracks, warnings: driveTrackWarnings } = processDriveTrackParams(parsedQuery);
54
58
 
55
59
  const extraRoms = [];
@@ -195,6 +199,7 @@ const keys = new KeyboardSetup({
195
199
  openPrinter: () => frontPanel.checkPrinterWindow(),
196
200
  pause: () => loop.stop(false),
197
201
  resume: () => loop.go(),
202
+ paste: (text) => keys.sendRawKeyboard(autoBoot.stringToMachineKeys(text), true),
198
203
  onAnyKeyDown: () => {
199
204
  audioHandler.tryResume();
200
205
  inputs.ensureMicrophoneRunning();
@@ -276,6 +281,7 @@ const snapshots = new SnapshotUI({
276
281
  urlState,
277
282
  modals,
278
283
  loop,
284
+ defaultBootDisc,
279
285
  });
280
286
 
281
287
  const inputs = new AnalogueInputs({
@@ -330,12 +336,7 @@ settings.wire({ audioHandler, display, layout, quickSettings, machine, keys, inp
330
336
 
331
337
  for (const el of document.querySelectorAll(".initially-hidden")) el.classList.remove("initially-hidden");
332
338
 
333
- const pastetext = document.getElementById("paste-text");
334
- pastetext.closest("form").addEventListener("submit", (event) => event.preventDefault());
335
- pastetext.addEventListener("paste", function (event) {
336
- const text = event.clipboardData.getData("text/plain");
337
- keys.sendRawKeyboard(autoBoot.stringToMachineKeys(text), true);
338
- });
339
+ document.getElementById("paste-form").addEventListener("submit", (event) => event.preventDefault());
339
340
 
340
341
  window.addEventListener("blur", function () {
341
342
  keys.clearKeys();
@@ -392,6 +393,7 @@ document.getElementById("soft-reset").addEventListener("click", function (event)
392
393
  });
393
394
 
394
395
  document.getElementById("download-filestore-link").addEventListener("click", function () {
396
+ if (!processor.filestore) return;
395
397
  downloadDriveData(processor.filestore.scsi, "scsi", ".dat");
396
398
  });
397
399
 
package/src/models.js CHANGED
@@ -19,9 +19,23 @@ const CpuModel = Object.freeze({
19
19
  * to any number of machines without one session's settings leaking into the next.
20
20
  */
21
21
  class Model {
22
- constructor({ name, synonyms, os, cpuModel, isMaster, isAtom, swram, fdc, cmosOverride, banks, clockMhz } = {}) {
22
+ constructor({
23
+ name,
24
+ shortName = name,
25
+ synonyms,
26
+ os,
27
+ cpuModel,
28
+ isMaster,
29
+ isAtom,
30
+ swram,
31
+ fdc,
32
+ cmosOverride,
33
+ banks,
34
+ clockMhz,
35
+ } = {}) {
23
36
  if (!(clockMhz > 0)) throw new Error(`Model ${name} has no clock speed`);
24
37
  this.name = name;
38
+ this.shortName = shortName;
25
39
  this.synonyms = synonyms;
26
40
  this.os = os;
27
41
  this.clockMhz = clockMhz;
@@ -79,6 +93,7 @@ function pickDfs(cmos) {
79
93
  function atomModel({ name, synonyms, os, banks }) {
80
94
  return new Model({
81
95
  name,
96
+ shortName: "Atom",
82
97
  synonyms,
83
98
  os,
84
99
  cpuModel: CpuModel.MOS6502,
@@ -132,6 +147,7 @@ const masterSwram = [
132
147
  export const allModels = [
133
148
  new Model({
134
149
  name: "BBC B with 8271 (DFS 1.2)",
150
+ shortName: "BBC B",
135
151
  synonyms: ["B-DFS1.2", "BBC B with DFS 1.2"],
136
152
  os: ["os.rom", "BASIC.ROM", "b/DFS-1.2.rom"],
137
153
  cpuModel: CpuModel.MOS6502,
@@ -142,6 +158,7 @@ export const allModels = [
142
158
  }),
143
159
  new Model({
144
160
  name: "BBC B with 8271 (DFS 0.9)",
161
+ shortName: "BBC B",
145
162
  synonyms: ["B-DFS0.9", "B", "BBC B with DFS 0.9"],
146
163
  os: ["os.rom", "BASIC.ROM", "b/DFS-0.9.rom"],
147
164
  cpuModel: CpuModel.MOS6502,
@@ -152,6 +169,7 @@ export const allModels = [
152
169
  }),
153
170
  new Model({
154
171
  name: "BBC B with 1770 (DFS)",
172
+ shortName: "BBC B",
155
173
  synonyms: ["B1770"],
156
174
  os: ["os.rom", "BASIC.ROM", "b1770/dfs1770.rom", "b1770/zADFS.ROM"],
157
175
  cpuModel: CpuModel.MOS6502,
@@ -163,6 +181,7 @@ export const allModels = [
163
181
  // putting ADFS in a higher ROM slot gives it priority
164
182
  new Model({
165
183
  name: "BBC B with 1770 (ADFS)",
184
+ shortName: "BBC B",
166
185
  synonyms: ["B1770A"],
167
186
  os: ["os.rom", "BASIC.ROM", "b1770/zADFS.ROM", "b1770/dfs1770.rom"],
168
187
  cpuModel: CpuModel.MOS6502,
@@ -173,6 +192,7 @@ export const allModels = [
173
192
  }),
174
193
  new Model({
175
194
  name: "BBC Master 128 (DFS)",
195
+ shortName: "Master 128",
176
196
  synonyms: ["Master"],
177
197
  os: ["master/mos3.20"],
178
198
  cpuModel: CpuModel.CMOS65C12,
@@ -184,6 +204,7 @@ export const allModels = [
184
204
  }),
185
205
  new Model({
186
206
  name: "BBC Master 128 (ADFS)",
207
+ shortName: "Master 128",
187
208
  synonyms: ["MasterADFS"],
188
209
  os: ["master/mos3.20"],
189
210
  cpuModel: CpuModel.CMOS65C12,
@@ -195,6 +216,7 @@ export const allModels = [
195
216
  }),
196
217
  new Model({
197
218
  name: "BBC Master 128 (ANFS)",
219
+ shortName: "Master 128",
198
220
  synonyms: ["MasterANFS"],
199
221
  os: ["master/mos3.20"],
200
222
  cpuModel: CpuModel.CMOS65C12,
package/src/web/config.js CHANGED
@@ -216,6 +216,9 @@ export class Config extends EventTarget {
216
216
  setModel(modelName) {
217
217
  this.model = findModel(modelName);
218
218
  for (const el of document.querySelectorAll(".bbc-model")) el.textContent = this.model.name;
219
+ for (const el of document.querySelectorAll(".bbc-model-short")) el.textContent = this.model.shortName;
220
+ const settingsLink = document.getElementById("model-settings");
221
+ if (settingsLink) settingsLink.title = `${this.model.name}: emulation settings`;
219
222
  }
220
223
 
221
224
  setKeyLayout(keyLayout) {
package/src/web/debug.js CHANGED
@@ -382,42 +382,7 @@ export class Debugger {
382
382
  }
383
383
 
384
384
  prevInstruction(address) {
385
- // Some attempt at making prevInstruction more accurate; score the sequence of instructions leading
386
- // up to the target by counting all "common" instructions as a point. The highest-scoring run of
387
- // instructions is picked as the most likely, and the previous from that is used. Common instructions
388
- // here mean loads, stores, branches, compares, arithmetic and carry-set/clear that don't use "unusual"
389
- // indexing modes like abs,X, abs,Y and (zp,X).
390
- // Good test cases:
391
- // Repton 2 @ 2cbb
392
- // MOS @ cfc8
393
- // also, just starting from the back of ROM and going up...
394
- const commonInstructions =
395
- /(RTS|B..|JMP|JSR|LD[AXY]|ST[AXY]|TA[XY]|T[XY]A|AD[DC]|SUB|SBC|CLC|SEC|CMP|EOR|ORR|AND|INC|DEC).*/;
396
- const uncommonInstrucions = /.*,\s*([XY]|X\))$/;
397
-
398
- address &= 0xffff;
399
- let bestAddr = address - 1;
400
- let bestScore = 0;
401
- for (let startingPoint = address - 20; startingPoint !== address; startingPoint++) {
402
- let score = 0;
403
- let addr = startingPoint & 0xffff;
404
- while (addr < address) {
405
- const result = this.disassemble(addr);
406
- if (result[0] === this.cpu.pc) score += 10; // huge boost if this instruction was executed
407
- if (result[0].match(commonInstructions) && !result[0].match(uncommonInstrucions)) {
408
- score++;
409
- }
410
- if (result[1] === address) {
411
- if (score > bestScore) {
412
- bestScore = score;
413
- bestAddr = addr;
414
- break;
415
- }
416
- }
417
- addr = result[1];
418
- }
419
- }
420
- return bestAddr;
385
+ return this.cpu.disassembler.prevInstruction(address, this.cpu.pc);
421
386
  }
422
387
 
423
388
  updatePrevMem() {
@@ -476,7 +441,7 @@ export class Debugger {
476
441
  }
477
442
 
478
443
  nextInstruction(address) {
479
- return this.disassemble(address)[1] & 0xffff;
444
+ return this.cpu.disassembler.nextInstruction(address);
480
445
  }
481
446
 
482
447
  instrClick(e) {
@@ -28,10 +28,13 @@ export class GoogleDrivePicker {
28
28
  this.authEl.style.display = "none";
29
29
  e.preventDefault();
30
30
  const authed = await this.auth(false);
31
- if (authed) this.authResolve();
32
- else this.authReject(new Error("Unable to authorize Google Drive"));
31
+ if (authed) this.authResolve?.(true);
32
+ else this.authReject?.(new Error("Unable to authorize Google Drive"));
33
33
  });
34
34
 
35
+ // Closing the loading dialog while the auth panel waits is a cancellation, not an error.
36
+ document.getElementById("loading-dialog").addEventListener("hidden.bs.modal", () => this.authResolve?.(false));
37
+
35
38
  // Loading the Google client holds the main thread for ~100ms, so it waits for
36
39
  // someone to ask for Drive.
37
40
  document.getElementById("open-drive-link").addEventListener("click", async (e) => {
@@ -74,11 +77,21 @@ export class GoogleDrivePicker {
74
77
  console.log("Google Drive authed=", authed);
75
78
 
76
79
  if (!authed) {
77
- await new Promise((resolve, reject) => {
78
- this.authResolve = resolve;
79
- this.authReject = reject;
80
- this.authEl.style.display = "";
81
- });
80
+ let granted;
81
+ try {
82
+ granted = await new Promise((resolve, reject) => {
83
+ this.authResolve = resolve;
84
+ this.authReject = reject;
85
+ this.authEl.style.display = "";
86
+ });
87
+ } finally {
88
+ this.authResolve = this.authReject = null;
89
+ }
90
+ if (!granted) {
91
+ console.log("Google Drive authorization dismissed");
92
+ this.modals.loadingFinished();
93
+ return null;
94
+ }
82
95
  }
83
96
 
84
97
  const ssd = await this.googleDrive.load(this.processor.fdc, cat.id, layout);
@@ -93,7 +106,8 @@ export class GoogleDrivePicker {
93
106
  return ssd;
94
107
  } catch (error) {
95
108
  console.error("Google Drive loading error:", error);
96
- this.modals.loadingFinished(`Unable to load ${cat.name} from Google Drive: ${errorText(error)}`);
109
+ this.modals.loadingFinished();
110
+ throw error;
97
111
  }
98
112
  }
99
113
 
@@ -122,8 +136,14 @@ export class GoogleDrivePicker {
122
136
  utils.noteEvent("google-drive", "click", item.name);
123
137
  this.media.setDisc1Image(`gd:${item.id}/${item.name}`);
124
138
  this.modal.hide();
125
- const ssd = await this.load(item, this.drives.layoutForDrive(0));
126
- if (ssd) this.drives.putDiscIn(0, ssd);
139
+ try {
140
+ const ssd = await this.load(item, this.drives.layoutForDrive(0));
141
+ if (ssd) this.drives.putDiscIn(0, ssd);
142
+ } catch (error) {
143
+ toast(`Unable to load ${item.name} from Google Drive: ${errorText(error)}`, {
144
+ title: "Google Drive",
145
+ });
146
+ }
127
147
  });
128
148
  }
129
149
  }
@@ -0,0 +1,42 @@
1
+ import arrowCounterclockwise from "bootstrap-icons/icons/arrow-counterclockwise.svg?raw";
2
+ import cassette from "bootstrap-icons/icons/cassette.svg?raw";
3
+ import clockHistory from "bootstrap-icons/icons/clock-history.svg?raw";
4
+ import display from "bootstrap-icons/icons/display.svg?raw";
5
+ import floppy from "bootstrap-icons/icons/floppy.svg?raw";
6
+ import gear from "bootstrap-icons/icons/gear.svg?raw";
7
+ import headphones from "bootstrap-icons/icons/headphones.svg?raw";
8
+ import pauseFill from "bootstrap-icons/icons/pause-fill.svg?raw";
9
+ import playFill from "bootstrap-icons/icons/play-fill.svg?raw";
10
+ import soundwave from "bootstrap-icons/icons/soundwave.svg?raw";
11
+ import stars from "bootstrap-icons/icons/stars.svg?raw";
12
+ import threeDots from "bootstrap-icons/icons/three-dots.svg?raw";
13
+ import tv from "bootstrap-icons/icons/tv.svg?raw";
14
+ import volumeUp from "bootstrap-icons/icons/volume-up.svg?raw";
15
+
16
+ /** The Bootstrap Icons the page uses, by their upstream names; nothing else of the set is bundled. */
17
+ const Icons = {
18
+ "arrow-counterclockwise": arrowCounterclockwise,
19
+ cassette,
20
+ "clock-history": clockHistory,
21
+ display,
22
+ floppy,
23
+ gear,
24
+ headphones,
25
+ "pause-fill": pauseFill,
26
+ "play-fill": playFill,
27
+ soundwave,
28
+ stars,
29
+ "three-dots": threeDots,
30
+ tv,
31
+ "volume-up": volumeUp,
32
+ };
33
+
34
+ export const IconNames = Object.freeze(Object.keys(Icons));
35
+
36
+ /** Puts the named SVG inside every `[data-icon]` element under `root`; an unknown name leaves them all untouched. */
37
+ export function installIcons(root = document) {
38
+ const placeholders = [...root.querySelectorAll("[data-icon]")];
39
+ const unknown = placeholders.find((el) => !(el.dataset.icon in Icons));
40
+ if (unknown) throw new Error(`No icon named ${unknown.dataset.icon}`);
41
+ for (const el of placeholders) el.innerHTML = Icons[el.dataset.icon];
42
+ }
@@ -2,19 +2,22 @@ import * as utils from "../utils.js";
2
2
  import { Keyboard } from "../keyboard.js";
3
3
  import { showNotice } from "./reporting.js";
4
4
 
5
+ const PasteBoxId = "paste-text";
6
+ const TypingTargets = 'input, textarea, select, [contenteditable]:not([contenteditable="false"])';
7
+
5
8
  /** The page's keyboard: the emulated one and the browser shortcuts around it. */
6
9
  export class KeyboardSetup {
7
10
  /**
8
11
  * @param {object} opts
9
12
  * @param {object} opts.actions what each shortcut does, supplied late-bound:
10
13
  * enterDebugger, reload, toggleFast, openRewind, openPrinter,
11
- * pause, resume, onAnyKeyDown
14
+ * pause, resume, paste, onAnyKeyDown
12
15
  * @param {import("./accessibility-switches.js").AccessibilitySwitches} opts.accessibilitySwitches
13
16
  */
14
17
  constructor({ actions, accessibilitySwitches, processor, dbgr, keyLayout }) {
15
18
  const keyboard = (this.keyboard = new Keyboard({
16
19
  processor,
17
- inputEnabledFunction: () => document.activeElement && document.activeElement.id === "paste-text",
20
+ inputEnabledFunction: () => document.activeElement && document.activeElement.id === PasteBoxId,
18
21
  keyLayout,
19
22
  dbgr,
20
23
  }));
@@ -62,6 +65,12 @@ export class KeyboardSetup {
62
65
  });
63
66
  document.addEventListener("keypress", (evt) => keyboard.keyPress(evt));
64
67
  document.addEventListener("keyup", (evt) => keyboard.keyUp(evt));
68
+ document.addEventListener("paste", (evt) => {
69
+ const target = document.activeElement;
70
+ if (target && target.id !== PasteBoxId && target.matches(TypingTargets)) return;
71
+ const text = evt.clipboardData?.getData("text/plain");
72
+ if (text) actions.paste(text);
73
+ });
65
74
  }
66
75
 
67
76
  sendRawKeyboard(keysToSend, checkCapsAndShiftLocks) {
@@ -162,15 +162,17 @@ export class Machine {
162
162
  }
163
163
 
164
164
  if (discImage) {
165
- startImageLoad(`disc ${discImage}`, async () =>
166
- drives.putDiscIn(0, await media.loadDiscImage(discImage, drives.layoutForDrive(0))),
167
- );
165
+ startImageLoad(`disc ${discImage}`, async () => {
166
+ const loadedDisc = await media.loadDiscImage(discImage, drives.layoutForDrive(0));
167
+ if (loadedDisc) drives.putDiscIn(0, loadedDisc);
168
+ });
168
169
  }
169
170
 
170
171
  if (secondDiscImage) {
171
- startImageLoad(`disc ${secondDiscImage}`, async () =>
172
- drives.putDiscIn(1, await media.loadDiscImage(secondDiscImage, drives.layoutForDrive(1))),
173
- );
172
+ startImageLoad(`disc ${secondDiscImage}`, async () => {
173
+ const loadedDisc = await media.loadDiscImage(secondDiscImage, drives.layoutForDrive(1));
174
+ if (loadedDisc) drives.putDiscIn(1, loadedDisc);
175
+ });
174
176
  }
175
177
 
176
178
  if (tape) {
@@ -210,8 +210,9 @@ export class MediaLoader extends EventTarget {
210
210
  }
211
211
 
212
212
  async loadSCSIFile(file) {
213
- const binaryData = await readFileAsBinaryString(file);
214
213
  const { processor } = this;
214
+ if (!processor.filestore) return;
215
+ const binaryData = await readFileAsBinaryString(file);
215
216
  processor.filestore.scsi = utils.stringToUint8Array(binaryData);
216
217
 
217
218
  processor.filestore.PC = 0x400;
@@ -1,7 +1,6 @@
1
1
  import * as disc from "../fdc.js";
2
2
  import { DiscLayout } from "../disc.js";
3
3
  import { downloadBlob } from "../dom-utils.js";
4
- import { toast } from "./toast.js";
5
4
  import {
6
5
  createSnapshot,
7
6
  restoreSnapshot,
@@ -58,9 +57,14 @@ async function readSnapshot(arrayBuffer) {
58
57
  * where they have one, the image bytes where they do not, and CRCs for
59
58
  * saying when a source has changed underneath a state.
60
59
  */
61
- export function snapshotMedia(fdcDrives, params) {
60
+ export function snapshotMedia(fdcDrives, params, defaultBootDisc) {
62
61
  const manifest = {};
62
+ const drive0Disc = fdcDrives[0].disc;
63
63
  if (params.disc1 || params.disc) manifest.disc1 = params.disc1 || params.disc;
64
+ // A default boot loads the built-in disc without naming it in the URL; record it by
65
+ // name so the state can reload it, but only while that disc is still in the drive.
66
+ else if (defaultBootDisc && drive0Disc && drive0Disc.name === defaultBootDisc && !drive0Disc.originalImageData)
67
+ manifest.disc1 = defaultBootDisc;
64
68
  if (params.disc2) manifest.disc2 = params.disc2;
65
69
 
66
70
  // For each drive with a disc loaded, include CRC32 for verification
@@ -84,7 +88,7 @@ export function snapshotMedia(fdcDrives, params) {
84
88
 
85
89
  /** Saving and restoring states: the menu item, the file input and the reload across a model change. */
86
90
  export class SnapshotUI {
87
- constructor({ processor, model, video, media, drives, urlState, modals, loop }) {
91
+ constructor({ processor, model, video, media, drives, urlState, modals, loop, defaultBootDisc }) {
88
92
  this.processor = processor;
89
93
  this.model = model;
90
94
  this.video = video;
@@ -93,6 +97,7 @@ export class SnapshotUI {
93
97
  this.urlState = urlState;
94
98
  this.modals = modals;
95
99
  this.loop = loop;
100
+ this.defaultBootDisc = defaultBootDisc;
96
101
 
97
102
  document.getElementById("save-state").addEventListener("click", async (event) => {
98
103
  event.preventDefault();
@@ -110,22 +115,27 @@ export class SnapshotUI {
110
115
  async saveState() {
111
116
  const wasRunning = this.loop.isRunning();
112
117
  if (wasRunning) this.loop.stop(false);
118
+ let failure = null;
113
119
  try {
114
- const manifest = snapshotMedia(this.processor.fdc.drives, this.urlState.params);
120
+ const manifest = snapshotMedia(this.processor.fdc.drives, this.urlState.params, this.defaultBootDisc);
115
121
  const snapshot = createSnapshot(this.processor, this.model, manifest);
116
122
  const json = snapshotToJSON(snapshot);
117
123
  const blob = await compressBlob(new Blob([json]));
118
124
  const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
119
125
  downloadBlob(blob, `jsbeeb-${this.model.name}-${timestamp}.json.gz`);
120
126
  } catch (e) {
121
- this.modals.showError("saving state", e);
127
+ failure = e;
122
128
  }
129
+ // Resume before reporting failure: the error dialog pauses a running
130
+ // loop itself, and resumes it on close.
123
131
  if (wasRunning) this.loop.go();
132
+ if (failure) this.modals.showError("saving state", failure);
124
133
  }
125
134
 
126
135
  async loadStateFromFile(file, preReadBuffer) {
127
136
  const wasRunning = this.loop.isRunning();
128
137
  if (wasRunning) this.loop.stop(false);
138
+ let failure = null;
129
139
  try {
130
140
  const arrayBuffer = preReadBuffer || (await file.arrayBuffer());
131
141
  const snapshot = await readSnapshot(arrayBuffer);
@@ -142,9 +152,10 @@ export class SnapshotUI {
142
152
  // Force a repaint so the display updates even while paused
143
153
  this.video.paint();
144
154
  } catch (e) {
145
- this.modals.showError("loading state", e);
155
+ failure = e;
146
156
  }
147
157
  if (wasRunning) this.loop.go();
158
+ if (failure) this.modals.showError("loading state", failure);
148
159
  }
149
160
 
150
161
  /** Picks up the state a cross-model reload stashed, once the matching machine is up. */
@@ -192,16 +203,38 @@ export class SnapshotUI {
192
203
  // Retain the image bytes so subsequent saves can re-embed them.
193
204
  loadedDisc.setOriginalImage(imageData);
194
205
  }
195
- if (!loadedDisc) continue;
196
-
197
- // Verify CRC32 if present
198
- if (savedMedia[crcKey] != null && loadedDisc.originalImageCrc32 != null) {
199
- if (loadedDisc.originalImageCrc32 !== savedMedia[crcKey]) {
200
- toast(
201
- `${loadedDisc.name} has changed since this state was saved. The state has been restored anyway and may not run correctly.`,
202
- { title: "Restoring state" },
206
+ if (!loadedDisc) {
207
+ if (savedMedia[crcKey] != null) {
208
+ // A state may name no source (older default-boot saves); the disc
209
+ // already in the drive can still satisfy the CRC, but only laid out
210
+ // the way the state's dirty tracks expect.
211
+ const currentDisc = this.processor.fdc.drives[driveIndex].disc;
212
+ const currentLayout = currentDisc?.is40Track ? DiscLayout.expanded40 : DiscLayout.contiguous;
213
+ if (
214
+ currentDisc &&
215
+ currentDisc.originalImageCrc32 === savedMedia[crcKey] &&
216
+ currentLayout === layout
217
+ )
218
+ continue;
219
+ const problem = savedMedia[discKey]
220
+ ? `The disc for drive ${driveIndex} (${savedMedia[discKey]}) could not be reloaded`
221
+ : `This state does not record where the disc in drive ${driveIndex} came from`;
222
+ throw new Error(
223
+ `${problem}, and the drive does not hold a matching disc. ` +
224
+ `Load the right disc, then load the state again.`,
203
225
  );
204
226
  }
227
+ continue;
228
+ }
229
+
230
+ if (
231
+ savedMedia[crcKey] != null &&
232
+ loadedDisc.originalImageCrc32 != null &&
233
+ loadedDisc.originalImageCrc32 !== savedMedia[crcKey]
234
+ ) {
235
+ throw new Error(
236
+ `${loadedDisc.name} has changed since this state was saved, so the state cannot be restored over it.`,
237
+ );
205
238
  }
206
239
 
207
240
  this.drives.putDiscIn(driveIndex, loadedDisc);