Files
windows95/src/renderer/debug-harness.ts
Felix Rieseberg 766497bd5d v86: defer V86-mode VBE disable; add DOS-box probe scenario (#359)
Rebuilds libv86.js from felixrieseberg/v86@windows95-base, which now
carries vga-defer-vbe-disable-v86: when a windowed DOS VM's vgabios
writes dispi[4]=0, Win9x's VDD passes that through (it doesn't know
about ports 1CE/1CF) while virtualising the rest of the mode-set, so
v86 used to drop out of LFB rendering with the legacy registers still
holding SVGA values and the screen turned to planar garbage. The fix
defers the disable until a legacy attribute-mode write actually
reaches the hardware.

debug-harness: WIN95_PROBE_DOSBOX=1 opens command from Run, types
dir, optionally Alt+Enters (WIN95_PROBE_DOSBOX_ALTENTER=1).
WIN95_PROBE_VGATRACE=1 wraps the VGA io.ports[] entries (not the
VGAScreen methods, which are captured by-value at registration) and
dumps [port, op, value, eip+VM/PE/CPL] tuples to
/tmp/win95-vgatrace.json — that EIP/mode column is what pinned the
leak on V86-mode vgabios at C000:2C8x.
2026-04-11 17:32:07 -07:00

408 lines
16 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_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.
const runCmd = process.env.WIN95_PROBE_RUN;
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";
let scriptArmed = !!scriptCmd || !!runCmd || dosBox;
const tick = () => {
try {
if (wantVgaTrace && !vgaTrace) armVgaTrace(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 {
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 },
...(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);
}