jsbeeb 1.17.1 → 1.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -3
- package/src/6502.js +9 -1
- package/src/bbcdiscs.js +31 -1
- package/src/canvas.js +95 -37
- package/src/jsbeeb.css +23 -1
- package/src/machine-session.js +57 -0
- package/src/main.js +171 -69
- package/src/snapshot-helpers.js +0 -1
- package/src/teletext.js +21 -13
- package/src/teletext_adaptor.js +40 -5
- package/src/touchscreen.js +24 -3
- package/src/utils.js +19 -2
- package/src/via.js +25 -8
- package/src/web/audio-renderer.js +13 -8
- package/tests/test-machine.js +6 -8
package/src/utils.js
CHANGED
|
@@ -1013,7 +1013,10 @@ function loadDataHttp(url) {
|
|
|
1013
1013
|
request.open("GET", baseUrl + url, true);
|
|
1014
1014
|
request.overrideMimeType("text/plain; charset=x-user-defined");
|
|
1015
1015
|
request.onload = function () {
|
|
1016
|
-
if (request.status !== 200)
|
|
1016
|
+
if (request.status !== 200) {
|
|
1017
|
+
reject(new Error("Unable to load " + url + ", http code " + request.status));
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1017
1020
|
if (typeof request.response !== "string") {
|
|
1018
1021
|
resolve(request.response);
|
|
1019
1022
|
} else {
|
|
@@ -1208,6 +1211,7 @@ async function unzipImage(data, knownExtensions) {
|
|
|
1208
1211
|
|
|
1209
1212
|
let uncompressed = null;
|
|
1210
1213
|
let loadedFile;
|
|
1214
|
+
const ignored = [];
|
|
1211
1215
|
|
|
1212
1216
|
for (const [filename, fileData] of Object.entries(files)) {
|
|
1213
1217
|
const match = filename.match(/.*\.([a-z]+)/i);
|
|
@@ -1217,6 +1221,7 @@ async function unzipImage(data, knownExtensions) {
|
|
|
1217
1221
|
}
|
|
1218
1222
|
if (uncompressed) {
|
|
1219
1223
|
console.log("Ignoring", filename, "as already found a file");
|
|
1224
|
+
ignored.push(filename);
|
|
1220
1225
|
continue;
|
|
1221
1226
|
}
|
|
1222
1227
|
loadedFile = filename;
|
|
@@ -1228,9 +1233,14 @@ async function unzipImage(data, knownExtensions) {
|
|
|
1228
1233
|
}
|
|
1229
1234
|
|
|
1230
1235
|
console.log("Unzipped '" + loadedFile + "'");
|
|
1231
|
-
return { data: uncompressed, name: loadedFile };
|
|
1236
|
+
return { data: uncompressed, name: loadedFile, ignored };
|
|
1232
1237
|
}
|
|
1233
1238
|
|
|
1239
|
+
/**
|
|
1240
|
+
* @param {Uint8Array|number[]} data a ZIP archive
|
|
1241
|
+
* @returns {Promise<{data: Uint8Array, name: string, ignored: string[]}>} the one file loaded, and the
|
|
1242
|
+
* names of any other loadable files the archive held
|
|
1243
|
+
*/
|
|
1234
1244
|
export async function unzipDiscImage(data) {
|
|
1235
1245
|
return unzipImage(data, knownDiscExtensions);
|
|
1236
1246
|
}
|
|
@@ -1290,4 +1300,11 @@ export class Fifo {
|
|
|
1290
1300
|
this._size--;
|
|
1291
1301
|
return res;
|
|
1292
1302
|
}
|
|
1303
|
+
|
|
1304
|
+
/** @returns {number[]} pending bytes, oldest first */
|
|
1305
|
+
toArray() {
|
|
1306
|
+
const result = [];
|
|
1307
|
+
for (let i = 0; i < this._size; ++i) result.push(this._buffer[(this._rPtr + i) % this._buffer.length]);
|
|
1308
|
+
return result;
|
|
1309
|
+
}
|
|
1293
1310
|
}
|
package/src/via.js
CHANGED
|
@@ -24,6 +24,10 @@ const ORB = 0x0,
|
|
|
24
24
|
INT_CB1 = 0x10,
|
|
25
25
|
INT_CB2 = 0x08;
|
|
26
26
|
|
|
27
|
+
// Pulse mode holds CA2/CB2 low for one 1MHz VIA cycle, here in the scheduler's 2MHz ticks.
|
|
28
|
+
// Figures 3-4 and 3-6 of https://6502.org/documents/datasheets/wdc/wdc_w65c22s_mar_2004.pdf
|
|
29
|
+
const PulseWidthCycles = 2;
|
|
30
|
+
|
|
27
31
|
class Via {
|
|
28
32
|
constructor(cpu, scheduler, irq) {
|
|
29
33
|
this.cpu = cpu;
|
|
@@ -59,6 +63,8 @@ class Via {
|
|
|
59
63
|
this.t1_pb7 = 0;
|
|
60
64
|
|
|
61
65
|
this.task = this.scheduler.newTask(() => this._onTimeout());
|
|
66
|
+
this.ca2PulseTask = this.scheduler.newTask(() => this.setca2(true));
|
|
67
|
+
this.cb2PulseTask = this.scheduler.newTask(() => this.setcb2(true));
|
|
62
68
|
this.lastPolltime = 0;
|
|
63
69
|
}
|
|
64
70
|
|
|
@@ -72,6 +78,8 @@ class Via {
|
|
|
72
78
|
this.t1hit = this.t2hit = true;
|
|
73
79
|
this.acr = this.pcr = 0;
|
|
74
80
|
this.t1_pb7 = 1;
|
|
81
|
+
this.ca2PulseTask.cancel();
|
|
82
|
+
this.cb2PulseTask.cancel();
|
|
75
83
|
this.updateNextTime();
|
|
76
84
|
}
|
|
77
85
|
|
|
@@ -166,7 +174,7 @@ class Via {
|
|
|
166
174
|
} else if (mode === 0x0a) {
|
|
167
175
|
// Pulse mode
|
|
168
176
|
this.setca2(false);
|
|
169
|
-
this.
|
|
177
|
+
this.ca2PulseTask.reschedule(PulseWidthCycles);
|
|
170
178
|
}
|
|
171
179
|
break;
|
|
172
180
|
|
|
@@ -193,7 +201,7 @@ class Via {
|
|
|
193
201
|
} else if (mode === 0x0a) {
|
|
194
202
|
// Pulse mode
|
|
195
203
|
this.setcb2(false);
|
|
196
|
-
this.
|
|
204
|
+
this.cb2PulseTask.reschedule(PulseWidthCycles);
|
|
197
205
|
}
|
|
198
206
|
break;
|
|
199
207
|
|
|
@@ -408,6 +416,15 @@ class Via {
|
|
|
408
416
|
this.portBUpdated();
|
|
409
417
|
}
|
|
410
418
|
|
|
419
|
+
_taskOffset(task) {
|
|
420
|
+
return task.scheduled() ? task.expireEpoch - this.scheduler.epoch : null;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
_restoreTask(task, offset) {
|
|
424
|
+
if (offset === null || offset === undefined) task.cancel();
|
|
425
|
+
else task.reschedule(offset);
|
|
426
|
+
}
|
|
427
|
+
|
|
411
428
|
snapshotState() {
|
|
412
429
|
return {
|
|
413
430
|
ora: this.ora,
|
|
@@ -436,7 +453,9 @@ class Via {
|
|
|
436
453
|
justhit: this.justhit,
|
|
437
454
|
t1_pb7: this.t1_pb7,
|
|
438
455
|
lastPolltime: this.lastPolltime,
|
|
439
|
-
taskOffset: this.
|
|
456
|
+
taskOffset: this._taskOffset(this.task),
|
|
457
|
+
ca2PulseTaskOffset: this._taskOffset(this.ca2PulseTask),
|
|
458
|
+
cb2PulseTaskOffset: this._taskOffset(this.cb2PulseTask),
|
|
440
459
|
};
|
|
441
460
|
}
|
|
442
461
|
|
|
@@ -468,11 +487,9 @@ class Via {
|
|
|
468
487
|
this.t1_pb7 = state.t1_pb7;
|
|
469
488
|
this.lastPolltime = state.lastPolltime;
|
|
470
489
|
this.updateIFR();
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
this.task.cancel();
|
|
475
|
-
}
|
|
490
|
+
this._restoreTask(this.task, state.taskOffset);
|
|
491
|
+
this._restoreTask(this.ca2PulseTask, state.ca2PulseTaskOffset);
|
|
492
|
+
this._restoreTask(this.cb2PulseTask, state.cb2PulseTaskOffset);
|
|
476
493
|
}
|
|
477
494
|
|
|
478
495
|
setca1(level) {
|
|
@@ -3,21 +3,26 @@
|
|
|
3
3
|
const lowPassFilterFreq = sampleRate / 2;
|
|
4
4
|
const RC = 1 / (2 * Math.PI * lowPassFilterFreq);
|
|
5
5
|
|
|
6
|
+
const InputSampleRate = 4000000.0 / 8;
|
|
7
|
+
const MaxQueuedMs = 250;
|
|
8
|
+
|
|
9
|
+
const samplesFor = (ms) => (InputSampleRate * ms) / 1000;
|
|
10
|
+
|
|
6
11
|
class SoundChipProcessor extends AudioWorkletProcessor {
|
|
7
12
|
constructor(...args) {
|
|
8
13
|
super(...args);
|
|
9
14
|
|
|
10
|
-
this.inputSampleRate =
|
|
15
|
+
this.inputSampleRate = InputSampleRate;
|
|
11
16
|
this._lastSample = 0;
|
|
12
17
|
this._lastFilteredOutput = 0;
|
|
13
18
|
this.queue = [];
|
|
14
|
-
this.
|
|
19
|
+
this._queueSizeSamples = 0;
|
|
15
20
|
this.dropped = 0;
|
|
16
21
|
this.underruns = 0;
|
|
17
22
|
this.targetLatencyMs = 1000 * (1 / 50); // One frame
|
|
18
|
-
this.
|
|
23
|
+
this.startQueueSizeSamples = samplesFor(this.targetLatencyMs);
|
|
19
24
|
this.running = false;
|
|
20
|
-
this.
|
|
25
|
+
this.maxQueueSizeSamples = samplesFor(MaxQueuedMs);
|
|
21
26
|
this.port.onmessage = (event) => {
|
|
22
27
|
// TODO: even better than this, send over register settings/catch up and run the audio work _here_
|
|
23
28
|
this.onBuffer(event.data.time, event.data.buffer);
|
|
@@ -47,19 +52,19 @@ class SoundChipProcessor extends AudioWorkletProcessor {
|
|
|
47
52
|
|
|
48
53
|
onBuffer(time, buffer) {
|
|
49
54
|
this.queue.push({ offset: 0, time, buffer });
|
|
50
|
-
this.
|
|
55
|
+
this._queueSizeSamples += buffer.length;
|
|
51
56
|
this.cleanQueue();
|
|
52
|
-
if (!this.running && this.
|
|
57
|
+
if (!this.running && this._queueSizeSamples >= this.startQueueSizeSamples) this.running = true;
|
|
53
58
|
}
|
|
54
59
|
|
|
55
60
|
_shift() {
|
|
56
61
|
const dropped = this.queue.shift();
|
|
57
|
-
this.
|
|
62
|
+
this._queueSizeSamples -= dropped.buffer.length;
|
|
58
63
|
}
|
|
59
64
|
|
|
60
65
|
cleanQueue() {
|
|
61
66
|
const maxLatency = this.targetLatencyMs * 2;
|
|
62
|
-
while (this.
|
|
67
|
+
while (this._queueSizeSamples > this.maxQueueSizeSamples || this._queueAge() > maxLatency) {
|
|
63
68
|
this._shift();
|
|
64
69
|
this.dropped++;
|
|
65
70
|
}
|
package/tests/test-machine.js
CHANGED
|
@@ -111,7 +111,9 @@ export class TestMachine {
|
|
|
111
111
|
stopped = !this.processor.execute(todo);
|
|
112
112
|
left -= todo;
|
|
113
113
|
}
|
|
114
|
-
|
|
114
|
+
// Not truthiness: a negative or NaN request clamps todo to zero,
|
|
115
|
+
// so left would never move and the loop never end.
|
|
116
|
+
if (left > 0 && !stopped) {
|
|
115
117
|
setTimeout(runAnIter, 0);
|
|
116
118
|
} else {
|
|
117
119
|
resolve(stopped);
|
|
@@ -136,17 +138,13 @@ export class TestMachine {
|
|
|
136
138
|
throw new Error(`Cursor did not reach state ${on} in time (cursorOnThisFrame=${video.cursorOnThisFrame})`);
|
|
137
139
|
}
|
|
138
140
|
|
|
139
|
-
|
|
140
|
-
* Run until the teletext flash state reaches the desired phase.
|
|
141
|
-
* @param {boolean} on - true for flash-on (flashing cells blanked), false for flash-off
|
|
142
|
-
*/
|
|
143
|
-
async runToFlashState(on) {
|
|
141
|
+
async runUntilFlashHidden() {
|
|
144
142
|
const teletext = this.processor.video.teletext;
|
|
145
143
|
for (let i = 0; i < 100; i++) {
|
|
146
|
-
if (teletext.
|
|
144
|
+
if (teletext.hideFlashing) return;
|
|
147
145
|
await this.runFor(40000);
|
|
148
146
|
}
|
|
149
|
-
throw new Error(
|
|
147
|
+
throw new Error("Flashing text did not reach its hidden phase in time");
|
|
150
148
|
}
|
|
151
149
|
|
|
152
150
|
async runUntilVblank() {
|