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 +97 -2
- package/package.json +23 -16
- package/public/roms/tube/65C102Tube.rom +0 -0
- package/src/6502.js +74 -17
- package/src/acia.js +29 -11
- package/src/bem-snapshot.js +154 -15
- package/src/config.js +114 -83
- package/src/disc.js +47 -5
- package/src/dom-utils.js +16 -0
- package/src/fake6502.js +7 -2
- package/src/gamepads.js +11 -4
- package/src/keyboard.js +3 -2
- package/src/machine-session.js +11 -2
- package/src/main.js +117 -100
- package/src/models.js +57 -5
- package/src/mouse-coordinates.js +16 -0
- package/src/serial.js +1 -0
- package/src/snapshot-helpers.js +17 -3
- package/src/snapshot.js +19 -1
- package/src/soundchip.js +23 -5
- package/src/teletext_adaptor.js +40 -14
- package/src/tube.js +33 -1
- package/src/url-params.js +23 -19
- package/src/utils.js +10 -5
- package/src/utils_atom.js +9 -5
- package/src/web/audio-handler.js +4 -1
- package/tests/test-machine.js +7 -5
package/src/snapshot.js
CHANGED
|
@@ -4,7 +4,15 @@ import { typedArrayToBase64, base64ToTypedArray } from "./state-utils.js";
|
|
|
4
4
|
import { findModel } from "./models.js";
|
|
5
5
|
|
|
6
6
|
const SnapshotFormat = "jsbeeb-snapshot";
|
|
7
|
-
const SnapshotVersion =
|
|
7
|
+
const SnapshotVersion = 3;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Whether a snapshot was taken on a machine with a second processor fitted.
|
|
11
|
+
* Nothing before version 3 captured tube state, so those snapshots are always host-only.
|
|
12
|
+
*/
|
|
13
|
+
export function hasCoProcessor(snapshot) {
|
|
14
|
+
return !!snapshot.coProcessor;
|
|
15
|
+
}
|
|
8
16
|
|
|
9
17
|
/**
|
|
10
18
|
* Check if two model names resolve to the same model (accounting for
|
|
@@ -70,6 +78,9 @@ export function createSnapshot(cpu, model, media) {
|
|
|
70
78
|
format: SnapshotFormat,
|
|
71
79
|
version: SnapshotVersion,
|
|
72
80
|
model: model.name,
|
|
81
|
+
// The model name cannot distinguish a Turbo from a plain Master: the co-processor is
|
|
82
|
+
// emulation config rather than part of the model.
|
|
83
|
+
coProcessor: cpu.hasTube,
|
|
73
84
|
timestamp: new Date().toISOString(),
|
|
74
85
|
state,
|
|
75
86
|
};
|
|
@@ -94,6 +105,13 @@ export function restoreSnapshot(cpu, model, snapshot) {
|
|
|
94
105
|
if (!isSameModel(snapshot.model, model.name)) {
|
|
95
106
|
throw new Error(`Model mismatch: snapshot is for "${snapshot.model}" but current model is "${model.name}"`);
|
|
96
107
|
}
|
|
108
|
+
if (hasCoProcessor(snapshot) !== cpu.hasTube) {
|
|
109
|
+
const fitted = (yes) => (yes ? "with a second processor" : "without a second processor");
|
|
110
|
+
throw new Error(
|
|
111
|
+
`Co-processor mismatch: snapshot was taken ${fitted(hasCoProcessor(snapshot))} ` +
|
|
112
|
+
`but this machine is ${fitted(cpu.hasTube)}`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
97
115
|
cpu.restoreState(snapshot.state);
|
|
98
116
|
}
|
|
99
117
|
|
package/src/soundchip.js
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
// The BBC volumeTable[0] (loudest) is 0.25 (1.0 / 4 channels).
|
|
3
3
|
const speakerVolume = 0.5;
|
|
4
4
|
|
|
5
|
+
// Samples per output chunk handed to the onBuffer callback.
|
|
6
|
+
export const SoundBufferSamples = 512;
|
|
7
|
+
|
|
5
8
|
const volumeTable = new Float32Array(16);
|
|
6
9
|
(() => {
|
|
7
10
|
let f = 1.0;
|
|
@@ -21,6 +24,11 @@ function makeSineTable(attenuation) {
|
|
|
21
24
|
}
|
|
22
25
|
|
|
23
26
|
export class SoundChip {
|
|
27
|
+
/**
|
|
28
|
+
* @param {function(Float32Array): void} onBuffer called with each full
|
|
29
|
+
* SoundBufferSamples-sized buffer of output. Receives the same buffer
|
|
30
|
+
* every call, overwritten afterwards: copy the contents if they are kept.
|
|
31
|
+
*/
|
|
24
32
|
constructor(onBuffer) {
|
|
25
33
|
this._onBuffer = onBuffer;
|
|
26
34
|
// 4MHz input signal. Internal divide-by-8
|
|
@@ -62,7 +70,7 @@ export class SoundChip {
|
|
|
62
70
|
|
|
63
71
|
this.residual = 0;
|
|
64
72
|
this.position = 0;
|
|
65
|
-
this.buffer = new Float32Array(
|
|
73
|
+
this.buffer = new Float32Array(SoundBufferSamples);
|
|
66
74
|
|
|
67
75
|
this.latchedRegister = 0;
|
|
68
76
|
this.slowDataBus = 0;
|
|
@@ -211,17 +219,27 @@ export class SoundChip {
|
|
|
211
219
|
const num = cycles * this.samplesPerCycle + this.residual;
|
|
212
220
|
let rounded = num | 0;
|
|
213
221
|
this.residual = num - rounded;
|
|
214
|
-
|
|
222
|
+
// The buffer is deliberately reused for the chip's whole life: the
|
|
223
|
+
// previous transfer-then-reallocate pattern is miscompiled by a V8
|
|
224
|
+
// optimiser bug (Chrome 150, crbug.com/537801199) which allocates the
|
|
225
|
+
// replacement with length 0, even when the reallocation is reordered
|
|
226
|
+
// before the transfer, wedging this loop forever. Reuse is also
|
|
227
|
+
// cheaper, so this needn't be reverted once the crbug is fixed. The
|
|
228
|
+
// guard fails loudly if the buffer is ever detached or the accounting
|
|
229
|
+
// goes bad.
|
|
215
230
|
while (rounded > 0) {
|
|
216
|
-
const leftInBuffer =
|
|
231
|
+
const leftInBuffer = SoundBufferSamples - this.position;
|
|
217
232
|
const numSamplesToGenerate = Math.min(rounded, leftInBuffer);
|
|
233
|
+
if (numSamplesToGenerate <= 0 || this.buffer.length !== SoundBufferSamples)
|
|
234
|
+
throw new Error(
|
|
235
|
+
`Sound buffer accounting error (buffer=${this.buffer.length}, position=${this.position}, rounded=${rounded})`,
|
|
236
|
+
);
|
|
218
237
|
this.generate(this.buffer, this.position, numSamplesToGenerate);
|
|
219
238
|
this.position += numSamplesToGenerate;
|
|
220
239
|
rounded -= numSamplesToGenerate;
|
|
221
240
|
|
|
222
|
-
if (this.position ===
|
|
241
|
+
if (this.position === SoundBufferSamples) {
|
|
223
242
|
this._onBuffer(this.buffer);
|
|
224
|
-
this.buffer = new Float32Array(bufferLength);
|
|
225
243
|
this.position = 0;
|
|
226
244
|
}
|
|
227
245
|
}
|
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) {
|
|
@@ -146,7 +171,8 @@ export class TeletextAdaptor {
|
|
|
146
171
|
this.teletextStatus &= 0x0f;
|
|
147
172
|
this.teletextStatus |= 0xd0; // data ready so latch INT, DOR, and FSYN
|
|
148
173
|
|
|
149
|
-
|
|
174
|
+
// The stream arrives asynchronously, so software can enable us before there is anything to copy.
|
|
175
|
+
if (this.teletextEnable && this.streamData) {
|
|
150
176
|
// Copy current stream position into the frame buffer
|
|
151
177
|
for (let i = 0; i < 16; ++i) {
|
|
152
178
|
if (this.streamData[offset + i * 43] !== 0) {
|
package/src/tube.js
CHANGED
|
@@ -219,7 +219,7 @@ export class Tube {
|
|
|
219
219
|
if (this.hostStatus[TUBE_ULA_R3] & TUBE_ULA_FLAG_DATA_REGISTER_NOT_FULL) {
|
|
220
220
|
if (this.internalStatusRegister & TUBE_ULA_FLAG_STATUS_ENABLE_2_BYTE_R3_DATA) {
|
|
221
221
|
if (this.hostToParasiteFifoByteCount3 < 2) {
|
|
222
|
-
this.hostToParasiteData[this.hostToParasiteFifoByteCount3++] = value;
|
|
222
|
+
this.hostToParasiteData[TUBE_ULA_R3][this.hostToParasiteFifoByteCount3++] = value;
|
|
223
223
|
}
|
|
224
224
|
if (this.hostToParasiteFifoByteCount3 === 2) {
|
|
225
225
|
this.parasiteStatus[TUBE_ULA_R3] |= TUBE_ULA_FLAG_DATA_AVAILABLE;
|
|
@@ -294,6 +294,38 @@ export class Tube {
|
|
|
294
294
|
}
|
|
295
295
|
return result;
|
|
296
296
|
}
|
|
297
|
+
snapshotState() {
|
|
298
|
+
return {
|
|
299
|
+
internalStatusRegister: this.internalStatusRegister,
|
|
300
|
+
hostStatus: this.hostStatus.slice(),
|
|
301
|
+
parasiteStatus: this.parasiteStatus.slice(),
|
|
302
|
+
parasiteToHostData: this.parasiteToHostData.map((fifo) => fifo.slice()),
|
|
303
|
+
hostToParasiteData: this.hostToParasiteData.map((fifo) => fifo.slice()),
|
|
304
|
+
parasiteToHostFifoByteCount1: this.parasiteToHostFifoByteCount1,
|
|
305
|
+
parasiteToHostFifoByteCount3: this.parasiteToHostFifoByteCount3,
|
|
306
|
+
hostToParasiteFifoByteCount3: this.hostToParasiteFifoByteCount3,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* The interrupt and reset lines are not saved: they are derived from the status registers
|
|
312
|
+
* and FIFO counts, in the same way the host's `interrupt` is rebuilt by the VIA and ACIA
|
|
313
|
+
* restores.
|
|
314
|
+
*/
|
|
315
|
+
restoreState(state) {
|
|
316
|
+
this.internalStatusRegister = state.internalStatusRegister;
|
|
317
|
+
this.hostStatus.set(state.hostStatus);
|
|
318
|
+
this.parasiteStatus.set(state.parasiteStatus);
|
|
319
|
+
for (let i = 0; i < 4; i++) {
|
|
320
|
+
this.parasiteToHostData[i].set(state.parasiteToHostData[i]);
|
|
321
|
+
this.hostToParasiteData[i].set(state.hostToParasiteData[i]);
|
|
322
|
+
}
|
|
323
|
+
this.parasiteToHostFifoByteCount1 = state.parasiteToHostFifoByteCount1;
|
|
324
|
+
this.parasiteToHostFifoByteCount3 = state.parasiteToHostFifoByteCount3;
|
|
325
|
+
this.hostToParasiteFifoByteCount3 = state.hostToParasiteFifoByteCount3;
|
|
326
|
+
this.updateInterrupts();
|
|
327
|
+
}
|
|
328
|
+
|
|
297
329
|
parasiteWrite(address, value) {
|
|
298
330
|
// Not implemented - needs to be integrated with the parasite CPU code:
|
|
299
331
|
// Boot mode is terminated by the software when it selects any one of the Tube addresses.
|
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;
|
package/src/web/audio-handler.js
CHANGED
|
@@ -123,7 +123,10 @@ export class AudioHandler {
|
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
_onBuffer(buffer) {
|
|
126
|
-
|
|
126
|
+
// No transfer list, deliberately: the chip reuses this buffer, and
|
|
127
|
+
// transferring would detach it and trip crbug.com/537801199. The clone
|
|
128
|
+
// costs little (512 floats per 1.024ms of chip output, ~2MB/s).
|
|
129
|
+
if (this._jsAudioNode) this._jsAudioNode.port.postMessage({ time: Date.now(), buffer });
|
|
127
130
|
}
|
|
128
131
|
|
|
129
132
|
// Recent browsers, particularly Safari and Chrome, require a user interaction in order to enable sound playback.
|
package/tests/test-machine.js
CHANGED
|
@@ -11,9 +11,9 @@ const MaxCyclesPerIter = 100 * 1000;
|
|
|
11
11
|
export class TestMachine {
|
|
12
12
|
constructor(model, opts) {
|
|
13
13
|
model = model || "B-DFS1.2";
|
|
14
|
-
|
|
15
|
-
this.model
|
|
16
|
-
this.processor = fake6502(
|
|
14
|
+
this.model = findModel(model);
|
|
15
|
+
if (!this.model) throw new Error(`Unknown model "${model}"`);
|
|
16
|
+
this.processor = fake6502(this.model, opts || {});
|
|
17
17
|
this._capturedChars = [];
|
|
18
18
|
this._captureHookInstalled = false;
|
|
19
19
|
}
|
|
@@ -362,7 +362,9 @@ export class TestMachine {
|
|
|
362
362
|
}
|
|
363
363
|
const fullText = text + "\n"; // append RETURN
|
|
364
364
|
const keys = fullText.split("").map((ch) => this._charToKey(ch));
|
|
365
|
-
|
|
365
|
+
// Key hold is counted in CPU cycles, so scale it to keep the hold constant in
|
|
366
|
+
// real time: the OS scans the keyboard on a peripheral-rate interrupt.
|
|
367
|
+
const holdCycles = (40000 * this.processor.cpuMultiplier) | 0;
|
|
366
368
|
let index = 0;
|
|
367
369
|
let phase = "idle"; // "idle" → "down" → "idle"
|
|
368
370
|
let nextEventCycle = 0;
|
|
@@ -414,7 +416,7 @@ export class TestMachine {
|
|
|
414
416
|
// toggle the ROM's internal caps lock state.
|
|
415
417
|
const keySequence = utils_atom.stringToATOMKeys(text + "\n");
|
|
416
418
|
const ppia = this.processor.atomppia;
|
|
417
|
-
const holdCycles = 80000; // Atom at 1 MHz needs longer hold than BBC at 2 MHz
|
|
419
|
+
const holdCycles = (80000 * this.processor.cpuMultiplier) | 0; // Atom at 1 MHz needs longer hold than BBC at 2 MHz
|
|
418
420
|
const SHIFT = utils_atom.ATOM.SHIFT;
|
|
419
421
|
|
|
420
422
|
let index = 0;
|