diff --git a/src/constants.ts b/src/constants.ts index 9dd52ad..9422870 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -2,6 +2,16 @@ import * as path from "path"; const IMAGES_PATH = path.join(__dirname, "../../images"); +// Bump when a release ships a v86/hardware/disk-image change that can't load +// older state-vN.bin snapshots. The app will detect an orphaned older state +// and offer to export the user's old C:\ as a mountable .img. +// +// That export splices the state's dirty-block overlay onto the *current* +// windows95.img — which only works while the partition table and FAT geometry +// stay constant across releases. If you ever resize the disk or reformat with +// different cluster params, the recovered .img won't mount. +export const STATE_VERSION = 4; + export const CONSTANTS = { IMAGES_PATH, IMAGE_PATH: path.join(IMAGES_PATH, "windows95.img"), @@ -32,6 +42,8 @@ export const IPC_COMMANDS = { // Else APP_QUIT: "APP_QUIT", GET_STATE_PATH: "GET_STATE_PATH", + GET_LEGACY_STATE_PATH: "GET_LEGACY_STATE_PATH", + GET_DOWNLOADS_PATH: "GET_DOWNLOADS_PATH", GET_SMB_SHARE_PATH: "GET_SMB_SHARE_PATH", SET_SMB_SHARE_PATH: "SET_SMB_SHARE_PATH", PICK_FOLDER: "PICK_FOLDER", diff --git a/src/css/start.css b/src/css/start.css index 7d799ed..3cd15c4 100644 --- a/src/css/start.css +++ b/src/css/start.css @@ -80,6 +80,24 @@ } } +.welcome-warn { + background: #fff; + + p { + margin: 0 0 8px; + } + + .welcome-warn-buttons { + display: flex; + gap: 6px; + margin-top: 4px; + + button { + height: 24px; + } + } +} + .welcome-actions { width: 130px; display: flex; diff --git a/src/main/ipc.ts b/src/main/ipc.ts index e61e3c0..1e34d2e 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -2,12 +2,31 @@ import { ipcMain, app, dialog, BrowserWindow } from "electron"; import * as path from "path"; import * as fs from "fs"; -import { IPC_COMMANDS } from "../constants"; +import { IPC_COMMANDS, STATE_VERSION } from "../constants"; import { settings } from "./settings"; +const statePathFor = (v: number) => + path.join(app.getPath("userData"), `state-v${v}.bin`); + export function setupIpcListeners() { ipcMain.handle(IPC_COMMANDS.GET_STATE_PATH, () => { - return path.join(app.getPath("userData"), "state-v4.bin"); + return statePathFor(STATE_VERSION); + }); + + ipcMain.handle(IPC_COMMANDS.GET_LEGACY_STATE_PATH, () => { + // If the user already has a current-version state, there's nothing to + // rescue — either they've migrated or never had an older one. + if (fs.existsSync(statePathFor(STATE_VERSION))) return null; + // v2/v3 predate the overlay-rescue machinery and aren't worth supporting. + for (let v = STATE_VERSION - 1; v >= 4; v--) { + const p = statePathFor(v); + if (fs.existsSync(p)) return p; + } + return null; + }); + + ipcMain.handle(IPC_COMMANDS.GET_DOWNLOADS_PATH, () => { + return app.getPath("downloads"); }); ipcMain.handle(IPC_COMMANDS.APP_QUIT, () => { diff --git a/src/main/main.ts b/src/main/main.ts index dd78fe8..4c8729d 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -64,7 +64,10 @@ export function main() { if (isDevMode()) { // Renderer DevTools Protocol — connect Chrome to chrome://inspect // or attach a debugger to localhost:9222 - app.commandLine.appendSwitch("remote-debugging-port", "9222"); + app.commandLine.appendSwitch( + "remote-debugging-port", + process.env.WIN95_DEBUG_PORT || "9222", + ); } // Set the app's name diff --git a/src/renderer/card-settings.tsx b/src/renderer/card-settings.tsx index fc73971..3ac7769 100644 --- a/src/renderer/card-settings.tsx +++ b/src/renderer/card-settings.tsx @@ -192,7 +192,10 @@ export class CardSettings extends React.Component<
Drive Z:
- +

