jsbeeb 1.14.0 → 1.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +94 -0
- package/package.json +8 -6
- package/public/roms/tube/65C102Tube.rom +0 -0
- package/src/6502.js +22 -16
- package/src/6847.js +50 -8
- package/src/acia.js +31 -12
- package/src/canvas.js +77 -18
- package/src/config.js +16 -4
- package/src/disc-drive.js +9 -0
- package/src/disc-hfe.js +1 -2
- package/src/disc-surface.js +350 -0
- package/src/disc-visualiser.js +569 -0
- package/src/disc.js +143 -49
- package/src/dom-utils.js +16 -0
- package/src/econet.js +6 -2
- package/src/fake6502.js +2 -2
- package/src/gamepads.js +11 -4
- package/src/jsbeeb.css +126 -0
- package/src/main.js +112 -55
- package/src/models.js +47 -5
- package/src/serial.js +1 -0
- package/src/snapshot-helpers.js +1 -1
- package/src/sth.js +21 -18
- package/src/tapes.js +10 -12
- package/src/teletext_adaptor.js +38 -13
- package/src/touchscreen.js +6 -6
- package/src/tube.js +89 -37
- package/src/url-params.js +23 -19
- package/src/utils.js +10 -5
- package/src/utils_atom.js +9 -5
- package/src/video-filters/pal-composite.js +14 -48
- package/src/video-filters/passthrough-filter.js +11 -39
- package/src/video-filters/pixel-grid.js +55 -0
- package/src/video-filters/shader-program.js +53 -0
- package/src/video-filters/shaders/xbr.frag.glsl +278 -0
- package/src/video-filters/shaders/xbr.vert.glsl +7 -0
- package/src/video-filters/xbr-filter.js +107 -0
- package/src/video.js +56 -16
- package/src/wd-fdc.js +17 -7
- package/tests/test-machine.js +7 -5
package/src/canvas.js
CHANGED
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
import webglDebug from "./lib/webgl-debug.js";
|
|
3
3
|
import { PALCompositeFilter } from "./video-filters/pal-composite.js";
|
|
4
4
|
import { PassthroughFilter } from "./video-filters/passthrough-filter.js";
|
|
5
|
+
import { XbrFilter } from "./video-filters/xbr-filter.js";
|
|
5
6
|
|
|
6
7
|
const DISPLAY_MODE_FILTERS = {
|
|
7
8
|
pal: PALCompositeFilter,
|
|
8
9
|
rgb: PassthroughFilter,
|
|
10
|
+
xbr: XbrFilter,
|
|
9
11
|
};
|
|
10
12
|
|
|
11
13
|
export function getFilterForMode(mode) {
|
|
@@ -13,8 +15,9 @@ export function getFilterForMode(mode) {
|
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
export class Canvas {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
+
/** The 2D canvas draws the framebuffer as-is, which is what this filter is. */
|
|
19
|
+
get filterClass() {
|
|
20
|
+
return PassthroughFilter;
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
constructor(canvas) {
|
|
@@ -27,24 +30,29 @@ export class Canvas {
|
|
|
27
30
|
this.backBuffer.height = 625;
|
|
28
31
|
this.backCtx = this.backBuffer.getContext("2d", { alpha: false });
|
|
29
32
|
this.imageData = this.backCtx.createImageData(this.backBuffer.width, this.backBuffer.height);
|
|
30
|
-
this.
|
|
31
|
-
this.canvasHeight = canvas.height;
|
|
33
|
+
this.canvas = canvas;
|
|
32
34
|
|
|
33
35
|
this.fb32 = new Uint32Array(this.imageData.data.buffer);
|
|
34
36
|
}
|
|
35
|
-
|
|
37
|
+
|
|
38
|
+
/** Nothing to release: the 2D context owns no objects of ours. */
|
|
39
|
+
dispose() {}
|
|
40
|
+
|
|
41
|
+
paint(minx, miny, maxx, maxy, _frame) {
|
|
36
42
|
const width = maxx - minx;
|
|
37
43
|
const height = maxy - miny;
|
|
38
44
|
this.backCtx.putImageData(this.imageData, 0, 0, minx, miny, width, height);
|
|
39
|
-
|
|
45
|
+
// Read the size each time: it can change when the window is resized.
|
|
46
|
+
this.ctx.drawImage(this.backBuffer, minx, miny, width, height, 0, 0, this.canvas.width, this.canvas.height);
|
|
40
47
|
}
|
|
41
48
|
}
|
|
42
49
|
|
|
43
50
|
const width = 1024;
|
|
44
51
|
const height = 1024;
|
|
45
52
|
export class GlCanvas {
|
|
46
|
-
|
|
47
|
-
|
|
53
|
+
/** The filter actually built, which may not be the one that was asked for. */
|
|
54
|
+
get filterClass() {
|
|
55
|
+
return this.filter.constructor;
|
|
48
56
|
}
|
|
49
57
|
|
|
50
58
|
constructor(canvas, filterClass) {
|
|
@@ -74,6 +82,10 @@ export class GlCanvas {
|
|
|
74
82
|
const program = this.filter.program;
|
|
75
83
|
checkedGl.useProgram(program);
|
|
76
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
|
+
|
|
77
89
|
this.fb8 = new Uint8Array(width * height * 4);
|
|
78
90
|
this.fb32 = new Uint32Array(this.fb8.buffer);
|
|
79
91
|
this.texture = checkedGl.createTexture();
|
|
@@ -81,8 +93,8 @@ export class GlCanvas {
|
|
|
81
93
|
checkedGl.pixelStorei(checkedGl.UNPACK_ALIGNMENT, 4);
|
|
82
94
|
checkedGl.texParameteri(checkedGl.TEXTURE_2D, checkedGl.TEXTURE_WRAP_S, checkedGl.CLAMP_TO_EDGE);
|
|
83
95
|
checkedGl.texParameteri(checkedGl.TEXTURE_2D, checkedGl.TEXTURE_WRAP_T, checkedGl.CLAMP_TO_EDGE);
|
|
84
|
-
checkedGl.texParameteri(checkedGl.TEXTURE_2D, checkedGl.TEXTURE_MAG_FILTER,
|
|
85
|
-
checkedGl.texParameteri(checkedGl.TEXTURE_2D, checkedGl.TEXTURE_MIN_FILTER,
|
|
96
|
+
checkedGl.texParameteri(checkedGl.TEXTURE_2D, checkedGl.TEXTURE_MAG_FILTER, sampling);
|
|
97
|
+
checkedGl.texParameteri(checkedGl.TEXTURE_2D, checkedGl.TEXTURE_MIN_FILTER, sampling);
|
|
86
98
|
checkedGl.texImage2D(
|
|
87
99
|
checkedGl.TEXTURE_2D,
|
|
88
100
|
0,
|
|
@@ -98,8 +110,8 @@ export class GlCanvas {
|
|
|
98
110
|
|
|
99
111
|
const vertexPositionAttrLoc = checkedGl.getAttribLocation(program, "pos");
|
|
100
112
|
checkedGl.enableVertexAttribArray(vertexPositionAttrLoc);
|
|
101
|
-
|
|
102
|
-
checkedGl.bindBuffer(checkedGl.ARRAY_BUFFER, vertexPositionBuffer);
|
|
113
|
+
this.vertexPositionBuffer = checkedGl.createBuffer();
|
|
114
|
+
checkedGl.bindBuffer(checkedGl.ARRAY_BUFFER, this.vertexPositionBuffer);
|
|
103
115
|
checkedGl.bufferData(checkedGl.ARRAY_BUFFER, new Float32Array([0, 0, 0, 1, 1, 0, 1, 1]), checkedGl.STATIC_DRAW);
|
|
104
116
|
checkedGl.vertexAttribPointer(vertexPositionAttrLoc, 2, checkedGl.FLOAT, false, 0, 0);
|
|
105
117
|
|
|
@@ -112,14 +124,42 @@ export class GlCanvas {
|
|
|
112
124
|
checkedGl.activeTexture(gl.TEXTURE0);
|
|
113
125
|
checkedGl.bindTexture(gl.TEXTURE_2D, this.texture);
|
|
114
126
|
|
|
127
|
+
this.checkedGl = checkedGl;
|
|
128
|
+
this.viewportWidth = this.viewportHeight = 0;
|
|
115
129
|
this.uvFloatArray = new Float32Array(8);
|
|
116
130
|
this.lastExtent = {};
|
|
117
131
|
|
|
118
132
|
console.log("GL Canvas set up");
|
|
119
133
|
}
|
|
120
134
|
|
|
121
|
-
|
|
135
|
+
/**
|
|
136
|
+
* Release the GL objects this canvas owns.
|
|
137
|
+
*
|
|
138
|
+
* Switching display mode builds a new canvas over the same element, and a
|
|
139
|
+
* canvas only ever hands out one WebGL context — so the new one inherits
|
|
140
|
+
* the old one's context and the old one's objects stay resident unless
|
|
141
|
+
* they are deleted here. That is a megabytes-per-switch leak: the
|
|
142
|
+
* framebuffer texture alone is 1024x1024 RGBA.
|
|
143
|
+
*/
|
|
144
|
+
dispose() {
|
|
145
|
+
const gl = this.checkedGl;
|
|
146
|
+
this.filter.dispose();
|
|
147
|
+
gl.deleteTexture(this.texture);
|
|
148
|
+
gl.deleteBuffer(this.vertexPositionBuffer);
|
|
149
|
+
gl.deleteBuffer(this.uvBuffer);
|
|
150
|
+
this.texture = this.vertexPositionBuffer = this.uvBuffer = null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
paint(minx, miny, maxx, maxy, frame) {
|
|
122
154
|
const gl = this.gl;
|
|
155
|
+
// The drawing buffer can be resized under us — modes that scale to the
|
|
156
|
+
// display do it on every window resize — and the viewport does not
|
|
157
|
+
// follow it.
|
|
158
|
+
if (gl.drawingBufferWidth !== this.viewportWidth || gl.drawingBufferHeight !== this.viewportHeight) {
|
|
159
|
+
this.viewportWidth = gl.drawingBufferWidth;
|
|
160
|
+
this.viewportHeight = gl.drawingBufferHeight;
|
|
161
|
+
gl.viewport(0, 0, this.viewportWidth, this.viewportHeight);
|
|
162
|
+
}
|
|
123
163
|
// We can't specify a stride for the source, so have to use the full width.
|
|
124
164
|
gl.texSubImage2D(
|
|
125
165
|
gl.TEXTURE_2D,
|
|
@@ -157,7 +197,17 @@ export class GlCanvas {
|
|
|
157
197
|
gl.bufferData(gl.ARRAY_BUFFER, this.uvFloatArray, gl.DYNAMIC_DRAW);
|
|
158
198
|
}
|
|
159
199
|
|
|
160
|
-
this.filter.setUniforms({
|
|
200
|
+
this.filter.setUniforms({
|
|
201
|
+
width,
|
|
202
|
+
height,
|
|
203
|
+
frameCount: frame.frameCount,
|
|
204
|
+
lineGrid: frame.lineGrid,
|
|
205
|
+
// How much of the framebuffer each output pixel covers, which sets
|
|
206
|
+
// how wide an edge-smoothing ramp should be. `extent` holds texel
|
|
207
|
+
// counts; the scaling into texture coordinates above applies only
|
|
208
|
+
// to the local copies that go into the UV buffer.
|
|
209
|
+
texelsPerOutputPixel: (extent.maxx - extent.minx) / gl.drawingBufferWidth,
|
|
210
|
+
});
|
|
161
211
|
|
|
162
212
|
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
|
163
213
|
}
|
|
@@ -167,11 +217,20 @@ export function bestCanvas(canvas, filterClass) {
|
|
|
167
217
|
try {
|
|
168
218
|
return new GlCanvas(canvas, filterClass);
|
|
169
219
|
} catch (e) {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
220
|
+
// Either WebGL is unavailable or this particular filter declined it.
|
|
221
|
+
console.log(`Unable to use ${filterClass.getDisplayConfig().name} with WebGL: ${e}`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// A canvas that has handed out a WebGL context can never hand out a 2D one,
|
|
225
|
+
// so if the failure came from the filter rather than from WebGL itself, the
|
|
226
|
+
// 2D fallback below would throw and take the emulator with it.
|
|
227
|
+
if (filterClass !== PassthroughFilter) {
|
|
228
|
+
try {
|
|
229
|
+
return new GlCanvas(canvas, PassthroughFilter);
|
|
230
|
+
} catch (e) {
|
|
231
|
+
console.log("Unable to fall back to the passthrough filter: " + e);
|
|
174
232
|
}
|
|
175
233
|
}
|
|
234
|
+
|
|
176
235
|
return new Canvas(canvas);
|
|
177
236
|
}
|
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-drive.js
CHANGED
|
@@ -33,6 +33,11 @@ export class BaseDiscDrive extends EventTarget {
|
|
|
33
33
|
throw new Error("Not implemented: headPosition getter");
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/** @returns {boolean} */
|
|
37
|
+
get isSideUpper() {
|
|
38
|
+
throw new Error("Not implemented: isSideUpper getter");
|
|
39
|
+
}
|
|
40
|
+
|
|
36
41
|
/** @returns {boolean} */
|
|
37
42
|
get indexPulse() {
|
|
38
43
|
throw new Error("Not implemented: indexPulse getter");
|
|
@@ -240,6 +245,10 @@ export class DiscDrive extends BaseDiscDrive {
|
|
|
240
245
|
return this._track;
|
|
241
246
|
}
|
|
242
247
|
|
|
248
|
+
get isSideUpper() {
|
|
249
|
+
return this._isSideUpper;
|
|
250
|
+
}
|
|
251
|
+
|
|
243
252
|
get positionFraction() {
|
|
244
253
|
return (this._headPosition + this._pulsePosition / 32) / this.trackLength;
|
|
245
254
|
}
|
package/src/disc-hfe.js
CHANGED
|
@@ -190,9 +190,8 @@ export function loadHfe(disc, data, onChange) {
|
|
|
190
190
|
}
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
-
// Set up write track callback if onChange is provided
|
|
194
193
|
if (onChange) {
|
|
195
|
-
disc.
|
|
194
|
+
disc.addTrackWriteListener((_side, _trackNum, _trackObj) => {
|
|
196
195
|
// Generate a complete HFE image from the current disc state
|
|
197
196
|
const hfeData = toHfe(disc);
|
|
198
197
|
// Call the onChange handler with the updated HFE data
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// Pure geometry and colour mapping, free of the DOM; disc-visualiser.js owns the canvases.
|
|
4
|
+
|
|
5
|
+
import { IbmDiscFormat } from "./disc.js";
|
|
6
|
+
|
|
7
|
+
/** One FM byte, or two MFM bytes; 2us a slot. */
|
|
8
|
+
export const PulsesPerWord = 32;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Both FM and MFM put between eight and sixteen flux transitions in a formatted word, so the ramp
|
|
12
|
+
* spans exactly that and gets one step per count.
|
|
13
|
+
*/
|
|
14
|
+
export const MinDensity = 8;
|
|
15
|
+
export const MaxDensity = 16;
|
|
16
|
+
|
|
17
|
+
/** Dark to light, for a dark surface. */
|
|
18
|
+
export const DensityRampHex = [
|
|
19
|
+
"#1c5cab",
|
|
20
|
+
"#256abf",
|
|
21
|
+
"#2a78d6",
|
|
22
|
+
"#3987e5",
|
|
23
|
+
"#5598e7",
|
|
24
|
+
"#6da7ec",
|
|
25
|
+
"#86b6ef",
|
|
26
|
+
"#9ec5f4",
|
|
27
|
+
"#cde2fb",
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
export const UnformattedHex = "#2a2a28";
|
|
31
|
+
|
|
32
|
+
export const Region = {
|
|
33
|
+
Unformatted: 0,
|
|
34
|
+
Gap: 1,
|
|
35
|
+
Header: 2,
|
|
36
|
+
Data: 3,
|
|
37
|
+
Deleted: 4,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Indexed by {@link Region}. Gap and unformatted are recessive neutrals; header, data and deleted
|
|
42
|
+
* carry identity.
|
|
43
|
+
*/
|
|
44
|
+
export const RegionStyles = [
|
|
45
|
+
{ name: "unformatted", hex: UnformattedHex },
|
|
46
|
+
{ name: "gap", hex: "#3f3f3c" },
|
|
47
|
+
{ name: "header", hex: "#d95926" },
|
|
48
|
+
{ name: "data", hex: "#3987e5" },
|
|
49
|
+
{ name: "deleted data", hex: "#199e70" },
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
/** Reserved status colour, never used as a fill. */
|
|
53
|
+
export const ErrorHex = "#d03b3b";
|
|
54
|
+
|
|
55
|
+
/** Canvas pixel buffers are little-endian ABGR, as in bbc-palette.js. */
|
|
56
|
+
function hexToAbgr(hex) {
|
|
57
|
+
const value = parseInt(hex.slice(1), 16);
|
|
58
|
+
const r = (value >>> 16) & 0xff;
|
|
59
|
+
const g = (value >>> 8) & 0xff;
|
|
60
|
+
const b = value & 0xff;
|
|
61
|
+
return ((0xff << 24) | (b << 16) | (g << 8) | r) >>> 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const UnformattedColour = hexToAbgr(UnformattedHex);
|
|
65
|
+
|
|
66
|
+
/** Indexed by pulse count. */
|
|
67
|
+
export const DensityPalette = Uint32Array.from({ length: PulsesPerWord + 1 }, (_, density) => {
|
|
68
|
+
if (density === 0) return UnformattedColour;
|
|
69
|
+
const step = Math.min(Math.max(density - MinDensity, 0), DensityRampHex.length - 1);
|
|
70
|
+
return hexToAbgr(DensityRampHex[step]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
export const RegionPalette = Uint32Array.from(RegionStyles, ({ hex }) => hexToAbgr(hex));
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @param {number} pulses one word of surface data
|
|
77
|
+
* @returns {number} flux transitions it holds
|
|
78
|
+
*/
|
|
79
|
+
export function pulseDensity(pulses) {
|
|
80
|
+
let bits = pulses - ((pulses >>> 1) & 0x55555555);
|
|
81
|
+
bits = (bits & 0x33333333) + ((bits >>> 2) & 0x33333333);
|
|
82
|
+
return (((bits + (bits >>> 4)) & 0x0f0f0f0f) * 0x01010101) >>> 24;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @param {Track} track
|
|
87
|
+
* @returns {Uint8Array} flux transitions in each word of the track
|
|
88
|
+
*/
|
|
89
|
+
export function trackPulseDensity(track) {
|
|
90
|
+
const density = new Uint8Array(track.length);
|
|
91
|
+
for (let word = 0; word < track.length; ++word) density[word] = pulseDensity(track.pulses2Us[word]);
|
|
92
|
+
return density;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Address mark aside, a sector header is four bytes of identity and two of CRC. */
|
|
96
|
+
const HeaderBytes = 6;
|
|
97
|
+
const CrcBytes = 2;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Pick the sectors out of a track and say what each word of it holds.
|
|
101
|
+
*
|
|
102
|
+
* @param {Track} track
|
|
103
|
+
* @param {function(string): void} [warn] where to send the decoder's complaints
|
|
104
|
+
* @returns {{codes: Uint8Array, sectorNumbers: Int16Array, errors: {firstWord: number, lastWord: number, kind: string, sectorNumber: number}[]}}
|
|
105
|
+
*/
|
|
106
|
+
export function trackRegions(track, warn) {
|
|
107
|
+
const codes = new Uint8Array(track.length);
|
|
108
|
+
const sectorNumbers = new Int16Array(track.length).fill(-1);
|
|
109
|
+
const errors = [];
|
|
110
|
+
for (let word = 0; word < track.length; ++word)
|
|
111
|
+
codes[word] = track.pulses2Us[word] === 0 ? Region.Unformatted : Region.Gap;
|
|
112
|
+
|
|
113
|
+
/** Marks the words a bit range covers, wrapping at the index. */
|
|
114
|
+
const fill = (startBit, endBit, code, sectorNumber) => {
|
|
115
|
+
for (let word = Math.floor(startBit / PulsesPerWord); word < Math.ceil(endBit / PulsesPerWord); ++word) {
|
|
116
|
+
const index = ((word % track.length) + track.length) % track.length;
|
|
117
|
+
codes[index] = code;
|
|
118
|
+
sectorNumbers[index] = sectorNumber;
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
for (const sector of track.findSectors(warn)) {
|
|
123
|
+
const pulsesPerByte = sector.isMfm ? PulsesPerWord / 2 : PulsesPerWord;
|
|
124
|
+
const noteError = (kind, startBit, endBit) =>
|
|
125
|
+
errors.push({
|
|
126
|
+
firstWord: Math.floor(startBit / PulsesPerWord) % track.length,
|
|
127
|
+
lastWord: Math.ceil(endBit / PulsesPerWord) % track.length,
|
|
128
|
+
kind,
|
|
129
|
+
sectorNumber: sector.sectorNumber,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// A region starts at its address mark, which sits one byte before the offset the reader
|
|
133
|
+
// was handed.
|
|
134
|
+
const idStart = sector.idPosBitOffset - pulsesPerByte;
|
|
135
|
+
const idEnd = sector.idPosBitOffset + HeaderBytes * pulsesPerByte;
|
|
136
|
+
fill(idStart, idEnd, Region.Header, sector.sectorNumber);
|
|
137
|
+
if (sector.hasHeaderCrcError) noteError("header CRC", idStart, idEnd);
|
|
138
|
+
|
|
139
|
+
if (sector.dataPosBitOffset === null) continue;
|
|
140
|
+
// A failed CRC leaves no confirmed length, so fall back to what the header claimed.
|
|
141
|
+
const bytes = sector.byteLength ?? 128 << Math.min(sector.header[3], 4);
|
|
142
|
+
const dataStart = sector.dataPosBitOffset - pulsesPerByte;
|
|
143
|
+
const dataEnd = sector.dataPosBitOffset + (bytes + CrcBytes) * pulsesPerByte;
|
|
144
|
+
const code = sector.isDeleted ? Region.Deleted : Region.Data;
|
|
145
|
+
fill(dataStart, dataEnd, code, sector.sectorNumber);
|
|
146
|
+
if (sector.hasDataCrcError) noteError("data CRC", dataStart, dataEnd);
|
|
147
|
+
}
|
|
148
|
+
return { codes, sectorNumbers, errors };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Enough to fill the view with a couple of tracks. */
|
|
152
|
+
export const MaxZoom = 32;
|
|
153
|
+
|
|
154
|
+
export const clampZoom = (zoom) => Math.min(Math.max(zoom, 1), MaxZoom);
|
|
155
|
+
|
|
156
|
+
const OuterRadiusFraction = 0.97;
|
|
157
|
+
const InnerRadiusFraction = 0.36;
|
|
158
|
+
const HubRadiusFraction = 0.26;
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Maps between a square canvas and the surface of a disc drawn on it. Disc space is the canvas at
|
|
162
|
+
* rest, with the platter filling a `size` square; zoom and pan move the canvas over it.
|
|
163
|
+
*/
|
|
164
|
+
export class DiscGeometry {
|
|
165
|
+
/**
|
|
166
|
+
* @param {number} size canvas edge in device pixels
|
|
167
|
+
* @param {number} numTracks
|
|
168
|
+
*/
|
|
169
|
+
constructor(size, numTracks = IbmDiscFormat.tracksPerDisc) {
|
|
170
|
+
this.size = size;
|
|
171
|
+
this.numTracks = numTracks;
|
|
172
|
+
this.centre = size / 2;
|
|
173
|
+
this.outerRadius = this.centre * OuterRadiusFraction;
|
|
174
|
+
this.innerRadius = this.centre * InnerRadiusFraction;
|
|
175
|
+
this.hubRadius = this.centre * HubRadiusFraction;
|
|
176
|
+
this.trackPitch = (this.outerRadius - this.innerRadius) / numTracks;
|
|
177
|
+
this.zoom = 1;
|
|
178
|
+
this.originX = 0;
|
|
179
|
+
this.originY = 0;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* @param {number} zoom canvas pixels per disc pixel, at least 1
|
|
184
|
+
* @param {number} originX disc-space coordinate shown at the canvas's left edge
|
|
185
|
+
* @param {number} originY disc-space coordinate shown at the canvas's top edge
|
|
186
|
+
*/
|
|
187
|
+
setView(zoom, originX, originY) {
|
|
188
|
+
this.zoom = clampZoom(zoom);
|
|
189
|
+
// Holding the window inside the platter's square keeps the disc from being panned away.
|
|
190
|
+
const slack = this.size - this.size / this.zoom;
|
|
191
|
+
this.originX = Math.min(Math.max(originX, 0), slack);
|
|
192
|
+
this.originY = Math.min(Math.max(originY, 0), slack);
|
|
193
|
+
return this;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The origin is in disc pixels, which scale with the canvas. */
|
|
197
|
+
adoptView({ zoom, originX, originY, size }) {
|
|
198
|
+
return this.setView(zoom, (originX * this.size) / size, (originY * this.size) / size);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
toDisc(x, y) {
|
|
202
|
+
return { x: x / this.zoom + this.originX, y: y / this.zoom + this.originY };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
get screenCentreX() {
|
|
206
|
+
return (this.centre - this.originX) * this.zoom;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
get screenCentreY() {
|
|
210
|
+
return (this.centre - this.originY) * this.zoom;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
screenRadiusOf(track) {
|
|
214
|
+
return this.radiusOf(track) * this.zoom;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Track 0 is the outermost, as on the real thing. */
|
|
218
|
+
trackAt(radius) {
|
|
219
|
+
const track = Math.floor((this.outerRadius - radius) / this.trackPitch);
|
|
220
|
+
return track >= 0 && track < this.numTracks ? track : null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
radiusOf(track) {
|
|
224
|
+
return this.outerRadius - (track + 0.5) * this.trackPitch;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Index sits at twelve o'clock, with the surface running clockwise from there. */
|
|
228
|
+
angleOf(fraction) {
|
|
229
|
+
return fraction * 2 * Math.PI - Math.PI / 2;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* @param {number} dx offset from the centre
|
|
234
|
+
* @param {number} dy offset from the centre
|
|
235
|
+
* @returns {number} how far round from the index, in [0, 1)
|
|
236
|
+
*/
|
|
237
|
+
fractionAt(dx, dy) {
|
|
238
|
+
const fraction = (Math.atan2(dy, dx) + Math.PI / 2) / (2 * Math.PI);
|
|
239
|
+
return fraction - Math.floor(fraction);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** @returns {{x: number, y: number}} where on the canvas that point of the surface is drawn */
|
|
243
|
+
pointAt(track, fraction) {
|
|
244
|
+
const angle = this.angleOf(fraction);
|
|
245
|
+
const radius = this.screenRadiusOf(track);
|
|
246
|
+
return { x: this.screenCentreX + radius * Math.cos(angle), y: this.screenCentreY + radius * Math.sin(angle) };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* @returns {{track: number, fraction: number}|null} what the surface holds under a canvas point
|
|
251
|
+
*/
|
|
252
|
+
positionAt(x, y) {
|
|
253
|
+
const disc = this.toDisc(x, y);
|
|
254
|
+
const dx = disc.x - this.centre;
|
|
255
|
+
const dy = disc.y - this.centre;
|
|
256
|
+
const track = this.trackAt(Math.hypot(dx, dy));
|
|
257
|
+
return track === null ? null : { track, fraction: this.fractionAt(dx, dy) };
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const Supersample = 2;
|
|
262
|
+
const InvTwoPi = 1 / (2 * Math.PI);
|
|
263
|
+
|
|
264
|
+
/** Math.hypot's overflow scaling is pure cost at canvas coordinates, and this is the hot path. */
|
|
265
|
+
function distance(dx, dy) {
|
|
266
|
+
return Math.sqrt(dx * dx + dy * dy);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Paint a range of tracks into a square ABGR pixel buffer. Only pixels whose centres fall in the
|
|
271
|
+
* band those tracks occupy are written.
|
|
272
|
+
*
|
|
273
|
+
* @param {Uint32Array} pixels size * size ABGR pixels
|
|
274
|
+
* @param {DiscGeometry} geometry
|
|
275
|
+
* @param {(Uint8Array|null)[]} codes one value per word, indexed by track
|
|
276
|
+
* @param {Uint32Array} palette ABGR for each code
|
|
277
|
+
* @param {number} firstTrack
|
|
278
|
+
* @param {number} lastTrack inclusive
|
|
279
|
+
* @param {number} [supersample] samples per pixel edge; 1 trades the smoothed edges for speed
|
|
280
|
+
*/
|
|
281
|
+
export function renderTracks(pixels, geometry, codes, palette, firstTrack, lastTrack, supersample = Supersample) {
|
|
282
|
+
const { size, outerRadius, trackPitch, numTracks, zoom } = geometry;
|
|
283
|
+
// Work in canvas pixels throughout: zooming and panning scale and shift the radius, and leave
|
|
284
|
+
// the angle alone.
|
|
285
|
+
const centreX = geometry.screenCentreX;
|
|
286
|
+
const centreY = geometry.screenCentreY;
|
|
287
|
+
const bandOuter = (outerRadius - firstTrack * trackPitch) * zoom;
|
|
288
|
+
const bandInner = (outerRadius - (lastTrack + 1) * trackPitch) * zoom;
|
|
289
|
+
const bandOuterSquared = bandOuter * bandOuter;
|
|
290
|
+
const bandInnerSquared = bandInner * bandInner;
|
|
291
|
+
const samplesPerPixel = supersample * supersample;
|
|
292
|
+
const subStep = 1 / supersample;
|
|
293
|
+
|
|
294
|
+
/** @returns {number} the pixel's ABGR, averaged over its samples, or zero if no track covers it */
|
|
295
|
+
const samplePixel = (x, y) => {
|
|
296
|
+
let covered = 0;
|
|
297
|
+
let red = 0;
|
|
298
|
+
let green = 0;
|
|
299
|
+
let blue = 0;
|
|
300
|
+
for (let subY = 0; subY < supersample; ++subY) {
|
|
301
|
+
const sampleY = y + (subY + 0.5) * subStep - centreY;
|
|
302
|
+
for (let subX = 0; subX < supersample; ++subX) {
|
|
303
|
+
const sampleX = x + (subX + 0.5) * subStep - centreX;
|
|
304
|
+
const at = (outerRadius - distance(sampleX, sampleY) / zoom) / trackPitch;
|
|
305
|
+
if (at < 0 || at >= numTracks) continue;
|
|
306
|
+
const trackCodes = codes[at | 0];
|
|
307
|
+
if (!trackCodes || trackCodes.length === 0) continue;
|
|
308
|
+
let fraction = Math.atan2(sampleY, sampleX) * InvTwoPi + 0.25;
|
|
309
|
+
if (fraction < 0) fraction += 1;
|
|
310
|
+
const word = Math.min((fraction * trackCodes.length) | 0, trackCodes.length - 1);
|
|
311
|
+
const colour = palette[trackCodes[word]];
|
|
312
|
+
red += colour & 0xff;
|
|
313
|
+
green += (colour >>> 8) & 0xff;
|
|
314
|
+
blue += (colour >>> 16) & 0xff;
|
|
315
|
+
covered++;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
if (covered === 0) return 0;
|
|
319
|
+
const alpha = ((255 * covered) / samplesPerPixel) | 0;
|
|
320
|
+
return (
|
|
321
|
+
((alpha << 24) |
|
|
322
|
+
(((blue / covered) | 0) << 16) |
|
|
323
|
+
(((green / covered) | 0) << 8) |
|
|
324
|
+
((red / covered) | 0)) >>>
|
|
325
|
+
0
|
|
326
|
+
);
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
const firstRow = Math.max(0, Math.floor(centreY - bandOuter));
|
|
330
|
+
const lastRow = Math.min(size, Math.ceil(centreY + bandOuter) + 1);
|
|
331
|
+
for (let y = firstRow; y < lastRow; ++y) {
|
|
332
|
+
const dy = y + 0.5 - centreY;
|
|
333
|
+
const halfWidth = Math.sqrt(Math.max(0, bandOuterSquared - dy * dy));
|
|
334
|
+
const low = Math.max(0, Math.floor(centreX - halfWidth));
|
|
335
|
+
const high = Math.min(size, Math.ceil(centreX + halfWidth) + 1);
|
|
336
|
+
const holeHalfWidth = Math.abs(dy) < bandInner ? Math.sqrt(bandInnerSquared - dy * dy) : 0;
|
|
337
|
+
const pastHole = Math.ceil(centreX + holeHalfWidth - 0.5);
|
|
338
|
+
for (let x = low; x < high; ++x) {
|
|
339
|
+
const dx = x + 0.5 - centreX;
|
|
340
|
+
if (Math.abs(dx) < holeHalfWidth) {
|
|
341
|
+
x = Math.max(x, pastHole - 1);
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
const radiusSquared = dx * dx + dy * dy;
|
|
345
|
+
if (radiusSquared < bandInnerSquared || radiusSquared >= bandOuterSquared) continue;
|
|
346
|
+
const colour = samplePixel(x, y);
|
|
347
|
+
if (colour !== 0) pixels[y * size + x] = colour;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|