jsbeeb 1.24.0 → 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 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.24.0",
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"
@@ -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 { loadDiscImage, loadTapeImage, loadStateFile, processor, modals, actions, settings, media } = args;
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
- const image = await loadDiscImage(path);
11
- processor.fdc.loadDisc(drive, image);
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
- const tape = await loadTapeImage(path);
17
- processor.tapeInterface.setTape(tape);
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/fake6502.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // Fakes out various 6502s for testing purposes.
2
2
 
3
- import { FakeVideo } from "./video.js";
3
+ import { FakeAtomVideo, FakeVideo } from "./video.js";
4
4
  import { FakeSoundChip } from "./soundchip.js";
5
5
  import { TEST_6502, TEST_65C02, TEST_65C12, tubeModelFor } from "./models.js";
6
6
  import { machineSpec, nullIo } from "./machine-spec.js";
@@ -11,7 +11,10 @@ const soundChip = new FakeSoundChip();
11
11
  export function fake6502(model, opts = {}) {
12
12
  model = model || TEST_6502;
13
13
  return new model.Cpu(model, {
14
- ...nullIo({ video: opts.video ?? fakeVideo, soundChip: opts.soundChip ?? soundChip }),
14
+ ...nullIo({
15
+ video: opts.video ?? (model.isAtom ? new FakeAtomVideo() : fakeVideo),
16
+ soundChip: opts.soundChip ?? soundChip,
17
+ }),
15
18
  config: machineSpec({
16
19
  tube: opts.tube ? tubeModelFor(model) : null,
17
20
  tubeCpuMultiplier: opts.tubeCpuMultiplier,
@@ -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 utils.keyCodes for named keys, or ASCII charCode for letters/digits.
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._machine.processor.sysvia.keyDown(keyCode, shiftDown);
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._machine.processor.sysvia.keyUp(keyCode);
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
- * Useful for timing-sensitive code.
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
- await this._machine.runFor(cycles);
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._machine.runFor(backstop);
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
- /** Read `length` bytes from emulator memory starting at `address` */
402
- readMemory(address, length = 16) {
403
- const bytes = [];
404
- for (let i = 0; i < length; i++) {
405
- bytes.push(this._machine.readbyte(address + i));
406
- }
407
- return bytes;
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
- /** Write an array of byte values into emulator memory at `address` */
411
- writeMemory(address, bytes) {
412
- for (let i = 0; i < bytes.length; i++) {
413
- this._machine.writebyte(address + i, bytes[i]);
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,8 +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 read loop, or BASIC's.
54
- this.idleAddress = this.isAtom ? 0xfe94 : isMaster ? 0xe7e6 : 0xe581;
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;
55
57
  // The Atom ROM polls its keyboard once per VSync, so pasted keys need longer apart.
56
58
  this.pasteKeyDelayMs = this.isAtom ? 80 : 50;
57
59
  // Two of those 60 Hz scans, 33 ms, with margin: the ROM wants a key seen up
@@ -32,32 +32,6 @@ export class TestMachine {
32
32
  async initialise() {
33
33
  setNodeBasePath(RepoRoot);
34
34
  await this.processor.initialise();
35
- if (this.model.isAtom) this._startAtomVSync();
36
- }
37
-
38
- /**
39
- * The Atom ROM's main loop waits for VSync (bit 7 of Port C) to
40
- * toggle before scanning the keyboard. The MC6847 video chip drives
41
- * this in the real emulator, but fake6502 doesn't create one, so we
42
- * simulate it with a scheduler task at ~60 Hz (NTSC).
43
- */
44
- _startAtomVSync() {
45
- const ppia = this.processor.atomppia;
46
- const VsyncPeriod = 16667; // 1 MHz / 60 Hz (NTSC 262-line frame)
47
- const VsyncPulse = 800;
48
- let inVsync = false;
49
- const task = this.processor.scheduler.newTask(() => {
50
- if (!inVsync) {
51
- ppia.setVBlankInt(1);
52
- inVsync = true;
53
- task.reschedule(VsyncPulse);
54
- } else {
55
- ppia.setVBlankInt(0);
56
- inVsync = false;
57
- task.reschedule(VsyncPeriod - VsyncPulse);
58
- }
59
- });
60
- task.schedule(VsyncPeriod);
61
35
  }
62
36
 
63
37
  /**
@@ -121,16 +95,24 @@ export class TestMachine {
121
95
  .join("");
122
96
  }
123
97
 
98
+ /**
99
+ * Run for `cycles` emulated cycles, or until something stops the CPU.
100
+ * Resolves true if it was stopped short.
101
+ */
124
102
  runFor(cycles) {
125
103
  let left = cycles;
126
104
  let stopped = false;
105
+ const cpu = this.processor;
127
106
  return new Promise((resolve) => {
128
107
  const runAnIter = () => {
129
108
  const todo = Math.max(0, Math.min(left, MaxCyclesPerIter));
130
109
  if (todo) {
131
- stopped = !this.processor.execute(todo);
110
+ stopped = !cpu.execute(todo);
132
111
  left -= todo;
133
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;
134
116
  // Not truthiness: a negative or NaN request clamps todo to zero,
135
117
  // so left would never move and the loop never end.
136
118
  if (left > 0 && !stopped) {
package/src/video.js CHANGED
@@ -1254,3 +1254,29 @@ export class FakeVideo {
1254
1254
 
1255
1255
  restoreState() {}
1256
1256
  }
1257
+
1258
+ // The 6847's field as the PPIA sees it: 262 lines at 60Hz, with field sync low
1259
+ // for the 32 lines of flyback (see 6847.js).
1260
+ const AtomFieldCycles = 16667;
1261
+ const AtomFlybackCycles = Math.round((AtomFieldCycles * 32) / 262);
1262
+
1263
+ /** No picture, but the field sync the Atom ROM waits on before each keyboard scan. */
1264
+ export class FakeAtomVideo extends FakeVideo {
1265
+ reset(cpu) {
1266
+ this.ppia = cpu.atomppia;
1267
+ this.cycles = 0;
1268
+ this.inFlyback = false;
1269
+ }
1270
+
1271
+ polltime(cycles) {
1272
+ this.cycles += cycles;
1273
+ if (!this.inFlyback && this.cycles >= AtomFieldCycles - AtomFlybackCycles) {
1274
+ this.inFlyback = true;
1275
+ this.ppia.setVBlankInt(1);
1276
+ } else if (this.inFlyback && this.cycles >= AtomFieldCycles) {
1277
+ this.inFlyback = false;
1278
+ this.cycles -= AtomFieldCycles;
1279
+ this.ppia.setVBlankInt(0);
1280
+ }
1281
+ }
1282
+ }
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, the 40/80 track
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 0, saying so when there is nothing to download */
57
- discToDownload() {
58
- const disc = this.fdc?.drives[0].disc;
59
- if (!disc) toast("There is no disc in drive 0 to download.", { title: "Disc" });
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) this.drives.putDiscIn(0, 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);
@@ -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) {
@@ -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) {
@@ -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(this.params.disc1, drives.layoutForDrive(0)));
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
- setDisc1Image(name) {
173
- this.urlState.set({ disc: undefined, disc1: name });
174
- this.dispatchEvent(new CustomEvent("media-changed", { detail: { disc1: name } }));
174
+ ejectDisc(driveIndex) {
175
+ this.drives.eject(driveIndex);
176
+ this.setDiscImage(driveIndex, undefined);
175
177
  }
176
178
 
177
- setDisc2Image(name) {
178
- this.urlState.set({ disc2: name });
179
- this.dispatchEvent(new CustomEvent("media-changed", { detail: { disc2: name } }));
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) {
@@ -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
  }
@@ -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);