jsbeeb 1.24.1 → 1.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/app/electron.js +15 -5
- package/src/disc.js +33 -0
- package/src/machine-session.js +148 -30
- package/src/main.js +1 -3
- package/src/models.js +4 -3
- package/src/test-machine.js +9 -1
- package/src/web/drives.js +18 -9
- package/src/web/google-drive-picker.js +6 -4
- package/src/web/google-drive.js +2 -2
- package/src/web/hfe-picker.js +1 -1
- package/src/web/media-loader.js +19 -8
- package/src/web/snapshot-ui.js +1 -4
- package/src/web/sth-picker.js +2 -3
package/package.json
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"name": "jsbeeb",
|
|
8
8
|
"description": "Emulate a BBC Micro",
|
|
9
9
|
"repository": "git@github.com:mattgodbolt/jsbeeb.git",
|
|
10
|
-
"version": "1.
|
|
10
|
+
"version": "1.25.0",
|
|
11
11
|
"//engines": "If you change the version of Node, it must also be updated at the top of the Dockerfile.",
|
|
12
12
|
"engines": {
|
|
13
13
|
"node": ">=24.15.0"
|
package/src/app/electron.js
CHANGED
|
@@ -1,20 +1,30 @@
|
|
|
1
1
|
// Electron integration for jsbeeb desktop application.
|
|
2
2
|
// Handles IPC communication for loading disc/tape images and showing modals from Electron's main process.
|
|
3
3
|
|
|
4
|
+
import { reportLoadFailure } from "../web/reporting.js";
|
|
5
|
+
|
|
4
6
|
function init(args) {
|
|
5
|
-
const {
|
|
7
|
+
const { loadStateFile, modals, actions, settings, media, drives } = args;
|
|
6
8
|
const api = window.electronAPI;
|
|
7
9
|
|
|
8
10
|
api.onLoadDisc(async (message) => {
|
|
9
11
|
const { drive, path } = message;
|
|
10
|
-
|
|
11
|
-
|
|
12
|
+
try {
|
|
13
|
+
drives.putDiscIn(drive, await media.loadDiscImage(path, drives.layoutForDrive(drive)));
|
|
14
|
+
media.setDiscImage(drive, path);
|
|
15
|
+
} catch (error) {
|
|
16
|
+
reportLoadFailure(`disc ${path}`, error);
|
|
17
|
+
}
|
|
12
18
|
});
|
|
13
19
|
|
|
14
20
|
api.onLoadTape(async (message) => {
|
|
15
21
|
const { path } = message;
|
|
16
|
-
|
|
17
|
-
|
|
22
|
+
try {
|
|
23
|
+
media.setProcessorTape(await media.loadTapeImage(path));
|
|
24
|
+
media.setTapeImage(path);
|
|
25
|
+
} catch (error) {
|
|
26
|
+
reportLoadFailure(`tape ${path}`, error);
|
|
27
|
+
}
|
|
18
28
|
});
|
|
19
29
|
|
|
20
30
|
api.onShowModal((message) => {
|
package/src/disc.js
CHANGED
|
@@ -604,6 +604,39 @@ export function sniffDfsLayout(data, isDsd) {
|
|
|
604
604
|
return { is40Track: true, reason };
|
|
605
605
|
}
|
|
606
606
|
|
|
607
|
+
// The title is split across the two catalogue sectors, and the cycle number follows its second half.
|
|
608
|
+
const DfsTitleFirstHalf = 8;
|
|
609
|
+
const DfsTitleSecondHalf = 4;
|
|
610
|
+
const DfsCycleOffset = 4;
|
|
611
|
+
const DfsEntryCountInSector1 = DfsEntryCountOffset - SsdFormat.sectorSize;
|
|
612
|
+
|
|
613
|
+
const isPrintableAscii = (byte) => byte >= 0x20 && byte < 0x7f;
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* What the DFS catalogue on one side of a loaded disc says it is called, and how many times it
|
|
617
|
+
* has been written to, which between them are what was on the sticker.
|
|
618
|
+
*
|
|
619
|
+
* @param {Disc} disc
|
|
620
|
+
* @param {boolean} [isSideUpper]
|
|
621
|
+
* @returns {{title: string, cycle: string}|null} null when the side holds no DFS catalogue
|
|
622
|
+
*/
|
|
623
|
+
export function dfsCatalogue(disc, isSideUpper = false) {
|
|
624
|
+
const sectors = new Map();
|
|
625
|
+
for (const sector of disc.getTrack(isSideUpper, 0).findSectors(() => {})) {
|
|
626
|
+
const usable =
|
|
627
|
+
!sector.hasHeaderCrcError && !sector.hasDataCrcError && sector.sectorData?.length === SsdFormat.sectorSize;
|
|
628
|
+
if (usable && !sectors.has(sector.sectorNumber)) sectors.set(sector.sectorNumber, sector.sectorData);
|
|
629
|
+
}
|
|
630
|
+
const sector0 = sectors.get(0);
|
|
631
|
+
const sector1 = sectors.get(1);
|
|
632
|
+
if (!sector0 || !sector1) return null;
|
|
633
|
+
const entryBytes = sector1[DfsEntryCountInSector1];
|
|
634
|
+
if (entryBytes % DfsEntrySize !== 0 || entryBytes > DfsMaxEntries * DfsEntrySize) return null;
|
|
635
|
+
const titleBytes = [...sector0.subarray(0, DfsTitleFirstHalf), ...sector1.subarray(0, DfsTitleSecondHalf)];
|
|
636
|
+
const title = String.fromCharCode(...titleBytes.filter(isPrintableAscii)).trimEnd();
|
|
637
|
+
return { title, cycle: hexbyte(sector1[DfsCycleOffset]) };
|
|
638
|
+
}
|
|
639
|
+
|
|
607
640
|
// One track could match by luck; a disc's worth of them could not.
|
|
608
641
|
const MinDoubleSteppedTracks = 4;
|
|
609
642
|
|
package/src/machine-session.js
CHANGED
|
@@ -28,6 +28,9 @@ import { setNodeBasePath } from "./loader.js";
|
|
|
28
28
|
const FB_WIDTH = 1024;
|
|
29
29
|
const FB_HEIGHT = 625;
|
|
30
30
|
|
|
31
|
+
// Bit X of ACCCON: shadow RAM in place of main at &3000 to &7FFF.
|
|
32
|
+
const AccconShadowBit = 4;
|
|
33
|
+
|
|
31
34
|
// Five times a frame, so only a machine that has stopped painting hits it.
|
|
32
35
|
const BackstopSecondsPerFrame = 0.1;
|
|
33
36
|
|
|
@@ -115,19 +118,88 @@ export class MachineSession {
|
|
|
115
118
|
return this.drainOutput();
|
|
116
119
|
}
|
|
117
120
|
|
|
121
|
+
get _keyboard() {
|
|
122
|
+
return this._machine.processor.keyboardInterface;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The keyboard is the typist's until everything from type() has been
|
|
127
|
+
* delivered, and a key pressed meanwhile would be silently dropped.
|
|
128
|
+
*/
|
|
129
|
+
_requireKeyboard() {
|
|
130
|
+
if (this.typingPending) {
|
|
131
|
+
throw new Error(
|
|
132
|
+
"Text from type() is still being typed: await type(), or if a breakpoint stopped it " +
|
|
133
|
+
"run the machine on to finish it, or cancelTyping() first",
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
118
138
|
/**
|
|
119
139
|
* Press a key (by browser keyCode).
|
|
120
|
-
* Use
|
|
140
|
+
* Use keyCodes from keymap.js for named keys, or ASCII charCode for letters/digits.
|
|
121
141
|
*/
|
|
122
142
|
keyDown(keyCode, shiftDown = false) {
|
|
123
|
-
this.
|
|
143
|
+
this._requireKeyboard();
|
|
144
|
+
this._keyboard.keyDown(keyCode, shiftDown);
|
|
124
145
|
}
|
|
125
146
|
|
|
126
147
|
/**
|
|
127
148
|
* Release a key (by browser keyCode).
|
|
128
149
|
*/
|
|
129
150
|
keyUp(keyCode) {
|
|
130
|
-
this.
|
|
151
|
+
this._requireKeyboard();
|
|
152
|
+
this._keyboard.keyUp(keyCode);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Press a key by its place in the keyboard matrix, as the model's key
|
|
157
|
+
* table (BBC or ATOM in the keymaps) gives it, with no host key map in
|
|
158
|
+
* between: a game reading the matrix sees exactly this key.
|
|
159
|
+
* @param {[number, number]} colRow
|
|
160
|
+
*/
|
|
161
|
+
keyDownRaw(colRow) {
|
|
162
|
+
this._requireKeyboard();
|
|
163
|
+
this._keyboard.keyDownRaw(colRow);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Release a key pressed by matrix position.
|
|
168
|
+
* @param {[number, number]} colRow
|
|
169
|
+
*/
|
|
170
|
+
keyUpRaw(colRow) {
|
|
171
|
+
this._requireKeyboard();
|
|
172
|
+
this._keyboard.keyUpRaw(colRow);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Every key currently down, as matrix positions keyDownRaw takes.
|
|
177
|
+
* @returns {Array<[number, number]>}
|
|
178
|
+
*/
|
|
179
|
+
heldKeys() {
|
|
180
|
+
const held = [];
|
|
181
|
+
this._keyboard.keys.forEach((column, col) => {
|
|
182
|
+
column.forEach((down, row) => {
|
|
183
|
+
if (down) held.push([col, row]);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
return held;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Whether text from type() is still to be delivered, which a breakpoint stopping the run leaves behind. */
|
|
190
|
+
get typingPending() {
|
|
191
|
+
return this._machine.typist.isTyping;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Drop any text from type() still to be delivered, and give the keyboard back. */
|
|
195
|
+
cancelTyping() {
|
|
196
|
+
this._machine.typist.cancel();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Release every key, and drop any typing still pending, so the keyboard is in a known state. */
|
|
200
|
+
releaseAllKeys() {
|
|
201
|
+
this.cancelTyping();
|
|
202
|
+
this._keyboard.clearKeys();
|
|
131
203
|
}
|
|
132
204
|
|
|
133
205
|
/**
|
|
@@ -196,11 +268,15 @@ export class MachineSession {
|
|
|
196
268
|
}
|
|
197
269
|
|
|
198
270
|
/**
|
|
199
|
-
* Run for an exact number of emulated CPU cycles
|
|
200
|
-
*
|
|
271
|
+
* Run for an exact number of emulated CPU cycles, or until something stops
|
|
272
|
+
* the CPU first: a breakpoint, or the paint runFrames stops at. `completed`
|
|
273
|
+
* is false if it was stopped short.
|
|
274
|
+
* @returns {Promise<{cyclesRun: number, completed: boolean}>}
|
|
201
275
|
*/
|
|
202
276
|
async runFor(cycles) {
|
|
203
|
-
|
|
277
|
+
const startCycles = this.elapsedCycles;
|
|
278
|
+
const stopped = await this._machine.runFor(cycles);
|
|
279
|
+
return { cyclesRun: this.elapsedCycles - startCycles, completed: !stopped };
|
|
204
280
|
}
|
|
205
281
|
|
|
206
282
|
/** Emulated cycles since power-on */
|
|
@@ -226,25 +302,15 @@ export class MachineSession {
|
|
|
226
302
|
const cpu = this._machine.processor;
|
|
227
303
|
const backstop = maxCycles ?? count * BackstopSecondsPerFrame * cpu.model.cyclesPerSecond;
|
|
228
304
|
const startFrame = this._frameCount;
|
|
229
|
-
const startCycles = this.elapsedCycles;
|
|
230
|
-
// execute() adds each request to a running targetCycles, so budget left
|
|
231
|
-
// unspent by an early stop would silently lengthen the caller's next run.
|
|
232
|
-
const unspentBefore = cpu.targetCycles - cpu.currentCycles;
|
|
233
305
|
|
|
234
306
|
this._stopAtFrame = startFrame + count;
|
|
235
307
|
try {
|
|
236
|
-
await this.
|
|
308
|
+
const { cyclesRun } = await this.runFor(backstop);
|
|
309
|
+
const framesRun = this._frameCount - startFrame;
|
|
310
|
+
return { framesRun, cyclesRun, completed: framesRun >= count };
|
|
237
311
|
} finally {
|
|
238
312
|
this._stopAtFrame = Infinity;
|
|
239
|
-
cpu.targetCycles = cpu.currentCycles + unspentBefore;
|
|
240
313
|
}
|
|
241
|
-
|
|
242
|
-
const framesRun = this._frameCount - startFrame;
|
|
243
|
-
return {
|
|
244
|
-
framesRun,
|
|
245
|
-
cyclesRun: this.elapsedCycles - startCycles,
|
|
246
|
-
completed: framesRun >= count,
|
|
247
|
-
};
|
|
248
314
|
}
|
|
249
315
|
|
|
250
316
|
/** Frames painted since the session was created; a hard reset does not zero it */
|
|
@@ -398,22 +464,74 @@ export class MachineSession {
|
|
|
398
464
|
};
|
|
399
465
|
}
|
|
400
466
|
|
|
401
|
-
/**
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
467
|
+
/**
|
|
468
|
+
* What the memory map has paged in: `romsel`, the sideways bank at
|
|
469
|
+
* &8000 to &BFFF, and on a Master `acccon`, whose bit 2 puts shadow
|
|
470
|
+
* RAM at &3000 to &7FFF.
|
|
471
|
+
* @returns {{romsel: number, acccon?: number}}
|
|
472
|
+
*/
|
|
473
|
+
pagingState() {
|
|
474
|
+
const cpu = this._machine.processor;
|
|
475
|
+
const state = { romsel: cpu.romsel };
|
|
476
|
+
if (cpu.model.isMaster) state.acccon = cpu.acccon;
|
|
477
|
+
return state;
|
|
408
478
|
}
|
|
409
479
|
|
|
410
|
-
/**
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
480
|
+
/**
|
|
481
|
+
* Runs `fn` with `bank` paged at &8000, or shadow RAM paged (or not)
|
|
482
|
+
* at &3000, putting the map back afterwards. Either left undefined
|
|
483
|
+
* leaves the map as the machine has it.
|
|
484
|
+
*/
|
|
485
|
+
_withPaging({ bank, shadow }, fn) {
|
|
486
|
+
const cpu = this._machine.processor;
|
|
487
|
+
const { romsel, acccon } = cpu;
|
|
488
|
+
if (bank !== undefined) {
|
|
489
|
+
if (!Number.isInteger(bank) || bank < 0 || bank > 15) throw new Error(`Bank ${bank} is not 0 to 15`);
|
|
490
|
+
cpu.romSelect(bank);
|
|
491
|
+
}
|
|
492
|
+
if (shadow !== undefined) {
|
|
493
|
+
if (!cpu.model.isMaster) throw new Error("Only a Master has shadow RAM");
|
|
494
|
+
cpu.writeAcccon(shadow ? acccon | AccconShadowBit : acccon & ~AccconShadowBit);
|
|
495
|
+
}
|
|
496
|
+
try {
|
|
497
|
+
return fn();
|
|
498
|
+
} finally {
|
|
499
|
+
if (bank !== undefined) cpu.romSelect(romsel);
|
|
500
|
+
if (shadow !== undefined) cpu.writeAcccon(acccon);
|
|
414
501
|
}
|
|
415
502
|
}
|
|
416
503
|
|
|
504
|
+
/**
|
|
505
|
+
* Read `length` bytes from emulator memory starting at `address`, from
|
|
506
|
+
* whatever is paged in unless `bank` or `shadow` says otherwise.
|
|
507
|
+
* @param {number} address
|
|
508
|
+
* @param {number} [length=16]
|
|
509
|
+
* @param {Object} [opts]
|
|
510
|
+
* @param {number} [opts.bank] sideways bank to read at &8000 to &BFFF
|
|
511
|
+
* @param {boolean} [opts.shadow] on a Master, read shadow RAM (true) or main RAM (false) at &3000 to &7FFF
|
|
512
|
+
*/
|
|
513
|
+
readMemory(address, length = 16, { bank, shadow } = {}) {
|
|
514
|
+
return this._withPaging({ bank, shadow }, () => {
|
|
515
|
+
const bytes = [];
|
|
516
|
+
for (let i = 0; i < length; i++) {
|
|
517
|
+
bytes.push(this._machine.readbyte(address + i));
|
|
518
|
+
}
|
|
519
|
+
return bytes;
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Write an array of byte values into emulator memory at `address`;
|
|
525
|
+
* `bank` and `shadow` pick where, as for readMemory.
|
|
526
|
+
*/
|
|
527
|
+
writeMemory(address, bytes, { bank, shadow } = {}) {
|
|
528
|
+
this._withPaging({ bank, shadow }, () => {
|
|
529
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
530
|
+
this._machine.writebyte(address + i, bytes[i]);
|
|
531
|
+
}
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
|
|
417
535
|
/** Read the current 6502 CPU registers */
|
|
418
536
|
registers() {
|
|
419
537
|
const cpu = this._machine.processor;
|
package/src/main.js
CHANGED
|
@@ -389,11 +389,9 @@ exposeConsoleSurface(window, { loop, processor, video, audioHandler });
|
|
|
389
389
|
|
|
390
390
|
// Hooks for electron.
|
|
391
391
|
electron({
|
|
392
|
-
loadDiscImage: media.loadDiscImage.bind(media),
|
|
393
|
-
loadTapeImage: media.loadTapeImage.bind(media),
|
|
394
|
-
processor,
|
|
395
392
|
settings,
|
|
396
393
|
media,
|
|
394
|
+
drives,
|
|
397
395
|
modals: {
|
|
398
396
|
show: (modalId, sthType) => {
|
|
399
397
|
if (modalId === "sth" && sthType) {
|
package/src/models.js
CHANGED
|
@@ -50,9 +50,10 @@ class Model {
|
|
|
50
50
|
this.stringToKeys = this.isAtom ? stringToATOMKeys : stringToBBCKeys;
|
|
51
51
|
// The OS write-character vector, watched to capture what the machine prints.
|
|
52
52
|
this.wrchvAddress = this.isAtom ? 0x0208 : 0x020e;
|
|
53
|
-
// Where the machine sits waiting for a key: the Atom kernel's
|
|
54
|
-
//
|
|
55
|
-
|
|
53
|
+
// Where the machine sits waiting for a key: BASIC's read loop, or on the Atom the kernel's
|
|
54
|
+
// wait-for-key scan at $FEA7. The scan before it at $FE9F only checks every key is up, and a
|
|
55
|
+
// 100 ms debounce delay follows, during which a key pressed is not seen.
|
|
56
|
+
this.idleAddress = this.isAtom ? 0xfea7 : isMaster ? 0xe7e6 : 0xe581;
|
|
56
57
|
// The Atom ROM polls its keyboard once per VSync, so pasted keys need longer apart.
|
|
57
58
|
this.pasteKeyDelayMs = this.isAtom ? 80 : 50;
|
|
58
59
|
// Two of those 60 Hz scans, 33 ms, with margin: the ROM wants a key seen up
|
package/src/test-machine.js
CHANGED
|
@@ -95,16 +95,24 @@ export class TestMachine {
|
|
|
95
95
|
.join("");
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Run for `cycles` emulated cycles, or until something stops the CPU.
|
|
100
|
+
* Resolves true if it was stopped short.
|
|
101
|
+
*/
|
|
98
102
|
runFor(cycles) {
|
|
99
103
|
let left = cycles;
|
|
100
104
|
let stopped = false;
|
|
105
|
+
const cpu = this.processor;
|
|
101
106
|
return new Promise((resolve) => {
|
|
102
107
|
const runAnIter = () => {
|
|
103
108
|
const todo = Math.max(0, Math.min(left, MaxCyclesPerIter));
|
|
104
109
|
if (todo) {
|
|
105
|
-
stopped = !
|
|
110
|
+
stopped = !cpu.execute(todo);
|
|
106
111
|
left -= todo;
|
|
107
112
|
}
|
|
113
|
+
// execute() adds each request to a running targetCycles, so budget
|
|
114
|
+
// left unspent by an early stop would silently lengthen the next run.
|
|
115
|
+
if (stopped) cpu.targetCycles = cpu.currentCycles;
|
|
108
116
|
// Not truthiness: a negative or NaN request clamps todo to zero,
|
|
109
117
|
// so left would never move and the loop never end.
|
|
110
118
|
if (left > 0 && !stopped) {
|
package/src/web/drives.js
CHANGED
|
@@ -7,11 +7,13 @@ import { DriveTracks } from "../url-params.js";
|
|
|
7
7
|
const tracksPerStepFor = (tracks) => (tracks === "40" ? 2 : 1);
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
* The disc drives as the page sees them: putting a disc in
|
|
11
|
-
* switches on the Discs menu, and downloading what is in drive 0.
|
|
10
|
+
* The disc drives as the page sees them: putting a disc in and taking it out,
|
|
11
|
+
* the 40/80 track switches on the Discs menu, and downloading what is in drive 0.
|
|
12
|
+
* Raises "disc-changed" with the drive index and what it now holds.
|
|
12
13
|
*/
|
|
13
|
-
export class Drives {
|
|
14
|
+
export class Drives extends EventTarget {
|
|
14
15
|
constructor({ fdc, driveTracks, confirm }) {
|
|
16
|
+
super();
|
|
15
17
|
this.fdc = fdc;
|
|
16
18
|
this.driveTracks = driveTracks;
|
|
17
19
|
this.saidWritesAreNotKept = false;
|
|
@@ -34,7 +36,7 @@ export class Drives {
|
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
document.getElementById("download-drive-link").addEventListener("click", async () => {
|
|
37
|
-
const disc = this.discToDownload();
|
|
39
|
+
const disc = this.discToDownload(0);
|
|
38
40
|
if (!disc) return;
|
|
39
41
|
const save = (options) =>
|
|
40
42
|
downloadDriveData(toSsdOrDsd(disc, options), disc.name, disc.isDoubleSided ? ".dsd" : ".ssd");
|
|
@@ -47,16 +49,16 @@ export class Drives {
|
|
|
47
49
|
});
|
|
48
50
|
|
|
49
51
|
document.getElementById("download-drive-hfe-link").addEventListener("click", () => {
|
|
50
|
-
const disc = this.discToDownload();
|
|
52
|
+
const disc = this.discToDownload(0);
|
|
51
53
|
if (!disc) return;
|
|
52
54
|
downloadDriveData(toHfe(disc), disc.name, ".hfe");
|
|
53
55
|
});
|
|
54
56
|
}
|
|
55
57
|
|
|
56
|
-
/** @returns {import("../disc.js").Disc|null} the disc in drive
|
|
57
|
-
discToDownload() {
|
|
58
|
-
const disc = this.fdc?.drives[
|
|
59
|
-
if (!disc) toast(
|
|
58
|
+
/** @returns {import("../disc.js").Disc|null} the disc in the drive, saying so when there is nothing to download */
|
|
59
|
+
discToDownload(driveIndex) {
|
|
60
|
+
const disc = this.fdc?.drives[driveIndex].disc;
|
|
61
|
+
if (!disc) toast(`There is no disc in drive ${driveIndex} to download.`, { title: "Disc" });
|
|
60
62
|
return disc ?? null;
|
|
61
63
|
}
|
|
62
64
|
|
|
@@ -80,6 +82,13 @@ export class Drives {
|
|
|
80
82
|
this.noteUnsavedWrites(loadedDisc);
|
|
81
83
|
// A switch the user fixed does not move, so anything it does is not news.
|
|
82
84
|
if (fixed === undefined && drive.tracksPerStep !== was) this.noteDriveTracks(driveIndex, loadedDisc.name);
|
|
85
|
+
this.dispatchEvent(new CustomEvent("disc-changed", { detail: { driveIndex, disc: loadedDisc } }));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
eject(driveIndex) {
|
|
89
|
+
this.fdc.loadDisc(driveIndex, undefined, this.tracksPerStepForDrive(driveIndex));
|
|
90
|
+
this.showDriveTracks(driveIndex);
|
|
91
|
+
this.dispatchEvent(new CustomEvent("disc-changed", { detail: { driveIndex, disc: undefined } }));
|
|
83
92
|
}
|
|
84
93
|
|
|
85
94
|
noteUnsavedWrites(loadedDisc) {
|
|
@@ -139,11 +139,13 @@ export class GoogleDrivePicker {
|
|
|
139
139
|
row.querySelector(".name").textContent = item.name;
|
|
140
140
|
row.addEventListener("click", async () => {
|
|
141
141
|
noteEvent("google-drive", "click", item.name);
|
|
142
|
-
this.media.setDisc1Image(`gd:${item.id}/${item.name}`);
|
|
143
142
|
this.modal.hide();
|
|
144
143
|
try {
|
|
145
144
|
const ssd = await this.load(item, this.drives.layoutForDrive(0));
|
|
146
|
-
if (ssd)
|
|
145
|
+
if (ssd) {
|
|
146
|
+
this.drives.putDiscIn(0, ssd);
|
|
147
|
+
this.media.setDiscImage(0, `gd:${item.id}/${item.name}`);
|
|
148
|
+
}
|
|
147
149
|
} catch (error) {
|
|
148
150
|
toast(`Unable to load ${item.name} from Google Drive: ${errorText(error)}`, {
|
|
149
151
|
title: "Google Drive",
|
|
@@ -189,9 +191,9 @@ export class GoogleDrivePicker {
|
|
|
189
191
|
}
|
|
190
192
|
|
|
191
193
|
try {
|
|
192
|
-
const result = await this.googleDrive.create(name, data);
|
|
193
|
-
this.media.setDisc1Image("gd:" + result.fileId + "/" + name);
|
|
194
|
+
const result = await this.googleDrive.create(name, data, this.drives.layoutForDrive(0));
|
|
194
195
|
this.drives.putDiscIn(0, result.disc);
|
|
196
|
+
this.media.setDiscImage(0, "gd:" + result.fileId + "/" + name);
|
|
195
197
|
this.modals.loadingFinished();
|
|
196
198
|
} catch (error) {
|
|
197
199
|
console.error(`Error creating Google Drive disc: ${error}`, error);
|
package/src/web/google-drive.js
CHANGED
|
@@ -137,11 +137,11 @@ export class GoogleDriveLoader {
|
|
|
137
137
|
});
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
-
async create(name, data) {
|
|
140
|
+
async create(name, data, layout) {
|
|
141
141
|
console.log(`Google Drive: creating disc image: '${name}'`);
|
|
142
142
|
const response = await this.saveFile(name, data);
|
|
143
143
|
const meta = response.result;
|
|
144
|
-
return { fileId: meta.id, disc: this.makeDisc(data, meta) };
|
|
144
|
+
return { fileId: meta.id, disc: this.makeDisc(data, meta, layout) };
|
|
145
145
|
}
|
|
146
146
|
|
|
147
147
|
makeDisc(data, meta, layout) {
|
package/src/web/hfe-picker.js
CHANGED
|
@@ -59,7 +59,6 @@ export class HfePicker {
|
|
|
59
59
|
async pick(file) {
|
|
60
60
|
noteEvent("hfe", "click", file.path);
|
|
61
61
|
const image = "hfe:" + file.path;
|
|
62
|
-
this.media.setDisc1Image(image);
|
|
63
62
|
const needsAutoboot = this.urlState.params.autoboot !== undefined;
|
|
64
63
|
if (needsAutoboot) this.processor.reset(true);
|
|
65
64
|
|
|
@@ -68,6 +67,7 @@ export class HfePicker {
|
|
|
68
67
|
try {
|
|
69
68
|
const loaded = await this.media.loadDiscImage(image, this.drives.layoutForDrive(0));
|
|
70
69
|
this.drives.putDiscIn(0, loaded);
|
|
70
|
+
this.media.setDiscImage(0, image);
|
|
71
71
|
this.modals.loadingFinished();
|
|
72
72
|
if (needsAutoboot) this.autoboot(name);
|
|
73
73
|
} catch (err) {
|
package/src/web/media-loader.js
CHANGED
|
@@ -144,10 +144,10 @@ export class MediaLoader extends EventTarget {
|
|
|
144
144
|
elem.querySelector(".description").textContent = image.desc;
|
|
145
145
|
elem.addEventListener("click", async () => {
|
|
146
146
|
noteEvent("images", "click", image.file);
|
|
147
|
-
this.setDisc1Image(image.file);
|
|
148
147
|
modals.hide("discs");
|
|
149
148
|
try {
|
|
150
|
-
drives.putDiscIn(0, await this.loadDiscImage(
|
|
149
|
+
drives.putDiscIn(0, await this.loadDiscImage(image.file, drives.layoutForDrive(0)));
|
|
150
|
+
this.setDiscImage(0, image.file);
|
|
151
151
|
} catch (error) {
|
|
152
152
|
reportLoadFailure(`${image.name} (${image.file})`, error);
|
|
153
153
|
}
|
|
@@ -165,18 +165,29 @@ export class MediaLoader extends EventTarget {
|
|
|
165
165
|
else this.resolver.addSource(schema, fetcher);
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
+
/** Puts a tape in the deck, or empties it; raises "tape-changed" with what the deck now holds. */
|
|
168
169
|
setProcessorTape(tape) {
|
|
169
170
|
this.processor.tapeInterface.setTape(tape);
|
|
171
|
+
this.dispatchEvent(new CustomEvent("tape-changed", { detail: { tape } }));
|
|
170
172
|
}
|
|
171
173
|
|
|
172
|
-
|
|
173
|
-
this.
|
|
174
|
-
this.
|
|
174
|
+
ejectDisc(driveIndex) {
|
|
175
|
+
this.drives.eject(driveIndex);
|
|
176
|
+
this.setDiscImage(driveIndex, undefined);
|
|
175
177
|
}
|
|
176
178
|
|
|
177
|
-
|
|
178
|
-
this.
|
|
179
|
-
this.
|
|
179
|
+
ejectTape() {
|
|
180
|
+
this.setProcessorTape(undefined);
|
|
181
|
+
this.setTapeImage(undefined);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Names the disc in a drive for the URL and the settings store, or unnames it. */
|
|
185
|
+
setDiscImage(driveIndex, name) {
|
|
186
|
+
// The URL has always called the drives disc1 and disc2, and a bare disc means disc1.
|
|
187
|
+
const changes = driveIndex === 0 ? { disc: undefined, disc1: name } : { disc2: name };
|
|
188
|
+
this.urlState.set(changes);
|
|
189
|
+
const detail = driveIndex === 0 ? { disc1: name } : { disc2: name };
|
|
190
|
+
this.dispatchEvent(new CustomEvent("media-changed", { detail }));
|
|
180
191
|
}
|
|
181
192
|
|
|
182
193
|
setTapeImage(name) {
|
package/src/web/snapshot-ui.js
CHANGED
|
@@ -235,10 +235,7 @@ export class SnapshotUI {
|
|
|
235
235
|
// Only update the URL/query for URL-sourced discs. For embedded
|
|
236
236
|
// (local-file) discs, setting parsedQuery would put a bogus source
|
|
237
237
|
// in the URL and break subsequent saves/reloads.
|
|
238
|
-
if (savedMedia[discKey])
|
|
239
|
-
if (driveIndex === 0) this.media.setDisc1Image(savedMedia[discKey]);
|
|
240
|
-
else this.media.setDisc2Image(savedMedia[discKey]);
|
|
241
|
-
}
|
|
238
|
+
if (savedMedia[discKey]) this.media.setDiscImage(driveIndex, savedMedia[discKey]);
|
|
242
239
|
}
|
|
243
240
|
}
|
|
244
241
|
}
|
package/src/web/sth-picker.js
CHANGED
|
@@ -71,7 +71,6 @@ export class SthPicker {
|
|
|
71
71
|
async pickDisc(item) {
|
|
72
72
|
noteEvent("sth", "click", item);
|
|
73
73
|
const image = "sth:" + item;
|
|
74
|
-
this.media.setDisc1Image(image);
|
|
75
74
|
const needsAutoboot = this.urlState.params.autoboot !== undefined;
|
|
76
75
|
if (needsAutoboot) {
|
|
77
76
|
this.processor.reset(true);
|
|
@@ -81,6 +80,7 @@ export class SthPicker {
|
|
|
81
80
|
try {
|
|
82
81
|
const loaded = await this.media.loadDiscImage(image, this.drives.layoutForDrive(0));
|
|
83
82
|
this.drives.putDiscIn(0, loaded);
|
|
83
|
+
this.media.setDiscImage(0, image);
|
|
84
84
|
this.modals.loadingFinished();
|
|
85
85
|
|
|
86
86
|
if (needsAutoboot) {
|
|
@@ -95,12 +95,11 @@ export class SthPicker {
|
|
|
95
95
|
async pickTape(item) {
|
|
96
96
|
noteEvent("sth", "clickTape", item);
|
|
97
97
|
const image = "sth:" + item;
|
|
98
|
-
this.media.setTapeImage(image);
|
|
99
|
-
|
|
100
98
|
this.modals.popupLoading("Loading " + item);
|
|
101
99
|
try {
|
|
102
100
|
const tape = await this.media.loadTapeImage(image);
|
|
103
101
|
this.media.setProcessorTape(tape);
|
|
102
|
+
this.media.setTapeImage(image);
|
|
104
103
|
this.modals.loadingFinished();
|
|
105
104
|
} catch (err) {
|
|
106
105
|
console.error("Error loading tape image:", err);
|