Enable CD-ROM via a synchronous fs-backed buffer (#362)

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.
This commit is contained in:
Felix Rieseberg
2026-04-11 19:31:57 -07:00
committed by GitHub
parent 5da7f94c5a
commit 6e73df11ae
5 changed files with 101 additions and 12 deletions

View File

@@ -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

View File

@@ -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;

View File

@@ -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,")) {

View File

@@ -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<string, unknown> = (() => {
@@ -336,9 +337,11 @@ export class Emulator extends React.Component<{}, EmulatorState> {
private async startEmulator() {
document.body.classList.remove("paused");
const cdromPath = this.state.cdromFile
const cdromPath =
process.env.WIN95_PROBE_CDROM ||
(this.state.cdromFile
? webUtils.getPathForFile(this.state.cdromFile)
: null;
: 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,
};

View File

@@ -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) {}
}