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
This commit is contained in:
Felix Rieseberg
2026-04-12 12:38:28 -07:00
committed by GitHub
parent fa0e4c691e
commit c847467de6
12 changed files with 669 additions and 123 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -192,7 +192,10 @@ export class CardSettings extends React.Component<
<fieldset>
<legend>Drive Z:</legend>
<div className="settings-row">
<img className="settings-icon" src="../../static/show-disk-image.png" />
<img
className="settings-icon"
src="../../static/show-disk-image.png"
/>
<p>
A folder on your computer is mounted inside Windows 95 as drive{" "}
<code>Z:</code>. Open My Computer inside Windows to find it.

View File

@@ -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<CardStartProps> {
<small>95</small>
</h1>
<div className="welcome-tip">
<div className="welcome-tip-header">
<strong>Did you know...</strong>
</div>
<p>{this.tip}</p>
</div>
{this.props.legacyStatePath
? this.renderLegacyNotice()
: this.renderTip()}
</div>
<div className="welcome-actions">
<button
@@ -62,4 +66,102 @@ export class CardStart extends React.Component<CardStartProps> {
</div>
);
}
private renderTip() {
return (
<div className="welcome-tip">
<div className="welcome-tip-header">
<strong>Did you know...</strong>
</div>
<p>{this.tip}</p>
</div>
);
}
private renderLegacyNotice() {
const { legacyRecovered, legacyRecoverBusy, legacyRecoverError } =
this.props;
if (legacyRecoverError) {
return (
<div className="welcome-tip welcome-warn">
<div className="welcome-tip-header">
<strong>Recovery failed</strong>
</div>
<p>
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.
</p>
<p>
<code>{legacyRecoverError}</code>
</p>
<div className="welcome-warn-buttons">
<button onClick={this.props.discardLegacy}>
Discard old snapshot
</button>
</div>
</div>
);
}
if (legacyRecovered) {
return (
<div className="welcome-tip welcome-warn">
<div className="welcome-tip-header">
<strong>Old C:\ recovered</strong>
</div>
<p>
{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.
</p>
<p>
<code>{legacyRecovered.dir}</code>
</p>
<div className="welcome-warn-buttons">
<button className="default" onClick={this.props.showRecovered}>
Open folder
</button>
<button onClick={this.props.discardLegacy}>
Discard old snapshot
</button>
</div>
</div>
);
}
return (
<div className="welcome-tip welcome-warn">
<div className="welcome-tip-header">
<strong>Your saved machine is from an older version</strong>
</div>
<p>
This release ships a new disk image and machine configuration. Files
you saved to <code>C:\</code> live only in the old snapshot.
</p>
<p>
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.
</p>
<div className="welcome-warn-buttons">
<button
className="default"
disabled={legacyRecoverBusy}
onClick={this.props.recoverLegacy}
>
{legacyRecoverBusy ? "Recovering…" : "Recover old C:\\ drive…"}
</button>
<button
disabled={legacyRecoverBusy}
onClick={this.props.discardLegacy}
>
Discard it
</button>
</div>
</div>
);
}
}

View File

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

View File

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

View File

@@ -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 = (
<CardStart startEmulator={this.startEmulator} navigate={navigate} />
<CardStart
startEmulator={this.startEmulator}
navigate={navigate}
legacyStatePath={this.state.legacyStatePath}
legacyRecovered={this.state.legacyRecovered}
legacyRecoverBusy={this.state.legacyRecoverBusy}
legacyRecoverError={this.state.legacyRecoverError}
recoverLegacy={async () => {
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<boolean> {
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<ArrayBuffer | null> {
const expectedStatePath = await getStatePath();

View File

@@ -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<number> {
// 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<number, Buffer>();
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;
}

View File

@@ -11,3 +11,7 @@ export async function getStatePath(): Promise<string> {
const statePath = await ipcRenderer.invoke(IPC_COMMANDS.GET_STATE_PATH);
return (_statePath = statePath);
}
export function getLegacyStatePath(): Promise<string | null> {
return ipcRenderer.invoke(IPC_COMMANDS.GET_LEGACY_STATE_PATH);
}

View File

@@ -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<void>((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<number, Uint8Array>;
block_cache_is_write: Set<number>;
};
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 };
}