mirror of
https://github.com/felixrieseberg/windows95.git
synced 2026-05-09 00:24:09 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,18 +118,25 @@ 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"]) {
|
||||
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"]) {
|
||||
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);
|
||||
@@ -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: [
|
||||
{
|
||||
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: [
|
||||
...(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,35 +322,51 @@ 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: [
|
||||
{
|
||||
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: [
|
||||
...(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 },
|
||||
]
|
||||
: []),
|
||||
...(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;
|
||||
}
|
||||
@@ -304,10 +377,13 @@ export function startProbe(emulator: any) {
|
||||
{ type: "wait", ms: 1000 },
|
||||
{ type: "keys", dn: SC.ESC_DN, up: SC.ESC_UP }, // again, for safety
|
||||
{ type: "wait", ms: 1000 },
|
||||
{ type: "chord", keys: [
|
||||
{
|
||||
type: "chord",
|
||||
keys: [
|
||||
{ dn: SC.CTRL_DN, up: SC.CTRL_UP },
|
||||
{ dn: SC.ESC_DN, up: SC.ESC_UP },
|
||||
]}, // Ctrl+Esc → Start
|
||||
],
|
||||
}, // Ctrl+Esc → Start
|
||||
{ type: "wait", ms: 1200 },
|
||||
{ type: "keys", dn: SC.R_DN, up: SC.R_UP }, // Run mnemonic
|
||||
{ type: "wait", ms: 1000 },
|
||||
@@ -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: "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: "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,13 +468,18 @@ 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" &&
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
132
src/renderer/utils/fat32-extract.ts
Normal file
132
src/renderer/utils/fat32-extract.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
88
src/renderer/utils/recover-legacy-disk.ts
Normal file
88
src/renderer/utils/recover-legacy-disk.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user