Files
windows95/src/renderer/debug-harness.ts
Felix Rieseberg c847467de6 Recover user files from orphaned state-vN.bin after a version bump (#365)
* Detect orphaned state-vN.bin and offer file recovery to a host folder

When STATE_VERSION is bumped, users previously lost their C:\ silently.
The Welcome card now detects an older state file (v4+), explains what
happened, and offers a one-click recovery: spin up a throwaway v86
(no boot), restore the legacy state to populate the hda dirty-block
overlay, walk the FAT32 tree reading overlay-if-dirty-else-base, and
copy any file the guest ever wrote out to ~/Downloads/Recovered C Drive.

Directories are created lazily so empty branches never appear; success
and failure render in the panel (no native dialogs). The geometry
constraint that keeps overlay+new-base valid is documented next to
STATE_VERSION. Also makes the dev-mode CDP port overridable via
WIN95_DEBUG_PORT so worktree instances don't fight over 9222.

* prettier
2026-04-12 12:38:28 -07:00

580 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Autonomous boot probe. Started from emulator.tsx when WIN95_PROBE=1.
// Writes status + screenshot to /tmp so an outer loop can read them
// without DevTools or CDP.
import * as fs from "fs";
const STATUS_FILE = process.env.WIN95_PROBE_STATUS || "/tmp/win95-probe.json";
const SCREEN_FILE = process.env.WIN95_PROBE_SCREEN || "/tmp/win95-screen.png";
const TICK_MS = 5000;
interface ProbeStatus {
ts: string;
uptimeSec: number;
phase: "init" | "running" | "text-mode" | "splash" | "desktop" | "done";
cpuRunning: boolean;
instructionCounter: number;
instructionDelta: number;
textScreen: string;
textHash: string;
gfxW: number;
gfxH: number;
dominantColor: string;
verdict:
| ""
| "SUCCESS"
| "FAIL_IOS"
| "FAIL_KRNL386"
| "FAIL_VXDLINK"
| "FAIL_PROTECTION"
| "FAIL_SPLASH_HANG"
| "FAIL_HUNG"
| "FAIL_OTHER";
}
let startTime = 0;
let lastInstr = 0;
let lastTextHash = "";
let stableTextTicks = 0;
// XT scancodes (set 1). Win95 doesn't have Win+R — that landed in Win98.
// Ctrl+Esc opens Start, then R is the underlined mnemonic for "Run...".
const SC = {
CTRL_DN: [0x1d],
CTRL_UP: [0x9d],
ESC_DN: [0x01],
ESC_UP: [0x81],
R_DN: [0x13],
R_UP: [0x93],
ENTER_DN: [0x1c],
ENTER_UP: [0x9c],
BACKSLASH_DN: [0x2b],
BACKSLASH_UP: [0xab],
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
// entry is [port, op, value, "eip VMPE cplN"] so you can tell vgabios in V86
// mode apart from the ring-0 display driver.
const VGATRACE_FILE = "/tmp/win95-vgatrace.json";
let vgaTrace: any[] | undefined;
function armVgaTrace(emulator: any) {
const cpu = emulator.v86?.cpu;
const io = cpu?.io;
if (!io || vgaTrace) return;
vgaTrace = [];
const ctx = () => {
try {
const ip = (cpu.instruction_pointer[0] >>> 0).toString(16);
const vm = cpu.flags[0] & (1 << 17) ? "VM" : " ";
const pe = cpu.cr[0] & 1 ? "PE" : " ";
return `${ip} ${vm}${pe} cpl${cpu.cpl[0]}`;
} catch {
return "?";
}
};
const W = [
0x3c0, 0x3c2, 0x3c4, 0x3c5, 0x3ce, 0x3cf, 0x3d4, 0x3d5, 0x3b4, 0x3b5, 0x1ce,
0x1cf,
];
const R = [0x1cf, 0x3da, 0x3c1];
for (const p of W)
for (const w of ["write8", "write16"]) {
const orig = io.ports[p][w];
io.ports[p][w] = function (v: number) {
vgaTrace!.push([p, w, v, ctx()]);
return orig.call(this, v);
};
}
for (const p of R)
for (const r of ["read8", "read16"]) {
const orig = io.ports[p][r];
io.ports[p][r] = function () {
const v = orig.call(this);
vgaTrace!.push([p, r, v, ctx()]);
return v;
};
}
console.log("[probe] vga trace armed");
}
function dumpVgaTrace(emulator: any) {
if (!vgaTrace) return;
const d = emulator.v86?.cpu?.devices?.vga;
const state = d && {
svga_enabled: d.svga_enabled,
graphical_mode: d.graphical_mode,
attribute_mode: d.attribute_mode,
miscellaneous_graphics_register: d.miscellaneous_graphics_register,
sequencer_memory_mode: d.sequencer_memory_mode,
clocking_mode: d.clocking_mode,
plane_write_bm: d.plane_write_bm,
crtc_mode: d.crtc_mode,
max_scan_line: d.max_scan_line,
underline_location_register: d.underline_location_register,
horizontal_display_enable_end: d.horizontal_display_enable_end,
horizontal_blank_start: d.horizontal_blank_start,
vertical_display_enable_end: d.vertical_display_enable_end,
vertical_blank_start: d.vertical_blank_start,
offset_register: d.offset_register,
dispi_enable_value: d.dispi_enable_value,
screen_width: d.screen_width,
screen_height: d.screen_height,
max_cols: d.max_cols,
max_rows: d.max_rows,
};
fs.writeFileSync(VGATRACE_FILE, JSON.stringify({ state, trace: vgaTrace }));
}
function sendChord(emu: any, ...keys: { dn: number[]; up: number[] }[]) {
for (const k of keys) emu.keyboard_send_scancodes(k.dn);
setTimeout(() => {
for (let i = keys.length - 1; i >= 0; i--)
emu.keyboard_send_scancodes(keys[i].up);
}, 60);
}
function sendKey(emu: any, dn: number[], up: number[]) {
emu.keyboard_send_scancodes(dn);
setTimeout(() => emu.keyboard_send_scancodes(up), 50);
}
/** Replay a list of actions: {type:"keys",dn,up} | {type:"text",text} | {type:"wait",ms} */
function runScript(emu: any, steps: any[]) {
let i = 0;
const next = () => {
if (i >= steps.length) {
console.log("[probe] script done");
return;
}
const s = steps[i++];
if (s.type === "wait") {
setTimeout(next, s.ms);
return;
}
if (s.type === "keys") {
sendKey(emu, s.dn, s.up);
setTimeout(next, 200);
return;
}
if (s.type === "chord") {
sendChord(emu, ...s.keys);
setTimeout(next, 200);
return;
}
if (s.type === "text") {
// keyboard_send_text handles ASCII → scancode for us
emu.keyboard_send_text(s.text);
setTimeout(next, 100 + s.text.length * 30);
return;
}
next();
};
next();
}
export function startProbe(emulator: any) {
startTime = Date.now();
console.log("[probe] writing to", STATUS_FILE);
// WIN95_PROBE_SCRIPT=\\HOST → after desktop, send Win+R, type, Enter
const scriptCmd = process.env.WIN95_PROBE_SCRIPT;
// WIN95_PROBE_RUN='telnet 1.2.3.4 7777' → literal text into Start→Run,
// Enter, then optional WIN95_PROBE_RUN_AFTER keystrokes after _RUN_WAIT ms.
// WIN95_PROBE_RUN2 fires a second Start→Run sequence after _RUN2_WAIT ms,
// for two-process scenarios (e.g., background ping + telnet).
const runCmd = process.env.WIN95_PROBE_RUN;
const runCmd2 = process.env.WIN95_PROBE_RUN2;
const runAfter = process.env.WIN95_PROBE_RUN_AFTER;
// WIN95_PROBE_DOSBOX=1 → after desktop, open COMMAND.COM, type `dir`,
// optionally Alt+Enter to fullscreen. Regression test for the windowed
// 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,")) {
const b64 = img.src.slice("data:image/png;base64,".length);
fs.writeFileSync(SCREEN_FILE, Buffer.from(b64, "base64"));
}
} catch {}
dumpVgaTrace(emulator);
// Once at desktop, fire the keyboard script (once). The 8s settle is
// for the "Welcome to Windows 95" tip dialog to be dismissable —
// we send Esc first to clear it.
if (scriptArmed && s.phase === "desktop" && s.uptimeSec > 8) {
scriptArmed = false;
if (dosBox) {
console.log("[probe] desktop detected, opening DOS box");
runScript(emulator, [
{ type: "wait", ms: 3000 },
{ type: "keys", dn: SC.ESC_DN, up: SC.ESC_UP },
{ type: "wait", ms: 1000 },
{ type: "keys", dn: SC.ESC_DN, up: SC.ESC_UP },
{ type: "wait", ms: 1000 },
{
type: "chord",
keys: [
{ dn: SC.CTRL_DN, up: SC.CTRL_UP },
{ dn: SC.ESC_DN, up: SC.ESC_UP },
],
},
{ type: "wait", ms: 1200 },
{ type: "keys", dn: SC.R_DN, up: SC.R_UP },
{ type: "wait", ms: 1000 },
{ type: "text", text: "command" },
{ type: "wait", ms: 400 },
{ type: "keys", dn: SC.ENTER_DN, up: SC.ENTER_UP },
{ type: "wait", ms: 5000 },
{ type: "text", text: "dir" },
{ type: "wait", ms: 200 },
{ type: "keys", dn: SC.ENTER_DN, up: SC.ENTER_UP },
{ type: "wait", ms: 3000 },
...(process.env.WIN95_PROBE_DOSBOX_ALTENTER === "1"
? [
{
type: "chord",
keys: [
{ dn: SC.ALT_DN, up: SC.ALT_UP },
{ dn: SC.ENTER_DN, up: SC.ENTER_UP },
],
},
{ type: "wait", ms: 4000 },
]
: []),
]);
return;
}
if (runCmd) {
console.log("[probe] desktop detected, Run →", runCmd);
runScript(emulator, [
{ type: "wait", ms: 3000 },
{ type: "keys", dn: SC.ESC_DN, up: SC.ESC_UP },
{ type: "wait", ms: 1000 },
{ type: "keys", dn: SC.ESC_DN, up: SC.ESC_UP },
{ type: "wait", ms: 1000 },
{
type: "chord",
keys: [
{ dn: SC.CTRL_DN, up: SC.CTRL_UP },
{ dn: SC.ESC_DN, up: SC.ESC_UP },
],
},
{ type: "wait", ms: 1200 },
{ type: "keys", dn: SC.R_DN, up: SC.R_UP },
{ type: "wait", ms: 1000 },
{ type: "text", text: runCmd },
{ type: "wait", ms: 400 },
{ type: "keys", dn: SC.ENTER_DN, up: SC.ENTER_UP },
...(runCmd2
? [
{
type: "wait",
ms: Number(process.env.WIN95_PROBE_RUN2_WAIT) || 3000,
},
{
type: "chord",
keys: [
{ dn: SC.CTRL_DN, up: SC.CTRL_UP },
{ dn: SC.ESC_DN, up: SC.ESC_UP },
],
},
{ type: "wait", ms: 1200 },
{ type: "keys", dn: SC.R_DN, up: SC.R_UP },
{ type: "wait", ms: 1000 },
{ type: "text", text: runCmd2 },
{ type: "wait", ms: 400 },
{ type: "keys", dn: SC.ENTER_DN, up: SC.ENTER_UP },
]
: []),
...(runAfter
? [
{
type: "wait",
ms: Number(process.env.WIN95_PROBE_RUN_WAIT) || 6000,
},
{ type: "text", text: runAfter },
{ type: "wait", ms: 200 },
{ type: "keys", dn: SC.ENTER_DN, up: SC.ENTER_UP },
]
: []),
]);
return;
}
console.log("[probe] desktop detected, running script:", scriptCmd);
runScript(emulator, [
{ type: "wait", ms: 3000 },
{ type: "keys", dn: SC.ESC_DN, up: SC.ESC_UP }, // dismiss any dialog
{ type: "wait", ms: 1000 },
{ type: "keys", dn: SC.ESC_DN, up: SC.ESC_UP }, // again, for safety
{ type: "wait", ms: 1000 },
{
type: "chord",
keys: [
{ dn: SC.CTRL_DN, up: SC.CTRL_UP },
{ dn: SC.ESC_DN, up: SC.ESC_UP },
],
}, // Ctrl+Esc → Start
{ type: "wait", ms: 1200 },
{ type: "keys", dn: SC.R_DN, up: SC.R_UP }, // Run mnemonic
{ type: "wait", ms: 1000 },
// keyboard_send_text can't reliably do backslash, so we interleave:
// scancode for each \ segment, text for each name segment.
// WIN95_PROBE_SCRIPT='HOST/HOST' → types \\HOST\HOST (we use / as
// the segment separator in the env var to dodge shell escaping hell)
...scriptCmd!.split("/").flatMap((seg, i) => [
...(i === 0
? [
{ type: "keys", dn: SC.BACKSLASH_DN, up: SC.BACKSLASH_UP },
{ type: "wait", ms: 60 },
{ type: "keys", dn: SC.BACKSLASH_DN, up: SC.BACKSLASH_UP },
]
: [{ type: "keys", dn: SC.BACKSLASH_DN, up: SC.BACKSLASH_UP }]),
{ type: "wait", ms: 60 },
{ type: "text", text: seg },
{ type: "wait", ms: 100 },
]),
{ type: "wait", ms: 400 },
{ type: "keys", dn: SC.ENTER_DN, up: SC.ENTER_UP },
]);
}
if (s.verdict) {
console.log("[probe] VERDICT:", s.verdict);
fs.writeFileSync(STATUS_FILE.replace(".json", ".done"), s.verdict);
}
} catch (e) {
console.log("[probe] tick error:", e);
}
};
tick();
setInterval(tick, TICK_MS);
}
function collectStatus(emulator: any): ProbeStatus {
const uptimeSec = (Date.now() - startTime) / 1000;
// CPU activity — instruction counter is u32 in wasm, wraps every ~4B
let instr = 0,
running = false;
try {
instr = emulator.get_instruction_counter() || 0;
} catch {}
try {
running = emulator.is_running();
} catch {}
const instrDelta = (instr - lastInstr) >>> 0;
lastInstr = instr;
// Text screen — only meaningful in text mode (BIOS, DOS, BSOD).
// In graphics mode this returns garbage or empty.
let textScreen = "";
try {
const screen = emulator.screen_adapter || emulator.v86?.screen_adapter;
if (screen) {
const rows = screen.get_text_screen?.() || [];
textScreen = rows
.map((r: string) => r.trimEnd())
.join("\n")
.trim();
}
} catch {}
// VGA state tells us everything: in graphics or text, and at what resolution.
// Win95 splash: 320×400. Win95 desktop: ≥640×480.
// Old v86 builds (pre-2025) don't expose screen_width/screen_height — fall
// back to the rendered canvas dimensions so the bisect harness works across
// versions.
let inGraphics = false,
gfxW = 0,
gfxH = 0;
try {
const vga = emulator.v86?.cpu?.devices?.vga;
if (vga) {
inGraphics = !!vga.graphical_mode;
gfxW = vga.screen_width || 0;
gfxH = vga.screen_height || 0;
}
} catch {}
if (gfxW === 0) {
try {
const canvas = document.querySelector(
"#emulator canvas",
) as HTMLCanvasElement | null;
if (canvas && canvas.width > 0) {
gfxW = canvas.width;
gfxH = canvas.height;
// Canvas exists with content → assume graphics. Text mode uses a div.
const textDiv = document.querySelector(
"#emulator div",
) as HTMLElement | null;
inGraphics =
canvas.style.display !== "none" &&
(!textDiv || textDiv.style.display === "none");
}
} catch {}
}
// Sample the framebuffer to identify which screen we're on.
// Splash is sky-blue gradient (R~120 G~175 B~215). Desktop is teal (0,128,128).
let dominantColor = "";
if (inGraphics) {
try {
const canvas = document.querySelector(
"#emulator canvas",
) as HTMLCanvasElement | null;
if (canvas) {
const ctx = canvas.getContext("2d")!;
const cx = Math.floor(canvas.width / 2);
const cy = Math.floor(canvas.height / 3); // upper-third → sky on splash, taskbar-free on desktop
const px = ctx.getImageData(cx, cy, 1, 1).data;
dominantColor = `${px[0]},${px[1]},${px[2]}`;
}
} catch {}
}
const textHash = hashStr(textScreen);
if (!inGraphics && textHash === lastTextHash && textScreen) stableTextTicks++;
else stableTextTicks = 0;
lastTextHash = textHash;
const hasMeaningfulText =
!inGraphics && textScreen.length > 20 && /[A-Za-z]{4,}/.test(textScreen);
const atSplash = inGraphics && gfxW > 0 && gfxW < 640;
const atDesktop = inGraphics && gfxW >= 640;
const phase: ProbeStatus["phase"] = !running
? "init"
: atDesktop
? "desktop"
: atSplash
? "splash"
: hasMeaningfulText
? "text-mode"
: "running";
let verdict: ProbeStatus["verdict"] = "";
const t = inGraphics ? "" : textScreen.toLowerCase();
if (t.includes("krnl386")) verdict = "FAIL_KRNL386";
else if (t.includes("vxd dynamic link")) verdict = "FAIL_VXDLINK";
else if (
t.includes("initializing device ios") &&
t.includes("protection error")
)
verdict = "FAIL_IOS";
else if (t.includes("windows protection error")) verdict = "FAIL_PROTECTION";
// Stuck at splash for >70s with CPU spinning → IDE IRQ never fired
else if (atSplash && uptimeSec > 70) verdict = "FAIL_SPLASH_HANG";
// Stuck on text for 40s
else if (stableTextTicks >= 8 && instrDelta > 1_000_000)
verdict = "FAIL_HUNG";
// CPU dead
else if (running && instrDelta < 1000 && uptimeSec > 30)
verdict = "FAIL_HUNG";
// Made it to ≥640×480 graphics → desktop reached. But if a keyboard
// script is running, hold off — the outer harness reads the SMB log
// directly and we just keep the app alive.
else if (
atDesktop &&
uptimeSec > 30 &&
!process.env.WIN95_PROBE_SCRIPT &&
!process.env.WIN95_PROBE_RUN &&
!process.env.WIN95_PROBE_DOSBOX
)
verdict = "SUCCESS";
// Timeout
else if (uptimeSec > 180) verdict = "FAIL_OTHER";
return {
ts: new Date().toISOString(),
uptimeSec: Math.round(uptimeSec),
phase,
cpuRunning: running,
instructionCounter: instr,
instructionDelta: instrDelta,
textScreen: textScreen.slice(0, 2000),
textHash,
gfxW,
gfxH,
dominantColor,
verdict,
};
}
function hashStr(s: string): string {
let h = 5381;
for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0;
return (h >>> 0).toString(16);
}