Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions .claude/skills/inspect-disk/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
---
name: inspect-disk
description: Inspect or modify the Windows 95 disk image offline with mtools — no QEMU, no Electron, no boot. Use when checking what's on the C: drive, extracting/reading files from the image, copying files into it, or verifying image contents after a change.
---

# Inspecting windows95.img without booting it

`images/windows95.img` is a 1 GB raw MBR disk with a single FAT32 (0x0C)
partition starting at sector 63. mtools (installed via Homebrew) can read
and write it directly — the only trick is the partition byte offset:

```
63 sectors × 512 bytes = 32256
```

Every command takes the same image spec: `-i images/windows95.img@@32256 ::`

## Read operations (always safe)

| Task | Command |
|---|---|
| List root | `mdir -i images/windows95.img@@32256 ::` |
| List a subdir | `mdir -i images/windows95.img@@32256 ::/WINDOWS` |
| Recursive bare listing | `mdir -/ -b -i images/windows95.img@@32256 ::` |
| Print a text file | `mtype -i images/windows95.img@@32256 ::/AUTOEXEC.BAT` |
| Extract a file to host | `mcopy -i images/windows95.img@@32256 ::/CONFIG.SYS /tmp/` |
| Extract a dir recursively | `mcopy -s -i images/windows95.img@@32256 ::/TOOLS /tmp/tools` |
| Filesystem info | `minfo -i images/windows95.img@@32256 ::` |
| Disk usage (in 4 KB clusters) | `mdu -i images/windows95.img@@32256 ::` |

Long filenames work (`PROGRA~1` ↔ `Program Files`); quote paths with
spaces. Paths use `::/` as the root of C:.

**Hidden files need `-a`.** Plain `mdir` silently skips hidden/system files
(`BOOTLOG.TXT`, `SYSTEM.DA0`, `ShellIconCache`, `ie5bak.DAT`, …). Existence
checks must use `mdir -a`, or you'll wrongly conclude a file isn't there.

**Check the image isn't in use first.** A running Electron *or QEMU* session
holds the image open and writes to it lazily (Win95's VCACHE flushes on its
own schedule):

```sh
lsof images/windows95.img # must come back empty
```

A copy taken while the VM runs will have FAT inconsistencies (directory
entries written, FAT not yet flushed). To repair a copy:

```sh
DEV=$(hdiutil attach -imagekey diskimage-class=CRawDiskImage -nomount copy.img | head -1 | awk '{print $1}')
fsck_msdos -y "${DEV}s1"
hdiutil detach "$DEV"
```

## Write operations (read the warnings first)

| Task | Command |
|---|---|
| Copy file into image | `mcopy -o -i images/windows95.img@@32256 /host/file.txt ::/TOOLS/` |
| Make a directory | `mmd -i images/windows95.img@@32256 ::/NEWDIR` |
| Delete a file | `mdel -i images/windows95.img@@32256 ::/FILE.TXT` |
| Delete a dir recursively | `mdeltree -i images/windows95.img@@32256 ::/DIR` |
| Clear R/S/H attributes | `mattrib -i images/windows95.img@@32256 -r -s -h ::/FILE` (`-/` for recursive) |

`mdel`/`mdeltree` fail on read-only/hidden/system files — run `mattrib`
first. Note that bulk offline cleanup is currently discouraged: offline
modification worsens v86 cold-boot reliability. Prefer doing cleanup
inside Windows; see "Slimming the image" in `docs/qemu.md`.

### Warning 1: never write while a VM holds the image

If Electron/v86 or QEMU is running against the image, offline writes race
the guest's own writes and corrupt the FAT. Check first:

```sh
pgrep -fl "windows95.*electron|qemu.*windows95"
```

### Warning 2: any offline write must be verified with the in-app probe

