Technical Documentation: felixrieseberg/windows95
โน๏ธ Provenance: Multi-Tier Ingestion ยท CodeWiki Reference
Overview (`README.md`)
windows95
This is Windows 95, running in an Electron app. Yes, it's the full thing. I'm sorry.
Downloads
| Windows | 32-bit
๐ฟ Installer
๐ฆ Standalone Zip
64-bit
๐ฟ Installer
๐ฆ Standalone Zip
โ Don't know what kind of chip you have? It's probably `x64`. To confirm, on your computer, hit Start, enter "processor" for info. || --- | --- |
| macOS | Apple Silicon Processor
๐ฆ Standalone Zip
Intel Processor
๐ฆ Standalone Zip
โ Don't know what kind of chip you have? If you bought your computer after 2020, select "Apple Silicon". Learn more at [apple.com](https://support.apple.com/en-us/HT211814). || Linux | 64-bit
๐ฟ rpm
๐ฟ deb
ARM64
๐ฟ rpm
๐ฟ deb
ARM
๐ฟ rpm
๐ฟ deb |Does it work?
Yes! Quite well, actually - on macOS, Windows, and Linux. Bear in mind that this is written entirely in JavaScript, so please adjust your expectations.
Should this have been a native app?
Absolutely.
Does it run Doom (or my other favorite game)?
You'll likely be better off with an actual virtualization app, but the short answer is yes. In fact, a few games are already preinstalled - and more can be found on the Internet, for instance at archive.org. Thanks to
@DisplacedGamers I can recommend that you switch to a resolution of
640x480 @ 256 colors before starting DOS games - just like in the good ol' days.
Credits
99% of the work was done over at v86 by Copy aka Fabian Hemmer and his contributors.
Contributing
Before you can run this from source, you'll need the disk image. It's not part of the
repository, but you can grab it using the Show Disk Image button from the packaged
release, which does include the disk image. You can find that button in theModify C: Drive section.
Unpack the images folder into the src folder, creating this layout:
- /images/windows95.img
- /images/default-state.bin
- /assets/...
- /bios/...
- /docs/...Once you've done so, run npm install and npm start to run your local build.
If you want to tinker with the image or make a new one, check out the QEMU docs.
Other Questions
- MS-DOS seems to brick the screen
- Windows 95 is stuck in a bad state
- I want to install additional apps or games
- Running in Docker
- Running in an online VM with Kubernetes and Gitpod
License
This project is provided for educational purposes only. It is not affiliated with and has
not been approved by Microsoft.
Overview (`guest-tools/README.md`)
guest-tools
Files and folders in this directory are exposed read-only inside the VM at\\HOST\TOOLS (alongside the synthetic _MAPZ.BAT and README.TXT).
Drop drivers and utilities here that you want available from within
Windows 95.
mouse-driver/ โ seamless mouse (VBADOS)
VBMOUSE.EXE (DOS TSR) + VBMOUSE.DRV (Windows 3.x/9x driver) from
VBADOS by Javier S. Pedro,
GPLv2. Talks to v86's VMware mouse backdoor (port 0x5658) so the Windows
95 cursor tracks the host cursor pixel-for-pixel without pointer lock.
Install inside the guest:
- Copy
\\HOST\TOOLS\mouse-driver\VBMOUSE.EXEtoC:\and add aC:\VBMOUSE.EXEline toC:\AUTOEXEC.BAT. - Windows Setup (or Control Panel โ Mouse โ General โ Change โ Have
Disk) โ browse to\\HOST\TOOLS\mouse-driverโ pick VBMouse int33
absolute mouse driver. - Reboot. The app detects the driver and stops grabbing pointer lock;
ESC still toggles lock for games that want raw relative input.
agent/ โ W95TOOLS guest agent
W95TOOLS.EXE is a hidden-window agent that talks to the emulator over
the VMware backdoor (port 0x5658). Currently it:
- bridges Windows 95's
CF_TEXTclipboard to the host (legacy backdoor
commands 6โ9; host side issrc/renderer/clipboard.ts, which polls
Electron's clipboard), - auto-maps
\\HOST\HOSTtoZ:at login viaWNetAddConnection, so
the shared folder shows up as a drive without a trip through
Start โ Run, - keeps the Windows clock set to the host's local time (backdoor
GETTIME, command 23). Windows only reads the RTC at boot, so without
this a resumed emulator session carries on with the clock from
whenever the state was saved. The agent checks every 5 seconds and
steps the clock whenever it's off by 2 seconds or more โ covering
resume, slow-emulation drift, and the UTC-only RTC at boot.
Host-initiated shutdown and a tray icon will live here too when those
land.
Install inside the guest:
- Copy
\\HOST\TOOLS\agent\W95TOOLS.EXEtoC:\WINDOWS\. - Drop a shortcut to it in
C:\WINDOWS\Start Menu\Programs\StartUpso it runs on login.
Copy text on either side and it appears on the other within ~250 ms.
Text only; conversion is Windows-1252 โ UTF-8 with CRLF โ LF, capped at
64 KB. Built from w95tools.c with Open Watcom v2 โ make -C guest-tools/agent (needs Docker).
To update an already-installed agent: end the running W95tools task
(Ctrl+Alt+Del โ End Task) or reboot Windows, copy the new\\HOST\TOOLS\agent\W95TOOLS.EXE over C:\WINDOWS\W95TOOLS.EXE, and
start it again (the StartUp shortcut also picks it up at next login).
Baking into the disk image
Fresh boots run whatever is baked into images/windows95.img โ the
agent is installed there twice: at C:\WINDOWS\W95TOOLS.EXE and as a
copy in C:\WINDOWS\Start Menu\Programs\StartUp. You can bake a new
build directly from the host with mtools (brew install mtools), no
QEMU boot needed. The FAT32 partition starts at sector 63 (byte offset
32256):
make -C guest-tools/agent
IMG="images/windows95.img@@32256"
mcopy -o -i "$IMG" guest-tools/agent/W95TOOLS.EXE '::/WINDOWS/W95TOOLS.EXE'
mcopy -o -i "$IMG" guest-tools/agent/W95TOOLS.EXE \
'::/WINDOWS/STARTM~1/PROGRAMS/STARTUP/W95TOOLS.EXE'Do this while the app is not running. Note that an existing saved
state (state-v4.bin+) keeps its own dirty-block overlay over the
image and the old agent stays loaded in the resumed session's RAM โ
so resumed sessions only pick up a new agent after a guest reboot or
an End Task + relaunch.
Overview (`src/renderer/smb/README.md`)
SMB1 server for Windows 95
Zero-dependency SMB1/CIFS server that lets Windows 95 (running inside v86) mount
a host folder as a network drive. Read-only. ~1500 lines.
Stack
| Layer | File | What it does |
|---|---|---|
| Ethernet/IP/UDP | nbns.ts |
Taps bus.register("net0-send") for raw frames, parses UDP 137, builds reply frames manually |
| NetBIOS Name Service | nbns.ts |
Answers Node Status (0x21) and Name Query (0x20) โ Win95 won't try TCP until this resolves |
| TCP 139 hook | index.ts |
Monkeypatches adapter.on_tcp_connection (old v86) or registers tcp-connection bus event (new v86) |
| NetBIOS Session | netbios.ts |
RFC 1002 framing โ 4-byte header, reassembles fragmented TCP |
| SMB1 wire | wire.ts, smb.ts |
Little-endian Reader/Writer, header parse/build |
| Commands | server.ts |
NEGOTIATE, SESSION_SETUP, TREE_CONNECT, TRANSACTION (RAP), TRANSACTION2, SEARCH, OPEN, READ, CLOSE, etc. |
Protocol gotchas (learned the hard way)
NEGOTIATE: NT LM 0.12 is the only path to long filenames
Win95 offers ["PC NETWORK PROGRAM 1.0", "MICROSOFT NETWORKS 3.0", "DOS LM1.2X002", "DOS LANMAN2.1", "Windows for Workgroups 3.1a", "NT LM 0.12"]. We pickNT LM 0.12 and send the 17-word NT response (Capabilities=0 โ no UNICODE, no
NT_STATUS, no NT_FIND, so the rest of the protocol stays OEM/DOS-error). On any
LANMAN dialect Win95's redirector lists directories via CMD_SEARCH (0x81) whose
13-byte name field hard-caps at 8.3; under NT LM 0.12 it switches toTRANS2/FIND_FIRST2 and asks for level 0x104 (FILE_BOTH_DIRECTORY_INFO)
regardless of CAP_NT_FIND. We implement that level โ the 94-byte fixed prefix
plus OEM long name, ShortName always UTF-16LE per spec. The 13-word LANMAN
response is kept as a fallback for clients that don't offer NT.
Shares
Two disk shares plus IPC$. The user share is named after path.basename() of the
mounted folder (sanitized, โค12 chars). TOOLS is purely synthetic โ _MAPZ.BAT,README.TXT โ so the user's listing isn't cluttered. treeConnect routes by
share name to a TID; every path-resolving handler branches on TID so the TOOLS
tree never touches the host fs.
The user share's backing directory is re-validated on every TREE_CONNECT and
NetShareEnum (it can be deleted โ or restored โ at any time after the path was
saved in settings). While it's missing, connects to that share return
ERRSRV/ERRinvnetname ("network name cannot be found"), the share is hidden from
enumeration, and TOOLS/IPC$ keep working. SmbSession accepts a null root for
the "no folder configured" case and must never throw from its constructor โ see
the bus-listener note below.
SEARCH (0x81): single-file probes vs wildcard listings
SEARCH "\FOO.TXT" is a stat probe โ Win95 wants exactly one entry back. If you
prepend . and .. like you would for \*, Win95 reads the first entry (.,
attr=DIRECTORY) and treats FOO.TXT as a folder. Only prepend dots when the
pattern contains * or ?.
SEARCH filename: null-terminate before padding
The 13-byte name field must be name\0\0\0..., not name \0. Space-padding
before the null means Win95 sees FOO.BAT (with trailing spaces) and can't
match the .BAT file association.
8.3 mapping needs `~N` suffixes, not just truncation
84 files in a real Downloads folder โ most have long names โ naive truncation
gives 30 copies of 15_UNDER.PDF. Use Windows-style ~N and keep a per-dir
SFNโreal-name map so OPEN can find the actual file. resolve() walks each path
component through the map.
RAP (TRANSACTION 0x25): Win95 loops until ServerGetInfo answers
After TREE_CONNECT \\HOST\IPC$, Win95 sends RAP NetShareEnum (func=0, WrLeh/B13BWz) then NetWkstaGetInfo (func=63, WrLh/zzzBBzz) then NetServerGetInfo
(func=13, WrLh/B16BBDz). The data descriptor tells you the layout:B16 = 16-byte inline name, z = string pointer (4 bytes into a heap that
follows the struct), B = byte, D = dword. We synthesize the struct from the
descriptor so any info-level Win95 asks for gets a plausible reply.
Virtual files need to be visible to QUERY_INFORMATION too
The injected _MAPZ.BAT showed in listings but Win95 stats before opening,
got ERR_BADFILE, said "cannot find". Hook getVirtual() into QUERY_INFO and
CHECK_DIRECTORY, not just OPEN.
v86 integration (the hard part)
Old v86 (Feb 2025 โ what currently boots): connection theft
The tcp-connection bus event was added later. The old API isadapter.on_tcp_connection(packet, tuple) โ you must construct TCPConnection
yourself, but it's closure-scoped in Closure-compiled libv86.js. Worse,.on()/.emit()/events_handlers were dead-code-eliminated; the data callback
is a flat .on_data property.
The trick: shadow adapter.receive with a no-op (own-prop on a prototype method
โ must restore via delete, not reassignment), call the original handler
with a fake port-80 SYN, take the TCPConnection it builds, re-aim it at port
139. accept(packet) overwrites all routing fields (sport/dport/hsrc/psrc/seq/
ack), .on_data = handler replaces the HTTP callback.
New v86: just `bus.register("tcp-connection")`
Clean API. The new code keeps both paths; the bus event is a no-op on old builds.
Exception in a bus listener kills the emulator
bus.send doesn't catch listener exceptions. They bubble through ne2k โport_write8 โ wasm and unwind do_tick() before it can schedulenext_tick() โ the whole VM freezes (instruction counter stops, CPU not
halted), not just SMB. The corrupted state then gets saved byonbeforeunload. Wrap everything that runs in a callback:
- the
tcp-connectionbus handler and the old-APIon_tcp_connectionhook
(index.ts) are wrapped in try/catch โ any failure declines the connection, - the per-connection data handler (
index.ts) drops the frame instead, - the NBNS
net0-sendhandler (nbns.ts) drops the frame instead, SmbSession's constructor never throws (a stale share root degrades to
per-tree SMB errors; see "Shares" above).
This was a real bug: a share path deleted after being saved madenew SmbSession throw ENOENT inside the tick on the guest's first port-139
SYN, freezing the entire VM seconds after every boot.
Security
- Read-only.
- Path traversal blocked lexically (
../) AND through symlinks:realpathSync
the deepest existing ancestor, re-append the unresolved tail, confirm under
root. Symlinks pointing inside the share still work; symlinks pointing out
return ERR_BADFILE. - Share path validated in main-process IPC (
realpathSync+isDirectory()),
re-checked at emulator startup (emulator.tsx), and re-resolved on every
TREE_CONNECT โ a directory deleted after being saved degrades to SMB errors
instead of crashing.
Tests
test-standalone.ts โ 76 protocol tests, full round-trips with real file I/O.
Run: npx ts-node --skip-project --transpile-only --compiler-options '{"module":"commonjs","moduleResolution":"bundler","ignoreDeprecations":"6.0"}' src/renderer/smb/test-standalone.ts
Docker Instructions (`docs/docker-instructions.md`)
Running windows95 in Docker
Display using a volume mount of the host X11 Unix Socket (Linux Only):
Requirements:
Linux OS with a running X-Server Display
-
text
docker run -it -v /tmp/.X11-unix:/tmp/.X11-unix -e DISPLAY=unix$DISPLAY --device /dev/snd --name windows95 toolboc/windows95
Note: You may need to run xhost + on your system to allow connections to the X server running on the host.
Display using Xming X11 Server over tcp Socket (Windows and beyond):
Requirements:
Start the Xming X11 Server
Run the command below:
textdocker run -e DISPLAY=host.docker.internal:0 --name windows95 toolboc/windows95
Display using the host XQuartz Server (MacOS Only):
Requirements:
- Start XQuartz, go to
Preferences->Security, and check the boxAllow connections from network clients - Restart XQuartz
- In the terminal, run
xhost +- run
docker run -it -e DISPLAY=host.docker.internal:0 toolboc/windows95Docker Kubernetes Gitpod (`docs/docker-kubernetes-gitpod.md`)
Running an online version of windows95
You can also run windows95 in Electron, in a virtual X server, in a JavaScript VNC client, in a Kubernetes workspace. What could go wrong?
Qemu (`docs/qemu.md`)
QEMU Instructions
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 1GISO CD image creation
hdiutil makehybrid -o output.iso /path/to/folder -iso -jolietInstallation
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- Boot from floppy
- Run
fdiskandformat c: - Run
D:\setup.exewith24796-OEM-0014736-66386 - After completing setup and restarting your computer, you might get an IOS Windows protection error
- Use
fix95cpu.imaas a bootable floppy to fix - Use
vga-driver.isoto install different video driver
qemu-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=10Mouse: keep `vmport=off`
The 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 โ anif, 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.
Slimming the image before a release
After working in the image (installing things, building, browsing), two
kinds of dead weight accumulate:
- 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). - 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:
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 zipUploading the packed image
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 intoimages/ (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
(currentlyfelixrieseberg/windows95-images)vars.DISK_TAGโ the release tag CI pulls (currentlyv5)secrets.IMAGES_REPO_TOKENโ token with read access toDISK_REPO
To 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 itConstraints imposed by the download script:
- Exactly one
.zipasset per release โ it downloads with-p '*.zip' -O images.zipand errors if several assets match. - The zip must be flat:
windows95.imganddefault-state.binat
the archive root, no containing directory (pack-disk.shgets this
right; zipping theimages/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").
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 regenerateimages/default-state.bin (the old saved state has the old FAT cached in
guest RAM) and bump STATE_VERSION in src/constants.ts.
Smb Share (`docs/smb-share.md`)
Host folder over SMB
Windows 95 can mount a host folder as a network drive. The server lives insrc/renderer/smb/ โ ~1500 lines, zero dependencies, read-only. Defaults to~/Downloads, configurable in Settings.
Inside Win95
Open My Computer โ Z:. The W95TOOLS.EXE guest agent auto-maps\\HOST\HOST to Z: at login, so the share is available as a drive letter
out of the box. If the agent isn't running, the underlying UNC path still
works via Start โ Run โ \\HOST\HOST.
NetBIOS over TCP/IP must be enabled (Control Panel โ Network โ TCP/IP
properties โ NetBIOS tab). This is baked into the default state image.
Architecture
One SMB session per TCPConnection, hooked off v86's network adapter. The
server speaks SMB1 (LANMAN2.1 dialect) because that's what Win95 negotiates.
Full breakdown in src/renderer/smb/README.md โ that file has the protocol
gotchas learned during implementation (NT dialect trap, NetBIOS name
null-termination, 8.3 ~N mapping, RAP descriptor parsing).
Security: read-only, symlink-aware path traversal guard, share path
validated in main-process IPC. Not exposed until smbSharePath is set in
settings or WIN95_SMB_SHARE=... is in the env.
Tests
npx tsc --ignoreConfig --module commonjs --target es2020 --esModuleInterop \
--moduleResolution bundler --outDir /tmp/smb-test --skipLibCheck \
src/renderer/smb/*.ts && node /tmp/smb-test/test-standalone.js35 protocol tests, full round-trips with real file I/O. No Electron needed.
What's not implemented
- Writes (read-only by design, but OPEN is easy to extend)
- Long filenames via TRANS2 (we serve 8.3 through the legacy SEARCH path,
which is enough for Win95 Explorer but loses the original casing/length) - Multiple shares โ everything is one share named
HOST - Authentication โ guest access only
V86 Patches (`docs/v86-patches.md`)
v86 patches carried by windows95
windows95 builds v86 from a fork branch rather than upstream master.
Each fix lives on its own feature branch in felixrieseberg/v86, has an
upstream PR against copy/v86, and is merged into the integration
branch felixrieseberg/v86:windows95-base (which is whattools/update-v86.js builds from). When a PR is merged upstream, drop
its row here, delete the feature branch, and rebase windows95-base.
| Fix | Branch | Upstream PR | windows95-base | Why we need it |
|---|---|---|---|---|
| Node fs loader in Electron renderer | electron-renderer-fs-loader |
copy/v86#1540 | โ | await import("node:fs/promises") fails in an Electron renderer; disk images don't load. |
| IDE shared Command Block registers | ide-shared-registers |
copy/v86#1541 | โ | Win9x hangs at splash on disks >535 MiB because per-device register writes violate the ATA shared-register-file spec. |
| VMware absolute-pointer backdoor | vmware-abspointer |
copy/v86#1542 | โ | Port 0x5658 GETVERSION + ABSPOINTER_* so VBMOUSE can track the host cursor 1:1 without pointer lock. |
| VMware text-clipboard backdoor | vmware-clipboard |
โ (stacked on #1542) | โ | Legacy backdoor commands 6โ9 so W95TOOLS.EXE can sync CF_TEXT with the host. |
| VMware GETTIME backdoor | vmware-gettime |
โ (stacked on the clipboard branch) | โ | Win95 only reads the RTC at boot, so a resumed state keeps a stale clock; W95TOOLS.EXE polls GETTIME (command 23) and steps the clock back to host time. |
| Defer V86-mode VBE disable | vga-defer-vbe-disable-v86 |
copy/v86#1543 | โ | Opening a windowed DOS box leaks vgabios's VBE-disable past Win9x's VDD and turns the screen to planar garbage. |
| fake_network: copy TCP addrs | fake-network-copy-tcp-addrs |
โ | โ | TCPConnection routing fields alias the NE2000 TX ring; concurrent guest traffic retargets async replies and the guest RSTs them. |
Adding a fix
- Branch off
origin/masterin../v86, commit, push tofork. - Open the PR against
copy/v86. git checkout windows95-base && git merge --no-ff && git push fork windows95-basenode tools/update-v86.jsin this repo to rebuildlibv86.js/v86.wasm.- Add a row above and a bullet in
.claude/skills/update-v86/SKILL.md.
--- METRICS ---
- Files Extracted: 8
- Estimated Token Budget: ~7223 tokens
- Canonical Reference: https://codewiki.google/github.com/felixrieseberg/windows95