mirror of
https://github.com/felixrieseberg/windows95.git
synced 2026-05-09 08:28:24 +00:00
Windows 95 can now mount a host folder as a network drive at \\HOST\HOST. Read-only, ~1500 lines, zero deps. Defaults to ~/Downloads, configurable in Settings. Protocol: NEGOTIATE (LANMAN2.1), SESSION_SETUP, TREE_CONNECT, TRANSACTION/RAP (NetShareEnum, NetServerGetInfo, NetWkstaGetInfo), TRANSACTION2/FIND_FIRST2, SEARCH (8.3 with ~N suffix mapping), OPEN_ANDX, NT_CREATE_ANDX, READ_ANDX, CLOSE, QUERY_INFORMATION, CHECK_DIRECTORY. NetBIOS Name Service on UDP 137 answers Node Status and Name Query so \\HOST resolves. v86 hook: monkeypatches adapter.on_tcp_connection (old API), shadows adapter.receive during a port-80 probe to steal a TCPConnection without side effects, re-aims it at port 139. Data via .on_data (Closure dead-code-eliminated .on/.emit). Also registers tcp-connection bus event for newer v86 builds. Security: read-only, path traversal blocked lexically and through symlinks (realpath the deepest existing ancestor, re-append tail, confirm under root). Share path validated in main-process IPC. BIOS updated to SeaBIOS 1.16.2 (compatible with old v86). v86 itself stays on the Feb 2025 prod build — newer builds hang at the splash screen on fresh boot (bisect tooling included in tools/). Also: tools/update-v86.js builds wasm+libv86+BIOS from a local v86 checkout and refuses to install JS/wasm pairs more than 14 days apart (copy.sh ships mismatched pairs). tools/parcel-build.js dynamic-import patch made tolerant of post-d4c5fa86 builds.
266 lines
10 KiB
TypeScript
266 lines
10 KiB
TypeScript
// 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 = "/tmp/win95-probe.json";
|
||
const SCREEN_FILE = "/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],
|
||
};
|
||
|
||
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;
|
||
let scriptArmed = !!scriptCmd;
|
||
|
||
const tick = () => {
|
||
try {
|
||
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 {}
|
||
|
||
// 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;
|
||
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) 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);
|
||
}
|