jsbeeb 1.14.0 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
@@ -170,6 +261,9 @@ sudo rpm -i out/dist/jsbeeb-1.0.1.x86_64.rpm
170
261
  - `cpuMultiplier=X` speeds up the CPU by a factor of `X` relative to the peripherals: video, sound and the VIAs keep
171
262
  running at their real-world rates. May be fractional or below one to slow the CPU down. NB disc loads become
172
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.
173
267
  - `sbLeft` / `sbRight` / `sbBottom` - a URL to place left of, right of, or below the cub monitor. The left and right
174
268
  should be around 648 high and the bottom image should be around 896 wide. Left and right wider than 300 will run into
175
269
  problems on smaller screens; bottom taller than 100 or so similarly.
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.14.0",
10
+ "version": "1.16.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"
@@ -54,10 +54,6 @@
54
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
- },
61
57
  "license": "GPL-3.0-or-later",
62
58
  "build": {
63
59
  "appId": "org.godbolt.bbc",
@@ -99,15 +95,21 @@
99
95
  "test:unit": "vitest run --silent=true tests/unit",
100
96
  "test:integration": "vitest run --silent=true tests/integration",
101
97
  "test:cpu": "node tests/test-suite.js",
98
+ "test:shader": "vitest run --silent=true tests/shader",
102
99
  "coverage:unit": "vitest run tests/unit --coverage",
103
100
  "coverage:all-tests": "vitest run --coverage",
104
101
  "benchmark": "node src/app-bench.js",
102
+ "mirror-sth": "node tools/mirror-sth.js --out .sth-mirror",
103
+ "mirror-sth:check": "node tools/mirror-sth.js --index-only",
104
+ "mirror-sth:upload": "npm-run-all mirror-sth:upload:*",
105
+ "mirror-sth:upload:blobs": "aws s3 sync .sth-mirror s3://bbc.xania.org/archive/sth/ --no-progress --exclude '*manifest.json' --exclude 'meta/*' --cache-control 'public, max-age=31536000, immutable'",
106
+ "mirror-sth:upload:index": "aws s3 sync .sth-mirror s3://bbc.xania.org/archive/sth/ --no-progress --exclude '*' --include '*manifest.json' --include 'meta/*' --cache-control 'public, max-age=300'",
105
107
  "electron": "npm run build && ELECTRON_DISABLE_SANDBOX=1 electron .",
106
108
  "electron:build": "electron-builder"
107
109
  },