A folder on your computer is mounted inside Windows 95 as drive{" "} Z:. Open My Computer inside Windows to find it. diff --git a/src/renderer/card-start.tsx b/src/renderer/card-start.tsx index e8d8693..d5c72b5 100644 --- a/src/renderer/card-start.tsx +++ b/src/renderer/card-start.tsx @@ -3,6 +3,13 @@ import * as React from "react"; export interface CardStartProps { startEmulator: () => void; navigate: (to: "start" | "settings") => void; + legacyStatePath: string | null; + legacyRecovered: { dir: string; files: number } | null; + legacyRecoverBusy: boolean; + legacyRecoverError: string | null; + recoverLegacy: () => void; + showRecovered: () => void; + discardLegacy: () => void; } const TIPS = [ @@ -37,12 +44,9 @@ export class CardStart extends React.Component { 95 -

-
- Did you know... -
-

{this.tip}

-
+ {this.props.legacyStatePath + ? this.renderLegacyNotice() + : this.renderTip()}
); } + + private renderTip() { + return ( +
+
+ Did you know... +
+

{this.tip}

+
+ ); + } + + private renderLegacyNotice() { + const { legacyRecovered, legacyRecoverBusy, legacyRecoverError } = + this.props; + + if (legacyRecoverError) { + return ( +
+
+ Recovery failed +
+

+ The old snapshot's format isn't compatible with the bundled + emulator, so files couldn't be extracted automatically. The snapshot + has been kept on disk. +

+

+ {legacyRecoverError} +

+
+ +
+
+ ); + } + + if (legacyRecovered) { + return ( +
+
+ Old C:\ recovered +
+

+ {legacyRecovered.files} file + {legacyRecovered.files === 1 ? "" : "s"} you created or modified + have been copied out as ordinary files. Starting Windows here will + be a fresh machine. +

+

+ {legacyRecovered.dir} +

+
+ + +
+
+ ); + } + + return ( +
+
+ Your saved machine is from an older version +
+

+ This release ships a new disk image and machine configuration. Files + you saved to C:\ live only in the old snapshot. +

+

+ Recovery copies anything you created or modified out to an ordinary + folder on this computer — no booting, no disk images. Pre-installed + programs are skipped. +

+
+ + +
+
+ ); + } } diff --git a/src/renderer/debug-harness.ts b/src/renderer/debug-harness.ts index 49cc607..76d8348 100644 --- a/src/renderer/debug-harness.ts +++ b/src/renderer/debug-harness.ts @@ -20,7 +20,16 @@ interface ProbeStatus { gfxW: number; gfxH: number; dominantColor: string; - verdict: "" | "SUCCESS" | "FAIL_IOS" | "FAIL_KRNL386" | "FAIL_VXDLINK" | "FAIL_PROTECTION" | "FAIL_SPLASH_HANG" | "FAIL_HUNG" | "FAIL_OTHER"; + verdict: + | "" + | "SUCCESS" + | "FAIL_IOS" + | "FAIL_KRNL386" + | "FAIL_VXDLINK" + | "FAIL_PROTECTION" + | "FAIL_SPLASH_HANG" + | "FAIL_HUNG" + | "FAIL_OTHER"; } let startTime = 0; @@ -31,18 +40,25 @@ 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], + 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"; +const CDTRACE_FILE = + process.env.WIN95_PROBE_CDTRACE_FILE || "/tmp/win95-cdtrace.log"; let cdTraceArmed = false; function armCdTrace(emulator: any) { @@ -50,10 +66,16 @@ function armCdTrace(emulator: any) { 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`); + 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 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]; @@ -61,10 +83,13 @@ function armCdTrace(emulator: any) { 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)}`); + 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(" "); + 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}]`); } } @@ -93,25 +118,32 @@ function armVgaTrace(emulator: any) { 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 "?"; } + } catch { + return "?"; + } }; - const W = [0x3c0, 0x3c2, 0x3c4, 0x3c5, 0x3ce, 0x3cf, 0x3d4, 0x3d5, 0x3b4, 0x3b5, 0x1ce, 0x1cf]; + 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; - }; - } + 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"); } @@ -146,7 +178,8 @@ function dumpVgaTrace(emulator: any) { 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); + for (let i = keys.length - 1; i >= 0; i--) + emu.keyboard_send_scancodes(keys[i].up); }, 60); } @@ -159,11 +192,25 @@ function sendKey(emu: any, dn: number[], up: number[]) { function runScript(emu: any, steps: any[]) { let i = 0; const next = () => { - if (i >= steps.length) { console.log("[probe] script done"); return; } + 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 === "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); @@ -208,7 +255,9 @@ export function startProbe(emulator: any) { 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 {} + 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,")) { @@ -232,10 +281,13 @@ export function startProbe(emulator: any) { { 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: "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 }, @@ -247,13 +299,18 @@ export function startProbe(emulator: any) { { 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 }, - ] : []), + ...(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; } @@ -265,51 +322,70 @@ export function startProbe(emulator: any) { { 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: "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 }, - ] : []), + ...(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: "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: "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: "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: "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. @@ -317,9 +393,11 @@ export function startProbe(emulator: any) { // 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: "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 }, @@ -347,9 +425,14 @@ 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 {} + 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; @@ -360,7 +443,10 @@ function collectStatus(emulator: any): ProbeStatus { 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(); + textScreen = rows + .map((r: string) => r.trimEnd()) + .join("\n") + .trim(); } } catch {} @@ -369,7 +455,9 @@ function collectStatus(emulator: any): ProbeStatus { // 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; + let inGraphics = false, + gfxW = 0, + gfxH = 0; try { const vga = emulator.v86?.cpu?.devices?.vga; if (vga) { @@ -380,14 +468,19 @@ function collectStatus(emulator: any): ProbeStatus { } catch {} if (gfxW === 0) { try { - const canvas = document.querySelector("#emulator canvas") as HTMLCanvasElement | null; + 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"); + const textDiv = document.querySelector( + "#emulator div", + ) as HTMLElement | null; + inGraphics = + canvas.style.display !== "none" && + (!textDiv || textDiv.style.display === "none"); } } catch {} } @@ -397,7 +490,9 @@ function collectStatus(emulator: any): ProbeStatus { let dominantColor = ""; if (inGraphics) { try { - const canvas = document.querySelector("#emulator canvas") as HTMLCanvasElement | null; + const canvas = document.querySelector( + "#emulator canvas", + ) as HTMLCanvasElement | null; if (canvas) { const ctx = canvas.getContext("2d")!; const cx = Math.floor(canvas.width / 2); @@ -413,45 +508,66 @@ function collectStatus(emulator: any): ProbeStatus { else stableTextTicks = 0; lastTextHash = textHash; - const hasMeaningfulText = !inGraphics && textScreen.length > 20 && /[A-Za-z]{4,}/.test(textScreen); + 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"; + 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("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"; + else if (stableTextTicks >= 8 && instrDelta > 1_000_000) + verdict = "FAIL_HUNG"; // CPU dead - else if (running && instrDelta < 1000 && uptimeSec > 30) verdict = "FAIL_HUNG"; + 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"; + 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, + phase, + cpuRunning: running, instructionCounter: instr, instructionDelta: instrDelta, textScreen: textScreen.slice(0, 2000), - textHash, gfxW, gfxH, dominantColor, + textHash, + gfxW, + gfxH, + dominantColor, verdict, }; } diff --git a/src/renderer/emulator-info.tsx b/src/renderer/emulator-info.tsx index 4705a19..89d78ff 100644 --- a/src/renderer/emulator-info.tsx +++ b/src/renderer/emulator-info.tsx @@ -237,8 +237,7 @@ export class EmulatorInfo extends React.Component< const netRx = Math.round(this.netRxBytes / deltaSec); const netTx = Math.round(this.netTxBytes / deltaSec); - const push = (arr: number[], v: number) => - [...arr, v].slice(-HISTORY_LEN); + const push = (arr: number[], v: number) => [...arr, v].slice(-HISTORY_LEN); this.setState((s) => ({ lastTick: now, diff --git a/src/renderer/emulator.tsx b/src/renderer/emulator.tsx index d6c98e2..de5e338 100644 --- a/src/renderer/emulator.tsx +++ b/src/renderer/emulator.tsx @@ -13,7 +13,8 @@ import { loadInfoBarSettings, saveInfoBarSettings, } from "./info-bar-settings"; -import { getStatePath } from "./utils/get-state-path"; +import { getStatePath, getLegacyStatePath } from "./utils/get-state-path"; +import { recoverLegacyDisk } from "./utils/recover-legacy-disk"; import { Win95Window } from "./app"; import { resetState } from "./utils/reset-state"; import { setupSmbShare } from "./smb"; @@ -48,6 +49,10 @@ export interface EmulatorState { isInfoDisplayed: boolean; isRunning: boolean; infoBarSettings: InfoBarSettings; + legacyStatePath: string | null; + legacyRecovered: { dir: string; files: number } | null; + legacyRecoverBusy: boolean; + legacyRecoverError: string | null; } export class Emulator extends React.Component<{}, EmulatorState> { @@ -71,6 +76,10 @@ export class Emulator extends React.Component<{}, EmulatorState> { isCursorCaptured: false, hasAbsoluteMouse: false, isRunning: false, + legacyStatePath: null, + legacyRecovered: null, + legacyRecoverBusy: false, + legacyRecoverError: null, currentUiCard: "start", isInfoDisplayed: true, smbSharePath: "", @@ -88,6 +97,8 @@ export class Emulator extends React.Component<{}, EmulatorState> { this.setState({ smbSharePath: p }); }); + getLegacyStatePath().then((p) => this.setState({ legacyStatePath: p })); + if (PROBE) { // Skip the start card; boot fresh immediately. The 100ms delay // lets React mount the #emulator div first. @@ -272,7 +283,46 @@ export class Emulator extends React.Component<{}, EmulatorState> { ); } else { card = ( - + { + const p = this.state.legacyStatePath; + if (!p) return; + this.setState({ + legacyRecoverBusy: true, + legacyRecoverError: null, + }); + try { + const downloads = + process.env.WIN95_RECOVER_DIR || + (await ipcRenderer.invoke(IPC_COMMANDS.GET_DOWNLOADS_PATH)); + const outDir = path.join(downloads, "Recovered C Drive"); + const out = await recoverLegacyDisk(p, outDir); + this.setState({ legacyRecovered: out }); + } catch (e) { + console.error("recoverLegacy:", e); + this.setState({ + legacyRecoverError: e instanceof Error ? e.message : String(e), + }); + } finally { + this.setState({ legacyRecoverBusy: false }); + } + }} + showRecovered={() => + this.state.legacyRecovered && + shell.openPath(this.state.legacyRecovered.dir) + } + discardLegacy={async () => { + const p = this.state.legacyStatePath; + if (p) await fs.promises.unlink(p).catch(() => {}); + this.setState({ legacyStatePath: null }); + }} + /> ); } @@ -517,36 +567,36 @@ export class Emulator extends React.Component<{}, EmulatorState> { /** * Restores state to the emulator. */ - private async restoreState() { + private async restoreState(): Promise { const { emulator, isBootingFresh } = this.state; const state = await this.getState(); if (isBootingFresh) { console.log(`restoreState: Booting fresh, not restoring.`); - return; + return true; } else if (!state) { console.log(`restoreState: No state present, not restoring.`); - return; + return false; } else if (!emulator) { console.log(`restoreState: No emulator present`); - return; + return false; } try { await this.state.emulator.restore_state(state); + return true; } catch (error) { console.log( `restoreState: Could not read state file. Maybe none exists?`, error, ); + return false; } } /** * Returns the current machine's state - either what * we have saved or alternatively the default state. - * - * @returns {ArrayBuffer} */ private async getState(): Promise { const expectedStatePath = await getStatePath(); diff --git a/src/renderer/utils/fat32-extract.ts b/src/renderer/utils/fat32-extract.ts new file mode 100644 index 0000000..d97a247 --- /dev/null +++ b/src/renderer/utils/fat32-extract.ts @@ -0,0 +1,132 @@ +import * as fs from "fs"; +import * as path from "path"; + +/** + * Minimal read-only FAT32 walker. Just enough to pull user files out of the + * recovered overlay+base view — no writes, no FAT12/16, no exFAT. + * + * `readSector(lba)` returns 512 bytes at absolute LBA from the *full disk* + * (MBR at LBA 0). `isDirty(lba)` reports whether that sector came from the + * guest's write overlay; we use it to skip files the user never touched so + * the output isn't 200 MB of possibly-mismatched OS binaries. + */ +export async function extractFat32( + readSector: (lba: number) => Buffer, + isDirty: (lba: number) => boolean, + outDir: string, +): Promise { + // First partition from the MBR. + const mbr = readSector(0); + const partLba = mbr.readUInt32LE(0x1be + 8); + + const bpb = readSector(partLba); + const bytesPerSec = bpb.readUInt16LE(11); + const secPerClus = bpb.readUInt8(13); + const rsvd = bpb.readUInt16LE(14); + const nFats = bpb.readUInt8(16); + const secPerFat = bpb.readUInt32LE(36); + const rootClus = bpb.readUInt32LE(44); + if (bytesPerSec !== 512) throw new Error("unexpected sector size"); + + const fatLba = partLba + rsvd; + const dataLba = partLba + rsvd + nFats * secPerFat; + const clusLba = (c: number) => dataLba + (c - 2) * secPerClus; + + const fatSecCache = new Map(); + const nextCluster = (c: number) => { + const off = c * 4; + const sec = fatLba + (off >> 9); + let b = fatSecCache.get(sec); + if (!b) fatSecCache.set(sec, (b = readSector(sec))); + return b.readUInt32LE(off & 511) & 0x0fffffff; + }; + + const chain = (c: number) => { + const out: number[] = []; + while (c >= 2 && c < 0x0ffffff8 && out.length < 1 << 20) { + out.push(c); + c = nextCluster(c); + } + return out; + }; + + const readClusters = (clusters: number[]) => { + const buf = Buffer.allocUnsafe(clusters.length * secPerClus * 512); + let o = 0; + for (const c of clusters) { + const base = clusLba(c); + for (let s = 0; s < secPerClus; s++) + readSector(base + s).copy(buf, o + s * 512); + o += secPerClus * 512; + } + return buf; + }; + + const anyDirty = (clusters: number[]) => { + for (const c of clusters) { + const base = clusLba(c); + for (let s = 0; s < secPerClus; s++) if (isDirty(base + s)) return true; + } + return false; + }; + + const safe = (n: string) => + n.replace(/[\\/:*?"<>|]/g, "_").replace(/[. ]+$/, "") || "_"; + + let files = 0; + const walk = async (clus: number, hostDir: string) => { + const raw = readClusters(chain(clus)); + let lfn = ""; + for (let i = 0; i + 32 <= raw.length; i += 32) { + const e = raw.subarray(i, i + 32); + if (e[0] === 0) break; + if (e[0] === 0xe5) { + lfn = ""; + continue; + } + const attr = e[11]; + if ((attr & 0x3f) === 0x0f) { + // VFAT LFN entries arrive last-first; each carries 13 UCS-2 chars. + let part = ""; + for (const o of [1, 3, 5, 7, 9, 14, 16, 18, 20, 22, 24, 28, 30]) { + const ch = e.readUInt16LE(o); + if (ch === 0 || ch === 0xffff) break; + part += String.fromCharCode(ch); + } + lfn = part + lfn; + continue; + } + if (attr & 0x08) { + lfn = ""; + continue; // volume label + } + const short = + e.toString("latin1", 0, 8).trimEnd() + + (e[8] !== 0x20 + ? "." + e.toString("latin1", 8, 11).trimEnd() + : ""); + const name = lfn || short; + lfn = ""; + if (name === "." || name === "..") continue; + const start = (e.readUInt16LE(20) << 16) | e.readUInt16LE(26); + if (attr & 0x10) { + if (start >= 2) await walk(start, path.join(hostDir, safe(name))); + } else { + const size = e.readUInt32LE(28); + if (size === 0 || start < 2) continue; + const cl = chain(start); + if (!anyDirty(cl)) continue; + await fs.promises.mkdir(hostDir, { recursive: true }); + await fs.promises.writeFile( + path.join(hostDir, safe(name)), + readClusters(cl).subarray(0, size), + ); + files++; + } + } + }; + + await fs.promises.mkdir(outDir, { recursive: true }); + await walk(rootClus, outDir); + return files; +} diff --git a/src/renderer/utils/get-state-path.ts b/src/renderer/utils/get-state-path.ts index 6fbb0c6..72e1d48 100644 --- a/src/renderer/utils/get-state-path.ts +++ b/src/renderer/utils/get-state-path.ts @@ -11,3 +11,7 @@ export async function getStatePath(): Promise { const statePath = await ipcRenderer.invoke(IPC_COMMANDS.GET_STATE_PATH); return (_statePath = statePath); } + +export function getLegacyStatePath(): Promise { + return ipcRenderer.invoke(IPC_COMMANDS.GET_LEGACY_STATE_PATH); +} diff --git a/src/renderer/utils/recover-legacy-disk.ts b/src/renderer/utils/recover-legacy-disk.ts new file mode 100644 index 0000000..84c7e8e --- /dev/null +++ b/src/renderer/utils/recover-legacy-disk.ts @@ -0,0 +1,88 @@ +import * as fs from "fs"; +import * as path from "path"; + +import { CONSTANTS } from "../../constants"; +import { getDiskImageSize } from "../../utils/disk-image-size"; +import { extractFat32 } from "./fat32-extract"; + +declare const V86: any; + +/** + * Reconstruct the user's old C:\ from a legacy state-vN.bin and extract any + * file the guest ever wrote to a host folder — without booting Windows. + * + * v86's async-hda buffer serialises every 256-byte block the guest *wrote* + * (libv86 xa.get_state). We spin up a throwaway v86 (autostart:false), + * restore the legacy state to populate that block cache, then walk the + * FAT32 tree reading each sector as overlay-if-present-else-base. + * + * The base supplies the partition table / BPB (which Windows only reads, + * never writes) — see STATE_VERSION in constants.ts for the geometry + * constraint that keeps that valid across releases. + */ +export async function recoverLegacyDisk( + legacyStatePath: string, + outDir: string, +): Promise<{ dir: string; files: number }> { + const emulator = new V86({ + wasm_path: path.join(__dirname, "build/v86.wasm"), + memory_size: 128 * 1024 * 1024, + vga_memory_size: 64 * 1024 * 1024, + bios: { url: path.join(__dirname, "../../bios/seabios.bin") }, + vga_bios: { url: path.join(__dirname, "../../bios/vgabios.bin") }, + hda: { + url: CONSTANTS.IMAGE_PATH, + async: true, + size: await getDiskImageSize(CONSTANTS.IMAGE_PATH), + }, + autostart: false, + disable_keyboard: true, + disable_mouse: true, + disable_speaker: true, + }); + + await new Promise((resolve) => + emulator.add_listener("emulator-loaded", resolve), + ); + + let files = 0; + const baseFd = fs.openSync(CONSTANTS.IMAGE_PATH, "r"); + try { + const state = fs.readFileSync(legacyStatePath); + await emulator.restore_state(state.buffer); + + const buf = emulator.v86?.cpu?.devices?.ide?.primary?.master?.buffer as { + block_cache: Map; + block_cache_is_write: Set; + }; + if (!buf?.block_cache) { + throw new Error("hda block cache not reachable after restore"); + } + + // v86 caches in 256-byte blocks; FAT works in 512-byte sectors. + const sec = Buffer.allocUnsafe(512); + const readSector = (lba: number) => { + const lo = buf.block_cache_is_write.has(lba * 2) + ? buf.block_cache.get(lba * 2) + : undefined; + const hi = buf.block_cache_is_write.has(lba * 2 + 1) + ? buf.block_cache.get(lba * 2 + 1) + : undefined; + if (lo && hi) return Buffer.concat([lo, hi]); + fs.readSync(baseFd, sec, 0, 512, lba * 512); + if (lo) sec.set(lo, 0); + if (hi) sec.set(hi, 256); + return Buffer.from(sec); + }; + const isDirty = (lba: number) => + buf.block_cache_is_write.has(lba * 2) || + buf.block_cache_is_write.has(lba * 2 + 1); + + files = await extractFat32(readSector, isDirty, outDir); + } finally { + fs.closeSync(baseFd); + await emulator.destroy(); + } + + return { dir: outDir, files }; +}