From 0e7163820c1a981a2b5ec7947745feea39fe2caa Mon Sep 17 00:00:00 2001 From: Andrew Hutchings Date: Wed, 15 Jul 2026 09:49:29 +0100 Subject: [PATCH 1/2] browser: recover cleanly from a rejected boot ROM boot() assigned emu before load_rom could throw, so a bad ROM image left a half-initialized machine behind: the ROM pickers then took the live-swap branch, which never updated the bootRom stash, and every Boot retry re-fed the rejected image. Once a boot had failed there was no way back short of reloading the page, even after picking a good ROM. boot() now fits the ROM into a fresh WebEmu before anything else and assigns emu only after the whole boot has succeeded, so a rejected image leaves the page in its pre-boot state. The picker and drag-drop paths share a new fitRom() helper that updates the stash on a successful live swap too, so a later reboot fits the ROM chosen last; a rejected image throws before the stash is touched. The frame loop's error path re-arms the boot button and boot() closes the previous audio stack, so a machine that hit an emulator error can be rebooted cleanly. --- crates/copperline-web/www/try.js | 55 +++++++++++++++++++------------- 1 file changed, 33 insertions(+), 22 deletions(-) diff --git a/crates/copperline-web/www/try.js b/crates/copperline-web/www/try.js index 9212a033..57840ac6 100644 --- a/crates/copperline-web/www/try.js +++ b/crates/copperline-web/www/try.js @@ -71,6 +71,19 @@ function refreshBootButton() { bootBtn.textContent = bootRom && bootRom.label !== 'AROS' ? 'Boot Kickstart' : 'Boot AROS'; } +// Route picked or dropped Kickstart bytes: live-swap a running machine, or +// stash them for the boot button. The stash is updated on a live swap too, +// so a reboot fits the ROM chosen last, not the one from the original boot; +// a rejected image throws before the stash is touched and changes nothing. +function fitRom(bytes, label) { + if (emu) emu.load_rom(bytes, undefined); + bootRom = { rom: bytes, ext: null, label }; + refreshBootButton(); + setLoadStatus( + emu ? `Kickstart loaded: ${label} - machine power-cycled` : `will boot ${label}`, + ); +} + // A disk image can also come from a link: /try/?df0= fetches it and // inserts it at boot, so a bootable demo is one shareable URL, and the // "DF0 from URL" button does the same for a pasted address. The fetch @@ -164,6 +177,19 @@ async function load() { async function boot() { bootBtn.disabled = true; try { + // Fit the ROM into a fresh machine before anything else: a bad image + // must abort the boot with the page still in its pre-boot state (emu + // stays null, so the pickers keep updating bootRom for the retry). + const machine = new WebEmu(); + machine.load_rom(bootRom.rom, bootRom.ext ?? undefined); + + // A reboot after an emulator error builds a new audio stack; close the + // previous one so it cannot keep playing alongside. + if (audioCtx) { + audioNode?.disconnect(); + audioCtx.close().catch(() => {}); + audioNode = null; + } audioCtx = new AudioContext({ sampleRate: 44100 }); await audioCtx.audioWorklet.addModule('./audio-worklet.js'); audioNode = new AudioWorkletNode(audioCtx, 'copperline-audio', { @@ -184,13 +210,12 @@ async function boot() { window.addEventListener('keydown', unlock, { once: true }); } - emu = new WebEmu(); - emu.load_rom(bootRom.rom, bootRom.ext ?? undefined); if (pendingDisk) { - emu.insert_floppy(0, pendingDisk.bytes, pendingDisk.name); + machine.insert_floppy(0, pendingDisk.bytes, pendingDisk.name); pendingDisk = null; } - emu.set_volume_percent(Number($('vol').value)); + machine.set_volume_percent(Number($('vol').value)); + emu = machine; window.__emu = emu; // for debugging/automation overlay.style.display = 'none'; @@ -222,6 +247,8 @@ function tick(nowMs) { running = false; setLoadStatus(`emulator error: ${e.message ?? e}`); overlay.style.display = ''; + // Re-arm the boot button: a fresh boot replaces the wedged machine. + refreshBootButton(); console.error(e); return; } @@ -671,15 +698,7 @@ $('kick').addEventListener('change', async (e) => { e.target.value = ''; if (!file) return; try { - const bytes = new Uint8Array(await file.arrayBuffer()); - if (emu) { - emu.load_rom(bytes, undefined); - setLoadStatus(`Kickstart loaded: ${file.name} - machine power-cycled`); - } else { - bootRom = { rom: bytes, ext: null, label: file.name }; - refreshBootButton(); - setLoadStatus(`will boot ${file.name}`); - } + fitRom(new Uint8Array(await file.arrayBuffer()), file.name); } catch (err) { setLoadStatus(`ROM load failed: ${err.message ?? err}`); } @@ -863,15 +882,7 @@ async function handleDroppedFiles(files) { const disk = list.find((f) => !/\.rom$/i.test(f.name)); try { if (rom) { - const bytes = new Uint8Array(await rom.arrayBuffer()); - if (emu) { - emu.load_rom(bytes, undefined); - setLoadStatus(`Kickstart loaded: ${rom.name} - machine power-cycled`); - } else { - bootRom = { rom: bytes, ext: null, label: rom.name }; - refreshBootButton(); - setLoadStatus(`will boot ${rom.name}`); - } + fitRom(new Uint8Array(await rom.arrayBuffer()), rom.name); } if (disk) { const bytes = new Uint8Array(await disk.arrayBuffer()); From a86924ddc5505ee576f88605f1b63cf7776b81df Mon Sep 17 00:00:00 2001 From: Andrew Hutchings Date: Wed, 15 Jul 2026 10:00:57 +0100 Subject: [PATCH 2/2] browser: drop the machine after an emulator error Review follow-up: tick()'s error path re-armed the boot button but left emu set, so the pickers still took the live-swap branch into the crashed instance (which may have panicked) instead of stashing for the fresh boot. Null it there, and guard the two deferred touch timers (long-press and tap-release) that can fire after the machine is gone. --- crates/copperline-web/www/try.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/copperline-web/www/try.js b/crates/copperline-web/www/try.js index 57840ac6..22d6a102 100644 --- a/crates/copperline-web/www/try.js +++ b/crates/copperline-web/www/try.js @@ -247,7 +247,10 @@ function tick(nowMs) { running = false; setLoadStatus(`emulator error: ${e.message ?? e}`); overlay.style.display = ''; - // Re-arm the boot button: a fresh boot replaces the wedged machine. + // Drop the wedged machine and re-arm the boot button: the pickers go + // back to stashing (never a live swap into a crashed instance, which + // may have panicked) and a fresh boot rebuilds from the stash. + emu = null; refreshBootButton(); console.error(e); return; @@ -481,7 +484,8 @@ canvas.addEventListener( padTouch = { id: t.identifier, x: t.clientX, y: t.clientY, start: now, moved: 0 }; clearTimeout(longPressTimer); longPressTimer = setTimeout(() => { - if (padTouch && padTouch.moved < TAP_SLOP_CSS_PX && padRmbTouchId === null) { + // emu can be gone by now: an emulator error drops the machine. + if (emu && padTouch && padTouch.moved < TAP_SLOP_CSS_PX && padRmbTouchId === null) { padDragging = true; emu.mouse_button(0, true); navigator.vibrate?.(15); @@ -535,7 +539,7 @@ function onTouchEnd(e) { padTouch.moved < TAP_SLOP_CSS_PX ) { emu.mouse_button(0, true); - setTimeout(() => emu.mouse_button(0, false), CLICK_HOLD_MS); + setTimeout(() => emu?.mouse_button(0, false), CLICK_HOLD_MS); } padTouch = null; } else if (t.identifier === padRmbTouchId) {