@fugloy/emu 0.0.0-stage → 0.2.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 +119 -2
- package/package.json +30 -3
- package/src/cpu/z80cpu.js +3343 -0
- package/src/debug/debugger.js +777 -0
- package/src/debug/instr.js +90 -0
- package/src/debug/label-file.js +423 -0
- package/src/debug/state.js +214 -0
- package/src/devices/cassette-tape.js +254 -0
- package/src/devices/cassette.js +259 -0
- package/src/devices/disk-image.js +578 -0
- package/src/devices/fdc-wd1793.js +466 -0
- package/src/devices/key-queue.js +261 -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 +66 -0
- package/src/index.js +22 -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 +135 -0
- package/src/machine/io.js +290 -0
- package/src/machine/machine.js +295 -0
- package/src/machine/memory.js +207 -0
- package/src/machine/rom-hashes.js +14 -0
- package/src/machine/sha256.js +74 -0
- package/src/machine/trs80-system.js +232 -0
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TRS-80 disk image reader/writer (in-memory)
|
|
3
|
+
*
|
|
4
|
+
* Three containers:
|
|
5
|
+
*
|
|
6
|
+
* JV1 — headerless linear dump: track 0 sector 0.. then track 1...,
|
|
7
|
+
* 256-byte sectors. Model I images use 10 sectors/track (single
|
|
8
|
+
* density); Model III TRSDOS 1.3-style images use 18. Geometry is
|
|
9
|
+
* inferred from the file size.
|
|
10
|
+
*
|
|
11
|
+
* JV3 — 2901 three-byte sector headers (track, sector, flags) + one
|
|
12
|
+
* write-protect byte, followed by sector data in header order.
|
|
13
|
+
* Free entries are 0xFF-filled.
|
|
14
|
+
*
|
|
15
|
+
* DMK — 16-byte header (write-protect, track count, track length LE,
|
|
16
|
+
* flags) then raw track bytes behind a 128-byte IDAM pointer
|
|
17
|
+
* table per track (pointer = offset of the 0xFE ID byte from
|
|
18
|
+
* track start, bit 15 = MFM). Supported here: single- and
|
|
19
|
+
* double-sided, DD tracks and doubled-byte SD; freshly
|
|
20
|
+
* formatted tracks are always written as DD raw. Real-drive
|
|
21
|
+
* passthrough images (nonzero bytes 12-15) are rejected.
|
|
22
|
+
*
|
|
23
|
+
* Writes modify the in-memory image only (session-local); `bytes` is
|
|
24
|
+
* always the exportable / serializable truth for every container.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const JV3_ENTRIES = 2901;
|
|
28
|
+
const JV3_HEADER_SIZE = JV3_ENTRIES * 3 + 1;
|
|
29
|
+
const JV3_FREE = 0xff;
|
|
30
|
+
|
|
31
|
+
// JV3 flag bits
|
|
32
|
+
const F_SIDE = 0x10;
|
|
33
|
+
const F_DAM = 0x60; // data address mark code (0x00 = normal 0xFB)
|
|
34
|
+
|
|
35
|
+
function jv3SectorSize(flags, free) {
|
|
36
|
+
// Size code meaning flips between used and free entries; used shown here
|
|
37
|
+
const code = flags & 0x03;
|
|
38
|
+
if (free) {
|
|
39
|
+
return [512, 1024, 128, 256][code];
|
|
40
|
+
}
|
|
41
|
+
return [256, 128, 1024, 512][code];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The WD1793 reports any non-0xFB mark as a deleted record (status bit
|
|
45
|
+
// 5). DOSes rely on it: directory sectors are written 0xF8 (or 0xFA on
|
|
46
|
+
// Model I TRSDOS) and verified on every directory read.
|
|
47
|
+
function jv3Deleted(flags) {
|
|
48
|
+
return (flags & F_DAM) !== 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// TRSDOS-family boot sectors name the directory cylinder in byte 2.
|
|
52
|
+
const DEFAULT_DIR_TRACK = 17;
|
|
53
|
+
|
|
54
|
+
// ---------------------------- DMK ----------------------------------
|
|
55
|
+
const DMK_HEADER = 16;
|
|
56
|
+
const DMK_IDAM_TABLE = 128;
|
|
57
|
+
|
|
58
|
+
/** CRC-CCITT, the checksum the WD1793 writes after ID and data fields. */
|
|
59
|
+
function crc16(bytes, init = 0xffff) {
|
|
60
|
+
let crc = init;
|
|
61
|
+
for (const b of bytes) {
|
|
62
|
+
crc ^= b << 8;
|
|
63
|
+
for (let i = 0; i < 8; i++) {
|
|
64
|
+
crc = crc & 0x8000 ? ((crc << 1) ^ 0x1021) & 0xffff : (crc << 1) & 0xffff;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return crc;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Build one canonical DD raw track (IDAM table + track bytes) from
|
|
72
|
+
* formatted sectors. Falls back to minimal gaps when the standard IBM
|
|
73
|
+
* layout wouldn't fit the image's track length.
|
|
74
|
+
*/
|
|
75
|
+
function buildDmkTrackRegion(cyl, side, sectors, trackLen) {
|
|
76
|
+
const rawCapacity = trackLen - DMK_IDAM_TABLE;
|
|
77
|
+
const perSector = (gap) => gap + 12 + 3 + 7 + 22 + 12 + 3 + 1 + 2;
|
|
78
|
+
const need = (gap) =>
|
|
79
|
+
48 + sectors.reduce((sum, s) => sum + perSector(gap) + (s.size ?? s.data.length), 0);
|
|
80
|
+
let gap = 24;
|
|
81
|
+
if (need(gap) > rawCapacity) gap = 8;
|
|
82
|
+
if (need(gap) > rawCapacity) {
|
|
83
|
+
throw new Error(`Formatted track needs ${need(gap)} bytes; DMK track holds ${rawCapacity}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const raw = [];
|
|
87
|
+
const idamPtrs = [];
|
|
88
|
+
const push = (...b) => raw.push(...b);
|
|
89
|
+
for (let i = 0; i < 32; i++) push(0x4e);
|
|
90
|
+
for (let i = 0; i < 12; i++) push(0x00);
|
|
91
|
+
push(0xc2, 0xc2, 0xc2, 0xfc);
|
|
92
|
+
for (const s of sectors) {
|
|
93
|
+
const size = s.size ?? s.data.length;
|
|
94
|
+
const sizeCode = { 128: 0, 256: 1, 512: 2, 1024: 3 }[size] ?? 1;
|
|
95
|
+
for (let i = 0; i < gap; i++) push(0x4e);
|
|
96
|
+
for (let i = 0; i < 12; i++) push(0x00);
|
|
97
|
+
push(0xa1, 0xa1, 0xa1);
|
|
98
|
+
idamPtrs.push((DMK_IDAM_TABLE + raw.length) | 0x8000);
|
|
99
|
+
const id = [0xfe, s.track ?? cyl, s.side ?? side, s.sector, sizeCode];
|
|
100
|
+
push(...id);
|
|
101
|
+
const idCrc = crc16([0xa1, 0xa1, 0xa1, ...id]);
|
|
102
|
+
push(idCrc >> 8, idCrc & 0xff);
|
|
103
|
+
for (let i = 0; i < 22; i++) push(0x4e);
|
|
104
|
+
for (let i = 0; i < 12; i++) push(0x00);
|
|
105
|
+
push(0xa1, 0xa1, 0xa1);
|
|
106
|
+
const mark = s.deleted ? 0xf8 : 0xfb;
|
|
107
|
+
push(mark);
|
|
108
|
+
const data = s.data.subarray ? s.data.subarray(0, size) : s.data.slice(0, size);
|
|
109
|
+
for (const b of data) push(b);
|
|
110
|
+
const dCrc = crc16([0xa1, 0xa1, 0xa1, mark, ...data]);
|
|
111
|
+
push(dCrc >> 8, dCrc & 0xff);
|
|
112
|
+
}
|
|
113
|
+
while (raw.length < rawCapacity) push(0x4e);
|
|
114
|
+
|
|
115
|
+
const region = new Uint8Array(trackLen);
|
|
116
|
+
idamPtrs.forEach((p, i) => {
|
|
117
|
+
region[i * 2] = p & 0xff;
|
|
118
|
+
region[i * 2 + 1] = p >> 8;
|
|
119
|
+
});
|
|
120
|
+
region.set(raw.slice(0, rawCapacity), DMK_IDAM_TABLE);
|
|
121
|
+
return region;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export class DiskImage {
|
|
125
|
+
/**
|
|
126
|
+
* @param {Uint8Array} bytes - raw .dsk contents
|
|
127
|
+
* @param {string} name - display name (file name)
|
|
128
|
+
*/
|
|
129
|
+
constructor(bytes, name = "disk") {
|
|
130
|
+
this.name = name;
|
|
131
|
+
this.bytes = new Uint8Array(bytes); // private working copy
|
|
132
|
+
this.writeProtected = false;
|
|
133
|
+
this.format = null; // "jv3" | "jv1"
|
|
134
|
+
this.sectorBase = null; // JV1: 0 or 1, learned on first hit
|
|
135
|
+
|
|
136
|
+
if (this._tryParseDMK()) {
|
|
137
|
+
this.format = "dmk";
|
|
138
|
+
} else if (this._tryParseJV3()) {
|
|
139
|
+
this.format = "jv3";
|
|
140
|
+
} else if (this._tryParseJV1()) {
|
|
141
|
+
this.format = "jv1";
|
|
142
|
+
} else {
|
|
143
|
+
throw new Error(
|
|
144
|
+
`Unrecognized disk image (${bytes.length} bytes): not DMK, not JV3, and not a multiple of 256-byte sectors`
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
_tryParseDMK() {
|
|
150
|
+
const b = this.bytes;
|
|
151
|
+
if (b.length < DMK_HEADER + 0x100) return false;
|
|
152
|
+
const tracks = b[1];
|
|
153
|
+
const trackLen = b[2] | (b[3] << 8);
|
|
154
|
+
const flags = b[4];
|
|
155
|
+
if (tracks < 1 || tracks > 96) return false;
|
|
156
|
+
if (trackLen < 0x100 || trackLen > 0x4000) return false;
|
|
157
|
+
if (b[12] | b[13] | b[14] | b[15]) return false; // real-drive marker
|
|
158
|
+
const sides = flags & 0x10 ? 1 : 2;
|
|
159
|
+
if (b.length !== DMK_HEADER + tracks * sides * trackLen) return false;
|
|
160
|
+
|
|
161
|
+
this.dmk = { tracks, trackLen, sides, sdSingleByte: (flags & 0x40) !== 0 };
|
|
162
|
+
this.writeProtected = b[0] === 0xff;
|
|
163
|
+
this._dmkCache = new Map();
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
_dmkTrackBase(track, side) {
|
|
168
|
+
const { tracks, trackLen, sides } = this.dmk;
|
|
169
|
+
if (track >= tracks || side >= sides) return -1;
|
|
170
|
+
const index = sides === 1 ? track : track * 2 + side;
|
|
171
|
+
return DMK_HEADER + index * trackLen;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Parse (and cache) one DMK track's sector layout from its IDAMs. */
|
|
175
|
+
_dmkSectors(track, side) {
|
|
176
|
+
const key = `${track}:${side}`;
|
|
177
|
+
if (this._dmkCache.has(key)) return this._dmkCache.get(key);
|
|
178
|
+
const out = [];
|
|
179
|
+
const base = this._dmkTrackBase(track, side);
|
|
180
|
+
if (base >= 0) {
|
|
181
|
+
const { trackLen, sdSingleByte } = this.dmk;
|
|
182
|
+
const b = this.bytes;
|
|
183
|
+
for (let i = 0; i < 64; i++) {
|
|
184
|
+
const ptr = b[base + i * 2] | (b[base + i * 2 + 1] << 8);
|
|
185
|
+
if (!ptr) break; // unused table entries are zero
|
|
186
|
+
const mfm = (ptr & 0x8000) !== 0;
|
|
187
|
+
const off = ptr & 0x3fff;
|
|
188
|
+
if (off < DMK_IDAM_TABLE || off >= trackLen) continue;
|
|
189
|
+
const step = mfm || sdSingleByte ? 1 : 2; // SD bytes usually doubled
|
|
190
|
+
const at = (k) => b[base + off + k * step];
|
|
191
|
+
if (at(0) !== 0xfe) continue;
|
|
192
|
+
const sector = at(3);
|
|
193
|
+
const size = 128 << (at(4) & 3);
|
|
194
|
+
// The data mark follows within the WD's address-to-data window
|
|
195
|
+
let dataOff = -1;
|
|
196
|
+
let deleted = false;
|
|
197
|
+
const limit = mfm ? 50 : 37;
|
|
198
|
+
for (let k = 7; k <= limit; k++) {
|
|
199
|
+
const byte = at(k);
|
|
200
|
+
if (byte === 0xfb || byte === 0xf8) {
|
|
201
|
+
dataOff = off + (k + 1) * step;
|
|
202
|
+
deleted = byte === 0xf8;
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (dataOff < 0 || dataOff + size * step > trackLen) continue;
|
|
207
|
+
out.push({ sector, size, deleted, mfm, step, dataOff });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
this._dmkCache.set(key, out);
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
_dmkExtend(newTrackCount) {
|
|
215
|
+
const { trackLen, sides } = this.dmk;
|
|
216
|
+
const grown = new Uint8Array(DMK_HEADER + newTrackCount * sides * trackLen);
|
|
217
|
+
grown.set(this.bytes, 0);
|
|
218
|
+
grown[1] = newTrackCount;
|
|
219
|
+
this.bytes = grown;
|
|
220
|
+
this.dmk.tracks = newTrackCount;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
_tryParseJV3() {
|
|
224
|
+
if (this.bytes.length < JV3_HEADER_SIZE) return false;
|
|
225
|
+
|
|
226
|
+
const map = [];
|
|
227
|
+
let dataOffset = JV3_HEADER_SIZE;
|
|
228
|
+
let used = 0;
|
|
229
|
+
for (let i = 0; i < JV3_ENTRIES; i++) {
|
|
230
|
+
const track = this.bytes[i * 3];
|
|
231
|
+
const sector = this.bytes[i * 3 + 1];
|
|
232
|
+
const flags = this.bytes[i * 3 + 2];
|
|
233
|
+
const free = track === JV3_FREE && sector === JV3_FREE;
|
|
234
|
+
const size = jv3SectorSize(flags, free);
|
|
235
|
+
if (!free) {
|
|
236
|
+
// Sanity: plausible CHS values
|
|
237
|
+
if (track > 96 || sector > 63) return false;
|
|
238
|
+
map.push({
|
|
239
|
+
track,
|
|
240
|
+
sector,
|
|
241
|
+
side: flags & F_SIDE ? 1 : 0,
|
|
242
|
+
offset: dataOffset,
|
|
243
|
+
size,
|
|
244
|
+
deleted: jv3Deleted(flags),
|
|
245
|
+
headerIndex: i, // to update the flags byte on DAM-changing writes
|
|
246
|
+
});
|
|
247
|
+
used++;
|
|
248
|
+
}
|
|
249
|
+
dataOffset += free ? 0 : size;
|
|
250
|
+
if (dataOffset > this.bytes.length) return false;
|
|
251
|
+
}
|
|
252
|
+
// A real disk has many sectors; an exactly-header-sized all-free
|
|
253
|
+
// image is a valid BLANK disk (DiskImage.blank, and its export /
|
|
254
|
+
// save-state round trip) waiting for a DOS FORMAT.
|
|
255
|
+
if (used < 10 && !(used === 0 && this.bytes.length === JV3_HEADER_SIZE)) {
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
this.jv3Map = map;
|
|
260
|
+
// Last header byte: 0xff = writable, anything else = write-protected
|
|
261
|
+
this.writeProtected = this.bytes[JV3_HEADER_SIZE - 1] !== 0xff;
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** A blank, writable, unformatted disk — FORMAT makes it real. */
|
|
266
|
+
static blank(name = "blank.dsk") {
|
|
267
|
+
return new DiskImage(new Uint8Array(JV3_HEADER_SIZE).fill(0xff), name);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
_tryParseJV1() {
|
|
271
|
+
const size = this.bytes.length;
|
|
272
|
+
if (size === 0 || size % 256 !== 0) return false;
|
|
273
|
+
|
|
274
|
+
const totalSectors = size / 256;
|
|
275
|
+
// Prefer geometries that yield a classic track count; prefer the
|
|
276
|
+
// Model III 18-sector layout when both fit.
|
|
277
|
+
const candidates = [];
|
|
278
|
+
for (const spt of [18, 10]) {
|
|
279
|
+
if (totalSectors % spt === 0) {
|
|
280
|
+
candidates.push({ spt, tracks: totalSectors / spt });
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if (candidates.length === 0) return false;
|
|
284
|
+
const classic = candidates.find((c) => [35, 40, 80].includes(c.tracks));
|
|
285
|
+
const chosen = classic || candidates[0];
|
|
286
|
+
|
|
287
|
+
this.sectorsPerTrack = chosen.spt;
|
|
288
|
+
this.tracks = chosen.tracks;
|
|
289
|
+
// Convention: Model III 18-sector disks number sectors 1-18 (TRSDOS
|
|
290
|
+
// 1.3 style); Model I 10-sector disks number 0-9. Seed the base
|
|
291
|
+
// accordingly; _locate still falls back to the other base on a miss.
|
|
292
|
+
this.preferredBase = chosen.spt === 18 ? 1 : 0;
|
|
293
|
+
// JV1 carries no DAM info, but DOSes verify the deleted mark on
|
|
294
|
+
// directory reads — synthesize it for the directory track the boot
|
|
295
|
+
// sector names (byte 2), as xtrs does for its fixed track 17.
|
|
296
|
+
const bootDir = this.bytes[2];
|
|
297
|
+
this.dirTrack =
|
|
298
|
+
bootDir >= 1 && bootDir < this.tracks ? bootDir : DEFAULT_DIR_TRACK;
|
|
299
|
+
return true;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Highest track number present (for FDC seek bounds). */
|
|
303
|
+
trackCount() {
|
|
304
|
+
if (this.format === "dmk") return this.dmk.tracks;
|
|
305
|
+
if (this.format === "jv1") return this.tracks;
|
|
306
|
+
if (this.jv3Map.length === 0) return 0; // blank disk
|
|
307
|
+
return Math.max(...this.jv3Map.map((e) => e.track)) + 1;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* The ID fields present on one track, in map order — what READ
|
|
312
|
+
* ADDRESS shows a verifying FORMAT. Size codes are the IBM ones the
|
|
313
|
+
* 1793 reports (0=128 ... 3=1024).
|
|
314
|
+
*/
|
|
315
|
+
sectorsOn(track, side) {
|
|
316
|
+
if (this.format === "dmk") {
|
|
317
|
+
const sizeToCode = { 128: 0, 256: 1, 512: 2, 1024: 3 };
|
|
318
|
+
return this._dmkSectors(track, side).map((s) => ({
|
|
319
|
+
sector: s.sector,
|
|
320
|
+
sizeCode: sizeToCode[s.size] ?? 1,
|
|
321
|
+
}));
|
|
322
|
+
}
|
|
323
|
+
if (this.format === "jv1") {
|
|
324
|
+
if (side !== 0 || track >= this.tracks) return [];
|
|
325
|
+
const base = this.sectorBase ?? this.preferredBase;
|
|
326
|
+
return Array.from({ length: this.sectorsPerTrack }, (_, i) => ({
|
|
327
|
+
sector: base + i,
|
|
328
|
+
sizeCode: 1,
|
|
329
|
+
}));
|
|
330
|
+
}
|
|
331
|
+
const sizeToCode = { 128: 0, 256: 1, 512: 2, 1024: 3 };
|
|
332
|
+
return this.jv3Map
|
|
333
|
+
.filter((e) => e.track === track && e.side === side)
|
|
334
|
+
.map((e) => ({ sector: e.sector, sizeCode: sizeToCode[e.size] ?? 1 }));
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Find a sector's byte range. JV1 images may number sectors from 0 or 1
|
|
339
|
+
* depending on how they were produced; the first successful lookup
|
|
340
|
+
* fixes the convention for the rest of the session.
|
|
341
|
+
* @returns {{offset:number,size:number}|null}
|
|
342
|
+
*/
|
|
343
|
+
_locate(track, side, sector) {
|
|
344
|
+
if (this.format === "jv3") {
|
|
345
|
+
const hit = this.jv3Map.find(
|
|
346
|
+
(e) => e.track === track && e.sector === sector && e.side === side
|
|
347
|
+
);
|
|
348
|
+
return hit ? { offset: hit.offset, size: hit.size, entry: hit } : null;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (side !== 0) return null; // JV1 is single-sided
|
|
352
|
+
const bases =
|
|
353
|
+
this.sectorBase !== null
|
|
354
|
+
? [this.sectorBase]
|
|
355
|
+
: [this.preferredBase, 1 - this.preferredBase];
|
|
356
|
+
for (const base of bases) {
|
|
357
|
+
const index = sector - base;
|
|
358
|
+
if (index >= 0 && index < this.sectorsPerTrack && track < this.tracks) {
|
|
359
|
+
this.sectorBase = base;
|
|
360
|
+
return {
|
|
361
|
+
offset: (track * this.sectorsPerTrack + index) * 256,
|
|
362
|
+
size: 256,
|
|
363
|
+
entry: null,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Read a sector with its record type, the way the FDC sees it.
|
|
372
|
+
* @returns {{data: Uint8Array, deleted: boolean}|null}
|
|
373
|
+
*/
|
|
374
|
+
readSectorEx(track, side, sector) {
|
|
375
|
+
if (this.format === "dmk") {
|
|
376
|
+
const hit = this._dmkSectors(track, side).find((s) => s.sector === sector);
|
|
377
|
+
if (!hit) return null;
|
|
378
|
+
const base = this._dmkTrackBase(track, side);
|
|
379
|
+
const data = new Uint8Array(hit.size);
|
|
380
|
+
for (let k = 0; k < hit.size; k++) {
|
|
381
|
+
data[k] = this.bytes[base + hit.dataOff + k * hit.step];
|
|
382
|
+
}
|
|
383
|
+
return { data, deleted: hit.deleted };
|
|
384
|
+
}
|
|
385
|
+
const loc = this._locate(track, side, sector);
|
|
386
|
+
if (!loc) return null;
|
|
387
|
+
const deleted = loc.entry ? loc.entry.deleted : track === this.dirTrack;
|
|
388
|
+
return {
|
|
389
|
+
data: this.bytes.slice(loc.offset, loc.offset + loc.size),
|
|
390
|
+
deleted,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** @returns {Uint8Array|null} a copy of the sector data */
|
|
395
|
+
readSector(track, side, sector) {
|
|
396
|
+
return this.readSectorEx(track, side, sector)?.data ?? null;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Replace one track's contents with freshly formatted sectors (the
|
|
401
|
+
* tail end of a WD1793 WRITE TRACK). JV3 images rebuild their bytes
|
|
402
|
+
* wholesale — `bytes` stays the exportable/serializable truth. A JV1
|
|
403
|
+
* whose geometry matches writes in place; one that doesn't (new
|
|
404
|
+
* track count, sector numbering, or size) is promoted to JV3 in
|
|
405
|
+
* memory, keeping every existing sector.
|
|
406
|
+
*
|
|
407
|
+
* @param {{sector:number, size?:number, deleted?:boolean,
|
|
408
|
+
* data:Uint8Array, mfm?:boolean}[]} sectors
|
|
409
|
+
* @returns {boolean} true if the track was written
|
|
410
|
+
*/
|
|
411
|
+
formatTrack(track, side, sectors) {
|
|
412
|
+
if (this.writeProtected) return false;
|
|
413
|
+
if (this.format === "dmk") {
|
|
414
|
+
if (side >= this.dmk.sides) return false;
|
|
415
|
+
if (track >= this.dmk.tracks) this._dmkExtend(track + 1);
|
|
416
|
+
const region = buildDmkTrackRegion(track, side, sectors, this.dmk.trackLen);
|
|
417
|
+
this.bytes.set(region, this._dmkTrackBase(track, side));
|
|
418
|
+
this._dmkCache.delete(`${track}:${side}`);
|
|
419
|
+
return true;
|
|
420
|
+
}
|
|
421
|
+
if (this.format === "jv1") {
|
|
422
|
+
if (this._jv1FormatInPlace(track, side, sectors)) return true;
|
|
423
|
+
this._promoteToJV3();
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Keep every other track's entries with their original flags bytes
|
|
427
|
+
const kept = this.jv3Map
|
|
428
|
+
.filter((e) => !(e.track === track && e.side === side))
|
|
429
|
+
.map((e) => ({
|
|
430
|
+
track: e.track,
|
|
431
|
+
sector: e.sector,
|
|
432
|
+
flags: this.bytes[e.headerIndex * 3 + 2],
|
|
433
|
+
data: this.bytes.slice(e.offset, e.offset + e.size),
|
|
434
|
+
}));
|
|
435
|
+
|
|
436
|
+
const sizeToCode = { 256: 0x00, 128: 0x01, 1024: 0x02, 512: 0x03 };
|
|
437
|
+
const fresh = sectors.map((s) => {
|
|
438
|
+
const mfm = s.mfm !== false;
|
|
439
|
+
const size = s.size ?? s.data.length;
|
|
440
|
+
let flags = sizeToCode[size] ?? 0x00;
|
|
441
|
+
if (side) flags |= F_SIDE;
|
|
442
|
+
if (mfm) flags |= 0x80;
|
|
443
|
+
if (s.deleted) flags |= mfm ? 0x20 : 0x60;
|
|
444
|
+
return {
|
|
445
|
+
track,
|
|
446
|
+
sector: s.sector,
|
|
447
|
+
flags,
|
|
448
|
+
data: new Uint8Array(s.data.subarray(0, size)),
|
|
449
|
+
};
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
this._rebuildJV3([...kept, ...fresh]);
|
|
453
|
+
return true;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** Write into the raw track bytes in place, CRCs recomputed. */
|
|
457
|
+
_dmkWriteSector(track, side, sector, data, deleted) {
|
|
458
|
+
const hit = this._dmkSectors(track, side).find((s) => s.sector === sector);
|
|
459
|
+
if (!hit) return false;
|
|
460
|
+
const base = this._dmkTrackBase(track, side);
|
|
461
|
+
const put = (rawOff, v) => {
|
|
462
|
+
this.bytes[base + rawOff] = v;
|
|
463
|
+
if (hit.step === 2) this.bytes[base + rawOff + 1] = v;
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
const mark = deleted ? 0xf8 : 0xfb;
|
|
467
|
+
put(hit.dataOff - hit.step, mark);
|
|
468
|
+
const payload = new Uint8Array(hit.size);
|
|
469
|
+
for (let k = 0; k < hit.size; k++) {
|
|
470
|
+
payload[k] = data[k] ?? 0x00;
|
|
471
|
+
put(hit.dataOff + k * hit.step, payload[k]);
|
|
472
|
+
}
|
|
473
|
+
const crc = crc16(
|
|
474
|
+
hit.mfm ? [0xa1, 0xa1, 0xa1, mark, ...payload] : [mark, ...payload]
|
|
475
|
+
);
|
|
476
|
+
put(hit.dataOff + hit.size * hit.step, crc >> 8);
|
|
477
|
+
put(hit.dataOff + (hit.size + 1) * hit.step, crc & 0xff);
|
|
478
|
+
hit.deleted = deleted;
|
|
479
|
+
return true;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** True (and written) when the layout matches this JV1's geometry. */
|
|
483
|
+
_jv1FormatInPlace(track, side, sectors) {
|
|
484
|
+
if (side !== 0 || track >= this.tracks) return false;
|
|
485
|
+
if (sectors.length !== this.sectorsPerTrack) return false;
|
|
486
|
+
const base = this.sectorBase ?? this.preferredBase;
|
|
487
|
+
const sorted = [...sectors].sort((a, b) => a.sector - b.sector);
|
|
488
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
489
|
+
const size = sorted[i].size ?? sorted[i].data.length;
|
|
490
|
+
if (sorted[i].sector !== base + i || size !== 256) return false;
|
|
491
|
+
}
|
|
492
|
+
for (const s of sorted) {
|
|
493
|
+
const offset = (track * this.sectorsPerTrack + (s.sector - base)) * 256;
|
|
494
|
+
this.bytes.set(s.data.subarray(0, 256), offset);
|
|
495
|
+
}
|
|
496
|
+
return true;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** Convert this JV1 into an equivalent in-memory JV3. */
|
|
500
|
+
_promoteToJV3() {
|
|
501
|
+
const base = this.sectorBase ?? this.preferredBase;
|
|
502
|
+
const dd = this.sectorsPerTrack === 18; // Model III 18-sector = DD
|
|
503
|
+
const entries = [];
|
|
504
|
+
for (let t = 0; t < this.tracks; t++) {
|
|
505
|
+
for (let i = 0; i < this.sectorsPerTrack; i++) {
|
|
506
|
+
let flags = 0x00; // size code 0 = 256 bytes
|
|
507
|
+
if (dd) flags |= 0x80;
|
|
508
|
+
if (t === this.dirTrack) flags |= dd ? 0x20 : 0x60; // keep the DAM synthesis
|
|
509
|
+
const offset = (t * this.sectorsPerTrack + i) * 256;
|
|
510
|
+
entries.push({
|
|
511
|
+
track: t,
|
|
512
|
+
sector: base + i,
|
|
513
|
+
flags,
|
|
514
|
+
data: this.bytes.slice(offset, offset + 256),
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
this._rebuildJV3(entries);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** Rebuild bytes + map from {track, sector, flags, data} entries. */
|
|
522
|
+
_rebuildJV3(entries) {
|
|
523
|
+
if (entries.length > JV3_ENTRIES) {
|
|
524
|
+
throw new Error(`Disk needs ${entries.length} JV3 entries (max ${JV3_ENTRIES})`);
|
|
525
|
+
}
|
|
526
|
+
let total = 0;
|
|
527
|
+
for (const e of entries) total += e.data.length;
|
|
528
|
+
const bytes = new Uint8Array(JV3_HEADER_SIZE + total).fill(0xff, 0, JV3_HEADER_SIZE);
|
|
529
|
+
const map = [];
|
|
530
|
+
let offset = JV3_HEADER_SIZE;
|
|
531
|
+
entries.forEach((e, i) => {
|
|
532
|
+
bytes[i * 3] = e.track;
|
|
533
|
+
bytes[i * 3 + 1] = e.sector;
|
|
534
|
+
bytes[i * 3 + 2] = e.flags;
|
|
535
|
+
bytes.set(e.data, offset);
|
|
536
|
+
map.push({
|
|
537
|
+
track: e.track,
|
|
538
|
+
sector: e.sector,
|
|
539
|
+
side: e.flags & F_SIDE ? 1 : 0,
|
|
540
|
+
offset,
|
|
541
|
+
size: e.data.length,
|
|
542
|
+
deleted: jv3Deleted(e.flags),
|
|
543
|
+
headerIndex: i,
|
|
544
|
+
});
|
|
545
|
+
offset += e.data.length;
|
|
546
|
+
});
|
|
547
|
+
this.bytes = bytes;
|
|
548
|
+
this.jv3Map = map;
|
|
549
|
+
this.format = "jv3";
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* @param {{deleted?: boolean}} [opts] - record type to write (WD1793
|
|
554
|
+
* write-sector bit a0). JV3 stores it in the header flags — in the
|
|
555
|
+
* image bytes, so exports keep it; JV1 has nowhere to put one and
|
|
556
|
+
* keeps synthesizing by directory track.
|
|
557
|
+
* @returns {boolean} true if written
|
|
558
|
+
*/
|
|
559
|
+
writeSector(track, side, sector, data, { deleted = false } = {}) {
|
|
560
|
+
if (this.writeProtected) return false;
|
|
561
|
+
if (this.format === "dmk") {
|
|
562
|
+
return this._dmkWriteSector(track, side, sector, data, deleted);
|
|
563
|
+
}
|
|
564
|
+
const loc = this._locate(track, side, sector);
|
|
565
|
+
if (!loc) return false;
|
|
566
|
+
this.bytes.set(data.subarray(0, loc.size), loc.offset);
|
|
567
|
+
const entry = loc.entry;
|
|
568
|
+
if (entry && entry.deleted !== deleted) {
|
|
569
|
+
const flagsAt = entry.headerIndex * 3 + 2;
|
|
570
|
+
const flags = this.bytes[flagsAt] & ~F_DAM;
|
|
571
|
+
// Density decides the code for 0xF8: DD uses 0x20, SD uses 0x60
|
|
572
|
+
const damCode = deleted ? (flags & 0x80 ? 0x20 : 0x60) : 0x00;
|
|
573
|
+
this.bytes[flagsAt] = flags | damCode;
|
|
574
|
+
entry.deleted = deleted;
|
|
575
|
+
}
|
|
576
|
+
return true;
|
|
577
|
+
}
|
|
578
|
+
}
|