108
110
  "lint-staged": {
109
111
  "*.js": [
110
- "vitest related --run --exclude tests/integration --no-file-parallelism",
112
+ "vitest related --run --exclude tests/integration --exclude tests/shader --no-file-parallelism",
111
113
  "eslint --cache --fix",
112
114
  "prettier --write"
113
115
  ],
Binary file
package/src/6502.js CHANGED
@@ -15,9 +15,6 @@ import { AtomMMC2 } from "./mmc.js";
15
15
 
16
16
  const signExtend = utils.signExtend;
17
17
 
18
- // Speed of the second processor relative to the host, as fitted to a Master Turbo.
19
- export const DefaultTubeCpuMultiplier = 2;
20
-
21
18
  function _set(byte, mask, set) {
22
19
  return (byte & ~mask) | (set ? mask : 0);
23
20
  }
@@ -216,6 +213,9 @@ class Base6502 {
216
213
  if (this._nmiLevel && !prevLevel) this._nmiEdge = true;
217
214
  }
218
215
 
216
+ /** Called as the NMI vector is taken, for devices that withdraw their request at that point. */
217
+ nmiAcknowledged() {}
218
+
219
219
  polltime() {
220
220
  throw new Error("Must be overridden");
221
221
  }
@@ -242,6 +242,7 @@ class Base6502 {
242
242
  if ((this.model.nmos || isIrq) && this._nmiEdge) {
243
243
  vector = 0xfffa;
244
244
  this._nmiEdge = false;
245
+ this.nmiAcknowledged();
245
246
  }
246
247
  this.takeInt = false;
247
248
  this.pc = this.readmem(vector) | (this.readmem(vector + 1) << 8);
@@ -410,10 +411,13 @@ class Base6502 {
410
411
  }
411
412
 
412
413
  class Tube6502 extends Base6502 {
413
- constructor(model, cpu, { cpuMultiplier = DefaultTubeCpuMultiplier } = {}) {
414
+ constructor(model, cpu, { cpuMultiplier = 1 } = {}) {
414
415
  super(model, { cycleAccurate: false });
415
416
 
417
+ if (!(cpuMultiplier > 0)) throw new Error(`Tube CPU multiplier must be positive, got ${cpuMultiplier}`);
418
+
416
419
  this.cycles = 0;
420
+ this.cyclesPerHostCycle = model.clockMhz / cpu.model.clockMhz;
417
421
  this.cpuMultiplier = cpuMultiplier;
418
422
  this.romPaged = true;
419
423
  this.memory = new Uint8Array(65536);
@@ -451,6 +455,10 @@ class Tube6502 extends Base6502 {
451
455
  return this.memory[offset & 0xffff];
452
456
  }
453
457
 
458
+ nmiAcknowledged() {
459
+ this.tube.acknowledgeNmi();
460
+ }
461
+
454
462
  writemem(addr, b) {
455
463
  if ((addr & 0xfff8) === 0xfef8) {
456
464
  return this.tube.parasiteWrite(addr, b);
@@ -479,7 +487,7 @@ class Tube6502 extends Base6502 {
479
487
  }
480
488
 
481
489
  execute(cycles) {
482
- this.cycles += (cycles * this.cpuMultiplier) | 0;
490
+ this.cycles += cycles * this.cyclesPerHostCycle * this.cpuMultiplier;
483
491
  if (this.cycles < 3) return;
484
492
  while (this.cycles > 0) {
485
493
  const opcode = this.readmem(this.pc);
@@ -666,10 +674,11 @@ export class Cpu6502 extends Base6502 {
666
674
  this.resetLine = true;
667
675
  this.cpuMultiplier = this.config.cpuMultiplier;
668
676
  this.videoCyclesBatch = this.config.videoCyclesBatch | 0;
669
- this.peripheralCyclesPerSecond = 2 * 1000 * 1000;
677
+ this.peripheralCyclesPerSecond = model.cyclesPerSecond;
670
678
  this.hasTube = !!this.config.tube;
671
679
  this.hasMusic5000 = !!this.config.hasMusic5000;
672
680
  this.hasTeletextAdaptor = !!this.config.hasTeletextAdaptor;
681
+ this.teletextAdaptor = this.hasTeletextAdaptor ? new TeletextAdaptor(this) : null;
673
682
  this.tube = this.hasTube
674
683
  ? new Tube6502(this.config.tube, this, { cpuMultiplier: this.config.tubeCpuMultiplier })
675
684
  : new FakeTube();
@@ -697,7 +706,7 @@ export class Cpu6502 extends Base6502 {
697
706
  getGamepads: this.config.getGamepads,
698
707
  });
699
708
  this.uservia = new via.UserVia(this, this.scheduler, this.model.isMaster, this.config.userPort);
700
- this.acia = new Acia(this, this.soundChip.toneGenerator, this.scheduler, this.touchScreen, this.relayNoise);
709
+ this.acia = new Acia(this, this.soundChip.toneGenerator, this.scheduler, this.relayNoise);
701
710
  this.serial = new Serial(this.acia);
702
711
  this.adconverter = new Adc(this.sysvia, this.scheduler);
703
712
  this.soundChip.setScheduler(this.scheduler);
@@ -1360,8 +1369,7 @@ export class Cpu6502 extends Base6502 {
1360
1369
  this.fdc.powerOnReset();
1361
1370
  this.adconverter.reset();
1362
1371
 
1363
- this.touchScreen = new TouchScreen(this.scheduler);
1364
- if (this.hasTeletextAdaptor) this.teletextAdaptor = new TeletextAdaptor(this);
1372
+ this.touchScreen = new TouchScreen(this.scheduler, this.model.cyclesPerSecond);
1365
1373
  if (this.econet) this.filestore = new Filestore(this, this.econet);
1366
1374
  }
1367
1375
 
@@ -1422,8 +1430,7 @@ export class Cpu6502 extends Base6502 {
1422
1430
  buildPolltime() {
1423
1431
  const nop = (_cycles) => {};
1424
1432
  const tubeStuff = this.hasTube ? (cycles) => this.tube.execute(cycles) : nop;
1425
- // The adaptor itself does not exist until reset() runs, so branch on the fitting, not the object.
1426
- const teletextStuff = this.hasTeletextAdaptor ? (cycles) => this.teletextAdaptor.polltime(cycles) : nop;
1433
+ const teletextStuff = this.teletextAdaptor ? (cycles) => this.teletextAdaptor.polltime(cycles) : nop;
1427
1434
  const musicStuff = this.music5000 ? (cycles) => this.music5000.polltime(cycles) : nop;
1428
1435
  const econetStuff = this.econet
1429
1436
  ? (cycles) => {
@@ -1479,9 +1486,10 @@ export class Cpu6502 extends Base6502 {
1479
1486
  // that from both, to keep the domain low (while accumulating seconds). Take care to preserve the bottom
1480
1487
  // bit though; as that encodes whether we're on an even or odd bus cycle.
1481
1488
  const smaller = Math.min(this.targetCycles, this.currentCycles) & 0xfffffffe;
1482
- if (smaller >= 2 * 1000 * 1000) {
1483
- this.targetCycles -= 2 * 1000 * 1000;
1484
- this.currentCycles -= 2 * 1000 * 1000;
1489
+ const cyclesPerSecond = this.model.cyclesPerSecond;
1490
+ if (smaller >= cyclesPerSecond) {
1491
+ this.targetCycles -= cyclesPerSecond;
1492
+ this.currentCycles -= cyclesPerSecond;
1485
1493
  this.cycleSeconds++;
1486
1494
  }
1487
1495
  // Any tracing or debugging means we need to run the potentially slower version: the debug read or
@@ -1615,8 +1623,6 @@ export class AtomCpu6502 extends Cpu6502 {
1615
1623
  this.ramRomOs = expanded;
1616
1624
  }
1617
1625
 
1618
- // Atom runs at 1 MHz
1619
- this.peripheralCyclesPerSecond = 1 * 1000 * 1000;
1620
1626
  // reset() and debugger.setCpu() are called by initialise() after loadOs().
1621
1627
  }
1622
1628
 
package/src/6847.js CHANGED
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  import * as utils from "./utils.js";
3
3
  import * as fontData from "./6847_fontdata.js";
4
+ import { encodeLineGrid } from "./video-filters/pixel-grid.js";
4
5
 
5
6
  const VDISPENABLE = 1 << 0,
6
7
  HDISPENABLE = 1 << 1,
@@ -111,6 +112,11 @@ only 1 bit is used of SG6 - to get yellow/red, cyan/orange
111
112
  // constant for the graphics mode
112
113
  const MODE_AG = 0x10; // graphics mode
113
114
 
115
+ // The MC6847 datasheet specifies 3.579545 MHz, but 3.638004 is used here as an
116
+ // empirical correction to align VSync/interrupt timing with the CPU clock.
117
+ // TODO: revisit once full integration is testable.
118
+ const VdgClockMhz = 3.638004;
119
+
114
120
  export class Video6847 {
115
121
  constructor(video) {
116
122
  this.video = video;
@@ -175,6 +181,7 @@ export class Video6847 {
175
181
  this.bitmapPxPerPixel = 2; // each pixel is 2 bitmap pixels wide and high
176
182
  this.pixelsPerBit = this.bitmapPxPerPixel;
177
183
  this.bpp = 1;
184
+ this.updateLineGrid();
178
185
 
179
186
  this.cpuAddr = 0;
180
187
  this.dispEnabled = 0;
@@ -225,6 +232,9 @@ export class Video6847 {
225
232
  reset(cpu, ppia) {
226
233
  this.cpu = cpu;
227
234
  this.ppia = ppia;
235
+ // polltime() is handed CPU cycles, so how far the VDG advances per
236
+ // cycle depends on how fast the CPU it's attached to runs.
237
+ this.vdgCyclesPerCpuCycle = cpu ? VdgClockMhz / cpu.model.clockMhz : 0;
228
238
  }
229
239
 
230
240
  // USE PAINT from VIDEO
@@ -315,6 +325,7 @@ export class Video6847 {
315
325
  this.pixelsPerBit = this.bitmapPxPerPixel * this.modes[mode][1];
316
326
  let linesPerRow = this.modes[mode][2]; // move to reg9
317
327
  this.bpp = this.modes[mode][3];
328
+ this.updateLineGrid();
318
329
 
319
330
  this.charLinesreg9 = linesPerRow - 1; //2 - scanlines per char
320
331
 
@@ -344,11 +355,7 @@ export class Video6847 {
344
355
  // it doesn't draw the whole frame. It regularly gives control back to the CPU.
345
356
 
346
357
  const vdgcharclock = this.pixelsPerChar / 2; // 4 or 8
347
- // The MC6847 datasheet specifies 3.579545 MHz, but 3.638004 is used here
348
- // as an empirical correction to align VSync/interrupt timing with the Atom's
349
- // 1 MHz CPU clock. TODO: revisit once full integration is testable.
350
- const vdgclock = 3.638004;
351
- this.vdg_cycles += clocks * vdgclock;
358
+ this.vdg_cycles += clocks * this.vdgCyclesPerCpuCycle;
352
359
 
353
360
  const vdgframelines = 262; // 312 PAL (but no pal on standard atom) 262; // NTSC
354
361
  const vdglinetime = 228; // vdg cycles to do a line; not 227.5
@@ -491,9 +498,9 @@ export class Video6847 {
491
498
  {
492
499
  // TODO: Add in the INTEXT modifiers to mode (if necessary)
493
500
  // blit into the fb32 buffer which is painted by VIDEO
494
- if ((mode & MODE_AG) === 0)
495
- // MODE_AG - bit 4; 0x10 is the AG bit
496
- this.blitChar(this.video.fb32, dat, offset, this.pixelsPerChar, css);
501
+ const textMode = (mode & MODE_AG) === 0; // 0x10 is the AG bit
502
+ this.recordLineGrid(textMode);
503
+ if (textMode) this.blitChar(this.video.fb32, dat, offset, this.pixelsPerChar, css);
497
504
  else this.blitPixels(this.video.fb32, dat, offset, css);
498
505
  }
499
506
  }
@@ -619,6 +626,41 @@ export class Video6847 {
619
626
  }
620
627
  }
621
628
 
629
+ /**
630
+ * Precompute this mode's grid descriptors, one per blitter.
631
+ *
632
+ * The two blitters derive their widths differently and cannot be unified:
633
+ * `blitChar` splits the character's texels across eight glyph bits, while
634
+ * `blitPixels` takes the bit width from the mode table — which for text
635
+ * mode holds -1, since it does not blit pixels at all.
636
+ *
637
+ * `pixelsPerBit` is right for graphics at 2bpp as well as 1bpp: the blitter
638
+ * steps its bit groups by `pixelsPerBit / bpp`, but reads the colour with
639
+ * `j & 0xe`, so pairs of groups share one and each pixel is twice as wide.
640
+ */
641
+ updateLineGrid() {
642
+ // Every 6847 blitter writes each pixel row into two framebuffer lines.
643
+ // Text mode's entry is only reachable through blitChar, which is just
644
+ // as well: the mode table holds -1 for it and would not encode.
645
+ this.lineGridText = encodeLineGrid((this.pixelsPerChar * this.bitmapPxPerPixel) / 8, true);
646
+ this.lineGridGraphics = this.pixelsPerBit > 0 ? encodeLineGrid(this.pixelsPerBit, true) : this.lineGridText;
647
+ }
648
+
649
+ /**
650
+ * Note the logical pixel size of the two rows about to be written, so
651
+ * display filters can see the picture as pixels rather than as raster
652
+ * samples (see video-filters/pixel-grid.js). Only the picture records a
653
+ * grid: the border is a solid colour, and letting it write here would
654
+ * leave the row describing the border rather than the picture on it.
655
+ *
656
+ * @param {boolean} textMode whether the character blitter is about to run
657
+ */
658
+ recordLineGrid(textMode) {
659
+ const grid = textMode ? this.lineGridText : this.lineGridGraphics;
660
+ this.video.lineGrid[this.bitmapY] = grid;
661
+ this.video.lineGrid[this.bitmapY + 1] = grid;
662
+ }
663
+
622
664
  blitChar(buf, data, destOffset, numPixels, css) {
623
665
  const scanline = this.scanlineCounter;
624
666
  const chr = data & 0x7f;
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();
@@ -253,8 +265,9 @@ export class Acia {
253
265
  }
254
266
  }
255
267
 
268
+ // Byte times are held in CPU cycles because that's what the scheduler counts.
256
269
  secondsToCycles(sec) {
257
- return Math.floor(2 * 1000 * 1000 * sec) | 0;
270
+ return Math.floor(this.cpu.model.cyclesPerSecond * sec) | 0;
258
271
  }
259
272
 
260
273
  numBitsPerByte() {
@@ -294,7 +307,8 @@ export class Acia {
294
307
  parityBits = 1;
295
308
  break;
296
309
  }
297
- return wordLength + stopBits + parityBits;
310
+ const startBits = 1;
311
+ return startBits + wordLength + stopBits + parityBits;
298
312
  }
299
313
 
300
314
  rts() {
@@ -307,6 +321,11 @@ export class Acia {
307
321
  this.serialReceiveCyclesPerByte = this.secondsToCycles(this.numBitsPerByte() / rate);
308
322
  }
309
323
 
324
+ setSerialTransmit(rate) {
325
+ this.serialTransmitRate = rate;
326
+ this.serialTransmitCyclesPerByte = this.secondsToCycles(this.numBitsPerByte() / rate);
327
+ }
328
+
310
329
  runTape() {
311
330
  if (this.tape) this.runTapeTask.reschedule(this.tape.poll(this));
312
331
  }