jsbeeb 1.17.1 → 1.18.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 +71 -29
- package/src/jsbeeb.css +23 -1
- package/src/machine-session.js +57 -0
- package/src/main.js +165 -69
- package/src/teletext.js +12 -2
- 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/tests/test-machine.js +3 -1
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.18.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"
|
|
@@ -106,10 +106,11 @@
|
|
|
106
106
|
"mirror-sth:upload": "npm-run-all mirror-sth:upload:*",
|
|
107
107
|
"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'",
|
|
108
108
|
"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'",
|
|
109
|
-
"mirror-bbcdiscs": "node tools/mirror-bbcdiscs.js --csv .bbcdiscs-sheet.csv --out .bbcdiscs-mirror",
|
|
109
|
+
"mirror-bbcdiscs": "node tools/mirror-bbcdiscs.js --csv .bbcdiscs-sheet.csv --fsd .bbcdiscs-mirror/cache-fsd --out .bbcdiscs-mirror",
|
|
110
110
|
"mirror-bbcdiscs:check": "node tools/mirror-bbcdiscs.js --csv .bbcdiscs-sheet.csv --check-only",
|
|
111
111
|
"mirror-bbcdiscs:seed": "aws s3 sync s3://bbc.xania.org/archive/bbcdiscs/hfe/ .bbcdiscs-mirror/hfe/ --no-progress",
|
|
112
|
-
"mirror-bbcdiscs:
|
|
112
|
+
"mirror-bbcdiscs:preflight": "node tools/mirror-bbcdiscs.js --preflight --out .bbcdiscs-mirror",
|
|
113
|
+
"mirror-bbcdiscs:upload": "npm-run-all mirror-bbcdiscs:preflight mirror-bbcdiscs:upload:*",
|
|
113
114
|
"mirror-bbcdiscs:upload:blobs": "aws s3 sync .bbcdiscs-mirror/hfe s3://bbc.xania.org/archive/bbcdiscs/hfe/ --no-progress --exclude 'manifest.json' --delete --content-encoding br --cache-control 'public, max-age=31536000, immutable'",
|
|
114
115
|
"mirror-bbcdiscs:upload:index": "aws s3 cp .bbcdiscs-mirror/hfe/manifest.json s3://bbc.xania.org/archive/bbcdiscs/hfe/manifest.json --cache-control 'public, max-age=300' && aws s3 cp .bbcdiscs-mirror/manifest.json s3://bbc.xania.org/archive/bbcdiscs/manifest.json --cache-control 'public, max-age=300'",
|
|
115
116
|
"electron": "npm run build && ELECTRON_DISABLE_SANDBOX=1 electron .",
|
package/src/6502.js
CHANGED
|
@@ -709,6 +709,7 @@ export class Cpu6502 extends Base6502 {
|
|
|
709
709
|
this.acia = new Acia(this, this.soundChip.toneGenerator, this.scheduler, this.relayNoise);
|
|
710
710
|
this.serial = new Serial(this.acia);
|
|
711
711
|
this.adconverter = new Adc(this.sysvia, this.scheduler);
|
|
712
|
+
this.touchScreen = new TouchScreen(this.scheduler, this.model.cyclesPerSecond);
|
|
712
713
|
this.soundChip.setScheduler(this.scheduler);
|
|
713
714
|
this.fdc = new this.model.Fdc(this, this.ddNoise, this.scheduler, this.debugFlags);
|
|
714
715
|
}
|
|
@@ -1240,7 +1241,9 @@ export class Cpu6502 extends Base6502 {
|
|
|
1240
1241
|
soundChip: this.soundChip.snapshotState(),
|
|
1241
1242
|
acia: this.acia.snapshotState(),
|
|
1242
1243
|
adc: this.adconverter.snapshotState(),
|
|
1244
|
+
touchScreen: this.touchScreen.snapshotState(),
|
|
1243
1245
|
fdc: this.fdc.snapshotState(),
|
|
1246
|
+
teletextAdaptor: this.teletextAdaptor ? this.teletextAdaptor.snapshotState() : undefined,
|
|
1244
1247
|
tube: this.hasTube ? this.tube.snapshotState({ includeRoms }) : undefined,
|
|
1245
1248
|
};
|
|
1246
1249
|
}
|
|
@@ -1297,6 +1300,11 @@ export class Cpu6502 extends Base6502 {
|
|
|
1297
1300
|
this.soundChip.restoreState(state.soundChip);
|
|
1298
1301
|
this.acia.restoreState(state.acia);
|
|
1299
1302
|
this.adconverter.restoreState(state.adc);
|
|
1303
|
+
if (this.teletextAdaptor && state.teletextAdaptor) this.teletextAdaptor.restoreState(state.teletextAdaptor);
|
|
1304
|
+
|
|
1305
|
+
// Touchscreen state, added without a version bump. Absent from an older snapshot, whose
|
|
1306
|
+
// touchscreen keeps its current state, unpolled.
|
|
1307
|
+
if (state.touchScreen) this.touchScreen.restoreState(state.touchScreen);
|
|
1300
1308
|
|
|
1301
1309
|
// FDC state (v2+). If absent (v1 snapshot), FDC keeps its current state.
|
|
1302
1310
|
if (state.fdc) {
|
|
@@ -1370,7 +1378,7 @@ export class Cpu6502 extends Base6502 {
|
|
|
1370
1378
|
this.fdc.powerOnReset();
|
|
1371
1379
|
this.adconverter.reset();
|
|
1372
1380
|
|
|
1373
|
-
this.touchScreen
|
|
1381
|
+
this.touchScreen.reset();
|
|
1374
1382
|
if (this.econet) this.filestore = new Filestore(this, this.econet);
|
|
1375
1383
|
}
|
|
1376
1384
|
|
package/src/bbcdiscs.js
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
const mirrorBase = "https://bbc.xania.org/archive/bbcdiscs";
|
|
4
4
|
|
|
5
|
+
/** How a disc came to be an image, which is the difference between the archives it came from. */
|
|
6
|
+
export const Provenance = {
|
|
7
|
+
/** Read off the disc itself, by flux capture. */
|
|
8
|
+
Captured: "captured",
|
|
9
|
+
/** Rebuilt from a sector dump, so the surface around the data is inferred. */
|
|
10
|
+
Reconstructed: "reconstructed",
|
|
11
|
+
};
|
|
12
|
+
|
|
5
13
|
// Numeric so a "Disc 2" would sort before a "Disc 10" rather than after it,
|
|
6
14
|
// and case-insensitive so a lower-cased title stays with its neighbours.
|
|
7
15
|
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
|
|
@@ -34,6 +42,25 @@ export function describe(file) {
|
|
|
34
42
|
};
|
|
35
43
|
}
|
|
36
44
|
|
|
45
|
+
/** The provenances a catalogue actually holds, so a source added later needs no code here. */
|
|
46
|
+
export const provenancesIn = (catalogue) => [...new Set(catalogue.map((file) => file.provenance))].sort();
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Whether a disc belongs in the picker as it is currently filtered.
|
|
50
|
+
*
|
|
51
|
+
* @param {object} file manifest entry
|
|
52
|
+
* @param {string} filter lower cased text to look for
|
|
53
|
+
* @param {?Set<string>} shown provenances to include, or null for all of them
|
|
54
|
+
*/
|
|
55
|
+
export function matches(file, filter, shown) {
|
|
56
|
+
if (shown && !shown.has(file.provenance)) return false;
|
|
57
|
+
if (!filter) return true;
|
|
58
|
+
// What the row says the disc is, rather than everything the row renders:
|
|
59
|
+
// the provenance is a word the tickboxes control, not one to search for.
|
|
60
|
+
const { title, publisher, detail } = describe(file);
|
|
61
|
+
return `${title} ${publisher} ${detail}`.toLowerCase().includes(filter);
|
|
62
|
+
}
|
|
63
|
+
|
|
37
64
|
export class BbcDiscArchive {
|
|
38
65
|
/** @param {string} [baseUrl] where the mirror lives, to point at a test prefix */
|
|
39
66
|
constructor(onStart, onCat, onError, baseUrl = mirrorBase) {
|
|
@@ -55,7 +82,10 @@ export class BbcDiscArchive {
|
|
|
55
82
|
if (!response.ok) throw new Error(`Network response was not ok (${response.status})`);
|
|
56
83
|
const data = await response.json();
|
|
57
84
|
if (!Array.isArray(data?.files)) throw new Error("Invalid manifest: missing files array");
|
|
58
|
-
this._catalogue =
|
|
85
|
+
this._catalogue = data.files
|
|
86
|
+
// The captured discs were published before provenance was recorded.
|
|
87
|
+
.map((file) => ({ ...file, provenance: file.provenance ?? Provenance.Captured }))
|
|
88
|
+
.sort(byTitle);
|
|
59
89
|
this._loaded = true;
|
|
60
90
|
} catch (error) {
|
|
61
91
|
console.error("Failed to fetch HFE archive catalogue:", error);
|
package/src/canvas.js
CHANGED
|
@@ -38,6 +38,11 @@ export class Canvas {
|
|
|
38
38
|
/** Nothing to release: the 2D context owns no objects of ours. */
|
|
39
39
|
dispose() {}
|
|
40
40
|
|
|
41
|
+
setFilter(filterClass) {
|
|
42
|
+
if (filterClass !== PassthroughFilter)
|
|
43
|
+
throw new Error(`${filterClass.getDisplayConfig().name} needs WebGL, which is not in use here`);
|
|
44
|
+
}
|
|
45
|
+
|
|
41
46
|
paint(minx, miny, maxx, maxy, _frame) {
|
|
42
47
|
const width = maxx - minx;
|
|
43
48
|
const height = maxy - miny;
|
|
@@ -78,23 +83,14 @@ export class GlCanvas {
|
|
|
78
83
|
|
|
79
84
|
checkedGl.depthMask(false);
|
|
80
85
|
|
|
81
|
-
this.filter = new filterClass(checkedGl);
|
|
82
|
-
const program = this.filter.program;
|
|
83
|
-
checkedGl.useProgram(program);
|
|
84
|
-
|
|
85
|
-
// Filters that pick their own samples want the texels they asked for,
|
|
86
|
-
// not a hardware blend of the ones either side.
|
|
87
|
-
const sampling = filterClass.getDisplayConfig().nearestSampling ? checkedGl.NEAREST : checkedGl.LINEAR;
|
|
88
|
-
|
|
89
86
|
this.fb8 = new Uint8Array(width * height * 4);
|
|
90
87
|
this.fb32 = new Uint32Array(this.fb8.buffer);
|
|
91
88
|
this.texture = checkedGl.createTexture();
|
|
89
|
+
checkedGl.activeTexture(checkedGl.TEXTURE0);
|
|
92
90
|
checkedGl.bindTexture(checkedGl.TEXTURE_2D, this.texture);
|
|
93
91
|
checkedGl.pixelStorei(checkedGl.UNPACK_ALIGNMENT, 4);
|
|
94
92
|
checkedGl.texParameteri(checkedGl.TEXTURE_2D, checkedGl.TEXTURE_WRAP_S, checkedGl.CLAMP_TO_EDGE);
|
|
95
93
|
checkedGl.texParameteri(checkedGl.TEXTURE_2D, checkedGl.TEXTURE_WRAP_T, checkedGl.CLAMP_TO_EDGE);
|
|
96
|
-
checkedGl.texParameteri(checkedGl.TEXTURE_2D, checkedGl.TEXTURE_MAG_FILTER, sampling);
|
|
97
|
-
checkedGl.texParameteri(checkedGl.TEXTURE_2D, checkedGl.TEXTURE_MIN_FILTER, sampling);
|
|
98
94
|
checkedGl.texImage2D(
|
|
99
95
|
checkedGl.TEXTURE_2D,
|
|
100
96
|
0,
|
|
@@ -106,44 +102,72 @@ export class GlCanvas {
|
|
|
106
102
|
checkedGl.UNSIGNED_BYTE,
|
|
107
103
|
this.fb8,
|
|
108
104
|
);
|
|
109
|
-
checkedGl.bindTexture(checkedGl.TEXTURE_2D, null);
|
|
110
105
|
|
|
111
|
-
const vertexPositionAttrLoc = checkedGl.getAttribLocation(program, "pos");
|
|
112
|
-
checkedGl.enableVertexAttribArray(vertexPositionAttrLoc);
|
|
113
106
|
this.vertexPositionBuffer = checkedGl.createBuffer();
|
|
114
107
|
checkedGl.bindBuffer(checkedGl.ARRAY_BUFFER, this.vertexPositionBuffer);
|
|
115
108
|
checkedGl.bufferData(checkedGl.ARRAY_BUFFER, new Float32Array([0, 0, 0, 1, 1, 0, 1, 1]), checkedGl.STATIC_DRAW);
|
|
116
|
-
checkedGl.vertexAttribPointer(vertexPositionAttrLoc, 2, checkedGl.FLOAT, false, 0, 0);
|
|
117
|
-
|
|
118
|
-
const uvAttrLoc = checkedGl.getAttribLocation(program, "uvIn");
|
|
119
|
-
checkedGl.enableVertexAttribArray(uvAttrLoc);
|
|
120
109
|
this.uvBuffer = checkedGl.createBuffer();
|
|
121
|
-
checkedGl.bindBuffer(checkedGl.ARRAY_BUFFER, this.uvBuffer);
|
|
122
|
-
checkedGl.vertexAttribPointer(uvAttrLoc, 2, checkedGl.FLOAT, false, 0, 0);
|
|
123
|
-
|
|
124
|
-
checkedGl.activeTexture(gl.TEXTURE0);
|
|
125
|
-
checkedGl.bindTexture(gl.TEXTURE_2D, this.texture);
|
|
126
110
|
|
|
127
111
|
this.checkedGl = checkedGl;
|
|
112
|
+
this.filter = null;
|
|
113
|
+
this.attribLocations = [];
|
|
128
114
|
this.viewportWidth = this.viewportHeight = 0;
|
|
129
115
|
this.uvFloatArray = new Float32Array(8);
|
|
130
116
|
this.lastExtent = {};
|
|
131
117
|
|
|
118
|
+
try {
|
|
119
|
+
this.setFilter(filterClass);
|
|
120
|
+
} catch (e) {
|
|
121
|
+
this.dispose();
|
|
122
|
+
throw e;
|
|
123
|
+
}
|
|
124
|
+
|
|
132
125
|
console.log("GL Canvas set up");
|
|
133
126
|
}
|
|
134
127
|
|
|
135
128
|
/**
|
|
136
|
-
*
|
|
129
|
+
* Draw with `filterClass` from here on, keeping the framebuffer texture and
|
|
130
|
+
* the vertex buffers: only the program, the texture sampling mode and the
|
|
131
|
+
* attribute locations differ between filters.
|
|
137
132
|
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
133
|
+
* The new filter is built before the old one is disposed, so a filter that
|
|
134
|
+
* will not build leaves the canvas drawing as it was.
|
|
135
|
+
*/
|
|
136
|
+
setFilter(filterClass) {
|
|
137
|
+
const gl = this.checkedGl;
|
|
138
|
+
const filter = new filterClass(gl);
|
|
139
|
+
this.filter?.dispose();
|
|
140
|
+
this.filter = filter;
|
|
141
|
+
gl.useProgram(filter.program);
|
|
142
|
+
|
|
143
|
+
// Filters that pick their own samples want the texels they asked for,
|
|
144
|
+
// not a hardware blend of the ones either side.
|
|
145
|
+
const sampling = filterClass.getDisplayConfig().nearestSampling ? gl.NEAREST : gl.LINEAR;
|
|
146
|
+
gl.activeTexture(gl.TEXTURE0);
|
|
147
|
+
gl.bindTexture(gl.TEXTURE_2D, this.texture);
|
|
148
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, sampling);
|
|
149
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, sampling);
|
|
150
|
+
|
|
151
|
+
const bindAttribute = (name, buffer) => {
|
|
152
|
+
const location = gl.getAttribLocation(filter.program, name);
|
|
153
|
+
gl.enableVertexAttribArray(location);
|
|
154
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
|
|
155
|
+
gl.vertexAttribPointer(location, 2, gl.FLOAT, false, 0, 0);
|
|
156
|
+
return location;
|
|
157
|
+
};
|
|
158
|
+
for (const location of this.attribLocations) gl.disableVertexAttribArray(location);
|
|
159
|
+
this.attribLocations = [bindAttribute("pos", this.vertexPositionBuffer), bindAttribute("uvIn", this.uvBuffer)];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Release the GL objects this canvas owns. Nothing else will: a canvas
|
|
164
|
+
* element hands out one WebGL context for its lifetime, so anything created
|
|
165
|
+
* through that context stays resident however many wrappers come and go.
|
|
143
166
|
*/
|
|
144
167
|
dispose() {
|
|
145
168
|
const gl = this.checkedGl;
|
|
146
|
-
this.filter
|
|
169
|
+
this.filter?.dispose();
|
|
170
|
+
this.filter = null;
|
|
147
171
|
gl.deleteTexture(this.texture);
|
|
148
172
|
gl.deleteBuffer(this.vertexPositionBuffer);
|
|
149
173
|
gl.deleteBuffer(this.uvBuffer);
|
|
@@ -218,6 +242,24 @@ function fellBackBecause(canvas, reason) {
|
|
|
218
242
|
return canvas;
|
|
219
243
|
}
|
|
220
244
|
|
|
245
|
+
/**
|
|
246
|
+
* Draw with `filterClass`, or with the unfiltered display if it will not build,
|
|
247
|
+
* in which case `fallbackReason` says why.
|
|
248
|
+
*/
|
|
249
|
+
export function useBestFilter(canvas, filterClass) {
|
|
250
|
+
let reason;
|
|
251
|
+
try {
|
|
252
|
+
canvas.setFilter(filterClass);
|
|
253
|
+
return fellBackBecause(canvas, undefined);
|
|
254
|
+
} catch (e) {
|
|
255
|
+
console.log(`Unable to use ${filterClass.getDisplayConfig().name}: ${e}`);
|
|
256
|
+
if (filterClass === PassthroughFilter) throw e;
|
|
257
|
+
reason = e?.message ?? e;
|
|
258
|
+
}
|
|
259
|
+
canvas.setFilter(PassthroughFilter);
|
|
260
|
+
return fellBackBecause(canvas, reason);
|
|
261
|
+
}
|
|
262
|
+
|
|
221
263
|
export function bestCanvas(canvas, filterClass) {
|
|
222
264
|
let reason;
|
|
223
265
|
try {
|
package/src/jsbeeb.css
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
body {
|
|
2
2
|
overflow: hidden;
|
|
3
|
+
visibility: visible !important; /* see index.html */
|
|
3
4
|
}
|
|
4
5
|
#outer {
|
|
5
6
|
display: block;
|
|
@@ -171,7 +172,7 @@ th {
|
|
|
171
172
|
|
|
172
173
|
#hfe-list li a {
|
|
173
174
|
display: grid;
|
|
174
|
-
grid-template-columns: minmax(0, 3fr) minmax(0, 2fr) minmax(0, 2fr);
|
|
175
|
+
grid-template-columns: minmax(0, 3fr) minmax(0, 2fr) minmax(0, 2fr) 7em;
|
|
175
176
|
gap: 0 1rem;
|
|
176
177
|
align-items: baseline;
|
|
177
178
|
padding: 3px 6px;
|
|
@@ -198,6 +199,22 @@ th {
|
|
|
198
199
|
font-family: monospace;
|
|
199
200
|
}
|
|
200
201
|
|
|
202
|
+
#hfe-list .provenance {
|
|
203
|
+
font-size: 0.8em;
|
|
204
|
+
opacity: 0.6;
|
|
205
|
+
font-style: italic;
|
|
206
|
+
text-align: right;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
.hfe-provenance label {
|
|
210
|
+
margin-left: 1.5rem;
|
|
211
|
+
opacity: 0.8;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
.hfe-provenance input {
|
|
215
|
+
margin-right: 0.3em;
|
|
216
|
+
}
|
|
217
|
+
|
|
201
218
|
.hfe-credit {
|
|
202
219
|
font-size: 0.9em;
|
|
203
220
|
opacity: 0.75;
|
|
@@ -208,6 +225,11 @@ th {
|
|
|
208
225
|
grid-template-columns: minmax(0, 1fr);
|
|
209
226
|
padding-bottom: 6px;
|
|
210
227
|
}
|
|
228
|
+
|
|
229
|
+
/* Stacked into one column, so there is no column end to sit against. */
|
|
230
|
+
#hfe-list .provenance {
|
|
231
|
+
text-align: left;
|
|
232
|
+
}
|
|
211
233
|
}
|
|
212
234
|
|
|
213
235
|
div.filter {
|
package/src/machine-session.js
CHANGED
|
@@ -25,6 +25,9 @@ import sharp from "sharp";
|
|
|
25
25
|
const FB_WIDTH = 1024;
|
|
26
26
|
const FB_HEIGHT = 625;
|
|
27
27
|
|
|
28
|
+
// Five times a frame, so only a machine that has stopped painting hits it.
|
|
29
|
+
const BackstopSecondsPerFrame = 0.1;
|
|
30
|
+
|
|
28
31
|
export class MachineSession {
|
|
29
32
|
/**
|
|
30
33
|
* @param {string} modelName - e.g. "B-DFS1.2", "Master"
|
|
@@ -46,6 +49,8 @@ export class MachineSession {
|
|
|
46
49
|
this._completeFb8 = new Uint8Array(FB_WIDTH * FB_HEIGHT * 4);
|
|
47
50
|
this._lastPaint = { minx: 0, miny: 0, maxx: FB_WIDTH, maxy: FB_HEIGHT };
|
|
48
51
|
this._frameDirty = false;
|
|
52
|
+
this._frameCount = 0;
|
|
53
|
+
this._stopAtFrame = Infinity;
|
|
49
54
|
|
|
50
55
|
// Create a real Video instance so we get pixel output
|
|
51
56
|
const modelObj = findModel(modelName);
|
|
@@ -59,6 +64,8 @@ export class MachineSession {
|
|
|
59
64
|
// Snapshot the complete frame now, before clearPaintBuffer() wipes _fb32.
|
|
60
65
|
// This mirrors what the browser does: paint_ext fires → canvas updated → fb32 cleared.
|
|
61
66
|
this._completeFb8.set(this._fb8);
|
|
67
|
+
this._frameCount++;
|
|
68
|
+
if (this._frameCount >= this._stopAtFrame) this._machine.processor.stop();
|
|
62
69
|
},
|
|
63
70
|
{ isAtom: modelObj.isAtom },
|
|
64
71
|
);
|
|
@@ -308,6 +315,56 @@ export class MachineSession {
|
|
|
308
315
|
await this._machine.runFor(cycles);
|
|
309
316
|
}
|
|
310
317
|
|
|
318
|
+
/** Emulated cycles since power-on, undoing the per-second rebasing execute() applies */
|
|
319
|
+
get elapsedCycles() {
|
|
320
|
+
const cpu = this._machine.processor;
|
|
321
|
+
return cpu.cycleSeconds * cpu.model.cyclesPerSecond + cpu.currentCycles;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Run until `count` more frames have been painted, stopping on the paint
|
|
326
|
+
* itself. A frame is 40000 cycles interlaced, 39936 not, and whatever a
|
|
327
|
+
* program driving the CRTC makes it, so stepping by cycles instead walks
|
|
328
|
+
* the sample point through the guest's frame.
|
|
329
|
+
*
|
|
330
|
+
* `completed` is false if a breakpoint fired, or the backstop ran out
|
|
331
|
+
* first.
|
|
332
|
+
*
|
|
333
|
+
* @param {number} [count=1] frames to advance
|
|
334
|
+
* @param {Object} [opts]
|
|
335
|
+
* @param {number} [opts.maxCycles] how long to wait on a machine that is not painting
|
|
336
|
+
* @returns {Promise<{framesRun: number, cyclesRun: number, completed: boolean}>}
|
|
337
|
+
*/
|
|
338
|
+
async runFrames(count = 1, { maxCycles } = {}) {
|
|
339
|
+
const cpu = this._machine.processor;
|
|
340
|
+
const backstop = maxCycles ?? count * BackstopSecondsPerFrame * cpu.model.cyclesPerSecond;
|
|
341
|
+
const startFrame = this._frameCount;
|
|
342
|
+
const startCycles = this.elapsedCycles;
|
|
343
|
+
// execute() adds each request to a running targetCycles, so budget left
|
|
344
|
+
// unspent by an early stop would silently lengthen the caller's next run.
|
|
345
|
+
const unspentBefore = cpu.targetCycles - cpu.currentCycles;
|
|
346
|
+
|
|
347
|
+
this._stopAtFrame = startFrame + count;
|
|
348
|
+
try {
|
|
349
|
+
await this._machine.runFor(backstop);
|
|
350
|
+
} finally {
|
|
351
|
+
this._stopAtFrame = Infinity;
|
|
352
|
+
cpu.targetCycles = cpu.currentCycles + unspentBefore;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const framesRun = this._frameCount - startFrame;
|
|
356
|
+
return {
|
|
357
|
+
framesRun,
|
|
358
|
+
cyclesRun: this.elapsedCycles - startCycles,
|
|
359
|
+
completed: framesRun >= count,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Frames painted since the session was created; a hard reset does not zero it */
|
|
364
|
+
get frameCount() {
|
|
365
|
+
return this._frameCount;
|
|
366
|
+
}
|
|
367
|
+
|
|
311
368
|
/**
|
|
312
369
|
* Run until PC reaches targetAddr (like a breakpoint), or timeout.
|
|
313
370
|
*/
|
package/src/main.js
CHANGED
|
@@ -12,7 +12,7 @@ import * as utils_atom from "./utils_atom.js";
|
|
|
12
12
|
import { LoadSD } from "./mmc.js";
|
|
13
13
|
import { Cmos, localStoragePersistence } from "./cmos.js";
|
|
14
14
|
import { StairwayToHell } from "./sth.js";
|
|
15
|
-
import { BbcDiscArchive, describe as describeHfe } from "./bbcdiscs.js";
|
|
15
|
+
import { BbcDiscArchive, Provenance, describe as describeHfe, matches, provenancesIn } from "./bbcdiscs.js";
|
|
16
16
|
import { GamePad } from "./gamepads.js";
|
|
17
17
|
import * as disc from "./fdc.js";
|
|
18
18
|
import { loadTapeFromData } from "./tapes.js";
|
|
@@ -397,6 +397,24 @@ function showError(context, error) {
|
|
|
397
397
|
|
|
398
398
|
const errorText = (error) => error?.message ?? `${error}`;
|
|
399
399
|
|
|
400
|
+
function reportLoadFailure(description, error) {
|
|
401
|
+
console.error(`Error loading ${description}:`, error);
|
|
402
|
+
toast(`Could not load ${description}: ${errorText(error)}`, { title: "Loading" });
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function reportIgnoredFiles(name, ignored) {
|
|
406
|
+
if (!ignored.length) return;
|
|
407
|
+
toast(`Loaded ${name}. The archive also holds ${ignored.join(", ")}, and only one file is loaded from it.`, {
|
|
408
|
+
title: "Archive",
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async function unzipAndReport(data) {
|
|
413
|
+
const unzipped = await utils.unzipDiscImage(data);
|
|
414
|
+
reportIgnoredFiles(unzipped.name, unzipped.ignored);
|
|
415
|
+
return unzipped;
|
|
416
|
+
}
|
|
417
|
+
|
|
400
418
|
function showNotice(event) {
|
|
401
419
|
const { message, title, quietKey } = event.detail;
|
|
402
420
|
toast(message, { title, quietKey });
|
|
@@ -471,59 +489,57 @@ function noteDriveTracks(driveIndex, discName) {
|
|
|
471
489
|
});
|
|
472
490
|
}
|
|
473
491
|
|
|
474
|
-
|
|
492
|
+
// Test which filter is actually in use, not merely whether we got WebGL: a
|
|
493
|
+
// filter can decline a context that works perfectly well for other modes, in
|
|
494
|
+
// which case we are quietly left with an unfiltered display.
|
|
495
|
+
function reportAnyFallback(displayCanvas, filterClass) {
|
|
496
|
+
if (displayCanvas.filterClass === filterClass) return;
|
|
497
|
+
const reason = displayCanvas.fallbackReason ? ` (${displayCanvas.fallbackReason})` : "";
|
|
498
|
+
const { name } = filterClass.getDisplayConfig();
|
|
499
|
+
toast(`${name} is not available on this device, so the standard display is in use${reason}.`, {
|
|
500
|
+
title: "Display",
|
|
501
|
+
quietKey: "quietDisplayFallback",
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function sizeCanvasFor(filterClass) {
|
|
475
506
|
// Not `config`: that is the emulator's live configuration object, declared
|
|
476
507
|
// at module scope and used throughout this file.
|
|
477
508
|
const displayConfig = filterClass.getDisplayConfig();
|
|
478
|
-
|
|
479
|
-
// creating the context, which fixes its initial viewport.
|
|
509
|
+
if (screenCanvas.width === displayConfig.canvasWidth && screenCanvas.height === displayConfig.canvasHeight) return;
|
|
480
510
|
screenCanvas.width = displayConfig.canvasWidth;
|
|
481
511
|
screenCanvas.height = displayConfig.canvasHeight;
|
|
512
|
+
}
|
|
482
513
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
//
|
|
486
|
-
|
|
487
|
-
// in which case bestCanvas quietly gives us an unfiltered GL canvas.
|
|
488
|
-
if (newCanvas.filterClass !== filterClass) {
|
|
489
|
-
const reason = newCanvas.fallbackReason ? ` (${newCanvas.fallbackReason})` : "";
|
|
490
|
-
toast(`${displayConfig.name} is not available on this device, so the standard display is in use${reason}.`, {
|
|
491
|
-
title: "Display",
|
|
492
|
-
quietKey: "quietDisplayFallback",
|
|
493
|
-
});
|
|
494
|
-
}
|
|
514
|
+
function createCanvasForFilter(filterClass) {
|
|
515
|
+
// Each mode says how many pixels it wants to draw into. Set this before
|
|
516
|
+
// creating the context, which fixes its initial viewport.
|
|
517
|
+
sizeCanvasFor(filterClass);
|
|
495
518
|
|
|
519
|
+
const newCanvas = tryGl ? canvasLib.bestCanvas(screenCanvas, filterClass) : new canvasLib.Canvas(screenCanvas);
|
|
520
|
+
reportAnyFallback(newCanvas, filterClass);
|
|
496
521
|
return newCanvas;
|
|
497
522
|
}
|
|
498
523
|
|
|
499
524
|
let displayModeFilter = canvasLib.getFilterForMode(parsedQuery.displayMode || "rgb");
|
|
500
525
|
function swapCanvas(newFilterClass) {
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
// Only once the replacement exists, so a failure to build it leaves the
|
|
506
|
-
// display we already had. The two share a GL context but no GL objects.
|
|
507
|
-
oldCanvas.dispose();
|
|
508
|
-
video.fb32 = newCanvas.fb32;
|
|
509
|
-
video.paint_ext = function paint(minx, miny, maxx, maxy) {
|
|
510
|
-
frames++;
|
|
511
|
-
if (frames < frameSkip) return;
|
|
512
|
-
frames = 0;
|
|
513
|
-
newCanvas.paint(minx, miny, maxx, maxy, { frameCount: this.frameCount, lineGrid: this.lineGrid });
|
|
514
|
-
};
|
|
515
|
-
canvas = newCanvas;
|
|
526
|
+
// Everything but the filter is the same whatever the mode: the framebuffer
|
|
527
|
+
// texture, the vertex buffers and fb32 all carry over untouched.
|
|
528
|
+
canvasLib.useBestFilter(canvas, newFilterClass);
|
|
529
|
+
reportAnyFallback(canvas, newFilterClass);
|
|
516
530
|
// Follow the filter we ended up with, not the one we asked for: everything
|
|
517
|
-
// downstream
|
|
518
|
-
//
|
|
519
|
-
displayModeFilter =
|
|
531
|
+
// downstream (the monitor picture, the canvas geometry, how large a drawing
|
|
532
|
+
// buffer to ask for) comes from its display config.
|
|
533
|
+
displayModeFilter = canvas.filterClass;
|
|
534
|
+
// Back to the mode's own size, undoing any scaling the last one asked for.
|
|
535
|
+
sizeCanvasFor(displayModeFilter);
|
|
520
536
|
// Nothing else will redraw: the mode is changed from a modal, which stops
|
|
521
537
|
// the emulator.
|
|
522
538
|
video.paint();
|
|
523
539
|
window.setTimeout(() => window.dispatchEvent(new Event("resize")), 1);
|
|
524
540
|
}
|
|
525
541
|
|
|
526
|
-
|
|
542
|
+
const canvas = createCanvasForFilter(displayModeFilter);
|
|
527
543
|
displayModeFilter = canvas.filterClass;
|
|
528
544
|
|
|
529
545
|
video = new Video(
|
|
@@ -641,14 +657,19 @@ pastetext.addEventListener("dragover", function (event) {
|
|
|
641
657
|
pastetext.addEventListener("drop", async function (event) {
|
|
642
658
|
utils.noteEvent("local", "drop");
|
|
643
659
|
const file = event.dataTransfer.files[0];
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
await
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
660
|
+
if (!file) return;
|
|
661
|
+
try {
|
|
662
|
+
const arrayBuffer = await file.arrayBuffer();
|
|
663
|
+
if (isSnapshotFile(file.name, arrayBuffer)) {
|
|
664
|
+
await loadStateFromFile(file, arrayBuffer);
|
|
665
|
+
} else if (file.name.toLowerCase().endsWith(".uef")) {
|
|
666
|
+
// Regular UEF tape image (not a BeebEm save state)
|
|
667
|
+
setProcessorTape(await loadTapeFromData(file.name, new Uint8Array(arrayBuffer), model));
|
|
668
|
+
} else {
|
|
669
|
+
await loadHTMLFile(file);
|
|
670
|
+
}
|
|
671
|
+
} catch (error) {
|
|
672
|
+
reportLoadFailure(file.name, error);
|
|
652
673
|
}
|
|
653
674
|
});
|
|
654
675
|
|
|
@@ -1211,13 +1232,15 @@ function hfeOnCat(catalogue) {
|
|
|
1211
1232
|
const list = document.getElementById("hfe-list");
|
|
1212
1233
|
document.querySelector("#hfe .loading").style.display = "none";
|
|
1213
1234
|
const template = list.querySelector(".template");
|
|
1235
|
+
showProvenanceChoices(catalogue, onHfeFilter);
|
|
1214
1236
|
|
|
1215
1237
|
const addSome = (remaining) => {
|
|
1216
1238
|
if (ticket !== hfeRender) return;
|
|
1217
1239
|
const MaxAtATime = 100;
|
|
1218
1240
|
const Delay = 30;
|
|
1219
|
-
// Read per batch:
|
|
1241
|
+
// Read per batch: both can be changed while this is still going.
|
|
1220
1242
|
const filter = document.getElementById("hfe-filter").value.toLowerCase();
|
|
1243
|
+
const shown = shownProvenances();
|
|
1221
1244
|
for (const file of remaining.slice(0, MaxAtATime)) {
|
|
1222
1245
|
const { title, publisher, detail } = describeHfe(file);
|
|
1223
1246
|
const row = template.cloneNode(true);
|
|
@@ -1225,6 +1248,8 @@ function hfeOnCat(catalogue) {
|
|
|
1225
1248
|
row.querySelector(".name").textContent = title;
|
|
1226
1249
|
row.querySelector(".publisher").textContent = publisher;
|
|
1227
1250
|
row.querySelector(".detail").textContent = detail;
|
|
1251
|
+
row.querySelector(".provenance").textContent =
|
|
1252
|
+
file.provenance === Provenance.Reconstructed ? "reconstructed" : "";
|
|
1228
1253
|
if (file.notes) row.title = file.notes;
|
|
1229
1254
|
// The row is an anchor, and letting it navigate to "#" would push a
|
|
1230
1255
|
// history entry of its own on top of the one updateUrl pushes.
|
|
@@ -1233,8 +1258,9 @@ function hfeOnCat(catalogue) {
|
|
|
1233
1258
|
hfeClick(file);
|
|
1234
1259
|
$hfeModal.hide();
|
|
1235
1260
|
});
|
|
1236
|
-
row.
|
|
1261
|
+
row.hfeFile = file;
|
|
1237
1262
|
list.appendChild(row);
|
|
1263
|
+
showHfeRow(row, file, filter, shown);
|
|
1238
1264
|
}
|
|
1239
1265
|
if (remaining.length > MaxAtATime) setTimeout(() => addSome(remaining.slice(MaxAtATime)), Delay);
|
|
1240
1266
|
};
|
|
@@ -1248,7 +1274,53 @@ document.getElementById("hfe").addEventListener("shown.bs.modal", () => {
|
|
|
1248
1274
|
document.getElementById("hfe").addEventListener("show.bs.modal", () => hfeArchive.populate());
|
|
1249
1275
|
|
|
1250
1276
|
const hfeFilter = document.getElementById("hfe-filter");
|
|
1251
|
-
const
|
|
1277
|
+
const hfeProvenance = document.getElementById("hfe-provenance");
|
|
1278
|
+
|
|
1279
|
+
const HfeProvenanceLabels = {
|
|
1280
|
+
[Provenance.Captured]: ["Captured", "Direct from disc"],
|
|
1281
|
+
[Provenance.Reconstructed]: ["Reconstructed", "Rebuilt from a sector dump"],
|
|
1282
|
+
};
|
|
1283
|
+
|
|
1284
|
+
/** Which provenances the picker is showing, or null when it is not offering the choice. */
|
|
1285
|
+
const shownProvenances = () => {
|
|
1286
|
+
const boxes = [...hfeProvenance.querySelectorAll("input")];
|
|
1287
|
+
return boxes.length ? new Set(boxes.filter((box) => box.checked).map((box) => box.value)) : null;
|
|
1288
|
+
};
|
|
1289
|
+
|
|
1290
|
+
// Offer one tick per provenance the archive actually holds, rather than naming
|
|
1291
|
+
// them here: a source added later should appear without this having to change.
|
|
1292
|
+
function showProvenanceChoices(catalogue, onChange) {
|
|
1293
|
+
const present = provenancesIn(catalogue);
|
|
1294
|
+
// Nothing to choose between: no ticks, and shownProvenances says "all".
|
|
1295
|
+
if (present.length < 2) {
|
|
1296
|
+
hfeProvenance.replaceChildren();
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1299
|
+
const wasShown = shownProvenances();
|
|
1300
|
+
hfeProvenance.replaceChildren(
|
|
1301
|
+
...present.map((provenance) => {
|
|
1302
|
+
const [text, why] = HfeProvenanceLabels[provenance] ?? [provenance, ""];
|
|
1303
|
+
const label = document.createElement("label");
|
|
1304
|
+
label.title = why;
|
|
1305
|
+
const box = document.createElement("input");
|
|
1306
|
+
box.type = "checkbox";
|
|
1307
|
+
box.value = provenance;
|
|
1308
|
+
box.checked = !wasShown || wasShown.has(provenance);
|
|
1309
|
+
box.addEventListener("change", onChange);
|
|
1310
|
+
label.append(box, text);
|
|
1311
|
+
return label;
|
|
1312
|
+
}),
|
|
1313
|
+
);
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
const showHfeRow = (row, file, filter, shown) => (row.style.display = matches(file, filter, shown) ? "" : "none");
|
|
1317
|
+
|
|
1318
|
+
const onHfeFilter = () => {
|
|
1319
|
+
const filter = hfeFilter.value.toLowerCase();
|
|
1320
|
+
const shown = shownProvenances();
|
|
1321
|
+
for (const row of document.querySelectorAll("#hfe-list li:not(.template)"))
|
|
1322
|
+
showHfeRow(row, row.hfeFile, filter, shown);
|
|
1323
|
+
};
|
|
1252
1324
|
hfeFilter.addEventListener("change", onHfeFilter);
|
|
1253
1325
|
hfeFilter.addEventListener("keyup", onHfeFilter);
|
|
1254
1326
|
|
|
@@ -1385,7 +1457,8 @@ async function loadDiscImage(discImage, layout = DiscLayout.auto) {
|
|
|
1385
1457
|
switch (schema) {
|
|
1386
1458
|
case "|":
|
|
1387
1459
|
case "sth": {
|
|
1388
|
-
const { name, data } = await discSth.fetch(discImage);
|
|
1460
|
+
const { name, data, ignored } = await discSth.fetch(discImage);
|
|
1461
|
+
reportIgnoredFiles(name, ignored);
|
|
1389
1462
|
return disc.discFor(processor.fdc, name, data, undefined, layout);
|
|
1390
1463
|
}
|
|
1391
1464
|
|
|
@@ -1406,7 +1479,7 @@ async function loadDiscImage(discImage, layout = DiscLayout.auto) {
|
|
|
1406
1479
|
|
|
1407
1480
|
case "data": {
|
|
1408
1481
|
const arr = Array.prototype.map.call(atob(discImage), (x) => x.charCodeAt(0));
|
|
1409
|
-
const { name, data } = await
|
|
1482
|
+
const { name, data } = await unzipAndReport(arr);
|
|
1410
1483
|
return disc.discFor(processor.fdc, name, data, undefined, layout);
|
|
1411
1484
|
}
|
|
1412
1485
|
case "http":
|
|
@@ -1417,7 +1490,7 @@ async function loadDiscImage(discImage, layout = DiscLayout.auto) {
|
|
|
1417
1490
|
discImage = new URL(asUrl).pathname;
|
|
1418
1491
|
let discData = await utils.loadData(asUrl);
|
|
1419
1492
|
if (/\.zip/i.test(discImage)) {
|
|
1420
|
-
const unzipped = await
|
|
1493
|
+
const unzipped = await unzipAndReport(discData);
|
|
1421
1494
|
discData = unzipped.data;
|
|
1422
1495
|
discImage = unzipped.name;
|
|
1423
1496
|
}
|
|
@@ -1436,13 +1509,14 @@ async function loadTapeImage(tapeImage) {
|
|
|
1436
1509
|
switch (schema) {
|
|
1437
1510
|
case "|":
|
|
1438
1511
|
case "sth": {
|
|
1439
|
-
const { name, data } = await tapeSth.fetch(tapeImage);
|
|
1512
|
+
const { name, data, ignored } = await tapeSth.fetch(tapeImage);
|
|
1513
|
+
reportIgnoredFiles(name, ignored);
|
|
1440
1514
|
return await loadTapeFromData(name, data, model);
|
|
1441
1515
|
}
|
|
1442
1516
|
|
|
1443
1517
|
case "data": {
|
|
1444
1518
|
const arr = Array.prototype.map.call(atob(tapeImage), (x) => x.charCodeAt(0));
|
|
1445
|
-
const { name, data } = await
|
|
1519
|
+
const { name, data } = await unzipAndReport(arr);
|
|
1446
1520
|
return await loadTapeFromData(name, data, model);
|
|
1447
1521
|
}
|
|
1448
1522
|
|
|
@@ -1454,7 +1528,7 @@ async function loadTapeImage(tapeImage) {
|
|
|
1454
1528
|
tapeImage = new URL(asUrl).pathname;
|
|
1455
1529
|
let tapeData = await utils.loadData(asUrl);
|
|
1456
1530
|
if (/\.zip/i.test(tapeImage)) {
|
|
1457
|
-
const unzipped = await
|
|
1531
|
+
const unzipped = await unzipAndReport(tapeData);
|
|
1458
1532
|
tapeData = unzipped.data;
|
|
1459
1533
|
tapeImage = unzipped.name;
|
|
1460
1534
|
}
|
|
@@ -1466,7 +1540,7 @@ async function loadTapeImage(tapeImage) {
|
|
|
1466
1540
|
let tapeData = await utils.loadData(tapePath);
|
|
1467
1541
|
let tapeName = tapeImage;
|
|
1468
1542
|
if (/\.zip/i.test(tapeName)) {
|
|
1469
|
-
const unzipped = await
|
|
1543
|
+
const unzipped = await unzipAndReport(tapeData);
|
|
1470
1544
|
tapeData = unzipped.data;
|
|
1471
1545
|
tapeName = unzipped.name;
|
|
1472
1546
|
}
|
|
@@ -1479,7 +1553,11 @@ document.getElementById("disc_load").addEventListener("change", async function (
|
|
|
1479
1553
|
if (evt.target.files.length === 0) return;
|
|
1480
1554
|
utils.noteEvent("local", "click"); // NB no filename here
|
|
1481
1555
|
const file = evt.target.files[0];
|
|
1482
|
-
|
|
1556
|
+
try {
|
|
1557
|
+
await loadHTMLFile(file);
|
|
1558
|
+
} catch (error) {
|
|
1559
|
+
reportLoadFailure(file.name, error);
|
|
1560
|
+
}
|
|
1483
1561
|
evt.target.value = ""; // clear so if the user picks the same file again after a reset we get a "change"
|
|
1484
1562
|
});
|
|
1485
1563
|
|
|
@@ -1487,7 +1565,11 @@ document.getElementById("fs_load").addEventListener("change", async function (ev
|
|
|
1487
1565
|
if (evt.target.files.length === 0) return;
|
|
1488
1566
|
utils.noteEvent("local", "click"); // NB no filename here
|
|
1489
1567
|
const file = evt.target.files[0];
|
|
1490
|
-
|
|
1568
|
+
try {
|
|
1569
|
+
await loadSCSIFile(file);
|
|
1570
|
+
} catch (error) {
|
|
1571
|
+
reportLoadFailure(file.name, error);
|
|
1572
|
+
}
|
|
1491
1573
|
evt.target.value = ""; // clear so if the user picks the same file again after a reset we get a "change"
|
|
1492
1574
|
});
|
|
1493
1575
|
|
|
@@ -1496,17 +1578,21 @@ document.getElementById("tape_load").addEventListener("change", async function (
|
|
|
1496
1578
|
const file = evt.target.files[0];
|
|
1497
1579
|
utils.noteEvent("local", "clickTape"); // NB no filename here
|
|
1498
1580
|
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1581
|
+
try {
|
|
1582
|
+
let tapeData = await readFileAsBinaryString(file);
|
|
1583
|
+
let tapeName = file.name;
|
|
1584
|
+
if (/\.zip/i.test(tapeName)) {
|
|
1585
|
+
const unzipped = await unzipAndReport(utils.stringToUint8Array(tapeData));
|
|
1586
|
+
tapeData = unzipped.data;
|
|
1587
|
+
tapeName = unzipped.name;
|
|
1588
|
+
}
|
|
1589
|
+
setProcessorTape(await loadTapeFromData(tapeName, tapeData, model));
|
|
1590
|
+
delete parsedQuery.tape;
|
|
1591
|
+
updateUrl();
|
|
1592
|
+
bootstrap.Modal.getInstance(document.getElementById("tapes"))?.hide();
|
|
1593
|
+
} catch (error) {
|
|
1594
|
+
reportLoadFailure(file.name, error);
|
|
1505
1595
|
}
|
|
1506
|
-
setProcessorTape(await loadTapeFromData(tapeName, tapeData, model));
|
|
1507
|
-
delete parsedQuery.tape;
|
|
1508
|
-
updateUrl();
|
|
1509
|
-
bootstrap.Modal.getInstance(document.getElementById("tapes"))?.hide();
|
|
1510
1596
|
|
|
1511
1597
|
evt.target.value = ""; // clear so if the user picks the same file again after a reset we get a "change"
|
|
1512
1598
|
});
|
|
@@ -1623,7 +1709,14 @@ googleDriveEl.addEventListener("show.bs.modal", async function () {
|
|
|
1623
1709
|
gdLoading.textContent = "Loading...";
|
|
1624
1710
|
gdLoading.style.display = "";
|
|
1625
1711
|
for (const el of googleDriveEl.querySelectorAll("li:not(.template)")) el.remove();
|
|
1626
|
-
|
|
1712
|
+
let cat;
|
|
1713
|
+
try {
|
|
1714
|
+
cat = await googleDrive.listFiles();
|
|
1715
|
+
} catch (error) {
|
|
1716
|
+
console.error("Error listing Google Drive files:", error);
|
|
1717
|
+
gdLoading.textContent = `Unable to list your Google Drive files: ${errorText(error)}`;
|
|
1718
|
+
return;
|
|
1719
|
+
}
|
|
1627
1720
|
const dbList = googleDriveEl.querySelector(".list");
|
|
1628
1721
|
gdLoading.style.display = "none";
|
|
1629
1722
|
const template = dbList.querySelector(".template");
|
|
@@ -1653,7 +1746,11 @@ for (const image of availableImages) {
|
|
|
1653
1746
|
utils.noteEvent("images", "click", image.file);
|
|
1654
1747
|
setDisc1Image(image.file);
|
|
1655
1748
|
$discsModal.hide();
|
|
1656
|
-
|
|
1749
|
+
try {
|
|
1750
|
+
putDiscIn(0, await loadDiscImage(parsedQuery.disc1, layoutForDrive(0)));
|
|
1751
|
+
} catch (error) {
|
|
1752
|
+
reportLoadFailure(`${image.name} (${image.file})`, error);
|
|
1753
|
+
}
|
|
1657
1754
|
});
|
|
1658
1755
|
}
|
|
1659
1756
|
|
|
@@ -1946,8 +2043,7 @@ const startPromise = (async () => {
|
|
|
1946
2043
|
try {
|
|
1947
2044
|
await load();
|
|
1948
2045
|
} catch (error) {
|
|
1949
|
-
|
|
1950
|
-
toast(`Could not load ${description}: ${error?.message ?? error}`, { title: "Loading" });
|
|
2046
|
+
reportLoadFailure(description, error);
|
|
1951
2047
|
}
|
|
1952
2048
|
})();
|
|
1953
2049
|
imageLoads.push(loading);
|
package/src/teletext.js
CHANGED
|
@@ -11,6 +11,7 @@ export class Teletext {
|
|
|
11
11
|
this.sep = false;
|
|
12
12
|
this.dbl = this.oldDbl = this.secondHalfOfDouble = this.wasDbl = false;
|
|
13
13
|
this.gfx = false;
|
|
14
|
+
this.conceal = false;
|
|
14
15
|
this.flash = this.flashOn = false;
|
|
15
16
|
this.flashTime = 0;
|
|
16
17
|
this.heldChar = 0;
|
|
@@ -172,6 +173,7 @@ export class Teletext {
|
|
|
172
173
|
secondHalfOfDouble: this.secondHalfOfDouble,
|
|
173
174
|
wasDbl: this.wasDbl,
|
|
174
175
|
gfx: this.gfx,
|
|
176
|
+
conceal: this.conceal,
|
|
175
177
|
flash: this.flash,
|
|
176
178
|
flashOn: this.flashOn,
|
|
177
179
|
flashTime: this.flashTime,
|
|
@@ -198,6 +200,7 @@ export class Teletext {
|
|
|
198
200
|
this.secondHalfOfDouble = state.secondHalfOfDouble;
|
|
199
201
|
this.wasDbl = state.wasDbl;
|
|
200
202
|
this.gfx = state.gfx;
|
|
203
|
+
this.conceal = state.conceal ?? false;
|
|
201
204
|
this.flash = state.flash;
|
|
202
205
|
this.flashOn = state.flashOn;
|
|
203
206
|
this.flashTime = state.flashTime;
|
|
@@ -245,6 +248,7 @@ export class Teletext {
|
|
|
245
248
|
case 7:
|
|
246
249
|
this.gfx = false;
|
|
247
250
|
this.col = data;
|
|
251
|
+
this.conceal = false;
|
|
248
252
|
this.setNextChars();
|
|
249
253
|
break;
|
|
250
254
|
case 8:
|
|
@@ -267,10 +271,11 @@ export class Teletext {
|
|
|
267
271
|
case 23:
|
|
268
272
|
this.gfx = true;
|
|
269
273
|
this.col = data & 7;
|
|
274
|
+
this.conceal = false;
|
|
270
275
|
this.setNextChars();
|
|
271
276
|
break;
|
|
272
277
|
case 24:
|
|
273
|
-
this.
|
|
278
|
+
this.conceal = true;
|
|
274
279
|
break;
|
|
275
280
|
case 25:
|
|
276
281
|
this.sep = false;
|
|
@@ -358,6 +363,7 @@ export class Teletext {
|
|
|
358
363
|
this.flash = false;
|
|
359
364
|
this.sep = false;
|
|
360
365
|
this.gfx = false;
|
|
366
|
+
this.conceal = false;
|
|
361
367
|
this.dbl = false;
|
|
362
368
|
|
|
363
369
|
this.scanlineCounter++;
|
|
@@ -396,6 +402,7 @@ export class Teletext {
|
|
|
396
402
|
this.curGlyphs = this.nextGlyphs;
|
|
397
403
|
|
|
398
404
|
let flashThisCell = this.flash;
|
|
405
|
+
let concealThisCell = this.conceal;
|
|
399
406
|
if (data < 0x20) {
|
|
400
407
|
data = this.handleControlCode(data);
|
|
401
408
|
} else if (this.gfx) {
|
|
@@ -419,7 +426,10 @@ export class Teletext {
|
|
|
419
426
|
// Steady (code 9) is "Set At" — update so this cell stops flashing immediately.
|
|
420
427
|
if (flashThisCell && !this.flash) flashThisCell = false;
|
|
421
428
|
|
|
422
|
-
|
|
429
|
+
// Conceal (code 24) is "Set At", and a colour code only reveals from the cell after itself.
|
|
430
|
+
if (this.conceal) concealThisCell = true;
|
|
431
|
+
|
|
432
|
+
if (concealThisCell || (flashThisCell && this.flashOn) || (this.secondHalfOfDouble && !this.dbl)) {
|
|
423
433
|
const backgroundColour = this.colour[(this.bg & 7) << 5];
|
|
424
434
|
for (let i = 0; i < 16; ++i) {
|
|
425
435
|
buf[offset++] = backgroundColour;
|
package/src/teletext_adaptor.js
CHANGED
|
@@ -94,6 +94,45 @@ export class TeletextAdaptor extends EventTarget {
|
|
|
94
94
|
this.currentFrame = 0;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
updateIrq() {
|
|
98
|
+
if (this.teletextInts && this.teletextStatus & 0x80) {
|
|
99
|
+
this.cpu.interrupt |= 1 << TELETEXT_IRQ;
|
|
100
|
+
} else {
|
|
101
|
+
this.cpu.interrupt &= ~(1 << TELETEXT_IRQ);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
snapshotState() {
|
|
106
|
+
return {
|
|
107
|
+
teletextStatus: this.teletextStatus,
|
|
108
|
+
teletextInts: this.teletextInts,
|
|
109
|
+
teletextEnable: this.teletextEnable,
|
|
110
|
+
channel: this.channel,
|
|
111
|
+
currentFrame: this.currentFrame,
|
|
112
|
+
rowPtr: this.rowPtr,
|
|
113
|
+
colPtr: this.colPtr,
|
|
114
|
+
pollCount: this.pollCount,
|
|
115
|
+
frameBuffer: this.frameBuffer.map((row) => row.slice()),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
restoreState(state) {
|
|
120
|
+
this.teletextStatus = state.teletextStatus;
|
|
121
|
+
this.teletextInts = state.teletextInts;
|
|
122
|
+
this.teletextEnable = state.teletextEnable;
|
|
123
|
+
this.currentFrame = state.currentFrame;
|
|
124
|
+
this.rowPtr = state.rowPtr;
|
|
125
|
+
this.colPtr = state.colPtr;
|
|
126
|
+
this.pollCount = state.pollCount;
|
|
127
|
+
this.frameBuffer = state.frameBuffer.map((row) => row.slice());
|
|
128
|
+
this.updateIrq();
|
|
129
|
+
// Refetching the multi-megabyte stream on every restore would be ruinous for rewind.
|
|
130
|
+
if (this.channel !== state.channel) {
|
|
131
|
+
this.channel = state.channel;
|
|
132
|
+
this.loadChannelStream(this.channel);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
97
136
|
read(addr) {
|
|
98
137
|
let data = 0x00;
|
|
99
138
|
|
|
@@ -120,11 +159,7 @@ export class TeletextAdaptor extends EventTarget {
|
|
|
120
159
|
case 0x00:
|
|
121
160
|
// Status register
|
|
122
161
|
this.teletextInts = (value & 0x08) === 0x08;
|
|
123
|
-
|
|
124
|
-
this.cpu.interrupt |= 1 << TELETEXT_IRQ; // Interrupt if INT and interrupts enabled
|
|
125
|
-
} else {
|
|
126
|
-
this.cpu.interrupt &= ~(1 << TELETEXT_IRQ); // Clear interrupt
|
|
127
|
-
}
|
|
162
|
+
this.updateIrq();
|
|
128
163
|
this.teletextEnable = (value & 0x04) === 0x04;
|
|
129
164
|
if ((value & 0x03) !== this.channel && this.teletextEnable) {
|
|
130
165
|
this.channel = value & 0x03;
|
package/src/touchscreen.js
CHANGED
|
@@ -13,11 +13,32 @@ export class TouchScreen {
|
|
|
13
13
|
constructor(scheduler, cyclesPerSecond) {
|
|
14
14
|
this.scheduler = scheduler;
|
|
15
15
|
this.pollCycles = cyclesPerSecond / PollHz;
|
|
16
|
-
this.mouse = { x: 0, y: 0, button: 0 };
|
|
17
16
|
this.outBuffer = new utils.Fifo(16);
|
|
18
|
-
this.delay = 0;
|
|
19
|
-
this.mode = 0;
|
|
20
17
|
this.pollTask = this.scheduler.newTask(() => this.poll());
|
|
18
|
+
this.reset();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
reset() {
|
|
22
|
+
this.mouse = { x: 0, y: 0, button: 0 };
|
|
23
|
+
this.mode = 0;
|
|
24
|
+
this.outBuffer.clear();
|
|
25
|
+
this.pollTask.cancel();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
snapshotState() {
|
|
29
|
+
return {
|
|
30
|
+
mode: this.mode,
|
|
31
|
+
outBuffer: this.outBuffer.toArray(),
|
|
32
|
+
pollTaskOffset: this.pollTask.scheduled() ? this.pollTask.expireEpoch - this.scheduler.epoch : null,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
restoreState(state) {
|
|
37
|
+
this.mode = state.mode;
|
|
38
|
+
this.outBuffer.clear();
|
|
39
|
+
for (const byte of state.outBuffer) this.store(byte);
|
|
40
|
+
this.pollTask.cancel();
|
|
41
|
+
if (state.pollTaskOffset !== null) this.pollTask.schedule(state.pollTaskOffset);
|
|
21
42
|
}
|
|
22
43
|
|
|
23
44
|
tryReceive(rts) {
|
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) {
|
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);
|