QEMU and v86 exercise different boot paths (different hardware → different
driver init), and offline modification can break v86 cold boot while QEMU
still boots fine ("Invalid VxD dynamic link call" — see "VXDLINK: flake
vs. real bug" in the probe-win95 skill). Verify with `tools/probe-boot.sh`,
not just `yarn run qemu`. In particular, **never zero free space via the
mcopy-a-giant-zero-file trick** — that breaks v86 cold boot
deterministically; use `tools/zero-free-clusters.py` if you must zero free
space at all.

### Warning 3: saved states cache the old disk

`images/default-state.bin` and `~/Library/Application Support/windows95/state-v*.bin`
contain Win95's RAM, including VCACHE/FAT caches that reference the disk
*as it was when the state was saved*. Modifying the disk offline and then
resuming a saved state = filesystem corruption.

After any offline write:

```sh
rm -f "$HOME/Library/Application Support/windows95/"state-v*.bin
```

…so the next boot is fresh (or from `default-state.bin`, which boots from
disk early enough to be safe only if the files you touched weren't open at
state-save time — when in doubt, do a full fresh boot and re-save).

## Alternative: mount in Finder

```sh
hdiutil attach -imagekey diskimage-class=CRawDiskImage -readonly images/windows95.img
# … browse /Volumes/WIN95 …
hdiutil detach /Volumes/WIN95
```

Read-only is deliberate; use mtools for writes so you control exactly what
changes.

## Worktree note

`images/` is gitignored — in a fresh worktree there is no image to inspect.
See "Running from a git worktree" in the probe-win95 skill for the
clone-from-main-checkout snippet.

## Why not QEMU/v86?

Booting to inspect the disk takes ~40s+ per round trip (see `probe-win95`)
and every boot mutates the image (registry, swap, SCANDISK.LOG). mtools is
instant and, for read operations, leaves the image bit-identical — which
matters when you're trying to diff or bisect image changes.
30 changes: 30 additions & 0 deletions .claude/skills/probe-win95/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,3 +132,33 @@ of the normal app.
| No SMB traffic at all | `$TMPDIR/windows95-smb.log` should have `hooked adapter` line. If absent, v86 API changed — see `src/renderer/smb/README.md` |
| SMB hooks fire, no connection | Win95's "NetBIOS over TCP/IP" checkbox — bake into default-state.bin |
| Boot hangs on `2996c087` or older v86 | You probably have a ABI-mismatched wasm/JS pair. Prod wasm is the ground truth; rebuild JS against it. |

## VXDLINK: flake vs. real bug

Two different things produce FAIL_VXDLINK:

1. **Sporadic flake** (~1 in 2–3 runs even on known-good images): passes on
retry. This is why the retry-3× rule exists.
2. **Deterministic failure** (same address every run, e.g.
`VMM(01)+000036E5 → device "C000" service E3E4`): the image's disk
layout triggers a real v86 disk-path bug. Known triggers: zeroing free
space via the "mcopy a giant zero file, then delete it" trick, and
offline (mtools) mass-deletion of recently-written file trees. The same
image boots fine in QEMU and passes fsck. Retrying never helps; the
image content must change.

This is the canonical verdict-interpretation policy (other docs link here):

- **One SUCCESS** = the image can boot. Good — same rule as bisecting.
- **Three identical failures** (same VxD address) = the image is in a bad
state. Stop retrying; the content must change.
- Anything in between = keep retrying, you are looking at flakes.

Never conclude anything from a single FAIL.

## Probing the state-restore path

`WIN95_PROBE_RESTORE=1 tools/probe-boot.sh` makes the probe restore state
(user state → `images/default-state.bin` fallback) instead of cold
booting. Use it to verify a freshly generated default-state.bin actually
resumes to the desktop — required after every image change.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,6 @@ guest-tools/**/*.EXE
guest-tools/**/*.exe
/.claude/scheduled_tasks.lock
/.claude/worktrees/

# Packed release images (uploaded to the private images release, never committed)
/images_*.zip
48 changes: 48 additions & 0 deletions docs/qemu.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,51 @@ is never more than one move behind by design.
Fixing it for real would mean either teaching QEMU's `hw/i386/vmmouse.c` to
coalesce motion events, or patching VBADOS to drain the whole queue per
interrupt and baking the rebuilt driver into the image.

## Slimming the image before a release

After working in the image (installing things, building, browsing), two
kinds of dead weight accumulate:

1. **Deleted-file remnants.** FAT deletion only marks clusters free — the
old bytes stay on disk and bloat the compressed zip badly (at one point
~200 MB of the packed zip was deleted-file garbage).
2. **Content that has no function in this app**: 1996/97 online-service
installers (AOL, CompuServe, MSN), setup cabinets, uninstall backups,
NetMeeting, browser caches. (Help files are dead weight too, but they
stay — F1 showing period-correct 1995 help is part of the charm.)

**The recommended workflow is to do all of this inside Windows** (in a
QEMU session): delete the dead content in Explorer, empty the Recycle Bin,
run ScanDisk, shut down cleanly. Win95's own filesystem operations keep
the disk in a state that v86 cold-boots. Then:

```sh
tools/probe-boot.sh # cold-boot in the APP (v86) — required
yarn run qemu # optional second opinion
# regenerate images/default-state.bin from a successful cold boot
# bump STATE_VERSION in src/constants.ts
tools/pack-disk.sh # build the new images zip
```

**Verify in the app, not just QEMU, and expect flakes.** Cold boot in v86
currently fails sporadically on any image, and certain disk states fail
deterministically. For how to interpret probe verdicts (when to retry,
when to ship, when to declare the image broken), follow the policy in
`.claude/skills/probe-win95/SKILL.md` ("VXDLINK: flake vs. real bug").

### Offline modification (currently not recommended)

The image can also be modified offline with mtools (see the inspect-disk
skill), and `tools/zero-free-clusters.py` can zero free clusters without
touching the FAT. The results pass fsck and boot in QEMU — but offline
modification measurably worsens v86 cold-boot reliability on images that
have been through recent QEMU sessions, again due to the bug above. Until
that bug is fixed in the v86 fork, prefer the in-Windows workflow and ship
the image untouched. (Never zero free space with the classic "`mcopy` a
giant zero file, then delete it" trick — that one breaks v86 cold boot
deterministically.)

After any image content change you MUST regenerate
`images/default-state.bin` (the old saved state has the old FAT cached in
guest RAM) and bump `STATE_VERSION` in `src/constants.ts`.
13 changes: 7 additions & 6 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@ 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.
// and offer to recover the user's files from it (extracted to a host folder,
// see recover-legacy-disk.ts).
//
// 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 = 5;
// That recovery walks the old state's dirty-block overlay on top of 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, recovery of older states breaks.
export const STATE_VERSION = 6;

export const CONSTANTS = {
IMAGES_PATH,
Expand Down
50 changes: 40 additions & 10 deletions src/renderer/emulator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ import { startProbe } from "./debug-harness";
import { SyncFileBuffer } from "./sync-file-buffer";

const PROBE = process.env.WIN95_PROBE === "1";
// WIN95_PROBE_RESTORE=1 makes the probe restore state (user state, falling
// back to images/default-state.bin) instead of cold-booting — for verifying
// that a freshly generated default-state.bin actually resumes to the desktop.
const PROBE_RESTORE = process.env.WIN95_PROBE_RESTORE === "1";
const PROBE_OPTS: Record<string, unknown> = (() => {
try {
return JSON.parse(process.env.WIN95_PROBE_OPTS || "{}");
Expand Down Expand Up @@ -73,7 +77,7 @@ export class Emulator extends React.Component<{}, EmulatorState> {
this.bootFromScratch = this.bootFromScratch.bind(this);

this.state = {
isBootingFresh: PROBE,
isBootingFresh: false,
isCursorCaptured: false,
hasAbsoluteMouse: false,
isRunning: false,
Expand Down Expand Up @@ -101,9 +105,26 @@ export class Emulator extends React.Component<{}, EmulatorState> {
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.
setTimeout(() => this.bootFromScratch(), 100);
// Skip the start card. Cold boot by default; with WIN95_PROBE_RESTORE=1
// start normally so the saved/default state gets restored. The 100ms
// delay lets React mount the #emulator div first.
setTimeout(async () => {
if (!PROBE_RESTORE) {
this.bootFromScratch();
return;
}

// Restore mode exists to verify a state file. Refuse to fall back
// to a cold boot — that also reaches the desktop, and would turn a
// missing state into a false SUCCESS verdict.
if (await this.getStateFilePath()) {
this.startEmulator();
} else {
console.error(
"WIN95_PROBE_RESTORE=1 but there is no state to restore — not booting",
);
}
}, 100);
}
}

Expand Down Expand Up @@ -661,24 +682,33 @@ export class Emulator extends React.Component<{}, EmulatorState> {
}

/**
* Returns the current machine's state - either what
* we have saved or alternatively the default state.
* Path of the state file that would be restored: the user's saved state
* if present, falling back to the bundled default state. Null if neither
* exists.
*/
private async getState(): Promise<ArrayBuffer | null> {
private async getStateFilePath(): Promise<string | null> {
const expectedStatePath = await getStatePath();
const statePath = fs.existsSync(expectedStatePath)
? expectedStatePath
: CONSTANTS.DEFAULT_STATE_PATH;

if (fs.existsSync(statePath)) {
return fs.readFileSync(statePath).buffer;
} else {
console.log(`getState: No state file found at ${statePath}`);
return statePath;
}

console.log(`getStateFilePath: No state file found at ${statePath}`);
return null;
}

/**
* Returns the current machine's state - either what
* we have saved or alternatively the default state.
*/
private async getState(): Promise<ArrayBuffer | null> {
const statePath = await this.getStateFilePath();
return statePath ? fs.readFileSync(statePath).buffer : null;
}

private unlockMouse() {
document.exitPointerLock();
}
Expand Down
Loading
Loading