From 6e73df11ae38e25dfd736595221f1c96703bb4c4 Mon Sep 17 00:00:00 2001 From: Felix Rieseberg Date: Sat, 11 Apr 2026 19:31:57 -0700 Subject: [PATCH] Enable CD-ROM via a synchronous fs-backed buffer (#362) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v86's async loaders leave the ATAPI drive in BSY across an event-loop turn after a READ(10) CDB. Win95's ESDI_506 reads status twice, sees BSY both times, and issues DEVICE RESET ~165 instructions later, which cancels the in-flight read — the drive enumerates but D: never mounts. Serve the ISO through a small fs.readSync-backed buffer so the data is available before the next emulated instruction runs, and re-enable the CD-ROM settings tab. Also: WIN95_PROBE_CDROM / WIN95_PROBE_CDTRACE harness hooks, and pump one screen-adapter frame before screenshotting so probe captures work when the Electron window is occluded. --- .claude/skills/probe-win95/SKILL.md | 3 ++ src/renderer/card-settings.tsx | 3 +- src/renderer/debug-harness.ts | 40 +++++++++++++++++++++++ src/renderer/emulator.tsx | 17 ++++------ src/renderer/sync-file-buffer.ts | 50 +++++++++++++++++++++++++++++ 5 files changed, 101 insertions(+), 12 deletions(-) create mode 100644 src/renderer/sync-file-buffer.ts diff --git a/.claude/skills/probe-win95/SKILL.md b/.claude/skills/probe-win95/SKILL.md index 9d433ee..3e9014f 100644 --- a/.claude/skills/probe-win95/SKILL.md +++ b/.claude/skills/probe-win95/SKILL.md @@ -40,6 +40,9 @@ WIN95_SMB_SHARE="$HOME/Downloads" \ desktop. `WIN95_PROBE_DOSBOX=1` instead opens `command`, types `dir`, and (with `WIN95_PROBE_DOSBOX_ALTENTER=1`) toggles fullscreen — this is the regression scenario for the windowed-DOS-box VBE leak. +`WIN95_PROBE_CDROM=/path/to.iso` mounts an ISO on the secondary-IDE +ATAPI drive (bypasses the settings UI). `WIN95_PROBE_CDTRACE=1` logs +every secondary-channel ATA/ATAPI command to `/tmp/win95-cdtrace.log`. `WIN95_PROBE_VGATRACE=1` wraps the VGA I/O ports at the `io.ports[]` layer and writes `[port, op, value, "eip VMPE cplN"]` tuples to `/tmp/win95-vgatrace.json` every tick (heavy — can hit 1M entries during diff --git a/src/renderer/card-settings.tsx b/src/renderer/card-settings.tsx index 7124bd7..73ecf5d 100644 --- a/src/renderer/card-settings.tsx +++ b/src/renderer/card-settings.tsx @@ -3,8 +3,7 @@ import * as React from "react"; import { resetState } from "./utils/reset-state"; import { InfoBarSettings } from "./info-bar-settings"; -// v86's IDE CD-ROM path is currently broken; flip this once it works again. -const CDROM_ENABLED = false; +const CDROM_ENABLED = true; interface CardSettingsProps { bootFromScratch: () => void; diff --git a/src/renderer/debug-harness.ts b/src/renderer/debug-harness.ts index 6c3051c..66d905c 100644 --- a/src/renderer/debug-harness.ts +++ b/src/renderer/debug-harness.ts @@ -39,6 +39,41 @@ const SC = { ALT_DN: [0x38], ALT_UP: [0xb8], }; +// WIN95_PROBE_CDTRACE=1 → wrap secondary-IDE ata_command/atapi_handle and +// log every command so we can see whether Win95's ESDI_506/CDVSD stack ever +// talks to the drive (and which ATAPI CDBs it sends). +const CDTRACE_FILE = process.env.WIN95_PROBE_CDTRACE_FILE || "/tmp/win95-cdtrace.log"; +let cdTraceArmed = false; + +function armCdTrace(emulator: any) { + const dev = emulator.v86?.cpu?.devices; + if (!dev || cdTraceArmed) return; + cdTraceArmed = true; + const sec = dev.ide?.secondary; + fs.writeFileSync(CDTRACE_FILE, + `[probe] cd buffer=${!!dev.cdrom?.buffer} bytes=${dev.cdrom?.buffer?.byteLength} is_atapi=${sec?.master?.is_atapi}\n`); + const t0 = Date.now(); + const log = (s: string) => fs.appendFileSync(CDTRACE_FILE, `[${((Date.now()-t0)/1000).toFixed(2)}s] ${s}\n`); + const proto = Object.getPrototypeOf(sec?.master || {}); + for (const m of ["ata_command", "atapi_handle"]) { + const orig = proto?.[m]; + if (typeof orig !== "function") continue; + proto[m] = function (this: any, ...a: any[]) { + if (this === sec?.master || this === sec?.slave) { + const who = this === sec.master ? "sm" : "ss"; + if (m === "ata_command") log(`${who} ata cmd=0x${(a[0] ?? 0).toString(16)}`); + else { + const d = this.data || []; + const cdb = Array.from(d.slice?.(0, 12) || []).map((b: any) => b.toString(16).padStart(2, "0")).join(" "); + log(`${who} atapi cmd=0x${(d[0] ?? 0).toString(16)} cdb=[${cdb}]`); + } + } + return orig.apply(this, a); + }; + } + console.log("[probe] cd trace armed"); +} + // WIN95_PROBE_VGATRACE=1 → wrap VGA I/O ports at the io.ports[] layer (the // VGAScreen.portXXX_write methods are captured by-value at registration time, // so monkey-patching them on the instance is a no-op for most ports). Each @@ -155,17 +190,22 @@ export function startProbe(emulator: any) { // DOS box clobbering VBE (felixrieseberg/v86 vga-defer-vbe-disable-v86). const dosBox = process.env.WIN95_PROBE_DOSBOX === "1"; const wantVgaTrace = process.env.WIN95_PROBE_VGATRACE === "1"; + const wantCdTrace = process.env.WIN95_PROBE_CDTRACE === "1"; let scriptArmed = !!scriptCmd || !!runCmd || dosBox; const tick = () => { try { if (wantVgaTrace && !vgaTrace) armVgaTrace(emulator); + if (wantCdTrace && !cdTraceArmed) armCdTrace(emulator); const s = collectStatus(emulator); fs.writeFileSync(STATUS_FILE, JSON.stringify(s, null, 2)); // Try to capture a screenshot — this can fail if the screen adapter // isn't ready yet, so we swallow that. try { + // rAF doesn't fire when the Electron window is occluded, so the + // screen adapter's render loop stalls. Pump one frame by hand. + try { emulator.screen_adapter?.update_screen?.(); } catch {} const img: HTMLImageElement = emulator.screen_make_screenshot(); // The Image has a data: URL src; decode it to bytes if (img && img.src && img.src.startsWith("data:image/png;base64,")) { diff --git a/src/renderer/emulator.tsx b/src/renderer/emulator.tsx index 25d7d87..9d3fdb1 100644 --- a/src/renderer/emulator.tsx +++ b/src/renderer/emulator.tsx @@ -20,6 +20,7 @@ import { setupSmbShare } from "./smb"; import { setupTcpRelay } from "./net/tcp-relay"; import { setupDnsShim } from "./net/dns-shim"; import { startProbe } from "./debug-harness"; +import { SyncFileBuffer } from "./sync-file-buffer"; const PROBE = process.env.WIN95_PROBE === "1"; const PROBE_OPTS: Record = (() => { @@ -336,9 +337,11 @@ export class Emulator extends React.Component<{}, EmulatorState> { private async startEmulator() { document.body.classList.remove("paused"); - const cdromPath = this.state.cdromFile - ? webUtils.getPathForFile(this.state.cdromFile) - : null; + const cdromPath = + process.env.WIN95_PROBE_CDROM || + (this.state.cdromFile + ? webUtils.getPathForFile(this.state.cdromFile) + : null); const options = { wasm_path: path.join(__dirname, "build/v86.wasm"), @@ -372,13 +375,7 @@ export class Emulator extends React.Component<{}, EmulatorState> { buffer: this.state.floppyFile, } : undefined, - cdrom: cdromPath - ? { - url: cdromPath, - async: true, - size: await getDiskImageSize(cdromPath), - } - : undefined, + cdrom: cdromPath ? new SyncFileBuffer(cdromPath) : undefined, boot_order: 0x132, }; diff --git a/src/renderer/sync-file-buffer.ts b/src/renderer/sync-file-buffer.ts new file mode 100644 index 0000000..9bd94e0 --- /dev/null +++ b/src/renderer/sync-file-buffer.ts @@ -0,0 +1,50 @@ +import * as fs from "fs"; + +/** + * v86 disk buffer backed by synchronous fs reads. + * + * v86's stock async loaders (AsyncXHRBuffer / AsyncFileBuffer) return from + * .get() immediately and resolve the data on a later event-loop turn. For an + * ATAPI PIO READ(10) that means atapi_read() leaves the drive in BSY while the + * emulated CPU keeps running. Win95's ESDI_506/CDVSD path checks status twice + * after pushing the CDB, sees BSY both times, and issues DEVICE RESET (08h) — + * which cancels the in-flight read. Net effect: D: shows up but the volume + * never mounts. Serving the bytes synchronously closes that window. + * + * The hard disk doesn't hit this because ESDI_506 drives it via bus-master + * DMA, which is purely IRQ-driven on the host side. + */ +export class SyncFileBuffer { + public byteLength: number; + public onload: undefined | ((e: { buffer?: ArrayBuffer }) => void); + public onprogress: undefined | (() => void); + + private fd: number; + + constructor(path: string) { + this.fd = fs.openSync(path, "r"); + this.byteLength = fs.fstatSync(this.fd).size; + this.onload = undefined; + this.onprogress = undefined; + } + + load() { + this.onload?.({}); + } + + get(start: number, len: number, fn: (data: Uint8Array) => void) { + const buf = Buffer.alloc(len); + fs.readSync(this.fd, buf, 0, len, start); + fn(new Uint8Array(buf.buffer, buf.byteOffset, len)); + } + + set(_start: number, _slice: Uint8Array, fn: () => void) { + fn(); + } + + get_state() { + return [[]]; + } + + set_state(_state: unknown) {} +}