docs/qemu.md
The image built here was made with QEMU. In this doc, I'm keeping instructions around.
Disk image creation
qemu-img create -f raw windows95_v4.raw 1G
ISO CD image creation
hdiutil makehybrid -o output.iso /path/to/folder -iso -joliet
Installation
qemu-system-i386 \
-cdrom Win95_OSR25.iso \
-m 128 \
-hda windows95.img \
-device sb16 \
-nic user,model=ne2k_pci \
-fda Win95_boot.img \
-boot a \
-M pc,acpi=off \
-cpu pentium
fdisk and format c:D:\setup.exe with 24796-OEM-0014736-66386fix95cpu.ima as a bootable floppy to fixvga-driver.iso to install different video driverqemu-system-i386 \
-m 128 \
-hda images/windows95.img \
-device sb16 \
-M pc,acpi=off \
-cpu pentium \
-netdev user,id=mynet0 \
-device ne2k_isa,netdev=mynet0,irq=10
vmport=offThe image has VBADOS (VBMOUSE.EXE + VBMOUSE.DRV) installed for seamless
host-cursor tracking in the app. Don't enable QEMU's VMware backdoor
(-M pc,vmport=on) expecting the same thing — the cursor becomes unusably
laggy. The yarn run qemu script therefore passes vmport=off, which makes
VBMOUSE fall back to a plain relative PS/2 mouse (click the window to grab,
Ctrl+Alt+G to release).
Why it breaks with vmport=on: QEMU's vmmouse queues every host
pointer event (4 words each, up to 256 events) and notifies the guest by
injecting a fake PS/2 packet per event. VBMOUSE reads exactly one
4-word packet per PS/2 interrupt (mousetsr.c, handle_ps2_packet — an
if, not a while). Whenever a single notification is dropped (PS/2
disabled during driver init, PS/2 output queue full, boot), the queue gains
a permanent backlog: from then on the guest only ever reads stale events
and the cursor trails minutes behind. v86 doesn't have this problem because
our vmware-abspointer patch coalesces motion packets in place — the guest
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.
After working in the image (installing things, building, browsing), two kinds of dead weight accumulate:
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:
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
CI does not use images/ from this repo. On tag builds it downloads the
zip from a GitHub release on a separate private repo and unzips it into
images/ (see tools/download-disk.sh / .ps1 and the "Download disk
image" steps in .github/workflows/build.yml). The wiring lives on
this repo:
vars.DISK_REPO — the private images repo
(currently felixrieseberg/windows95-images)vars.DISK_TAG — the release tag CI pulls (currently v5)secrets.IMAGES_REPO_TOKEN — token with read access to DISK_REPOTo ship a new image:
tools/pack-disk.sh images_v6.zip # pack from images/
gh release create v6 -R felixrieseberg/windows95-images \
--title "v6 disk image" images_v6.zip # upload
gh variable set DISK_TAG --body v6 # point CI at it
Constraints imposed by the download script:
.zip asset per release — it downloads with
-p '*.zip' -O images.zip and errors if several assets match.windows95.img and default-state.bin at
the archive root, no containing directory (pack-disk.sh gets this
right; zipping the images/ folder in Finder does not).Existing releases/tags on the images repo are immutable history — make a new tag for a new image rather than replacing assets on an old one, so a re-run of an old build still gets the bytes it shipped with.
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").
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.