jsbeeb 1.13.1 → 1.15.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/README.md CHANGED
@@ -11,6 +11,7 @@ different peripherals.
11
11
  ## Table of Contents
12
12
 
13
13
  - [Keyboard Mappings](#keyboard-mappings)
14
+ - [Remapping Keys](#remapping-keys)
14
15
  - [Emulator Shortcuts](#emulator-shortcuts)
15
16
  - [Save State and Rewind](#save-state-and-rewind)
16
17
  - [Getting Set Up to Run Locally](#getting-set-up-to-run-locally)
@@ -36,6 +37,65 @@ The BBC had a somewhat different-looking keyboard to a modern PC, and so it's us
36
37
  To play right now, visit [https://bbc.xania.org/](https://bbc.xania.org/). To load the default disc image (Elite in this
37
38
  case), press shift-F12 (which is shift-Break on the BBC).
38
39
 
40
+ ### Remapping Keys
41
+
42
+ Plenty of games use keys that are awkward on a modern keyboard: `COPY` (which is `End`, or `fn`+`→` on a Mac), or
43
+ `CAPS LOCK` (which on a Mac toggles rather than acting as a key you hold down). Any host key can be made to press any
44
+ BBC key by adding a `KEY.` parameter to the URL:
45
+
46
+ ```
47
+ KEY.<host key>=<BBC key>
48
+ ```
49
+
50
+ Add one for each key you want to change. For example, Superior Software's Space Invaders fires with `COPY`; this makes
51
+ `Enter` fire instead:
52
+
53
+ [`https://bbc.xania.org/?disc1=sth:Superior/SpaceInvaders-Superior.zip&autoboot&KEY.ENTER=COPY`](https://bbc.xania.org/?disc1=sth%3ASuperior%2FSpaceInvaders-Superior.zip&autoboot&KEY.ENTER=COPY)
54
+
55
+ Superior's Frogger uses `A`/`Z`/`DELETE`/`COPY` to move; this puts it on the arrow keys:
56
+
57
+ [`https://bbc.xania.org/?disc1=sth:Superior/Frogger-Superior.zip&autoboot&KEY.UP=A&KEY.DOWN=Z&KEY.LEFT=DELETE&KEY.RIGHT=COPY`](https://bbc.xania.org/?disc1=sth%3ASuperior%2FFrogger-Superior.zip&autoboot&KEY.UP=A&KEY.DOWN=Z&KEY.LEFT=DELETE&KEY.RIGHT=COPY)
58
+
59
+ And Superior's Hunchback steers with `CAPS LOCK` and `CTRL`, which the arrow keys can stand in for:
60
+
61
+ [`https://bbc.xania.org/?disc1=sth:Superior/Hunchback-Superior.zip&autoboot&KEY.LEFT=CAPSLOCK&KEY.RIGHT=CTRL`](https://bbc.xania.org/?disc1=sth%3ASuperior%2FHunchback-Superior.zip&autoboot&KEY.LEFT=CAPSLOCK&KEY.RIGHT=CTRL)
62
+
63
+ The **host key** names are jsbeeb's names for the keys on your own keyboard. Most are what you'd expect, but note:
64
+
65
+ - `ENTER` (the BBC's `RETURN` key is called `ENTER` on the host side)
66
+ - `K0` to `K9` for the number keys, `NUMPAD0` to `NUMPAD9` for the keypad
67
+ - `SHIFT_LEFT` / `SHIFT_RIGHT`, `CTRL_LEFT` / `CTRL_RIGHT`, `ALT_LEFT` / `ALT_RIGHT` to distinguish the two of each
68
+ - `BACK_QUOTE`, `APOSTROPHE`, `SEMICOLON`, `MINUS`, `EQUALS`, `HASH`, `BACKSLASH`, `LEFT_SQUARE_BRACKET`,
69
+ `RIGHT_SQUARE_BRACKET` for punctuation
70
+
71
+ The **BBC key** names are:
72
+
73
+ ```
74
+ RETURN COPY DELETE ESCAPE TAB SPACE SHIFT SHIFTLOCK CAPSLOCK CTRL
75
+ LEFT RIGHT UP DOWN
76
+ A-Z, K0-K9 (the number keys), F0-F9 (the red function keys)
77
+ SEMICOLON_PLUS MINUS COMMA PERIOD SLASH AT COLON_STAR HAT_TILDE
78
+ UNDERSCORE_POUND PIPE_BACKSLASH LEFT_SQUARE_BRACKET RIGHT_SQUARE_BRACKET
79
+
80
+ (and, on the Master's numeric keypad only)
81
+ NUMPAD0-NUMPAD9 NUMPADPLUS NUMPADMINUS NUMPADSLASH NUMPADASTERISK NUMPADCOMMA
82
+ NUMPADHASH NUMPADENTER NUMPAD_DELETE NUMPAD_DECIMAL_POINT
83
+ ```
84
+
85
+ Some things to know:
86
+
87
+ - Names are case-insensitive, and a remapped key ignores the `SHIFT` state, so `KEY.ENTER=COPY` presses `COPY` whether
88
+ or not shift is held.
89
+ - Remapping replaces what that host key normally does; in the Space Invaders example above, `Enter` no longer presses
90
+ `RETURN`.
91
+ - The remapping is applied on top of whichever keyboard layout is selected, and survives changing layout or model.
92
+ - If a name isn't recognised the mapping is skipped, and the emulator says so on startup, naming the parameter that
93
+ was at fault.
94
+ - On the Atom, use the Atom's own key names (`LOCK`, `UP_DOWN`, `LEFT_RIGHT` and so on) rather than the BBC's.
95
+
96
+ The definitive lists are `keyCodes` (host) and `BBC` (BBC micro) in [`src/utils.js`](src/utils.js), and `ATOM` in
97
+ [`src/utils_atom.js`](src/utils_atom.js).
98
+
39
99
  ### Emulator Shortcuts
40
100
 
41
101
  | Shortcut | Action |
@@ -63,6 +123,35 @@ jsbeeb supports both USB/Bluetooth gamepads and mouse-based analogue joystick em
63
123
  - X-axis: Left = 65535, Right = 0
64
124
  - Y-axis: Up = 65535, Down = 0
65
125
 
126
+ A gamepad presses BBC keys, and which key each control presses can be changed from the URL in the same way as
127
+ [remapping the keyboard](#remapping-keys):
128
+
129
+ ```
130
+ GP.<gamepad control>=<BBC key>
131
+ ```
132
+
133
+ By default the D-pad presses the "Snapper" keys (`Z`, `X`, `:`, `/`), the `A` button presses `RETURN` and `Start`
134
+ presses `SPACE`. To play Superior's Space Invaders on a pad, where `COPY` fires:
135
+
136
+ [`https://bbc.xania.org/?disc1=sth:Superior/SpaceInvaders-Superior.zip&autoboot&GP.FIRE=COPY`](https://bbc.xania.org/?disc1=sth%3ASuperior%2FSpaceInvaders-Superior.zip&autoboot&GP.FIRE=COPY)
137
+
138
+ The gamepad control names are:
139
+
140
+ - `FIRE` — every button at once, which is usually what you want for a one-button game
141
+ - `UP` `DOWN` `LEFT` `RIGHT` — both analogue sticks at once, plus one face button each (`UP` is also `A`, `DOWN` is
142
+ `X`, `LEFT` is `Y`, `RIGHT` is `B`)
143
+ - `UP1` `DOWN1` `LEFT1` `RIGHT1` — the left stick only; `UP2` `DOWN2` … — the right stick only; `UP3` `DOWN3` … — the
144
+ face buttons only
145
+ - `A` `B` `X` `Y` `START` `BACK` `LB` `RB` `LT` `RT` — individual buttons, by their Xbox 360 names
146
+ - `FIRE1` `FIRE2` — clicking the left and right sticks
147
+
148
+ The BBC key names are the same as for the keyboard, and digits may be written either way round: `GP.A=1` and `GP.A=K1`
149
+ both press `1`. Unlike `KEY.`, gamepad mappings are BBC-only: there's no Atom equivalent. The D-pad's default mapping
150
+ can't currently be changed.
151
+
152
+ The older `LEFT=`, `RIGHT=`, `UP=`, `DOWN=` and `FIRE=` parameters (no `GP.` prefix) still work and mean the same
153
+ thing.
154
+
66
155
  ## Getting Set Up to Run Locally
67
156
 
68
157
  ### Prerequisites
@@ -157,6 +246,8 @@ sudo rpm -i out/dist/jsbeeb-1.0.1.x86_64.rpm
157
246
  - `disc1=sth:ZZZ` - loads disc ZZZ from the Stairway to Hell archive
158
247
  - `tape=XXX` - loads tape XXX (from the `tapes/` directory)
159
248
  - `tape=sth:ZZZ` - loads tape ZZZ from the Stairway to Hell archive
249
+ - `KEY.X=Y` - makes host key `X` press BBC key `Y`, e.g. `KEY.ENTER=COPY`. See
250
+ [Remapping Keys](#remapping-keys).
160
251
  - `patch=P` - applies a memory patch `P`. See below.
161
252
  - `loadBasic=X` - loads 'X' (a resource on the webserver) as text, tokenises it and puts it in `PAGE` as if you'd typed
162
253
  it in to the emulator
@@ -167,8 +258,12 @@ sudo rpm -i out/dist/jsbeeb-1.0.1.x86_64.rpm
167
258
  - `autotype` - types whatever you put after. e.g. `&autotype=PRINT"Matt is cool"%0a` (return is URI escaped to `%0a`)
168
259
  - `embed` - Remove the margins around the screen, hide most navigation entries and make the page background
169
260
  transparent (intended for use when running within an iframe in a third-party site).
170
- - `cpuMultiplier=X` speeds up the CPU by a factor of `X`. May be fractional or below one to slow the CPU down. NB disc
171
- loads become unreliable with a too-slow CPU, and running too fast might cause the browser to hang.
261
+ - `cpuMultiplier=X` speeds up the CPU by a factor of `X` relative to the peripherals: video, sound and the VIAs keep
262
+ running at their real-world rates. May be fractional or below one to slow the CPU down. NB disc loads become
263
+ unreliable with a too-slow CPU, and running too fast might cause the browser to hang.
264
+ - `tubeCpuMultiplier=X` overclocks the second processor by a factor of `X`, which may be fractional. `1`, the default,
265
+ runs it at the real part's own clock: 3MHz for the 6502 second processor a BBC B takes, 4MHz for the 65C102 Turbo
266
+ board a Master takes. Below about 2.2MHz the MOS's unhandshaken tube transfers lose data.
172
267
  - `sbLeft` / `sbRight` / `sbBottom` - a URL to place left of, right of, or below the cub monitor. The left and right
173
268
  should be around 648 high and the bottom image should be around 896 wide. Left and right wider than 300 will run into
174
269
  problems on smaller screens; bottom taller than 100 or so similarly.
package/package.json CHANGED
@@ -7,10 +7,10 @@
7
7
  "name": "jsbeeb",
8
8
  "description": "Emulate a BBC Micro",
9
9
  "repository": "git@github.com:mattgodbolt/jsbeeb.git",
10
- "version": "1.13.1",
11
- "//": "If you change the version of Node, it must also be updated at the top of the Dockerfile.",
10
+ "version": "1.15.0",
11
+ "//engines": "If you change the version of Node, it must also be updated at the top of the Dockerfile.",
12
12
  "engines": {
13
- "node": ">=22.12.0"
13
+ "node": ">=24.15.0"
14
14
  },
15
15
  "type": "module",
16
16
  "main": "./src/app/app.js",
@@ -27,33 +27,37 @@
27
27
  ],
28
28
  "dependencies": {
29
29
  "@popperjs/core": "^2.11.8",
30
- "argparse": "^2.0.1",
30
+ "argparse": "^3.0.0",
31
31
  "bootstrap": "^5.3.8",
32
32
  "bootswatch": "^5.3.8",
33
- "sharp": "^0.34.5",
33
+ "sharp": "^0.35.3",
34
34
  "smoothie": "^1.36.1"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@eslint/js": "^10.0.1",
38
38
  "@vitest/coverage-v8": "^4.0.18",
39
- "eslint": "^10.2.0",
39
+ "eslint": "^10.7.0",
40
40
  "eslint-config-prettier": "^10.1.8",
41
- "eslint-plugin-prettier": "^5.5.5",
42
- "globals": "^17.5.0",
41
+ "eslint-plugin-prettier": "^5.5.6",
42
+ "globals": "^17.7.0",
43
43
  "husky": "^9.1.7",
44
- "jsdom": "^29.0.2",
45
- "lint-staged": "^16.4.0",
46
- "npm-run-all2": "^8.0.4",
47
- "pixelmatch": "^7.1.0",
48
- "prettier": "^3.8.2",
49
- "vite": "^8.0.8",
44
+ "jsdom": "^30.0.1",
45
+ "lint-staged": "^17.1.1",
46
+ "npm-run-all2": "^9.0.2",
47
+ "pixelmatch": "^7.2.0",
48
+ "prettier": "^3.9.6",
49
+ "vite": "^8.1.5",
50
50
  "vitest": "^4.0.10"
51
51
  },
52
52
  "optionalDependencies": {
53
- "electron": "^41.2.0",
54
- "electron-builder": "^26.8.1",
53
+ "electron": "^43.2.0",
54
+ "electron-builder": "^26.15.3",
55
55
  "electron-store": "^11.0.2"
56
56
  },
57
+ "//overrides": "brace-expansion forced to the patched version for GHSA-mh99-v99m-4gvg; the electron-builder dependency chain still pins vulnerable versions. Remove once `npm audit` is clean without it.",
58
+ "overrides": {
59
+ "brace-expansion": "^5.0.8"
60
+ },
57
61
  "license": "GPL-3.0-or-later",
58
62
  "build": {
59
63
  "appId": "org.godbolt.bbc",
@@ -108,5 +112,8 @@
108
112
  "prettier --write"
109
113
  ],
110
114
  "*.{md,json,yml,yaml,css,html}": "prettier --write"
115
+ },
116
+ "allowScripts": {
117
+ "electron-winstaller@5.4.0": true
111
118
  }
112
119
  }
Binary file
package/src/6502.js CHANGED
@@ -407,11 +407,14 @@ class Base6502 {
407
407
  }
408
408
 
409
409
  class Tube6502 extends Base6502 {
410
- constructor(model, cpu) {
410
+ constructor(model, cpu, { cpuMultiplier = 1 } = {}) {
411
411
  super(model, { cycleAccurate: false });
412
412
 
413
+ if (!(cpuMultiplier > 0)) throw new Error(`Tube CPU multiplier must be positive, got ${cpuMultiplier}`);
414
+
413
415
  this.cycles = 0;
414
- this.cpuMultiplier = 2;
416
+ this.cyclesPerHostCycle = model.clockMhz / cpu.model.clockMhz;
417
+ this.cpuMultiplier = cpuMultiplier;
415
418
  this.romPaged = true;
416
419
  this.memory = new Uint8Array(65536);
417
420
  this.rom = new Uint8Array(4096);
@@ -424,6 +427,10 @@ class Tube6502 extends Base6502 {
424
427
  this.pc = this.readmem(0xfffc) | (this.readmem(0xfffd) << 8);
425
428
  this.p.i = true;
426
429
  this.s = (this.s - 3) & 0xff; // Simulate 3 dummy pushes during reset
430
+ // A latched NMI would otherwise survive the reset and be taken on the first instruction
431
+ // after it, defeating the empty R3 FIFO the ULA seeds for exactly that reason.
432
+ this._nmiLevel = this._nmiEdge = false;
433
+ this.takeInt = false;
427
434
  this.tube.reset(hard);
428
435
  }
429
436
 
@@ -472,7 +479,7 @@ class Tube6502 extends Base6502 {
472
479
  }
473
480
 
474
481
  execute(cycles) {
475
- this.cycles += (cycles * this.cpuMultiplier) | 0;
482
+ this.cycles += cycles * this.cyclesPerHostCycle * this.cpuMultiplier;
476
483
  if (this.cycles < 3) return;
477
484
  while (this.cycles > 0) {
478
485
  const opcode = this.readmem(this.pc);
@@ -482,6 +489,46 @@ class Tube6502 extends Base6502 {
482
489
  }
483
490
  }
484
491
 
492
+ snapshotState({ includeRoms = false } = {}) {
493
+ return {
494
+ a: this.a,
495
+ x: this.x,
496
+ y: this.y,
497
+ s: this.s,
498
+ pc: this.pc,
499
+ p: this.p.asByte(),
500
+ nmiLevel: this._nmiLevel,
501
+ nmiEdge: this._nmiEdge,
502
+ takeInt: this.takeInt,
503
+ // The parasite has no scheduler of its own, so this is not relative to any epoch.
504
+ cycles: this.cycles,
505
+ romPaged: this.romPaged,
506
+ memory: this.memory.slice(),
507
+ rom: includeRoms ? this.rom.slice() : undefined,
508
+ ula: this.tube.snapshotState(),
509
+ };
510
+ }
511
+
512
+ restoreState(state) {
513
+ this.a = state.a;
514
+ this.x = state.x;
515
+ this.y = state.y;
516
+ this.s = state.s;
517
+ this.pc = state.pc;
518
+ this.p.setFromByte(state.p);
519
+ this._nmiLevel = state.nmiLevel;
520
+ this._nmiEdge = state.nmiEdge;
521
+ this.takeInt = state.takeInt;
522
+ this.cycles = state.cycles;
523
+ this.romPaged = state.romPaged;
524
+ this.memory.set(state.memory);
525
+ if (state.rom) this.rom.set(state.rom);
526
+
527
+ // After the registers, so the edge-triggered NMI moves from its saved level to the one
528
+ // the ULA implies: no edge if they agree, and the edge it is owed if they do not.
529
+ this.tube.restoreState(state.ula);
530
+ }
531
+
485
532
  async loadOs() {
486
533
  console.log("Loading tube rom from roms/" + this.model.os);
487
534
  const tubeRom = this.rom;
@@ -620,10 +667,13 @@ export class Cpu6502 extends Base6502 {
620
667
  this.cpuMultiplier = this.config.cpuMultiplier;
621
668
  this.videoCyclesBatch = this.config.videoCyclesBatch | 0;
622
669
  this.peripheralCyclesPerSecond = 2 * 1000 * 1000;
623
- this.tube = model.tube ? new Tube6502(model.tube, this) : new FakeTube();
624
- if (model.tube && this.config.tubeCpuMultiplier) {
625
- this.tube.cpuMultiplier = this.config.tubeCpuMultiplier;
626
- }
670
+ this.hasTube = !!this.config.tube;
671
+ this.hasMusic5000 = !!this.config.hasMusic5000;
672
+ this.hasTeletextAdaptor = !!this.config.hasTeletextAdaptor;
673
+ this.teletextAdaptor = this.hasTeletextAdaptor ? new TeletextAdaptor(this) : null;
674
+ this.tube = this.hasTube
675
+ ? new Tube6502(this.config.tube, this, { cpuMultiplier: this.config.tubeCpuMultiplier })
676
+ : new FakeTube();
627
677
  this.music5000PageSel = 0;
628
678
  this.econet = econet;
629
679
 
@@ -648,7 +698,7 @@ export class Cpu6502 extends Base6502 {
648
698
  getGamepads: this.config.getGamepads,
649
699
  });
650
700
  this.uservia = new via.UserVia(this, this.scheduler, this.model.isMaster, this.config.userPort);
651
- this.acia = new Acia(this, this.soundChip.toneGenerator, this.scheduler, this.touchScreen, this.relayNoise);
701
+ this.acia = new Acia(this, this.soundChip.toneGenerator, this.scheduler, this.relayNoise);
652
702
  this.serial = new Serial(this.acia);
653
703
  this.adconverter = new Adc(this.sysvia, this.scheduler);
654
704
  this.soundChip.setScheduler(this.scheduler);
@@ -799,7 +849,7 @@ export class Cpu6502 extends Base6502 {
799
849
 
800
850
  switch (addr & ~0x0003) {
801
851
  case 0xfc10:
802
- if (this.model.hasTeletextAdaptor) return this.teletextAdaptor.read(addr - 0xfc10);
852
+ if (this.hasTeletextAdaptor) return this.teletextAdaptor.read(addr - 0xfc10);
803
853
  break;
804
854
  case 0xfc20:
805
855
  case 0xfc24:
@@ -822,7 +872,7 @@ export class Cpu6502 extends Base6502 {
822
872
  // IDE
823
873
  break;
824
874
  case 0xfcfc:
825
- if (addr === 0xfcff && this.model.hasMusic5000) return this.music5000PageSel;
875
+ if (addr === 0xfcff && this.hasMusic5000) return this.music5000PageSel;
826
876
  break;
827
877
  case 0xfe00:
828
878
  case 0xfe04:
@@ -909,7 +959,7 @@ export class Cpu6502 extends Base6502 {
909
959
  return this.tube.read(addr);
910
960
  }
911
961
 
912
- if (this.model.hasMusic5000) {
962
+ if (this.hasMusic5000) {
913
963
  if ((this.music5000PageSel & 0xf0) === 0x30 && (addr & 0xff00) === 0xfd00) {
914
964
  return this.music5000.read(this.music5000PageSel, addr);
915
965
  }
@@ -966,14 +1016,14 @@ export class Cpu6502 extends Base6502 {
966
1016
  addr &= 0xffff;
967
1017
  b |= 0;
968
1018
 
969
- if (this.model.hasMusic5000 && (addr & 0xff00) === 0xfd00 && (this.music5000PageSel & 0xf0) === 0x30) {
1019
+ if (this.hasMusic5000 && (addr & 0xff00) === 0xfd00 && (this.music5000PageSel & 0xf0) === 0x30) {
970
1020
  this.music5000.write(this.music5000PageSel, addr, b);
971
1021
  return;
972
1022
  }
973
1023
 
974
1024
  switch (addr & ~0x0003) {
975
1025
  case 0xfc10:
976
- if (this.model.hasTeletextAdaptor) return this.teletextAdaptor.write(addr - 0xfc10, b);
1026
+ if (this.hasTeletextAdaptor) return this.teletextAdaptor.write(addr - 0xfc10, b);
977
1027
  break;
978
1028
  case 0xfc20:
979
1029
  case 0xfc24:
@@ -996,7 +1046,7 @@ export class Cpu6502 extends Base6502 {
996
1046
  // IDE
997
1047
  break;
998
1048
  case 0xfcfc:
999
- if (addr === 0xfcff && this.model.hasMusic5000) {
1049
+ if (addr === 0xfcff && this.hasMusic5000) {
1000
1050
  this.music5000PageSel = b;
1001
1051
  }
1002
1052
  break;
@@ -1183,6 +1233,7 @@ export class Cpu6502 extends Base6502 {
1183
1233
  acia: this.acia.snapshotState(),
1184
1234
  adc: this.adconverter.snapshotState(),
1185
1235
  fdc: this.fdc.snapshotState(),
1236
+ tube: this.hasTube ? this.tube.snapshotState({ includeRoms }) : undefined,
1186
1237
  };
1187
1238
  }
1188
1239
 
@@ -1243,6 +1294,13 @@ export class Cpu6502 extends Base6502 {
1243
1294
  if (state.fdc) {
1244
1295
  this.fdc.restoreState(state.fdc);
1245
1296
  }
1297
+
1298
+ // Tube state (v3+). Rather than leave the parasite running on state the host knows
1299
+ // nothing about, reset it; restoreSnapshot() rejects such a pairing before reaching here.
1300
+ if (this.hasTube) {
1301
+ if (state.tube) this.tube.restoreState(state.tube);
1302
+ else this.tube.reset(true);
1303
+ }
1246
1304
  }
1247
1305
 
1248
1306
  reset(hard) {
@@ -1304,7 +1362,6 @@ export class Cpu6502 extends Base6502 {
1304
1362
  this.adconverter.reset();
1305
1363
 
1306
1364
  this.touchScreen = new TouchScreen(this.scheduler);
1307
- if (this.model.hasTeletextAdaptor) this.teletextAdaptor = new TeletextAdaptor(this);
1308
1365
  if (this.econet) this.filestore = new Filestore(this, this.econet);
1309
1366
  }
1310
1367
 
@@ -1364,7 +1421,7 @@ export class Cpu6502 extends Base6502 {
1364
1421
  // Builds common code between polltimeSlow and polltimeFast
1365
1422
  buildPolltime() {
1366
1423
  const nop = (_cycles) => {};
1367
- const tubeStuff = (cycles) => (this.model.tube ? this.tube.execute(cycles) : nop);
1424
+ const tubeStuff = this.hasTube ? (cycles) => this.tube.execute(cycles) : nop;
1368
1425
  const teletextStuff = this.teletextAdaptor ? (cycles) => this.teletextAdaptor.polltime(cycles) : nop;
1369
1426
  const musicStuff = this.music5000 ? (cycles) => this.music5000.polltime(cycles) : nop;
1370
1427
  const econetStuff = this.econet
@@ -1506,7 +1563,7 @@ export class Cpu6502 extends Base6502 {
1506
1563
  if (this.model.os.length) {
1507
1564
  await this.loadOs.apply(this, this.model.os);
1508
1565
  }
1509
- if (this.model.tube) {
1566
+ if (this.hasTube) {
1510
1567
  await this.tube.loadOs();
1511
1568
  }
1512
1569
  this.reset(true);
package/src/acia.js CHANGED
@@ -5,10 +5,10 @@
5
5
  // http://www.classiccmp.org/dunfield/r/6850.pdf
6
6
 
7
7
  export class Acia {
8
- constructor(cpu, toneGen, scheduler, rs423Handler, relayNoise) {
8
+ constructor(cpu, toneGen, scheduler, relayNoise) {
9
9
  this.cpu = cpu;
10
10
  this.toneGen = toneGen;
11
- this.rs423Handler = rs423Handler;
11
+ this.rs423Handler = null;
12
12
  this.relayNoise = relayNoise;
13
13
 
14
14
  this.sr = 0x00;
@@ -22,17 +22,27 @@ export class Acia {
22
22
  this.hadDcdHigh = false;
23
23
  this.serialReceiveRate = 0;
24
24
  this.serialReceiveCyclesPerByte = 0;
25
+ this.serialTransmitRate = 0;
26
+ this.serialTransmitCyclesPerByte = 0;
25
27
 
26
28
  this.setSerialReceive(19200);
29
+ this.setSerialTransmit(19200);
27
30
  this.txCompleteTask = scheduler.newTask(() => {
28
31
  this.sr |= 0x02; // set the TDRE
32
+ this.updateIrq();
29
33
  });
30
34
  this.runTapeTask = scheduler.newTask(() => this.runTape());
31
35
  this.runRs423Task = scheduler.newTask(() => this.runRs423());
32
36
  }
33
37
 
38
+ // MC6850: the transmit interrupt is selected by CR6:CR5 = 01 and follows
39
+ // TDRE, which a high CTS inhibits.
40
+ txIrq() {
41
+ return (this.cr & 0x60) === 0x20 && (this.sr & 0x0a) === 0x02;
42
+ }
43
+
34
44
  updateIrq() {
35
- if (this.sr & this.cr & 0x80) {
45
+ if (this.sr & this.cr & 0x80 || this.txIrq()) {
36
46
  this.cpu.interrupt |= 0x04;
37
47
  } else {
38
48
  this.cpu.interrupt &= ~0x04;
@@ -77,6 +87,7 @@ export class Acia {
77
87
  return this.dr;
78
88
  } else {
79
89
  let result = (this.sr & 0x7f) | (this.sr & this.cr & 0x80);
90
+ if (this.txIrq()) result |= 0x80;
80
91
  // MC6850: "A low CTS indicates that there is a Clear-to-Send
81
92
  // from the modem. In the high state, the Transmit Data Register
82
93
  // Empty bit is inhibited".
@@ -100,10 +111,7 @@ export class Acia {
100
111
  write(addr, val) {
101
112
  if (addr & 1) {
102
113
  this.sr &= ~0x02;
103
- // It's not clear how long this can take; it's when the shift register is loaded.
104
- // That could be straight away if not already tx-ing, but as we don't really tx,
105
- // be conservative here.
106
- this.txCompleteTask.reschedule(2000);
114
+ this.txCompleteTask.reschedule(this.serialTransmitCyclesPerByte);
107
115
  this.updateIrq();
108
116
  if (this.rs423Selected && this.rs423Handler) this.rs423Handler.onTransmit(val);
109
117
  } else {
@@ -114,6 +122,8 @@ export class Acia {
114
122
  } else {
115
123
  this.cr = val;
116
124
  this.setSerialReceive(this.serialReceiveRate);
125
+ this.setSerialTransmit(this.serialTransmitRate);
126
+ this.updateIrq();
117
127
  }
118
128
  }
119
129
  }
@@ -212,7 +222,7 @@ export class Acia {
212
222
  tapeDcdLineLevel: this.tapeDcdLineLevel,
213
223
  hadDcdHigh: this.hadDcdHigh,
214
224
  serialReceiveRate: this.serialReceiveRate,
215
- serialReceiveCyclesPerByte: this.serialReceiveCyclesPerByte,
225
+ serialTransmitRate: this.serialTransmitRate,
216
226
  txCompleteTaskOffset: this.txCompleteTask.scheduled()
217
227
  ? this.txCompleteTask.expireEpoch - scheduler.epoch
218
228
  : null,
@@ -230,8 +240,10 @@ export class Acia {
230
240
  this.tapeCarrierCount = state.tapeCarrierCount;
231
241
  this.tapeDcdLineLevel = state.tapeDcdLineLevel;
232
242
  this.hadDcdHigh = state.hadDcdHigh;
233
- this.serialReceiveRate = state.serialReceiveRate;
234
- this.serialReceiveCyclesPerByte = state.serialReceiveCyclesPerByte;
243
+ // The byte times are derived from the rates and the restored word format, not saved.
244
+ this.setSerialReceive(state.serialReceiveRate);
245
+ // Snapshots predating transmit timing have no saved rate.
246
+ this.setSerialTransmit(state.serialTransmitRate ?? 19200);
235
247
  this.updateIrq();
236
248
 
237
249
  this.txCompleteTask.cancel();
@@ -294,7 +306,8 @@ export class Acia {
294
306
  parityBits = 1;
295
307
  break;
296
308
  }
297
- return wordLength + stopBits + parityBits;
309
+ const startBits = 1;
310
+ return startBits + wordLength + stopBits + parityBits;
298
311
  }
299
312
 
300
313
  rts() {
@@ -307,6 +320,11 @@ export class Acia {
307
320
  this.serialReceiveCyclesPerByte = this.secondsToCycles(this.numBitsPerByte() / rate);
308
321
  }
309
322
 
323
+ setSerialTransmit(rate) {
324
+ this.serialTransmitRate = rate;
325
+ this.serialTransmitCyclesPerByte = this.secondsToCycles(this.numBitsPerByte() / rate);
326
+ }
327
+
310
328
  runTape() {
311
329
  if (this.tape) this.runTapeTask.reschedule(this.tape.poll(this));
312
330
  }