@fugloy/emu 0.0.0-stage → 0.3.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/LICENSE +21 -0
- package/NOTICE.md +27 -0
- package/README.md +235 -2
- package/package.json +30 -3
- package/src/cpu/z80cpu.js +3350 -0
- package/src/debug/debugger.js +788 -0
- package/src/debug/instr.js +90 -0
- package/src/debug/label-file.js +429 -0
- package/src/debug/state.js +273 -0
- package/src/devices/cassette-tape.js +254 -0
- package/src/devices/cassette.js +263 -0
- package/src/devices/disk-image.js +965 -0
- package/src/devices/fdc-wd1771.js +180 -0
- package/src/devices/fdc-wd1793.js +1065 -0
- package/src/devices/interrupt-latch.js +76 -0
- package/src/devices/key-queue.js +278 -0
- package/src/devices/keyboard.js +176 -0
- package/src/devices/printer.js +44 -0
- package/src/devices/sound.js +53 -0
- package/src/devices/video.js +69 -0
- package/src/index.js +23 -0
- package/src/loaders/bas.js +107 -0
- package/src/loaders/cas-format.js +213 -0
- package/src/loaders/cmd-format.js +88 -0
- package/src/loaders/handoff.js +29 -0
- package/src/loaders/load.js +299 -0
- package/src/machine/drive-select.js +42 -0
- package/src/machine/io.js +513 -0
- package/src/machine/machine.js +333 -0
- package/src/machine/memory.js +290 -0
- package/src/machine/models.js +123 -0
- package/src/machine/rom-hashes.js +21 -0
- package/src/machine/sha256.js +74 -0
- package/src/machine/trs80-system.js +289 -0
|
@@ -0,0 +1,965 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TRS-80 disk images in memory: read JV1, JV3, and DMK exactly, write in place, export DMK.
|
|
3
|
+
*
|
|
4
|
+
* The containers, as Tim Mann's format notes (the xtrs distribution's "TRS-80 Disk Formats")
|
|
5
|
+
* define them:
|
|
6
|
+
*
|
|
7
|
+
* JV1 — a headerless dump, single-sided, 256-byte sectors in track order. Jeff Vavasour's
|
|
8
|
+
* format is single density with 10 sectors per track numbered 0-9 (Model I); the
|
|
9
|
+
* Model III images of TRSDOS 1.3 are the same dump with 18 double-density sectors
|
|
10
|
+
* numbered 1-18. The file size is the only evidence, so only 35, 40, 77, or 80 tracks
|
|
11
|
+
* of 10 or 18 sectors count, and the geometry fixes the numbering (a Model I's drives
|
|
12
|
+
* take 10-sector JV1s of 35 to 42 tracks too: `jv1SdTracks`). JV1 records no density,
|
|
13
|
+
* data mark, or CRC: its IDs read in either density, and the directory track's marks
|
|
14
|
+
* are synthesized: FA on a 10-sector disk, the mark Model I TRSDOS writes its
|
|
15
|
+
* directory with for the WD1771, F8 on an 18-sector one.
|
|
16
|
+
*
|
|
17
|
+
* JV3 — blocks of 2,901 three-byte sector headers (track, sector, flags) and a write-protect
|
|
18
|
+
* byte (FF writable, anything else protected), each block followed by the data of its
|
|
19
|
+
* sectors in header order. A free header (track and sector FF) still owns its data
|
|
20
|
+
* area, sized by the free entries' size code, so the sectors after it keep their
|
|
21
|
+
* offsets; the untouched FF FF FF entries at the end own nothing that is in the file.
|
|
22
|
+
* A second header block, when there is one, starts after the first block's whole data
|
|
23
|
+
* area. Flags: 80 double density; 60 the data address mark (single density: 00 FB,
|
|
24
|
+
* 20 FA, 40 F9, 60 F8; double density: 00 FB, 20 F8); 10 side 1; 08 CRC error; 04
|
|
25
|
+
* non-IBM; 03 the size (used: 256, 128, 1024, 512; free: 512, 1024, 128, 256).
|
|
26
|
+
*
|
|
27
|
+
* DMK — a 16-byte header (write protect FF or 00, track count, track length little-endian,
|
|
28
|
+
* flags: 10 single-sided, 40 single-density bytes stored once, 80 ignore density;
|
|
29
|
+
* bytes 12-15 nonzero mark a real-drive image, which is refused), then each track
|
|
30
|
+
* side: 64 IDAM pointers (the offset of the FE ID mark from the track start, bit 15
|
|
31
|
+
* set for an MFM ID, zero ending the table) and the raw track bytes. Single-density
|
|
32
|
+
* bytes are stored twice unless flag 40 or 80 says otherwise; a track may mix
|
|
33
|
+
* densities.
|
|
34
|
+
*
|
|
35
|
+
* Writes change the in-memory image only (the session's copy); `bytes` is always the image's
|
|
36
|
+
* truth, what a snapshot saves, and `toDMK()` writes any of the three as a DMK (spec §6: read
|
|
37
|
+
* all three, write DMK only).
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
const JV3_ENTRIES = 2901;
|
|
41
|
+
|
|
42
|
+
// The JV1 geometries a real disk has: sectors per track (Model III
|
|
43
|
+
// double density, Model I single density) and track counts (35- and
|
|
44
|
+
// 40-track 5.25" drives, 77-track 8", 80-track 5.25").
|
|
45
|
+
const JV1_SPT = [18, 10];
|
|
46
|
+
const JV1_TRACKS = [35, 40, 77, 80];
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The track counts a 10-sector JV1 may have on a Model I (`jv1SdTracks`, passed by `m.fdc.mount`
|
|
50
|
+
* on a Model I). Model I archives hold single-density JV1s of 35 tracks (TRSDOS 2.3's 35, Super
|
|
51
|
+
* Utility Tech Manual p. 3, "TRSDOS 2.3") and of up to 40 and beyond, as drives and DOSes that
|
|
52
|
+
* format 36 to 42 tracks wrote them; xtrs takes any whole number of 2,560-byte tracks. Accepted
|
|
53
|
+
* here: 35 to 42, 77, and 80, single-sided. A file of another length still refuses, so a .CMD
|
|
54
|
+
* does not mount as a short JV1 (phase 6 plan, Task 3 ruling; spec §5).
|
|
55
|
+
*/
|
|
56
|
+
export const JV1_MODEL1_SD_TRACKS = Object.freeze([35, 36, 37, 38, 39, 40, 41, 42, 77, 80]);
|
|
57
|
+
|
|
58
|
+
/** "35, 40, 77, 80" or, for a run of consecutive counts, "35-42, 77, 80". */
|
|
59
|
+
function trackList(list) {
|
|
60
|
+
const out = [];
|
|
61
|
+
for (let i = 0; i < list.length; ) {
|
|
62
|
+
let j = i;
|
|
63
|
+
while (j + 1 < list.length && list[j + 1] === list[j] + 1) j++;
|
|
64
|
+
out.push(j - i >= 2 ? `${list[i]}-${list[j]}` : list.slice(i, j + 1).join(", "));
|
|
65
|
+
i = j + 1;
|
|
66
|
+
}
|
|
67
|
+
return out.join(", ");
|
|
68
|
+
}
|
|
69
|
+
const JV3_HEADER_SIZE = JV3_ENTRIES * 3 + 1;
|
|
70
|
+
const JV3_FREE = 0xff;
|
|
71
|
+
|
|
72
|
+
// JV3 flag bits
|
|
73
|
+
const F_DD = 0x80;
|
|
74
|
+
const F_DAM = 0x60; // data address mark code (0x00 = normal 0xFB)
|
|
75
|
+
const F_SIDE = 0x10;
|
|
76
|
+
const F_CRC_ERROR = 0x08;
|
|
77
|
+
|
|
78
|
+
/** The data size a JV3 header's size code gives: used and free entries number them differently. */
|
|
79
|
+
function jv3SectorSize(flags, free) {
|
|
80
|
+
const code = flags & 0x03;
|
|
81
|
+
return free ? [512, 1024, 128, 256][code] : [256, 128, 1024, 512][code];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const JV3_SIZE_CODE = { 256: 0x00, 128: 0x01, 1024: 0x02, 512: 0x03 };
|
|
85
|
+
|
|
86
|
+
/** The data address mark a JV3 flags byte records. */
|
|
87
|
+
function jv3Dam(flags) {
|
|
88
|
+
const code = flags & F_DAM;
|
|
89
|
+
if (flags & F_DD) return code === 0 ? 0xfb : 0xf8;
|
|
90
|
+
return [0xfb, 0xfa, 0xf9, 0xf8][code >> 5];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The JV3 flags bits for a data address mark at a density. */
|
|
94
|
+
function jv3DamBits(dam, dd) {
|
|
95
|
+
if (dam === 0xfb) return 0x00;
|
|
96
|
+
if (dd) return 0x20; // F8; MFM has no code for FA or F9
|
|
97
|
+
return { 0xfa: 0x20, 0xf9: 0x40, 0xf8: 0x60 }[dam] ?? 0x60;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// The WD1793 sets Record Type (status bit 5) for a deleted data mark,
|
|
101
|
+
// F8 ("Status Register Summary"); the sheet names only FB and F8. Of the
|
|
102
|
+
// FM marks the WD1771 also writes, the Model III's 1793 "recognizes 'Read
|
|
103
|
+
// Protected' [FA] and 'Standard Data' both as 'Standard Data'" and
|
|
104
|
+
// "'Deleted Data' and 'User Defined' [F8, F9] as 'Read Protected'"
|
|
105
|
+
// (Super Utility Tech Manual, Breeze/QSD 1982, p. 2): FA reads as normal,
|
|
106
|
+
// F9 as deleted (F9 unverified: spec §5). The WD1771 model reads `dam`
|
|
107
|
+
// itself (fdc-wd1771.js), not this.
|
|
108
|
+
const isDeleted = (dam) => dam === 0xf8 || dam === 0xf9;
|
|
109
|
+
|
|
110
|
+
// TRSDOS-family boot sectors name the directory cylinder in byte 2.
|
|
111
|
+
const DEFAULT_DIR_TRACK = 17;
|
|
112
|
+
|
|
113
|
+
// ---------------------------- DMK ----------------------------------
|
|
114
|
+
const DMK_HEADER = 16;
|
|
115
|
+
const DMK_IDAM_TABLE = 128;
|
|
116
|
+
const DMK_IDAMS = 64;
|
|
117
|
+
/** The usual 5.25" double-density DMK track length: the IDAM table and 6,272 raw bytes. */
|
|
118
|
+
const DMK_TRACK_LEN = 0x1900;
|
|
119
|
+
const DMK_SINGLE_SIDED = 0x10;
|
|
120
|
+
const DMK_SD_SINGLE_BYTE = 0x40;
|
|
121
|
+
const DMK_IGNORE_DENSITY = 0x80;
|
|
122
|
+
|
|
123
|
+
const SIZE_CODE = { 128: 0, 256: 1, 512: 2, 1024: 3 };
|
|
124
|
+
|
|
125
|
+
/** CRC-CCITT, the checksum the WD1793 writes after ID and data fields. */
|
|
126
|
+
function crc16(bytes, init = 0xffff) {
|
|
127
|
+
let crc = init;
|
|
128
|
+
for (const b of bytes) {
|
|
129
|
+
crc ^= b << 8;
|
|
130
|
+
for (let i = 0; i < 8; i++) {
|
|
131
|
+
crc = crc & 0x8000 ? ((crc << 1) ^ 0x1021) & 0xffff : (crc << 1) & 0xffff;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return crc;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* @typedef {{ sector: number, data: ArrayLike<number>, track?: number, side?: number,
|
|
139
|
+
* size?: number, sizeCode?: number, mfm?: boolean | null, deleted?: boolean, dam?: number,
|
|
140
|
+
* idCrcOk?: boolean, dataCrcOk?: boolean }} TrackSector
|
|
141
|
+
*/
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Lay one DMK track's raw bytes from its sectors, in the FD179X data sheet's track formats ("IBM
|
|
145
|
+
* System 34 Format" for MFM sectors, "IBM 3740 Format" for FM ones) with shortened gaps: an index
|
|
146
|
+
* area in the first sector's density, then per sector its gap, the sync zeros (and A1s in MFM),
|
|
147
|
+
* the ID field and its CRC, Gap II, the sync again, the data mark, the data, and its CRC. FM
|
|
148
|
+
* bytes are stored twice when `doubled`. A sector with `idCrcOk` or `dataCrcOk` false gets that
|
|
149
|
+
* CRC inverted, so a bad CRC exports as bad. `tight` uses the data sheet's "Non-IBM Formats"
|
|
150
|
+
* minimum gaps. Returns the raw bytes and the IDAM pointers (offsets from the raw start).
|
|
151
|
+
* @param {number} cyl
|
|
152
|
+
* @param {number} side
|
|
153
|
+
* @param {TrackSector[]} sectors
|
|
154
|
+
* @param {boolean} doubled
|
|
155
|
+
* @param {boolean} tight
|
|
156
|
+
*/
|
|
157
|
+
function layDmkTrack(cyl, side, sectors, doubled, tight) {
|
|
158
|
+
/** @type {number[]} */
|
|
159
|
+
const raw = [];
|
|
160
|
+
/** @type {number[]} */
|
|
161
|
+
const ptrs = [];
|
|
162
|
+
const put = (v, mfm) => {
|
|
163
|
+
raw.push(v);
|
|
164
|
+
if (!mfm && doubled) raw.push(v);
|
|
165
|
+
};
|
|
166
|
+
const run = (n, v, mfm) => {
|
|
167
|
+
for (let k = 0; k < n; k++) put(v, mfm);
|
|
168
|
+
};
|
|
169
|
+
const firstMfm = sectors.length === 0 || sectors[0].mfm !== false;
|
|
170
|
+
if (firstMfm) {
|
|
171
|
+
run(tight ? 16 : 32, 0x4e, true);
|
|
172
|
+
run(12, 0x00, true);
|
|
173
|
+
for (const v of [0xc2, 0xc2, 0xc2, 0xfc]) put(v, true);
|
|
174
|
+
} else {
|
|
175
|
+
run(tight ? 8 : 16, 0xff, false);
|
|
176
|
+
run(6, 0x00, false);
|
|
177
|
+
put(0xfc, false);
|
|
178
|
+
}
|
|
179
|
+
for (const s of sectors) {
|
|
180
|
+
const mfm = s.mfm !== false;
|
|
181
|
+
const gap = mfm ? 0x4e : 0xff;
|
|
182
|
+
const size = s.size ?? s.data.length;
|
|
183
|
+
const sync = mfm ? [0xa1, 0xa1, 0xa1] : [];
|
|
184
|
+
run(mfm ? (tight ? 8 : 24) : tight ? 6 : 12, gap, mfm);
|
|
185
|
+
run(mfm ? 12 : 6, 0x00, mfm);
|
|
186
|
+
for (const v of sync) put(v, mfm);
|
|
187
|
+
ptrs.push(raw.length | (mfm ? 0x8000 : 0));
|
|
188
|
+
const id = [0xfe, s.track ?? cyl, s.side ?? side, s.sector, s.sizeCode ?? SIZE_CODE[size] ?? 1];
|
|
189
|
+
let idCrc = crc16([...sync, ...id]);
|
|
190
|
+
if (s.idCrcOk === false) idCrc ^= 0xffff;
|
|
191
|
+
for (const v of [...id, idCrc >> 8, idCrc & 0xff]) put(v, mfm);
|
|
192
|
+
run(mfm ? 22 : 11, gap, mfm);
|
|
193
|
+
run(mfm ? 12 : 6, 0x00, mfm);
|
|
194
|
+
for (const v of sync) put(v, mfm);
|
|
195
|
+
const mark = s.dam ?? (s.deleted ? 0xf8 : 0xfb);
|
|
196
|
+
const data = new Uint8Array(size);
|
|
197
|
+
for (let k = 0; k < size; k++) data[k] = s.data[k] ?? 0;
|
|
198
|
+
let dataCrc = crc16([...sync, mark, ...data]);
|
|
199
|
+
if (s.dataCrcOk === false) dataCrc ^= 0xffff;
|
|
200
|
+
put(mark, mfm);
|
|
201
|
+
for (const v of data) put(v, mfm);
|
|
202
|
+
put(dataCrc >> 8, mfm);
|
|
203
|
+
put(dataCrc & 0xff, mfm);
|
|
204
|
+
}
|
|
205
|
+
if (ptrs.length > DMK_IDAMS) {
|
|
206
|
+
throw new Error(`A DMK track holds at most ${DMK_IDAMS} sectors; this one has ${ptrs.length}`);
|
|
207
|
+
}
|
|
208
|
+
return { raw, ptrs, fill: firstMfm ? 0x4e : 0xff };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Write a laid track into a DMK track region (IDAM table + raw bytes) of `trackLen` bytes. */
|
|
212
|
+
function dmkRegion(laid, trackLen) {
|
|
213
|
+
const region = new Uint8Array(trackLen);
|
|
214
|
+
laid.ptrs.forEach((p, i) => {
|
|
215
|
+
const ptr = p + DMK_IDAM_TABLE;
|
|
216
|
+
region[i * 2] = ptr & 0xff;
|
|
217
|
+
region[i * 2 + 1] = ptr >> 8;
|
|
218
|
+
});
|
|
219
|
+
region.fill(laid.fill, DMK_IDAM_TABLE);
|
|
220
|
+
region.set(laid.raw, DMK_IDAM_TABLE);
|
|
221
|
+
return region;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* One DMK track region for freshly formatted sectors, IBM-style gaps, or the minimum gaps when
|
|
226
|
+
* those do not fit the image's track length.
|
|
227
|
+
*/
|
|
228
|
+
function buildDmkTrackRegion(cyl, side, sectors, trackLen, doubled) {
|
|
229
|
+
const capacity = trackLen - DMK_IDAM_TABLE;
|
|
230
|
+
let laid = layDmkTrack(cyl, side, sectors, doubled, false);
|
|
231
|
+
if (laid.raw.length > capacity) laid = layDmkTrack(cyl, side, sectors, doubled, true);
|
|
232
|
+
if (laid.raw.length > capacity) {
|
|
233
|
+
throw new Error(`Formatted track needs ${laid.raw.length} bytes; DMK track holds ${capacity}`);
|
|
234
|
+
}
|
|
235
|
+
return dmkRegion(laid, trackLen);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export class DiskImage {
|
|
239
|
+
/**
|
|
240
|
+
* @param {Uint8Array} bytes - raw .dsk contents
|
|
241
|
+
* @param {string} name - display name (file name)
|
|
242
|
+
* @param {{ format?: "dmk" | "jv3" | "jv1", dirTrack?: number, jv1SdTracks?: readonly number[] }} [opts] - `jv1SdTracks`: the
|
|
243
|
+
* track counts a 10-sector JV1 may have (35, 40, 77, 80 by default; a Model I passes
|
|
244
|
+
* JV1_MODEL1_SD_TRACKS); for a save state's
|
|
245
|
+
* disk, whose container is known: `format` parses the bytes as that container only, without
|
|
246
|
+
* the detection's plausibility floor (a JV3 of fewer than ten sectors, which a format of a
|
|
247
|
+
* blank disk can leave, is a valid disk the session made); `dirTrack` restores a JV1's
|
|
248
|
+
* directory track as it was mounted (a guest rewrite of track 0, sector 0 changes byte 2, which
|
|
249
|
+
* is read only at mount).
|
|
250
|
+
*/
|
|
251
|
+
constructor(bytes, name = "disk", { format, dirTrack, jv1SdTracks = JV1_TRACKS } = {}) {
|
|
252
|
+
this.name = name;
|
|
253
|
+
this.bytes = new Uint8Array(bytes); // private working copy
|
|
254
|
+
this.writeProtected = false;
|
|
255
|
+
this.format = null; // "dmk" | "jv3" | "jv1"
|
|
256
|
+
this.sectorBase = null; // JV1: the first sector number, 0 or 1, from the geometry
|
|
257
|
+
|
|
258
|
+
if (format !== undefined) {
|
|
259
|
+
const parsed =
|
|
260
|
+
format === "dmk"
|
|
261
|
+
? this._tryParseDMK()
|
|
262
|
+
: format === "jv3"
|
|
263
|
+
? this._tryParseJV3(false)
|
|
264
|
+
: format === "jv1"
|
|
265
|
+
? this._tryParseJV1(JV1_MODEL1_SD_TRACKS) // a mounted JV1: any geometry a mount took
|
|
266
|
+
: false;
|
|
267
|
+
if (!parsed) throw new Error(`Disk image (${bytes.length} bytes) does not parse as ${String(format)}`);
|
|
268
|
+
this.format = format;
|
|
269
|
+
if (format === "jv1" && dirTrack !== undefined) this.dirTrack = dirTrack;
|
|
270
|
+
} else if (this._tryParseDMK()) {
|
|
271
|
+
this.format = "dmk";
|
|
272
|
+
} else if (this._tryParseJV3()) {
|
|
273
|
+
this.format = "jv3";
|
|
274
|
+
} else if (this._tryParseJV1(jv1SdTracks)) {
|
|
275
|
+
this.format = "jv1";
|
|
276
|
+
} else {
|
|
277
|
+
const sizes =
|
|
278
|
+
trackList(jv1SdTracks) === trackList(JV1_TRACKS)
|
|
279
|
+
? `${trackList(JV1_TRACKS)} tracks of ${JV1_SPT.join(" or ")} 256-byte sectors`
|
|
280
|
+
: `${trackList(JV1_TRACKS)} tracks of 18 256-byte sectors, or ${trackList(jv1SdTracks)} tracks of 10`;
|
|
281
|
+
throw new Error(
|
|
282
|
+
`Unrecognized disk image (${bytes.length} bytes): no DMK header, no JV3 sector table, and not a JV1 size (${sizes})`
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
_tryParseDMK() {
|
|
288
|
+
const b = this.bytes;
|
|
289
|
+
if (b.length < DMK_HEADER) return false;
|
|
290
|
+
const tracks = b[1];
|
|
291
|
+
const trackLen = b[2] | (b[3] << 8);
|
|
292
|
+
const flags = b[4];
|
|
293
|
+
// The header's defined values only: write protect FF or 00, flags 10, 40, 80; a track long
|
|
294
|
+
// enough for the IDAM table and short enough for its 14-bit offsets.
|
|
295
|
+
if (b[0] !== 0x00 && b[0] !== 0xff) return false;
|
|
296
|
+
if (flags & ~(DMK_SINGLE_SIDED | DMK_SD_SINGLE_BYTE | DMK_IGNORE_DENSITY)) return false;
|
|
297
|
+
if (trackLen < 0x100 || trackLen > 0x4000) return false;
|
|
298
|
+
if (b[12] | b[13] | b[14] | b[15]) return false; // real-drive marker
|
|
299
|
+
if (tracks > 96) return false;
|
|
300
|
+
// A DMK with no tracks is an unformatted disk (what toDMK writes for a blank one); it is
|
|
301
|
+
// taken as double-sided so a format of either side fits.
|
|
302
|
+
const sides = flags & DMK_SINGLE_SIDED ? 1 : 2;
|
|
303
|
+
if (b.length !== DMK_HEADER + tracks * sides * trackLen) return false;
|
|
304
|
+
// Every IDAM pointer before the table's end must point into its track.
|
|
305
|
+
for (let i = 0; i < tracks * sides; i++) {
|
|
306
|
+
const base = DMK_HEADER + i * trackLen;
|
|
307
|
+
for (let k = 0; k < DMK_IDAMS; k++) {
|
|
308
|
+
const ptr = b[base + k * 2] | (b[base + k * 2 + 1] << 8);
|
|
309
|
+
if (!ptr) break;
|
|
310
|
+
const off = ptr & 0x3fff;
|
|
311
|
+
if (off < DMK_IDAM_TABLE || off >= trackLen) return false;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
this.dmk = {
|
|
316
|
+
tracks,
|
|
317
|
+
trackLen,
|
|
318
|
+
sides,
|
|
319
|
+
sdSingleByte: (flags & DMK_SD_SINGLE_BYTE) !== 0,
|
|
320
|
+
ignoreDensity: (flags & DMK_IGNORE_DENSITY) !== 0,
|
|
321
|
+
};
|
|
322
|
+
this.writeProtected = b[0] === 0xff;
|
|
323
|
+
this._dmkCache = new Map();
|
|
324
|
+
return true;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
_dmkTrackBase(track, side) {
|
|
328
|
+
const { tracks, trackLen, sides } = this.dmk;
|
|
329
|
+
if (track >= tracks || side >= sides) return -1;
|
|
330
|
+
const index = sides === 1 ? track : track * 2 + side;
|
|
331
|
+
return DMK_HEADER + index * trackLen;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Parse (and cache) one DMK track's sector layout from its IDAMs. */
|
|
335
|
+
_dmkSectors(track, side) {
|
|
336
|
+
const key = `${track}:${side}`;
|
|
337
|
+
if (this._dmkCache.has(key)) return this._dmkCache.get(key);
|
|
338
|
+
const out = [];
|
|
339
|
+
const base = this._dmkTrackBase(track, side);
|
|
340
|
+
if (base >= 0) {
|
|
341
|
+
const { trackLen, sdSingleByte, ignoreDensity } = this.dmk;
|
|
342
|
+
const b = this.bytes;
|
|
343
|
+
for (let i = 0; i < DMK_IDAMS; i++) {
|
|
344
|
+
const ptr = b[base + i * 2] | (b[base + i * 2 + 1] << 8);
|
|
345
|
+
if (!ptr) break; // unused table entries are zero
|
|
346
|
+
const mfm = (ptr & 0x8000) !== 0;
|
|
347
|
+
const off = ptr & 0x3fff;
|
|
348
|
+
// FM bytes are stored twice unless the header says single bytes (flag 40) or density
|
|
349
|
+
// is ignored (flag 80, where every byte is stored once).
|
|
350
|
+
const step = mfm || sdSingleByte || ignoreDensity ? 1 : 2;
|
|
351
|
+
const at = (k) => (off + k * step < trackLen ? b[base + off + k * step] : -1);
|
|
352
|
+
if (at(0) !== 0xfe) continue;
|
|
353
|
+
const sector = at(3);
|
|
354
|
+
const size = 128 << (at(4) & 3);
|
|
355
|
+
// "Read Sector": the data address mark (F8-FB, "Control Bytes for Initialization") must
|
|
356
|
+
// be found within 30 bytes in FM and 43 in MFM of the last ID CRC byte. In MFM a mark
|
|
357
|
+
// is preceded by its A1 sync bytes.
|
|
358
|
+
let dataOff = -1;
|
|
359
|
+
let dam = 0xfb;
|
|
360
|
+
const limit = 6 + (mfm ? 43 : 30);
|
|
361
|
+
for (let k = 7; k <= limit; k++) {
|
|
362
|
+
const byte = at(k);
|
|
363
|
+
if (byte >= 0xf8 && byte <= 0xfb && (!mfm || at(k - 1) === 0xa1)) {
|
|
364
|
+
dataOff = off + (k + 1) * step;
|
|
365
|
+
dam = byte;
|
|
366
|
+
break;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
// An ID with no data mark in the window is still an ID: Read Address and a Type I verify
|
|
370
|
+
// read it, and Write Sector writes its data field after it (`_dmkWriteSector`), but "Read
|
|
371
|
+
// Sector" finds no data there: "the ID field is searched for and verified again", and
|
|
372
|
+
// after the search window, Record Not Found (FD179X data sheet, "Read Sector").
|
|
373
|
+
const sync = mfm ? [0xa1, 0xa1, 0xa1] : [];
|
|
374
|
+
const id = [0xfe, at(1), at(2), sector, at(4)];
|
|
375
|
+
const idCrcOk = crc16([...sync, ...id]) === ((at(5) << 8) | at(6));
|
|
376
|
+
if (dataOff < 0) {
|
|
377
|
+
out.push({
|
|
378
|
+
sector, size, dam: null, deleted: false, mfm: ignoreDensity ? null : mfm, recordedMfm: mfm, step, dataOff: -1,
|
|
379
|
+
off, idTrack: at(1), idSide: at(2), sizeCode: at(4) & 3,
|
|
380
|
+
idCrcOk, dataCrcOk: false, noData: true,
|
|
381
|
+
});
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
if (dataOff >= trackLen) continue;
|
|
385
|
+
// The ID and data fields' CRCs as the WD1793 checks them (FD179X data sheet, "Read
|
|
386
|
+
// Sector" and "Read Address"): CRC-CCITT preset FFFF over the address mark and the field,
|
|
387
|
+
// and in MFM over the three A1 sync bytes before the mark too.
|
|
388
|
+
// A field cut off by the end of the track reads as bad.
|
|
389
|
+
const dataAt = (k) => (dataOff + k * step < trackLen ? b[base + dataOff + k * step] : -1);
|
|
390
|
+
const field = [dam];
|
|
391
|
+
for (let k = 0; k < size; k++) field.push(dataAt(k));
|
|
392
|
+
const dataCrc = (dataAt(size) << 8) | dataAt(size + 1);
|
|
393
|
+
const dataCrcOk = !field.includes(-1) && dataAt(size + 1) >= 0 && crc16([...sync, ...field]) === dataCrc;
|
|
394
|
+
out.push({
|
|
395
|
+
sector, size, dam, deleted: isDeleted(dam), mfm: ignoreDensity ? null : mfm, recordedMfm: mfm, step, dataOff,
|
|
396
|
+
off, idTrack: at(1), idSide: at(2), sizeCode: at(4) & 3,
|
|
397
|
+
idCrcOk, dataCrcOk, noData: false,
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
this._dmkCache.set(key, out);
|
|
402
|
+
return out;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
_dmkExtend(newTrackCount) {
|
|
406
|
+
const { trackLen, sides } = this.dmk;
|
|
407
|
+
const grown = new Uint8Array(DMK_HEADER + newTrackCount * sides * trackLen);
|
|
408
|
+
grown.set(this.bytes, 0);
|
|
409
|
+
grown[1] = newTrackCount;
|
|
410
|
+
// Tracks not yet formatted: an empty IDAM table and gap bytes.
|
|
411
|
+
for (let i = this.dmk.tracks * sides; i < newTrackCount * sides; i++) {
|
|
412
|
+
grown.fill(0x4e, DMK_HEADER + i * trackLen + DMK_IDAM_TABLE, DMK_HEADER + (i + 1) * trackLen);
|
|
413
|
+
}
|
|
414
|
+
this.bytes = grown;
|
|
415
|
+
this.dmk.tracks = newTrackCount;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Read the JV3 header blocks. Each used entry gets its data's offset; free entries advance the
|
|
420
|
+
* offset by their own size (see the file comment). Garbage is refused: an entry with a track
|
|
421
|
+
* over 96 or a sector over 63, or data running past the end of the file, means it is no JV3.
|
|
422
|
+
* `detect` adds the floor detection needs (ten sectors, or a blank disk); a known JV3 skips it.
|
|
423
|
+
*/
|
|
424
|
+
_tryParseJV3(detect = true) {
|
|
425
|
+
const b = this.bytes;
|
|
426
|
+
if (b.length < JV3_HEADER_SIZE) return false;
|
|
427
|
+
|
|
428
|
+
const map = [];
|
|
429
|
+
let blockAt = 0;
|
|
430
|
+
for (let block = 0; block < 2; block++) {
|
|
431
|
+
if (block > 0 && b.length < blockAt + JV3_HEADER_SIZE) break;
|
|
432
|
+
let dataOffset = blockAt + JV3_HEADER_SIZE;
|
|
433
|
+
for (let i = 0; i < JV3_ENTRIES; i++) {
|
|
434
|
+
const at = blockAt + i * 3;
|
|
435
|
+
const track = b[at];
|
|
436
|
+
const sector = b[at + 1];
|
|
437
|
+
const flags = b[at + 2];
|
|
438
|
+
const free = track === JV3_FREE && sector === JV3_FREE;
|
|
439
|
+
const size = jv3SectorSize(flags, free);
|
|
440
|
+
if (!free) {
|
|
441
|
+
if (track > 96 || sector > 63) return false;
|
|
442
|
+
if (dataOffset + size > b.length) return false;
|
|
443
|
+
map.push({
|
|
444
|
+
track,
|
|
445
|
+
sector,
|
|
446
|
+
side: flags & F_SIDE ? 1 : 0,
|
|
447
|
+
offset: dataOffset,
|
|
448
|
+
size,
|
|
449
|
+
flagsAt: at + 2, // to update the flags byte on DAM-changing writes
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
dataOffset += size;
|
|
453
|
+
}
|
|
454
|
+
blockAt = dataOffset;
|
|
455
|
+
}
|
|
456
|
+
// A real disk has many sectors; an exactly-header-sized all-free
|
|
457
|
+
// image is a valid BLANK disk (DiskImage.blank, and its export /
|
|
458
|
+
// save-state round trip) waiting for a DOS FORMAT.
|
|
459
|
+
if (detect && map.length < 10 && !(map.length === 0 && b.length === JV3_HEADER_SIZE)) {
|
|
460
|
+
return false;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
this.jv3Map = map;
|
|
464
|
+
// Last header byte: 0xff = writable, anything else = write-protected
|
|
465
|
+
this.writeProtected = b[JV3_HEADER_SIZE - 1] !== 0xff;
|
|
466
|
+
return true;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** A blank, writable, unformatted disk — FORMAT makes it real. */
|
|
470
|
+
static blank(name = "blank.dsk") {
|
|
471
|
+
return new DiskImage(new Uint8Array(JV3_HEADER_SIZE).fill(0xff), name);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** @param {readonly number[]} [sdTracks] the track counts a 10-sector JV1 may have */
|
|
475
|
+
_tryParseJV1(sdTracks = JV1_TRACKS) {
|
|
476
|
+
// JV1 has no header, so its size is the only evidence: accept only
|
|
477
|
+
// a geometry a real disk has, 10 sectors per track (Model I single
|
|
478
|
+
// density) or 18 (Model III double density) on 35, 40, 77, or 80
|
|
479
|
+
// tracks (10-sector: `sdTracks`). Anything else (a .CMD, a text file)
|
|
480
|
+
// would otherwise mount as a short JV1 whose garbage the ROM then
|
|
481
|
+
// boots. No two of these sizes coincide (10-sector tracks 35-42, 77,
|
|
482
|
+
// 80 and 18-sector 35, 40, 77, 80), so the geometry is never ambiguous.
|
|
483
|
+
const size = this.bytes.length;
|
|
484
|
+
let chosen = null;
|
|
485
|
+
for (const spt of JV1_SPT) {
|
|
486
|
+
for (const tracks of spt === 10 ? sdTracks : JV1_TRACKS) {
|
|
487
|
+
if (size === tracks * spt * 256) chosen = { spt, tracks };
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
if (chosen === null) return false;
|
|
491
|
+
|
|
492
|
+
this.sectorsPerTrack = chosen.spt;
|
|
493
|
+
this.tracks = chosen.tracks;
|
|
494
|
+
// Model III 18-sector disks number sectors 1-18 (TRSDOS 1.3); Model I
|
|
495
|
+
// 10-sector disks number 0-9 (JV1 as Vavasour defined it).
|
|
496
|
+
this.sectorBase = chosen.spt === 18 ? 1 : 0;
|
|
497
|
+
// JV1 carries no DAM info, but DOSes verify the deleted mark on
|
|
498
|
+
// directory reads — synthesize it for the directory track the boot
|
|
499
|
+
// sector names (byte 2), as xtrs does for its fixed track 17. A
|
|
500
|
+
// 10-sector (single-density, Model I) disk's directory is FA: the
|
|
501
|
+
// Model I DOSes write data "STANDARD" and the directory "READ
|
|
502
|
+
// PROTECTED", the WD1771 write command A9H, a1 a0 = 01, FA (Super
|
|
503
|
+
// Utility Tech Manual, Breeze/QSD 1982, p. 20 "@WRTYPE" and p. 61;
|
|
504
|
+
// FD1771-01 data sheet, Table 4). The 1771 reports it as written
|
|
505
|
+
// (record type 01); the WD1793 model reads it as normal (isDeleted,
|
|
506
|
+
// above), as the Model III's 1793 does. An 18-sector
|
|
507
|
+
// disk's is F8, the only MFM deleted mark (Model III TRSDOS: A1H).
|
|
508
|
+
this.dirDam = chosen.spt === 10 ? 0xfa : 0xf8;
|
|
509
|
+
const bootDir = this.bytes[2];
|
|
510
|
+
this.dirTrack =
|
|
511
|
+
bootDir >= 1 && bootDir < this.tracks ? bootDir : DEFAULT_DIR_TRACK;
|
|
512
|
+
return true;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** Highest track number present (for FDC seek bounds). */
|
|
516
|
+
trackCount() {
|
|
517
|
+
if (this.format === "dmk") return this.dmk.tracks;
|
|
518
|
+
if (this.format === "jv1") return this.tracks;
|
|
519
|
+
if (this.jv3Map.length === 0) return 0; // blank disk
|
|
520
|
+
return Math.max(...this.jv3Map.map((e) => e.track)) + 1;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** Sides the image holds: a DMK's header says; a JV3 has two when any sector is on side 1. */
|
|
524
|
+
sideCount() {
|
|
525
|
+
if (this.format === "dmk") return this.dmk.sides;
|
|
526
|
+
if (this.format === "jv3" && this.jv3Map.some((e) => e.side === 1)) return 2;
|
|
527
|
+
return 1;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* The ID fields present on one track, in map order — what READ
|
|
532
|
+
* ADDRESS shows a verifying FORMAT. Size codes are the IBM ones the
|
|
533
|
+
* 1793 reports (0=128 ... 3=1024).
|
|
534
|
+
*/
|
|
535
|
+
sectorsOn(track, side) {
|
|
536
|
+
return this.trackIds(track, side).map((id) => ({ sector: id.sector, sizeCode: id.sizeCode }));
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** JV3 entries on one track side, in header order (rotational order). */
|
|
540
|
+
_jv3Entries(track, side) {
|
|
541
|
+
return this.jv3Map.filter((e) => e.track === track && e.side === side);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* The ID fields on one track side as the controller meets them in a revolution, for the WD1793's
|
|
546
|
+
* ID search (FD179X data sheet, "Type II Commands"): each with the ID's track, side, sector, and
|
|
547
|
+
* size code, its density (`mfm`: true, false, or null when the container does not record it, as
|
|
548
|
+
* in JV1), where its address mark passes as a fraction of a revolution from the index pulse
|
|
549
|
+
* (`pos`), how many bytes after that mark the first data byte comes (`dataGap`), whether its ID
|
|
550
|
+
* and data CRCs are good, its data address mark (`dam`, F8-FB) and record type, and its place in
|
|
551
|
+
* this list (`index`, which `readSectorEx` and `writeSector` take to reach this very ID when two
|
|
552
|
+
* share a sector number). DMK gives all of these from the raw track. JV1 and JV3 hold only
|
|
553
|
+
* sectors, so their IDs carry the physical track and side, are spaced evenly round the track in
|
|
554
|
+
* image order, and use the IBM gaps of the data sheet's "IBM Track Format" (an ID field of 7
|
|
555
|
+
* bytes, then Gap II, the sync zeros, the A1s in MFM, and the data mark: 45 bytes in MFM, 25 in
|
|
556
|
+
* FM). JV3's flag bit 3 marks a data CRC error. A DMK ID with no data mark within the data
|
|
557
|
+
* sheet's window has `noData` true, `dataGap` and `dam` null, and a bad data CRC.
|
|
558
|
+
* @returns {{track:number, side:number, sector:number, sizeCode:number, size:number,
|
|
559
|
+
* mfm:boolean|null, pos:number, dataGap:number|null, idCrcOk:boolean, dataCrcOk:boolean,
|
|
560
|
+
* dam:number|null, deleted:boolean, index:number, noData?:boolean}[]}
|
|
561
|
+
*/
|
|
562
|
+
trackIds(track, side) {
|
|
563
|
+
const gap = (mfm) => (mfm === false ? 25 : 45);
|
|
564
|
+
if (this.format === "dmk") {
|
|
565
|
+
const raw = this.dmk.trackLen - DMK_IDAM_TABLE;
|
|
566
|
+
return this._dmkSectors(track, side).map((s, index) => ({
|
|
567
|
+
track: s.idTrack,
|
|
568
|
+
side: s.idSide,
|
|
569
|
+
sector: s.sector,
|
|
570
|
+
sizeCode: s.sizeCode,
|
|
571
|
+
size: s.size,
|
|
572
|
+
mfm: s.mfm,
|
|
573
|
+
pos: (s.off - DMK_IDAM_TABLE) / raw,
|
|
574
|
+
dataGap: s.noData ? null : (s.dataOff - s.off) / s.step,
|
|
575
|
+
idCrcOk: s.idCrcOk,
|
|
576
|
+
dataCrcOk: s.dataCrcOk,
|
|
577
|
+
dam: s.dam,
|
|
578
|
+
deleted: s.deleted,
|
|
579
|
+
index,
|
|
580
|
+
noData: s.noData,
|
|
581
|
+
}));
|
|
582
|
+
}
|
|
583
|
+
if (this.format === "jv1") {
|
|
584
|
+
if (side !== 0 || track >= this.tracks) return [];
|
|
585
|
+
const n = this.sectorsPerTrack;
|
|
586
|
+
const dam = track === this.dirTrack ? this.dirDam : 0xfb;
|
|
587
|
+
return Array.from({ length: n }, (_, i) => ({
|
|
588
|
+
track,
|
|
589
|
+
side,
|
|
590
|
+
sector: this.sectorBase + i,
|
|
591
|
+
sizeCode: 1,
|
|
592
|
+
size: 256,
|
|
593
|
+
mfm: null,
|
|
594
|
+
pos: (i + 0.5) / n,
|
|
595
|
+
dataGap: 45,
|
|
596
|
+
idCrcOk: true,
|
|
597
|
+
dataCrcOk: true,
|
|
598
|
+
dam,
|
|
599
|
+
deleted: isDeleted(dam),
|
|
600
|
+
index: i,
|
|
601
|
+
}));
|
|
602
|
+
}
|
|
603
|
+
const entries = this._jv3Entries(track, side);
|
|
604
|
+
return entries.map((e, i) => {
|
|
605
|
+
const flags = this.bytes[e.flagsAt];
|
|
606
|
+
const mfm = (flags & F_DD) !== 0;
|
|
607
|
+
const dam = jv3Dam(flags);
|
|
608
|
+
return {
|
|
609
|
+
track,
|
|
610
|
+
side,
|
|
611
|
+
sector: e.sector,
|
|
612
|
+
sizeCode: SIZE_CODE[e.size] ?? 1,
|
|
613
|
+
size: e.size,
|
|
614
|
+
mfm,
|
|
615
|
+
pos: (i + 0.5) / entries.length,
|
|
616
|
+
dataGap: gap(mfm),
|
|
617
|
+
idCrcOk: true,
|
|
618
|
+
dataCrcOk: (flags & F_CRC_ERROR) === 0,
|
|
619
|
+
dam,
|
|
620
|
+
deleted: isDeleted(dam),
|
|
621
|
+
index: i,
|
|
622
|
+
};
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* A DMK track side's raw bytes (what READ TRACK returns), or null for the sector containers,
|
|
628
|
+
* which keep no raw track. `doubled` says single-density bytes are stored twice.
|
|
629
|
+
* @returns {{bytes: Uint8Array, doubled: boolean}|null}
|
|
630
|
+
*/
|
|
631
|
+
rawTrack(track, side) {
|
|
632
|
+
if (this.format !== "dmk") return null;
|
|
633
|
+
const base = this._dmkTrackBase(track, side);
|
|
634
|
+
if (base < 0) return null;
|
|
635
|
+
return {
|
|
636
|
+
bytes: this.bytes.slice(base + DMK_IDAM_TABLE, base + this.dmk.trackLen),
|
|
637
|
+
doubled: !this.dmk.sdSingleByte && !this.dmk.ignoreDensity,
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Find a sector's byte range in a JV1 or JV3: by its place in the track's ID list when `index`
|
|
643
|
+
* is given (and its sector number matches), else the first ID with that sector number.
|
|
644
|
+
* @returns {{offset:number, size:number, entry:object|null}|null}
|
|
645
|
+
*/
|
|
646
|
+
_locate(track, side, sector, index) {
|
|
647
|
+
if (this.format === "jv3") {
|
|
648
|
+
const entries = this._jv3Entries(track, side);
|
|
649
|
+
const hit =
|
|
650
|
+
index !== undefined
|
|
651
|
+
? entries[index]?.sector === sector
|
|
652
|
+
? entries[index]
|
|
653
|
+
: undefined
|
|
654
|
+
: entries.find((e) => e.sector === sector);
|
|
655
|
+
return hit ? { offset: hit.offset, size: hit.size, entry: hit } : null;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
if (side !== 0 || track >= this.tracks) return null; // JV1 is single-sided
|
|
659
|
+
const i = sector - this.sectorBase;
|
|
660
|
+
if (i < 0 || i >= this.sectorsPerTrack) return null;
|
|
661
|
+
if (index !== undefined && index !== i) return null;
|
|
662
|
+
return { offset: (track * this.sectorsPerTrack + i) * 256, size: 256, entry: null };
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* A DMK track's parsed sector: by list place when `index` is given, else by sector number, an ID
|
|
667
|
+
* with a data field first.
|
|
668
|
+
*/
|
|
669
|
+
_dmkHit(track, side, sector, index) {
|
|
670
|
+
const list = this._dmkSectors(track, side);
|
|
671
|
+
if (index !== undefined) return list[index]?.sector === sector ? list[index] : undefined;
|
|
672
|
+
return list.find((s) => s.sector === sector && !s.noData) ?? list.find((s) => s.sector === sector);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* Read a sector with its record type, the way the FDC sees it.
|
|
677
|
+
* @param {number} [index] the ID's place in `trackIds(track, side)`, for a track where two IDs
|
|
678
|
+
* carry the same sector number
|
|
679
|
+
* @returns {{data: Uint8Array, deleted: boolean, dam: number}|null}
|
|
680
|
+
*/
|
|
681
|
+
readSectorEx(track, side, sector, index) {
|
|
682
|
+
if (this.format === "dmk") {
|
|
683
|
+
const hit = this._dmkHit(track, side, sector, index);
|
|
684
|
+
if (!hit || hit.noData) return null;
|
|
685
|
+
const base = this._dmkTrackBase(track, side);
|
|
686
|
+
const { trackLen } = this.dmk;
|
|
687
|
+
const data = new Uint8Array(hit.size);
|
|
688
|
+
for (let k = 0; k < hit.size; k++) {
|
|
689
|
+
const at = hit.dataOff + k * hit.step;
|
|
690
|
+
data[k] = at < trackLen ? this.bytes[base + at] : 0;
|
|
691
|
+
}
|
|
692
|
+
return { data, deleted: hit.deleted, dam: hit.dam };
|
|
693
|
+
}
|
|
694
|
+
const loc = this._locate(track, side, sector, index);
|
|
695
|
+
if (!loc) return null;
|
|
696
|
+
const dam = loc.entry
|
|
697
|
+
? jv3Dam(this.bytes[loc.entry.flagsAt])
|
|
698
|
+
: track === this.dirTrack
|
|
699
|
+
? this.dirDam
|
|
700
|
+
: 0xfb;
|
|
701
|
+
return {
|
|
702
|
+
data: this.bytes.slice(loc.offset, loc.offset + loc.size),
|
|
703
|
+
deleted: isDeleted(dam),
|
|
704
|
+
dam,
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/** @returns {Uint8Array|null} a copy of the sector data */
|
|
709
|
+
readSector(track, side, sector) {
|
|
710
|
+
return this.readSectorEx(track, side, sector)?.data ?? null;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* Replace one track's contents with freshly formatted sectors (the
|
|
715
|
+
* tail end of a WD1793 WRITE TRACK). A DMK track is laid out again in
|
|
716
|
+
* each sector's density. JV3 images rebuild their bytes wholesale —
|
|
717
|
+
* `bytes` stays the exportable/serializable truth. A JV1 whose
|
|
718
|
+
* geometry matches writes in place; one that doesn't (new track
|
|
719
|
+
* count, sector numbering, or size) is promoted to JV3 in memory,
|
|
720
|
+
* keeping every existing sector.
|
|
721
|
+
*
|
|
722
|
+
* @param {{sector:number, size?:number, deleted?:boolean, dam?:number,
|
|
723
|
+
* data:Uint8Array, mfm?:boolean, track?:number, side?:number}[]} sectors
|
|
724
|
+
* @returns {boolean} true if the track was written
|
|
725
|
+
*/
|
|
726
|
+
formatTrack(track, side, sectors) {
|
|
727
|
+
if (this.writeProtected) return false;
|
|
728
|
+
if (this.format === "dmk") {
|
|
729
|
+
if (side >= this.dmk.sides) return false;
|
|
730
|
+
if (track >= this.dmk.tracks) this._dmkExtend(track + 1);
|
|
731
|
+
const doubled = !this.dmk.sdSingleByte && !this.dmk.ignoreDensity;
|
|
732
|
+
const region = buildDmkTrackRegion(track, side, sectors, this.dmk.trackLen, doubled);
|
|
733
|
+
this.bytes.set(region, this._dmkTrackBase(track, side));
|
|
734
|
+
this._dmkCache.delete(`${track}:${side}`);
|
|
735
|
+
return true;
|
|
736
|
+
}
|
|
737
|
+
if (this.format === "jv1") {
|
|
738
|
+
if (this._jv1FormatInPlace(track, side, sectors)) return true;
|
|
739
|
+
this._promoteToJV3();
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// Keep every other track's entries with their original flags bytes
|
|
743
|
+
const kept = this.jv3Map
|
|
744
|
+
.filter((e) => !(e.track === track && e.side === side))
|
|
745
|
+
.map((e) => ({
|
|
746
|
+
track: e.track,
|
|
747
|
+
sector: e.sector,
|
|
748
|
+
flags: this.bytes[e.flagsAt],
|
|
749
|
+
data: this.bytes.slice(e.offset, e.offset + e.size),
|
|
750
|
+
}));
|
|
751
|
+
|
|
752
|
+
const fresh = sectors.map((s) => {
|
|
753
|
+
const mfm = s.mfm !== false;
|
|
754
|
+
const size = s.size ?? s.data.length;
|
|
755
|
+
let flags = JV3_SIZE_CODE[size] ?? 0x00;
|
|
756
|
+
if (side) flags |= F_SIDE;
|
|
757
|
+
if (mfm) flags |= F_DD;
|
|
758
|
+
flags |= jv3DamBits(s.dam ?? (s.deleted ? 0xf8 : 0xfb), mfm);
|
|
759
|
+
return {
|
|
760
|
+
track,
|
|
761
|
+
sector: s.sector,
|
|
762
|
+
flags,
|
|
763
|
+
data: new Uint8Array(s.data.subarray(0, size)),
|
|
764
|
+
};
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
this._rebuildJV3([...kept, ...fresh]);
|
|
768
|
+
return true;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/** Write into the raw track bytes in place, CRCs recomputed; `mark` is the data mark, F8-FB. */
|
|
772
|
+
_dmkWriteSector(track, side, sector, data, mark, index) {
|
|
773
|
+
const hit = this._dmkHit(track, side, sector, index);
|
|
774
|
+
if (!hit) return false;
|
|
775
|
+
const base = this._dmkTrackBase(track, side);
|
|
776
|
+
const { trackLen } = this.dmk;
|
|
777
|
+
const put = (rawOff, v) => {
|
|
778
|
+
if (rawOff < trackLen) this.bytes[base + rawOff] = v;
|
|
779
|
+
if (hit.step === 2 && rawOff + 1 < trackLen) this.bytes[base + rawOff + 1] = v;
|
|
780
|
+
};
|
|
781
|
+
|
|
782
|
+
let dataOff = hit.dataOff;
|
|
783
|
+
if (hit.noData) {
|
|
784
|
+
// No data field yet: lay it where Write Sector puts it ("Write Sector"): after the ID's
|
|
785
|
+
// seven bytes, 22 bytes (MFM) or 11 (FM) of Gap II, then 12 or 6 zeros, the A1s in MFM, and
|
|
786
|
+
// the mark.
|
|
787
|
+
const mfm = hit.recordedMfm;
|
|
788
|
+
let k = 7 + (mfm ? 22 : 11);
|
|
789
|
+
for (let z = 0; z < (mfm ? 12 : 6); z++) put(hit.off + k++ * hit.step, 0x00);
|
|
790
|
+
if (mfm) for (let z = 0; z < 3; z++) put(hit.off + k++ * hit.step, 0xa1);
|
|
791
|
+
dataOff = hit.off + (k + 1) * hit.step;
|
|
792
|
+
}
|
|
793
|
+
put(dataOff - hit.step, mark);
|
|
794
|
+
const payload = new Uint8Array(hit.size);
|
|
795
|
+
for (let k = 0; k < hit.size; k++) {
|
|
796
|
+
payload[k] = data[k] ?? 0x00;
|
|
797
|
+
put(dataOff + k * hit.step, payload[k]);
|
|
798
|
+
}
|
|
799
|
+
const crc = crc16(hit.recordedMfm ? [0xa1, 0xa1, 0xa1, mark, ...payload] : [mark, ...payload]);
|
|
800
|
+
put(dataOff + hit.size * hit.step, crc >> 8);
|
|
801
|
+
put(dataOff + (hit.size + 1) * hit.step, crc & 0xff);
|
|
802
|
+
this._dmkCache.delete(`${track}:${side}`);
|
|
803
|
+
return true;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/** True (and written) when the layout matches this JV1's geometry. */
|
|
807
|
+
_jv1FormatInPlace(track, side, sectors) {
|
|
808
|
+
if (side !== 0 || track >= this.tracks) return false;
|
|
809
|
+
if (sectors.length !== this.sectorsPerTrack) return false;
|
|
810
|
+
const base = this.sectorBase;
|
|
811
|
+
const sorted = [...sectors].sort((a, b) => a.sector - b.sector);
|
|
812
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
813
|
+
const size = sorted[i].size ?? sorted[i].data.length;
|
|
814
|
+
if (sorted[i].sector !== base + i || size !== 256) return false;
|
|
815
|
+
}
|
|
816
|
+
for (const s of sorted) {
|
|
817
|
+
const offset = (track * this.sectorsPerTrack + (s.sector - base)) * 256;
|
|
818
|
+
this.bytes.set(s.data.subarray(0, 256), offset);
|
|
819
|
+
}
|
|
820
|
+
return true;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/** Convert this JV1 into an equivalent in-memory JV3. */
|
|
824
|
+
_promoteToJV3() {
|
|
825
|
+
const base = this.sectorBase;
|
|
826
|
+
const dd = this.sectorsPerTrack === 18; // Model III 18-sector = DD
|
|
827
|
+
const entries = [];
|
|
828
|
+
for (let t = 0; t < this.tracks; t++) {
|
|
829
|
+
for (let i = 0; i < this.sectorsPerTrack; i++) {
|
|
830
|
+
let flags = 0x00; // size code 0 = 256 bytes
|
|
831
|
+
if (dd) flags |= F_DD;
|
|
832
|
+
if (t === this.dirTrack) flags |= jv3DamBits(this.dirDam, dd); // keep the DAM synthesis
|
|
833
|
+
const offset = (t * this.sectorsPerTrack + i) * 256;
|
|
834
|
+
entries.push({
|
|
835
|
+
track: t,
|
|
836
|
+
sector: base + i,
|
|
837
|
+
flags,
|
|
838
|
+
data: this.bytes.slice(offset, offset + 256),
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
this._rebuildJV3(entries);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* Rebuild bytes + map from {track, sector, flags, data} entries: one header block, or two when
|
|
847
|
+
* there are more than 2,901 sectors.
|
|
848
|
+
*/
|
|
849
|
+
_rebuildJV3(entries) {
|
|
850
|
+
if (entries.length > 2 * JV3_ENTRIES) {
|
|
851
|
+
throw new Error(`Disk needs ${entries.length} JV3 entries (max ${2 * JV3_ENTRIES})`);
|
|
852
|
+
}
|
|
853
|
+
const blocks = [entries.slice(0, JV3_ENTRIES)];
|
|
854
|
+
if (entries.length > JV3_ENTRIES) blocks.push(entries.slice(JV3_ENTRIES));
|
|
855
|
+
let total = 0;
|
|
856
|
+
for (const block of blocks) {
|
|
857
|
+
total += JV3_HEADER_SIZE;
|
|
858
|
+
for (const e of block) total += e.data.length;
|
|
859
|
+
}
|
|
860
|
+
// A block that is followed by another must own the data of all its entries, so the next
|
|
861
|
+
// block starts where a reader expects it: the first block is full when there are two.
|
|
862
|
+
const bytes = new Uint8Array(total);
|
|
863
|
+
const map = [];
|
|
864
|
+
let at = 0;
|
|
865
|
+
for (const block of blocks) {
|
|
866
|
+
bytes.fill(0xff, at, at + JV3_HEADER_SIZE);
|
|
867
|
+
let offset = at + JV3_HEADER_SIZE;
|
|
868
|
+
block.forEach((e, i) => {
|
|
869
|
+
bytes[at + i * 3] = e.track;
|
|
870
|
+
bytes[at + i * 3 + 1] = e.sector;
|
|
871
|
+
bytes[at + i * 3 + 2] = e.flags;
|
|
872
|
+
bytes.set(e.data, offset);
|
|
873
|
+
map.push({
|
|
874
|
+
track: e.track,
|
|
875
|
+
sector: e.sector,
|
|
876
|
+
side: e.flags & F_SIDE ? 1 : 0,
|
|
877
|
+
offset,
|
|
878
|
+
size: e.data.length,
|
|
879
|
+
flagsAt: at + i * 3 + 2,
|
|
880
|
+
});
|
|
881
|
+
offset += e.data.length;
|
|
882
|
+
});
|
|
883
|
+
at = offset;
|
|
884
|
+
}
|
|
885
|
+
this.bytes = bytes;
|
|
886
|
+
this.jv3Map = map;
|
|
887
|
+
this.format = "jv3";
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/**
|
|
891
|
+
* @param {{deleted?: boolean, dam?: number, index?: number}} [opts] - record type to
|
|
892
|
+
* write (WD1793 write-sector bit a0: F8 or FB; `dam`, when given, the
|
|
893
|
+
* mark itself, F8-FB, as the WD1771's a1 a0 choose it). JV3 stores it in the
|
|
894
|
+
* header flags — in the image bytes, so exports keep it — and clears
|
|
895
|
+
* the CRC error flag, since the write leaves a good CRC; JV1 has
|
|
896
|
+
* nowhere to put one and keeps synthesizing by directory track.
|
|
897
|
+
* `index` picks the ID by its place in `trackIds`, as `readSectorEx`.
|
|
898
|
+
* @returns {boolean} true if written
|
|
899
|
+
*/
|
|
900
|
+
writeSector(track, side, sector, data, { deleted = false, dam, index } = {}) {
|
|
901
|
+
if (this.writeProtected) return false;
|
|
902
|
+
const mark = dam ?? (deleted ? 0xf8 : 0xfb);
|
|
903
|
+
if (this.format === "dmk") {
|
|
904
|
+
return this._dmkWriteSector(track, side, sector, data, mark, index);
|
|
905
|
+
}
|
|
906
|
+
const loc = this._locate(track, side, sector, index);
|
|
907
|
+
if (!loc) return false;
|
|
908
|
+
this.bytes.set(data.subarray(0, loc.size), loc.offset);
|
|
909
|
+
const entry = loc.entry;
|
|
910
|
+
if (entry) {
|
|
911
|
+
const flags = this.bytes[entry.flagsAt] & ~(F_DAM | F_CRC_ERROR);
|
|
912
|
+
this.bytes[entry.flagsAt] = flags | jv3DamBits(mark, (flags & F_DD) !== 0);
|
|
913
|
+
}
|
|
914
|
+
return true;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/**
|
|
918
|
+
* The image as a DMK file (spec §6: write DMK only). A DMK comes back as its own bytes, with
|
|
919
|
+
* the header's write-protect byte following `writeProtected`. A JV1 or JV3 is laid out track by
|
|
920
|
+
* track from `trackIds`: each ID in its order, with its track, side, sector, and size code, its
|
|
921
|
+
* density, its data mark, and its CRCs (a JV3 CRC error stays an error), at the usual 5.25"
|
|
922
|
+
* double-density track length (`0x1900`, longer only if a track needs it). JV1 records no
|
|
923
|
+
* density, so its 10-sector tracks are written in FM and its 18-sector ones in MFM, the
|
|
924
|
+
* densities those geometries are recorded in. A disk with no formatted track (a blank JV3)
|
|
925
|
+
* becomes a DMK with no tracks, which a format extends.
|
|
926
|
+
* @returns {Uint8Array}
|
|
927
|
+
*/
|
|
928
|
+
toDMK() {
|
|
929
|
+
if (this.format === "dmk") {
|
|
930
|
+
const out = this.bytes.slice();
|
|
931
|
+
out[0] = this.writeProtected ? 0xff : 0x00;
|
|
932
|
+
return out;
|
|
933
|
+
}
|
|
934
|
+
const tracks = this.trackCount();
|
|
935
|
+
const sides = tracks === 0 ? 2 : this.sideCount();
|
|
936
|
+
const jv1Mfm = this.format === "jv1" ? this.sectorsPerTrack === 18 : true;
|
|
937
|
+
const laid = [];
|
|
938
|
+
for (let t = 0; t < tracks; t++) {
|
|
939
|
+
for (let side = 0; side < sides; side++) {
|
|
940
|
+
const sectors = this.trackIds(t, side).map((id) => ({
|
|
941
|
+
track: id.track,
|
|
942
|
+
side: id.side,
|
|
943
|
+
sector: id.sector,
|
|
944
|
+
size: id.size,
|
|
945
|
+
sizeCode: id.sizeCode,
|
|
946
|
+
mfm: id.mfm ?? jv1Mfm,
|
|
947
|
+
dam: id.dam,
|
|
948
|
+
idCrcOk: id.idCrcOk,
|
|
949
|
+
dataCrcOk: id.dataCrcOk,
|
|
950
|
+
data: this.readSectorEx(t, side, id.sector, id.index)?.data ?? new Uint8Array(id.size),
|
|
951
|
+
}));
|
|
952
|
+
laid.push(layDmkTrack(t, side, sectors, true, false));
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
const trackLen = Math.max(DMK_TRACK_LEN, DMK_IDAM_TABLE + Math.max(0, ...laid.map((l) => l.raw.length)));
|
|
956
|
+
const out = new Uint8Array(DMK_HEADER + laid.length * trackLen);
|
|
957
|
+
out[0] = this.writeProtected ? 0xff : 0x00;
|
|
958
|
+
out[1] = tracks;
|
|
959
|
+
out[2] = trackLen & 0xff;
|
|
960
|
+
out[3] = trackLen >> 8;
|
|
961
|
+
out[4] = sides === 1 ? DMK_SINGLE_SIDED : 0x00;
|
|
962
|
+
laid.forEach((l, i) => out.set(dmkRegion(l, trackLen), DMK_HEADER + i * trackLen));
|
|
963
|
+
return out;
|
|
964
|
+
}
|
|
965
|
+
}
|