jsbeeb 1.14.0 → 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 +94 -0
- package/package.json +1 -1
- package/public/roms/tube/65C102Tube.rom +0 -0
- package/src/6502.js +8 -9
- package/src/acia.js +29 -11
- package/src/config.js +16 -4
- package/src/disc.js +47 -5
- package/src/dom-utils.js +16 -0
- package/src/fake6502.js +2 -2
- package/src/gamepads.js +11 -4
- package/src/main.js +43 -38
- package/src/models.js +37 -5
- package/src/serial.js +1 -0
- package/src/snapshot-helpers.js +1 -1
- package/src/teletext_adaptor.js +38 -13
- package/src/url-params.js +23 -19
- package/src/utils.js +10 -5
- package/src/utils_atom.js +9 -5
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.
|
|
10
|
+
"version": "1.15.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"
|
|
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
|
}
|
|
@@ -410,10 +407,13 @@ class Base6502 {
|
|
|
410
407
|
}
|
|
411
408
|
|
|
412
409
|
class Tube6502 extends Base6502 {
|
|
413
|
-
constructor(model, cpu, { cpuMultiplier =
|
|
410
|
+
constructor(model, cpu, { cpuMultiplier = 1 } = {}) {
|
|
414
411
|
super(model, { cycleAccurate: false });
|
|
415
412
|
|
|
413
|
+
if (!(cpuMultiplier > 0)) throw new Error(`Tube CPU multiplier must be positive, got ${cpuMultiplier}`);
|
|
414
|
+
|
|
416
415
|
this.cycles = 0;
|
|
416
|
+
this.cyclesPerHostCycle = model.clockMhz / cpu.model.clockMhz;
|
|
417
417
|
this.cpuMultiplier = cpuMultiplier;
|
|
418
418
|
this.romPaged = true;
|
|
419
419
|
this.memory = new Uint8Array(65536);
|
|
@@ -479,7 +479,7 @@ class Tube6502 extends Base6502 {
|
|
|
479
479
|
}
|
|
480
480
|
|
|
481
481
|
execute(cycles) {
|
|
482
|
-
this.cycles +=
|
|
482
|
+
this.cycles += cycles * this.cyclesPerHostCycle * this.cpuMultiplier;
|
|
483
483
|
if (this.cycles < 3) return;
|
|
484
484
|
while (this.cycles > 0) {
|
|
485
485
|
const opcode = this.readmem(this.pc);
|
|
@@ -670,6 +670,7 @@ export class Cpu6502 extends Base6502 {
|
|
|
670
670
|
this.hasTube = !!this.config.tube;
|
|
671
671
|
this.hasMusic5000 = !!this.config.hasMusic5000;
|
|
672
672
|
this.hasTeletextAdaptor = !!this.config.hasTeletextAdaptor;
|
|
673
|
+
this.teletextAdaptor = this.hasTeletextAdaptor ? new TeletextAdaptor(this) : null;
|
|
673
674
|
this.tube = this.hasTube
|
|
674
675
|
? new Tube6502(this.config.tube, this, { cpuMultiplier: this.config.tubeCpuMultiplier })
|
|
675
676
|
: new FakeTube();
|
|
@@ -697,7 +698,7 @@ export class Cpu6502 extends Base6502 {
|
|
|
697
698
|
getGamepads: this.config.getGamepads,
|
|
698
699
|
});
|
|
699
700
|
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.
|
|
701
|
+
this.acia = new Acia(this, this.soundChip.toneGenerator, this.scheduler, this.relayNoise);
|
|
701
702
|
this.serial = new Serial(this.acia);
|
|
702
703
|
this.adconverter = new Adc(this.sysvia, this.scheduler);
|
|
703
704
|
this.soundChip.setScheduler(this.scheduler);
|
|
@@ -1361,7 +1362,6 @@ export class Cpu6502 extends Base6502 {
|
|
|
1361
1362
|
this.adconverter.reset();
|
|
1362
1363
|
|
|
1363
1364
|
this.touchScreen = new TouchScreen(this.scheduler);
|
|
1364
|
-
if (this.hasTeletextAdaptor) this.teletextAdaptor = new TeletextAdaptor(this);
|
|
1365
1365
|
if (this.econet) this.filestore = new Filestore(this, this.econet);
|
|
1366
1366
|
}
|
|
1367
1367
|
|
|
@@ -1422,8 +1422,7 @@ export class Cpu6502 extends Base6502 {
|
|
|
1422
1422
|
buildPolltime() {
|
|
1423
1423
|
const nop = (_cycles) => {};
|
|
1424
1424
|
const tubeStuff = this.hasTube ? (cycles) => this.tube.execute(cycles) : nop;
|
|
1425
|
-
|
|
1426
|
-
const teletextStuff = this.hasTeletextAdaptor ? (cycles) => this.teletextAdaptor.polltime(cycles) : nop;
|
|
1425
|
+
const teletextStuff = this.teletextAdaptor ? (cycles) => this.teletextAdaptor.polltime(cycles) : nop;
|
|
1427
1426
|
const musicStuff = this.music5000 ? (cycles) => this.music5000.polltime(cycles) : nop;
|
|
1428
1427
|
const econetStuff = this.econet
|
|
1429
1428
|
? (cycles) => {
|
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,
|
|
8
|
+
constructor(cpu, toneGen, scheduler, relayNoise) {
|
|
9
9
|
this.cpu = cpu;
|
|
10
10
|
this.toneGen = toneGen;
|
|
11
|
-
this.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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
234
|
-
this.
|
|
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
|
-
|
|
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
|
}
|
package/src/config.js
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
import { allModels, findModel } from "./models.js";
|
|
2
|
+
import { allModels, findModel, tubeModelFor } from "./models.js";
|
|
3
3
|
import { getFilterForMode } from "./canvas.js";
|
|
4
4
|
|
|
5
|
+
const round = (value) => Number(value.toFixed(2));
|
|
6
|
+
|
|
7
|
+
/** @returns {string} the speed a multiplier gives this machine's co-processor, e.g. "1.6x (4.8MHz)". */
|
|
8
|
+
export function tubeCpuSpeedLabel(multiplier, model) {
|
|
9
|
+
return `${round(multiplier)}x (${round(multiplier * tubeModelFor(model).clockMhz)}MHz)`;
|
|
10
|
+
}
|
|
11
|
+
|
|
5
12
|
/**
|
|
6
13
|
* The sideways ROMs the optional fittings need, in the order they claim banks.
|
|
7
14
|
*
|
|
@@ -100,6 +107,7 @@ export class Config extends EventTarget {
|
|
|
100
107
|
if (!link) return;
|
|
101
108
|
this.changed.model = link.dataset.target;
|
|
102
109
|
this.setDropdownText(link.textContent);
|
|
110
|
+
this.showTubeCpuMultiplier(this.tubeCpuMultiplier, findModel(link.dataset.target));
|
|
103
111
|
this.showRestartPending();
|
|
104
112
|
});
|
|
105
113
|
|
|
@@ -113,8 +121,8 @@ export class Config extends EventTarget {
|
|
|
113
121
|
}
|
|
114
122
|
|
|
115
123
|
document.getElementById("tubeCpuMultiplier").addEventListener("input", () => {
|
|
116
|
-
const val =
|
|
117
|
-
|
|
124
|
+
const val = parseFloat(document.getElementById("tubeCpuMultiplier").value);
|
|
125
|
+
this.showTubeCpuMultiplier(val);
|
|
118
126
|
this.changed.tubeCpuMultiplier = val;
|
|
119
127
|
});
|
|
120
128
|
|
|
@@ -193,7 +201,11 @@ export class Config extends EventTarget {
|
|
|
193
201
|
setTubeCpuMultiplier(value) {
|
|
194
202
|
this.tubeCpuMultiplier = value;
|
|
195
203
|
document.getElementById("tubeCpuMultiplier").value = value;
|
|
196
|
-
|
|
204
|
+
this.showTubeCpuMultiplier(value);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
showTubeCpuMultiplier(value, model = this.model) {
|
|
208
|
+
document.getElementById("tubeCpuMultiplierValue").textContent = tubeCpuSpeedLabel(value, model);
|
|
197
209
|
}
|
|
198
210
|
|
|
199
211
|
setDropdownText(modelName) {
|
package/src/disc.js
CHANGED
|
@@ -672,11 +672,56 @@ export function loadAdf(disc, data, isDsd) {
|
|
|
672
672
|
return disc;
|
|
673
673
|
}
|
|
674
674
|
|
|
675
|
+
/** Why a sector will not fit in an SSD or DSD image, or null if it will. */
|
|
676
|
+
function sectorShortfall(sector, trackNum) {
|
|
677
|
+
if (sector.hasDataCrcError || sector.hasHeaderCrcError) return "with a CRC error";
|
|
678
|
+
// A header whose data mark never arrives leaves the sector with nothing to write.
|
|
679
|
+
if (!sector.sectorData) return "with no data";
|
|
680
|
+
if (sector.sectorNumber >= SsdFormat.sectorsPerTrack)
|
|
681
|
+
return `numbered past the ${SsdFormat.sectorsPerTrack} a track holds`;
|
|
682
|
+
if (sector.sectorData.length !== SsdFormat.sectorSize) return `not ${SsdFormat.sectorSize} bytes`;
|
|
683
|
+
if (trackNum >= SsdFormat.tracksPerDisc) return `past track ${SsdFormat.tracksPerDisc}`;
|
|
684
|
+
return null;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* SSD and DSD images hold sector contents and nothing else, so anything a DFS sector could not
|
|
689
|
+
* have held is lost. Copy protection usually shows up as one of these.
|
|
690
|
+
*
|
|
691
|
+
* @returns {string[]} what `disc` holds that an SSD or DSD cannot, worst first
|
|
692
|
+
* @param {Disc} disc
|
|
693
|
+
*/
|
|
694
|
+
export function ssdOrDsdShortfalls(disc) {
|
|
695
|
+
const counts = new Map();
|
|
696
|
+
for (let trackNum = 0; trackNum < disc.tracksUsed; ++trackNum) {
|
|
697
|
+
for (const upper of disc.isDoubleSided ? [false, true] : [false]) {
|
|
698
|
+
for (const sector of disc.getTrack(upper, trackNum).findSectors()) {
|
|
699
|
+
const shortfall = sectorShortfall(sector, trackNum);
|
|
700
|
+
if (shortfall) counts.set(shortfall, (counts.get(shortfall) ?? 0) + 1);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
return [...counts]
|
|
705
|
+
.sort(([, a], [, b]) => b - a)
|
|
706
|
+
.map(([shortfall, count]) => `${count} sector${count === 1 ? "" : "s"} ${shortfall}`);
|
|
707
|
+
}
|
|
708
|
+
|
|
675
709
|
/**
|
|
676
710
|
* @returns {Uint8Array}
|
|
677
711
|
* @param {Disc} disc
|
|
712
|
+
* @param {object} [options]
|
|
713
|
+
* @param {boolean} [options.force] save what fits instead of refusing a disc that will not fit
|
|
714
|
+
* @throws if the disc holds anything an SSD or DSD cannot, and `force` is not set
|
|
678
715
|
*/
|
|
679
|
-
export function toSsdOrDsd(disc) {
|
|
716
|
+
export function toSsdOrDsd(disc, { force = false } = {}) {
|
|
717
|
+
if (!force) {
|
|
718
|
+
const shortfalls = ssdOrDsdShortfalls(disc);
|
|
719
|
+
if (shortfalls.length)
|
|
720
|
+
throw new Error(
|
|
721
|
+
`This disc cannot be saved as SSD or DSD: it has ${shortfalls.join(", ")}. ` +
|
|
722
|
+
`Save it as HFE to keep everything.`,
|
|
723
|
+
);
|
|
724
|
+
}
|
|
680
725
|
const numSides = disc.isDoubleSided ? 2 : 1;
|
|
681
726
|
const result = new Uint8Array(
|
|
682
727
|
numSides * SsdFormat.tracksPerDisc * SsdFormat.sectorsPerTrack * SsdFormat.sectorSize,
|
|
@@ -686,11 +731,8 @@ export function toSsdOrDsd(disc) {
|
|
|
686
731
|
for (let side = 0; side < numSides; ++side) {
|
|
687
732
|
const trackObj = disc.getTrack(side === 1, trackNum);
|
|
688
733
|
for (const sector of trackObj.findSectors()) {
|
|
734
|
+
if (sectorShortfall(sector, trackNum)) continue;
|
|
689
735
|
const sectorOffset = offset + sector.sectorNumber * SsdFormat.sectorSize;
|
|
690
|
-
if (sector.hasDataCrcError || sector.hasHeaderCrcError) {
|
|
691
|
-
console.log(`Skipping sector ${sector.description} with bad CRC`);
|
|
692
|
-
continue;
|
|
693
|
-
}
|
|
694
736
|
for (let x = 0; x < SsdFormat.sectorSize; ++x) result[sectorOffset + x] = sector.sectorData[x];
|
|
695
737
|
}
|
|
696
738
|
offset += SsdFormat.sectorsPerTrack * SsdFormat.sectorSize;
|
package/src/dom-utils.js
CHANGED
|
@@ -30,3 +30,19 @@ export function fadeOut(el, duration = 400) {
|
|
|
30
30
|
if (el.style.opacity === "0") el.style.display = "none";
|
|
31
31
|
}, duration);
|
|
32
32
|
}
|
|
33
|
+
|
|
34
|
+
// Safari fetches the blob a task or more after the click, so the URL must outlive it.
|
|
35
|
+
// 40s matches FileSaver.js.
|
|
36
|
+
const BlobUrlLifetimeMs = 40000;
|
|
37
|
+
|
|
38
|
+
/** Save a blob to the user's downloads under the given file name. */
|
|
39
|
+
export function downloadBlob(blob, fileName) {
|
|
40
|
+
const url = URL.createObjectURL(blob);
|
|
41
|
+
const a = document.createElement("a");
|
|
42
|
+
a.href = url;
|
|
43
|
+
a.download = fileName;
|
|
44
|
+
document.body.appendChild(a);
|
|
45
|
+
a.click();
|
|
46
|
+
a.remove();
|
|
47
|
+
setTimeout(() => URL.revokeObjectURL(url), BlobUrlLifetimeMs);
|
|
48
|
+
}
|
package/src/fake6502.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import { FakeVideo } from "./video.js";
|
|
5
5
|
import { FakeSoundChip } from "./soundchip.js";
|
|
6
|
-
import { TEST_6502, TEST_65C02, TEST_65C12,
|
|
6
|
+
import { TEST_6502, TEST_65C02, TEST_65C12, tubeModelFor } from "./models.js";
|
|
7
7
|
import { FakeDdNoise } from "./ddnoise.js";
|
|
8
8
|
import { FakeRelayNoise } from "./relaynoise.js";
|
|
9
9
|
import { Cpu6502, AtomCpu6502 } from "./6502.js";
|
|
@@ -30,7 +30,7 @@ export function fake6502(model, opts) {
|
|
|
30
30
|
cmos: new Cmos(),
|
|
31
31
|
cycleAccurate: opts.cycleAccurate,
|
|
32
32
|
config: {
|
|
33
|
-
tube: opts.tube ?
|
|
33
|
+
tube: opts.tube ? tubeModelFor(model) : null,
|
|
34
34
|
tubeCpuMultiplier: opts.tubeCpuMultiplier,
|
|
35
35
|
cpuMultiplier: opts.cpuMultiplier,
|
|
36
36
|
hasTeletextAdaptor: opts.hasTeletextAdaptor,
|
package/src/gamepads.js
CHANGED
|
@@ -39,16 +39,21 @@ export class GamePad {
|
|
|
39
39
|
this.gamepadAxisMapping[3][-1] = BBC.COLON_STAR; // up
|
|
40
40
|
this.gamepadAxisMapping[3][1] = BBC.SLASH; // down
|
|
41
41
|
*/
|
|
42
|
+
/**
|
|
43
|
+
* Maps a gamepad button or stick direction to a BBC key.
|
|
44
|
+
* @param {string} gamepadKey - the gamepad control, eg `FIRE2`
|
|
45
|
+
* @param {string} bbcKey - the BBC key to press, eg `RETURN`
|
|
46
|
+
* @returns {?string} a description of the problem, or null if the mapping was applied
|
|
47
|
+
*/
|
|
42
48
|
remap(gamepadKey, bbcKey) {
|
|
43
49
|
// convert "1" into "K1"
|
|
44
|
-
if ("
|
|
50
|
+
if (bbcKey.length === 1 && bbcKey >= "0" && bbcKey <= "9") {
|
|
45
51
|
bbcKey = "K" + bbcKey;
|
|
46
52
|
}
|
|
47
53
|
|
|
48
54
|
const mappedBbcKey = BBC[bbcKey];
|
|
49
55
|
if (!mappedBbcKey) {
|
|
50
|
-
|
|
51
|
-
return;
|
|
56
|
+
return `unknown BBC key "${bbcKey}".`;
|
|
52
57
|
}
|
|
53
58
|
|
|
54
59
|
switch (gamepadKey) {
|
|
@@ -152,8 +157,10 @@ export class GamePad {
|
|
|
152
157
|
this.gamepadMapping[6] = mappedBbcKey;
|
|
153
158
|
break;
|
|
154
159
|
default:
|
|
155
|
-
|
|
160
|
+
return `unknown gamepad control "${gamepadKey}".`;
|
|
156
161
|
}
|
|
162
|
+
|
|
163
|
+
return null;
|
|
157
164
|
}
|
|
158
165
|
|
|
159
166
|
update(sysvia) {
|
package/src/main.js
CHANGED
|
@@ -7,7 +7,7 @@ import "./jsbeeb.css";
|
|
|
7
7
|
import * as utils from "./utils.js";
|
|
8
8
|
import { FakeVideo, Video } from "./video.js";
|
|
9
9
|
import { Debugger } from "./web/debug.js";
|
|
10
|
-
import { Cpu6502, AtomCpu6502
|
|
10
|
+
import { Cpu6502, AtomCpu6502 } from "./6502.js";
|
|
11
11
|
import * as utils_atom from "./utils_atom.js";
|
|
12
12
|
import { LoadSD } from "./mmc.js";
|
|
13
13
|
import { Cmos } from "./cmos.js";
|
|
@@ -19,7 +19,7 @@ import { GoogleDriveLoader } from "./google-drive.js";
|
|
|
19
19
|
import * as tokeniser from "./basic-tokenise.js";
|
|
20
20
|
import * as canvasLib from "./canvas.js";
|
|
21
21
|
import { Config } from "./config.js";
|
|
22
|
-
import {
|
|
22
|
+
import { tubeModelFor } from "./models.js";
|
|
23
23
|
import { initialise as electron } from "./app/electron.js";
|
|
24
24
|
import { AudioHandler } from "./web/audio-handler.js";
|
|
25
25
|
import { Econet } from "./econet.js";
|
|
@@ -44,6 +44,7 @@ import { isBemSnapshot, parseBemSnapshot } from "./bem-snapshot.js";
|
|
|
44
44
|
import { isUefSnapshot, parseUefSnapshot } from "./uef-snapshot.js";
|
|
45
45
|
import { RewindBuffer } from "./rewind.js";
|
|
46
46
|
import { RewindUI } from "./rewind-ui.js";
|
|
47
|
+
import { downloadBlob } from "./dom-utils.js";
|
|
47
48
|
import {
|
|
48
49
|
buildUrlFromParams,
|
|
49
50
|
guessModelFromHostname,
|
|
@@ -51,7 +52,7 @@ import {
|
|
|
51
52
|
parseMediaParams,
|
|
52
53
|
parseQueryString,
|
|
53
54
|
processAutobootParams,
|
|
54
|
-
|
|
55
|
+
processInputParams,
|
|
55
56
|
} from "./url-params.js";
|
|
56
57
|
|
|
57
58
|
let processor;
|
|
@@ -138,7 +139,7 @@ const paramTypes = {
|
|
|
138
139
|
audiofilterfreq: ParamTypes.FLOAT,
|
|
139
140
|
audiofilterq: ParamTypes.FLOAT,
|
|
140
141
|
cpuMultiplier: ParamTypes.FLOAT,
|
|
141
|
-
tubeCpuMultiplier: ParamTypes.
|
|
142
|
+
tubeCpuMultiplier: ParamTypes.FLOAT,
|
|
142
143
|
microphoneChannel: ParamTypes.INT,
|
|
143
144
|
|
|
144
145
|
// String parameters (these are the default but listed for clarity)
|
|
@@ -176,9 +177,6 @@ const { discImage: queryDiscImage, secondDiscImage: querySecondDisc, mmcImage }
|
|
|
176
177
|
if (queryDiscImage) discImage = queryDiscImage;
|
|
177
178
|
if (querySecondDisc) secondDiscImage = querySecondDisc;
|
|
178
179
|
|
|
179
|
-
// Process keyboard mappings
|
|
180
|
-
parsedQuery = processKeyboardParams(parsedQuery, BBC, keyCodes, utils.userKeymap, gamepad);
|
|
181
|
-
|
|
182
180
|
// Handle specific query parameters
|
|
183
181
|
if (Array.isArray(parsedQuery.rom)) {
|
|
184
182
|
parsedQuery.rom.forEach((romPath) => {
|
|
@@ -284,7 +282,7 @@ config.mapLegacyModels(parsedQuery);
|
|
|
284
282
|
|
|
285
283
|
config.setModel(parsedQuery.model || guessModelFromHostname(window.location.hostname));
|
|
286
284
|
config.setKeyLayout(keyLayout);
|
|
287
|
-
config.setTubeCpuMultiplier(parsedQuery.tubeCpuMultiplier ||
|
|
285
|
+
config.setTubeCpuMultiplier(parsedQuery.tubeCpuMultiplier || 1);
|
|
288
286
|
config.setMicrophoneChannel(parsedQuery.microphoneChannel);
|
|
289
287
|
config.setCheckboxes({
|
|
290
288
|
coProcessor: !!parsedQuery.coProcessor,
|
|
@@ -299,13 +297,22 @@ config.setDisplayMode(displayMode);
|
|
|
299
297
|
|
|
300
298
|
model = config.model;
|
|
301
299
|
|
|
300
|
+
// Must come after we know the model, to validate names against those of the hardware.
|
|
301
|
+
const keyMappingWarnings = processInputParams(
|
|
302
|
+
parsedQuery,
|
|
303
|
+
model.isAtom ? utils_atom.ATOM : BBC,
|
|
304
|
+
keyCodes,
|
|
305
|
+
utils.userKeymap,
|
|
306
|
+
gamepad,
|
|
307
|
+
);
|
|
308
|
+
|
|
302
309
|
// Depends on the config.setX calls above having applied the URL parameters.
|
|
303
310
|
const emulationConfig = {
|
|
304
311
|
keyLayout,
|
|
305
312
|
cpuMultiplier,
|
|
306
313
|
tubeCpuMultiplier: config.tubeCpuMultiplier,
|
|
307
314
|
videoCyclesBatch: parsedQuery.videoCyclesBatch,
|
|
308
|
-
tube: config.coProcessor ?
|
|
315
|
+
tube: config.coProcessor ? tubeModelFor(config.model) : null,
|
|
309
316
|
hasMusic5000: config.hasMusic5000,
|
|
310
317
|
hasTeletextAdaptor: config.hasTeletextAdaptor,
|
|
311
318
|
// ROM order determines sideways bank allocation, and the fittings' ROMs claim banks
|
|
@@ -345,7 +352,7 @@ sbBind(document.querySelector(".sidebar.bottom"), parsedQuery.sbBottom, function
|
|
|
345
352
|
});
|
|
346
353
|
|
|
347
354
|
if (cpuMultiplier !== 1) console.log(`CPU multiplier set to ${cpuMultiplier}`);
|
|
348
|
-
const cpuSpeed = model.
|
|
355
|
+
const cpuSpeed = model.clockMhz * 1000 * 1000;
|
|
349
356
|
const clocksPerSecond = (cpuMultiplier * cpuSpeed) | 0;
|
|
350
357
|
const MaxCyclesPerFrame = clocksPerSecond / 10;
|
|
351
358
|
|
|
@@ -374,6 +381,10 @@ function showError(context, error) {
|
|
|
374
381
|
errorDialogModal.show();
|
|
375
382
|
}
|
|
376
383
|
|
|
384
|
+
if (keyMappingWarnings.length) {
|
|
385
|
+
showError("applying the key mappings in the URL", keyMappingWarnings.join(" "));
|
|
386
|
+
}
|
|
387
|
+
|
|
377
388
|
function createCanvasForFilter(filterClass) {
|
|
378
389
|
const newCanvas = tryGl ? canvasLib.bestCanvas(screenCanvas, filterClass) : new canvasLib.Canvas(screenCanvas);
|
|
379
390
|
|
|
@@ -471,18 +482,8 @@ function replaceOrAddExtension(name, newExt) {
|
|
|
471
482
|
* @param {string} extension - The file extension to use
|
|
472
483
|
*/
|
|
473
484
|
function downloadDriveData(data, name, extension) {
|
|
474
|
-
const a = document.createElement("a");
|
|
475
|
-
document.body.appendChild(a);
|
|
476
|
-
a.style = "display: none";
|
|
477
|
-
|
|
478
|
-
const fileName = replaceOrAddExtension(name, extension);
|
|
479
485
|
const blob = new Blob([data], { type: "application/octet-stream" });
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
a.href = url;
|
|
483
|
-
a.download = fileName;
|
|
484
|
-
a.click();
|
|
485
|
-
window.URL.revokeObjectURL(url);
|
|
486
|
+
downloadBlob(blob, replaceOrAddExtension(name, extension));
|
|
486
487
|
}
|
|
487
488
|
|
|
488
489
|
async function loadHTMLFile(file) {
|
|
@@ -667,6 +668,8 @@ processor = new CpuClass(model, {
|
|
|
667
668
|
econet,
|
|
668
669
|
});
|
|
669
670
|
|
|
671
|
+
processor.teletextAdaptor?.addEventListener("showError", (e) => showError(e.detail.context, e.detail.error));
|
|
672
|
+
|
|
670
673
|
// Create input sources
|
|
671
674
|
const gamepadSource = new GamepadSource(emulationConfig.getGamepads);
|
|
672
675
|
// Create MicrophoneInput but don't enable by default
|
|
@@ -681,18 +684,16 @@ const mouseJoystickSource = new MouseJoystickSource(screenCanvas);
|
|
|
681
684
|
/**
|
|
682
685
|
* Attach an RS-423 composite handler to the ACIA that combines the touchscreen
|
|
683
686
|
* (which sends position data to the BBC) with the speech output (which speaks
|
|
684
|
-
* text the BBC sends out).
|
|
685
|
-
* again whenever speechOutput.enabled changes.
|
|
687
|
+
* text the BBC sends out).
|
|
686
688
|
*/
|
|
687
689
|
function setupRs423Handler() {
|
|
688
|
-
const touchScreen = processor.touchScreen;
|
|
689
690
|
processor.acia.setRs423Handler({
|
|
690
691
|
onTransmit(val) {
|
|
691
|
-
touchScreen.onTransmit(val);
|
|
692
|
+
processor.touchScreen.onTransmit(val);
|
|
692
693
|
speechOutput.onTransmit(val);
|
|
693
694
|
},
|
|
694
695
|
tryReceive(rts) {
|
|
695
|
-
return touchScreen.tryReceive(rts);
|
|
696
|
+
return processor.touchScreen.tryReceive(rts);
|
|
696
697
|
},
|
|
697
698
|
});
|
|
698
699
|
}
|
|
@@ -1445,7 +1446,12 @@ document.querySelector("#google-drive form").addEventListener("submit", async fu
|
|
|
1445
1446
|
let data;
|
|
1446
1447
|
if (document.querySelector("#google-drive .create-from-existing").checked) {
|
|
1447
1448
|
const discType = disc.guessDiscTypeFromName(name);
|
|
1448
|
-
|
|
1449
|
+
try {
|
|
1450
|
+
data = discType.saver(processor.fdc.drives[0].disc);
|
|
1451
|
+
} catch (e) {
|
|
1452
|
+
loadingFinished(`Create failed: ${e.message}`);
|
|
1453
|
+
return;
|
|
1454
|
+
}
|
|
1449
1455
|
name = replaceOrAddExtension(name, discType.extension);
|
|
1450
1456
|
console.log(`Saving existing disc: ${name}`);
|
|
1451
1457
|
} else {
|
|
@@ -1474,11 +1480,15 @@ document.querySelector("#google-drive form").addEventListener("submit", async fu
|
|
|
1474
1480
|
|
|
1475
1481
|
document.getElementById("download-drive-link").addEventListener("click", function () {
|
|
1476
1482
|
const disc = processor.fdc.drives[0].disc;
|
|
1477
|
-
const
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1483
|
+
const save = (options) =>
|
|
1484
|
+
downloadDriveData(toSsdOrDsd(disc, options), disc.name, disc.isDoubleSided ? ".dsd" : ".ssd");
|
|
1485
|
+
try {
|
|
1486
|
+
save();
|
|
1487
|
+
} catch (e) {
|
|
1488
|
+
areYouSure(`${e.message} Save anyway, losing what will not fit?`, "Save anyway", "Cancel", () =>
|
|
1489
|
+
save({ force: true }),
|
|
1490
|
+
);
|
|
1491
|
+
}
|
|
1482
1492
|
});
|
|
1483
1493
|
|
|
1484
1494
|
document.getElementById("download-drive-hfe-link").addEventListener("click", function () {
|
|
@@ -1539,13 +1549,8 @@ document.getElementById("save-state").addEventListener("click", async function (
|
|
|
1539
1549
|
const snapshot = createSnapshot(processor, model, Object.keys(media).length > 0 ? media : undefined);
|
|
1540
1550
|
const json = snapshotToJSON(snapshot);
|
|
1541
1551
|
const blob = await compressBlob(new Blob([json]));
|
|
1542
|
-
const url = URL.createObjectURL(blob);
|
|
1543
1552
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
1544
|
-
|
|
1545
|
-
a.href = url;
|
|
1546
|
-
a.download = `jsbeeb-${model.name}-${timestamp}.json.gz`;
|
|
1547
|
-
a.click();
|
|
1548
|
-
URL.revokeObjectURL(url);
|
|
1553
|
+
downloadBlob(blob, `jsbeeb-${model.name}-${timestamp}.json.gz`);
|
|
1549
1554
|
} catch (e) {
|
|
1550
1555
|
showError("saving state", e);
|
|
1551
1556
|
}
|
package/src/models.js
CHANGED
|
@@ -19,10 +19,12 @@ 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 } = {}) {
|
|
22
|
+
constructor({ name, synonyms, os, cpuModel, isMaster, isAtom, swram, fdc, cmosOverride, banks, clockMhz } = {}) {
|
|
23
|
+
if (!(clockMhz > 0)) throw new Error(`Model ${name} has no clock speed`);
|
|
23
24
|
this.name = name;
|
|
24
25
|
this.synonyms = synonyms;
|
|
25
26
|
this.os = os;
|
|
27
|
+
this.clockMhz = clockMhz;
|
|
26
28
|
this.banks = banks;
|
|
27
29
|
this._cpuModel = cpuModel;
|
|
28
30
|
this.isMaster = isMaster;
|
|
@@ -72,6 +74,7 @@ function atomModel({ name, synonyms, os, banks }) {
|
|
|
72
74
|
os,
|
|
73
75
|
cpuModel: CpuModel.MOS6502,
|
|
74
76
|
isMaster: false,
|
|
77
|
+
clockMhz: 1,
|
|
75
78
|
isAtom: true,
|
|
76
79
|
swram: beebSwram,
|
|
77
80
|
fdc: NoiseAwareIntelFdc,
|
|
@@ -124,6 +127,7 @@ export const allModels = [
|
|
|
124
127
|
os: ["os.rom", "BASIC.ROM", "b/DFS-1.2.rom"],
|
|
125
128
|
cpuModel: CpuModel.MOS6502,
|
|
126
129
|
isMaster: false,
|
|
130
|
+
clockMhz: 2,
|
|
127
131
|
swram: beebSwram,
|
|
128
132
|
fdc: NoiseAwareIntelFdc,
|
|
129
133
|
}),
|
|
@@ -133,6 +137,7 @@ export const allModels = [
|
|
|
133
137
|
os: ["os.rom", "BASIC.ROM", "b/DFS-0.9.rom"],
|
|
134
138
|
cpuModel: CpuModel.MOS6502,
|
|
135
139
|
isMaster: false,
|
|
140
|
+
clockMhz: 2,
|
|
136
141
|
swram: beebSwram,
|
|
137
142
|
fdc: NoiseAwareIntelFdc,
|
|
138
143
|
}),
|
|
@@ -142,6 +147,7 @@ export const allModels = [
|
|
|
142
147
|
os: ["os.rom", "BASIC.ROM", "b1770/dfs1770.rom", "b1770/zADFS.ROM"],
|
|
143
148
|
cpuModel: CpuModel.MOS6502,
|
|
144
149
|
isMaster: false,
|
|
150
|
+
clockMhz: 2,
|
|
145
151
|
swram: beebSwram,
|
|
146
152
|
fdc: NoiseAwareWdFdc,
|
|
147
153
|
}),
|
|
@@ -152,6 +158,7 @@ export const allModels = [
|
|
|
152
158
|
os: ["os.rom", "BASIC.ROM", "b1770/zADFS.ROM", "b1770/dfs1770.rom"],
|
|
153
159
|
cpuModel: CpuModel.MOS6502,
|
|
154
160
|
isMaster: false,
|
|
161
|
+
clockMhz: 2,
|
|
155
162
|
swram: beebSwram,
|
|
156
163
|
fdc: NoiseAwareWdFdc,
|
|
157
164
|
}),
|
|
@@ -161,6 +168,7 @@ export const allModels = [
|
|
|
161
168
|
os: ["master/mos3.20"],
|
|
162
169
|
cpuModel: CpuModel.CMOS65C12,
|
|
163
170
|
isMaster: true,
|
|
171
|
+
clockMhz: 2,
|
|
164
172
|
swram: masterSwram,
|
|
165
173
|
fdc: NoiseAwareWdFdc,
|
|
166
174
|
cmosOverride: pickDfs,
|
|
@@ -171,6 +179,7 @@ export const allModels = [
|
|
|
171
179
|
os: ["master/mos3.20"],
|
|
172
180
|
cpuModel: CpuModel.CMOS65C12,
|
|
173
181
|
isMaster: true,
|
|
182
|
+
clockMhz: 2,
|
|
174
183
|
swram: masterSwram,
|
|
175
184
|
fdc: NoiseAwareWdFdc,
|
|
176
185
|
cmosOverride: pickAdfs,
|
|
@@ -181,6 +190,7 @@ export const allModels = [
|
|
|
181
190
|
os: ["master/mos3.20"],
|
|
182
191
|
cpuModel: CpuModel.CMOS65C12,
|
|
183
192
|
isMaster: true,
|
|
193
|
+
clockMhz: 2,
|
|
184
194
|
swram: masterSwram,
|
|
185
195
|
fdc: NoiseAwareWdFdc,
|
|
186
196
|
cmosOverride: pickAnfs,
|
|
@@ -210,13 +220,24 @@ export const allModels = [
|
|
|
210
220
|
synonyms: ["Atom-DOS"],
|
|
211
221
|
os: ["atom/Atom_Kernel.rom", "atom/Atom_DOS.rom", "atom/Atom_FloatingPoint.rom", "atom/Atom_Basic.rom"],
|
|
212
222
|
}),
|
|
213
|
-
//
|
|
223
|
+
// Neither can be selected as a model: they are fitted to one, by the configuration builder later.
|
|
214
224
|
new Model({
|
|
215
225
|
name: "Tube65C02",
|
|
216
226
|
synonyms: [],
|
|
217
227
|
os: ["tube/6502Tube.rom"],
|
|
228
|
+
// TODO(#746): the external second processor was an NMOS 6502A.
|
|
218
229
|
cpuModel: CpuModel.CMOS65C02,
|
|
219
230
|
isMaster: false,
|
|
231
|
+
clockMhz: 3,
|
|
232
|
+
}),
|
|
233
|
+
new Model({
|
|
234
|
+
name: "Tube65C102",
|
|
235
|
+
synonyms: [],
|
|
236
|
+
os: ["tube/65C102Tube.rom"],
|
|
237
|
+
// TODO(#746): Acorn's 65C102 has no Rockwell bit instructions.
|
|
238
|
+
cpuModel: CpuModel.CMOS65C02,
|
|
239
|
+
isMaster: false,
|
|
240
|
+
clockMhz: 4,
|
|
220
241
|
}),
|
|
221
242
|
];
|
|
222
243
|
|
|
@@ -236,6 +257,7 @@ export const TEST_6502 = new Model({
|
|
|
236
257
|
name: "TEST",
|
|
237
258
|
synonyms: ["TEST"],
|
|
238
259
|
os: [],
|
|
260
|
+
clockMhz: 2,
|
|
239
261
|
cpuModel: CpuModel.MOS6502,
|
|
240
262
|
isMaster: false,
|
|
241
263
|
swram: beebSwram,
|
|
@@ -246,6 +268,7 @@ export const TEST_65C02 = new Model({
|
|
|
246
268
|
name: "TEST",
|
|
247
269
|
synonyms: ["TEST"],
|
|
248
270
|
os: [],
|
|
271
|
+
clockMhz: 2,
|
|
249
272
|
cpuModel: CpuModel.CMOS65C02,
|
|
250
273
|
isMaster: false,
|
|
251
274
|
swram: masterSwram,
|
|
@@ -256,6 +279,7 @@ export const TEST_65C12 = new Model({
|
|
|
256
279
|
name: "TEST",
|
|
257
280
|
synonyms: ["TEST"],
|
|
258
281
|
os: [],
|
|
282
|
+
clockMhz: 2,
|
|
259
283
|
cpuModel: CpuModel.CMOS65C12,
|
|
260
284
|
isMaster: false,
|
|
261
285
|
swram: masterSwram,
|
|
@@ -265,6 +289,7 @@ TEST_65C12.isTest = true;
|
|
|
265
289
|
|
|
266
290
|
export const basicOnly = new Model({
|
|
267
291
|
name: "Basic only",
|
|
292
|
+
clockMhz: 2,
|
|
268
293
|
synonyms: ["Basic only"],
|
|
269
294
|
os: ["master/mos3.20"],
|
|
270
295
|
cpuModel: CpuModel.CMOS65C12,
|
|
@@ -274,11 +299,18 @@ export const basicOnly = new Model({
|
|
|
274
299
|
});
|
|
275
300
|
|
|
276
301
|
/**
|
|
277
|
-
* The
|
|
278
|
-
*
|
|
279
|
-
* import cycle via the FDC
|
|
302
|
+
* The second processors jsbeeb emulates: the external 3MHz box, and the Master Turbo's
|
|
303
|
+
* internal 4MHz board. Machine-building code passes one of these as the emulation config's
|
|
304
|
+
* `tube`, so that 6502.js needn't import this module and close an import cycle via the FDC
|
|
305
|
+
* modules.
|
|
280
306
|
*/
|
|
281
307
|
export const TubeModel = findModel("Tube65C02");
|
|
308
|
+
export const TurboTubeModel = findModel("Tube65C102");
|
|
309
|
+
|
|
310
|
+
/** @returns {Model} the second processor sold for this machine: the Turbo board for a Master. */
|
|
311
|
+
export function tubeModelFor(model) {
|
|
312
|
+
return model.isMaster ? TurboTubeModel : TubeModel;
|
|
313
|
+
}
|
|
282
314
|
|
|
283
315
|
// After the isTest assignments above, so those still apply.
|
|
284
316
|
for (const model of [...allModels, TEST_6502, TEST_65C02, TEST_65C12, basicOnly]) {
|
package/src/serial.js
CHANGED
|
@@ -20,6 +20,7 @@ export class Serial {
|
|
|
20
20
|
this.transmitRate = val & 0x07;
|
|
21
21
|
this.receiveRate = (val >>> 3) & 0x07;
|
|
22
22
|
this.acia.setSerialReceive(table[this.receiveRate]);
|
|
23
|
+
this.acia.setSerialTransmit(table[this.transmitRate]);
|
|
23
24
|
this.acia.setMotor(!!(val & 0x80));
|
|
24
25
|
this.acia.selectRs423(!!(val & 0x40));
|
|
25
26
|
}
|
package/src/snapshot-helpers.js
CHANGED
package/src/teletext_adaptor.js
CHANGED
|
@@ -34,9 +34,20 @@ Status register:
|
|
|
34
34
|
|
|
35
35
|
*/
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
/**
|
|
38
|
+
* Emulates the Acorn teletext adaptor. Dispatches a `showError` CustomEvent, carrying
|
|
39
|
+
* `context` and `error` in its detail, when a channel's stream cannot be loaded.
|
|
40
|
+
*/
|
|
41
|
+
export class TeletextAdaptor extends EventTarget {
|
|
38
42
|
constructor(cpu) {
|
|
43
|
+
super();
|
|
39
44
|
this.cpu = cpu;
|
|
45
|
+
// Not cleared by a reset, so a fetch still in flight across one is recognised as stale.
|
|
46
|
+
this.streamRequest = 0;
|
|
47
|
+
this.clearState();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
clearState() {
|
|
40
51
|
this.teletextStatus = 0x0f; /* low nibble comes from LK4-7 and mystery links which are left floating */
|
|
41
52
|
this.teletextInts = false;
|
|
42
53
|
this.teletextEnable = false;
|
|
@@ -45,26 +56,40 @@ export class TeletextAdaptor {
|
|
|
45
56
|
this.totalFrames = 0;
|
|
46
57
|
this.rowPtr = 0x00;
|
|
47
58
|
this.colPtr = 0x00;
|
|
48
|
-
this.frameBuffer = new Array(16).fill(0).map(() => new Array(64).fill(0));
|
|
49
59
|
this.streamData = null;
|
|
50
60
|
this.pollCount = 0;
|
|
61
|
+
this.frameBuffer = new Array(16).fill(0).map(() => new Array(64).fill(0));
|
|
62
|
+
// Only a register access clears our IRQ, so an interrupt latched before the reset would hang the machine.
|
|
63
|
+
this.cpu.interrupt &= ~(1 << TELETEXT_IRQ);
|
|
51
64
|
}
|
|
52
65
|
|
|
53
66
|
reset(hard) {
|
|
54
|
-
if (hard)
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
67
|
+
if (!hard) return;
|
|
68
|
+
this.clearState();
|
|
69
|
+
this.loadChannelStream(this.channel);
|
|
58
70
|
}
|
|
59
71
|
|
|
60
|
-
loadChannelStream(channel) {
|
|
72
|
+
async loadChannelStream(channel) {
|
|
61
73
|
console.log("Teletext adaptor: switching to channel " + channel);
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
74
|
+
const request = ++this.streamRequest;
|
|
75
|
+
let data;
|
|
76
|
+
try {
|
|
77
|
+
data = await utils.loadData(`teletext/txt${channel}.dat`);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (request !== this.streamRequest) return;
|
|
80
|
+
console.error(`Teletext adaptor: failed to load channel ${channel}`, error);
|
|
81
|
+
this.dispatchEvent(
|
|
82
|
+
new CustomEvent("showError", {
|
|
83
|
+
detail: { context: `loading teletext channel ${channel}`, error },
|
|
84
|
+
}),
|
|
85
|
+
);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
// Fetches can resolve out of order; only the newest request may apply its data.
|
|
89
|
+
if (request !== this.streamRequest) return;
|
|
90
|
+
this.streamData = data;
|
|
91
|
+
this.totalFrames = Math.floor(data.length / TELETEXT_FRAME_SIZE);
|
|
92
|
+
this.currentFrame = 0;
|
|
68
93
|
}
|
|
69
94
|
|
|
70
95
|
read(addr) {
|
package/src/url-params.js
CHANGED
|
@@ -161,52 +161,56 @@ export function buildUrlFromParams(baseUrl, parsedQuery, paramTypes = {}) {
|
|
|
161
161
|
}
|
|
162
162
|
|
|
163
163
|
/**
|
|
164
|
-
* Process keyboard mapping parameters from query string
|
|
164
|
+
* Process keyboard and gamepad mapping parameters from query string
|
|
165
165
|
* @param {Object} parsedQuery - The parsed query parameters
|
|
166
|
-
* @param {Object}
|
|
166
|
+
* @param {Object} machineKeys - Emulated machine's key constants (`BBC`, or `ATOM` for the Atom)
|
|
167
167
|
* @param {Object} keyCodes - Key code constants
|
|
168
168
|
* @param {Array} userKeymap - Array to store user key mappings
|
|
169
169
|
* @param {Object} gamepad - Gamepad object for handling mapping
|
|
170
|
-
* @returns {
|
|
170
|
+
* @returns {string[]} descriptions of any mappings that were skipped, for showing to the user
|
|
171
171
|
*/
|
|
172
|
-
export function
|
|
172
|
+
export function processInputParams(parsedQuery, machineKeys, keyCodes, userKeymap, gamepad) {
|
|
173
|
+
const warnings = [];
|
|
174
|
+
|
|
173
175
|
Object.entries(parsedQuery).forEach(([key, val]) => {
|
|
174
176
|
if (!val) return;
|
|
175
177
|
|
|
176
|
-
// eg KEY.CAPSLOCK=CTRL
|
|
178
|
+
// `KEY.<host key>=<machine key>`, eg `KEY.CAPSLOCK=CTRL`. Host names come from
|
|
179
|
+
// `keyCodes`, so the BBC's RETURN is ENTER here; both lists are in the README.
|
|
177
180
|
if (key.toUpperCase().indexOf("KEY.") === 0) {
|
|
178
|
-
const
|
|
181
|
+
const machineKey = val.toUpperCase();
|
|
182
|
+
const nativeKey = key.substring(4).toUpperCase(); // remove KEY.
|
|
179
183
|
|
|
180
|
-
if (
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
userKeymap.push({ native: nativeKey, bbc: bbcKey });
|
|
185
|
-
} else {
|
|
186
|
-
console.log("unknown key: " + nativeKey);
|
|
187
|
-
}
|
|
184
|
+
if (!machineKeys[machineKey]) {
|
|
185
|
+
warnings.push(`${key}=${val}: "${machineKey}" is not a key on the emulated machine.`);
|
|
186
|
+
} else if (!keyCodes[nativeKey]) {
|
|
187
|
+
warnings.push(`${key}=${val}: "${nativeKey}" is not a key on your keyboard.`);
|
|
188
188
|
} else {
|
|
189
|
-
console.log("
|
|
189
|
+
console.log("mapping " + nativeKey + " to " + machineKey);
|
|
190
|
+
userKeymap.push({ native: nativeKey, key: machineKey });
|
|
190
191
|
}
|
|
191
192
|
} else if (key.indexOf("GP.") === 0) {
|
|
192
193
|
// gamepad mapping
|
|
193
194
|
// eg ?GP.FIRE2=RETURN
|
|
194
195
|
const gamepadKey = key.substring(3).toUpperCase(); // remove GP. prefix
|
|
195
|
-
gamepad.remap(gamepadKey, val.toUpperCase());
|
|
196
|
+
const problem = gamepad.remap(gamepadKey, val.toUpperCase());
|
|
197
|
+
if (problem) warnings.push(`${key}=${val}: ${problem}`);
|
|
196
198
|
} else {
|
|
197
199
|
switch (key) {
|
|
198
200
|
case "LEFT":
|
|
199
201
|
case "RIGHT":
|
|
200
202
|
case "UP":
|
|
201
203
|
case "DOWN":
|
|
202
|
-
case "FIRE":
|
|
203
|
-
gamepad.remap(key, val.toUpperCase());
|
|
204
|
+
case "FIRE": {
|
|
205
|
+
const problem = gamepad.remap(key, val.toUpperCase());
|
|
206
|
+
if (problem) warnings.push(`${key}=${val}: ${problem}`);
|
|
204
207
|
break;
|
|
208
|
+
}
|
|
205
209
|
}
|
|
206
210
|
}
|
|
207
211
|
});
|
|
208
212
|
|
|
209
|
-
return
|
|
213
|
+
return warnings;
|
|
210
214
|
}
|
|
211
215
|
|
|
212
216
|
/**
|
package/src/utils.js
CHANGED
|
@@ -657,6 +657,12 @@ export function getKeyMap(keyLayout) {
|
|
|
657
657
|
keys2[shiftDown][s] = colRow;
|
|
658
658
|
}
|
|
659
659
|
|
|
660
|
+
// Overriding a default is the point here, so unlike `map` this doesn't warn about the clash.
|
|
661
|
+
function remap(s, colRow) {
|
|
662
|
+
keys2[true][s] = colRow;
|
|
663
|
+
keys2[false][s] = colRow;
|
|
664
|
+
}
|
|
665
|
+
|
|
660
666
|
// shiftDown undefined -> map both
|
|
661
667
|
function map(s, colRow, shiftDown) {
|
|
662
668
|
if ((!s && s !== 0) || !colRow) {
|
|
@@ -935,11 +941,10 @@ export function getKeyMap(keyLayout) {
|
|
|
935
941
|
// eg Master Dunjunz needs # Del 3 , * Enter
|
|
936
942
|
// https://web.archive.org/web/20080305042238/http://bbc.nvg.org/doc/games/Dunjunz-docs.txt
|
|
937
943
|
|
|
938
|
-
//
|
|
939
|
-
//
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
map(keyCodes[mapping.native], BBC[mapping.bbc]);
|
|
944
|
+
// `KEY.` URL parameters, applied last so they win. Not consumed: this map is rebuilt on
|
|
945
|
+
// layout and model changes, and the user's mapping has to survive that.
|
|
946
|
+
for (const mapping of userKeymap) {
|
|
947
|
+
remap(keyCodes[mapping.native], BBC[mapping.key]);
|
|
943
948
|
}
|
|
944
949
|
|
|
945
950
|
return keys2;
|
package/src/utils_atom.js
CHANGED
|
@@ -249,6 +249,12 @@ export function getKeyMapAtom(keyLayout) {
|
|
|
249
249
|
keys2[shiftDown][s] = colRow;
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
+
// Overriding a default is the point here, so unlike `map` this doesn't warn about the clash.
|
|
253
|
+
function remap(s, colRow) {
|
|
254
|
+
keys2[true][s] = colRow;
|
|
255
|
+
keys2[false][s] = colRow;
|
|
256
|
+
}
|
|
257
|
+
|
|
252
258
|
// shiftDown undefined -> map both
|
|
253
259
|
function map(s, colRow, shiftDown) {
|
|
254
260
|
if ((!s && s !== 0) || !colRow) {
|
|
@@ -477,11 +483,9 @@ export function getKeyMapAtom(keyLayout) {
|
|
|
477
483
|
// Z - M normal
|
|
478
484
|
}
|
|
479
485
|
|
|
480
|
-
//
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
const mapping = userKeymap.pop();
|
|
484
|
-
map(keyCodes[mapping.native], ATOM[mapping.atom]);
|
|
486
|
+
// `KEY.` URL parameters, applied last so they win. See the equivalent in `getKeyMap`.
|
|
487
|
+
for (const mapping of userKeymap) {
|
|
488
|
+
remap(keyCodes[mapping.native], ATOM[mapping.key]);
|
|
485
489
|
}
|
|
486
490
|
|
|
487
491
|
return keys2;
|