mirror of
https://github.com/felixrieseberg/windows95.git
synced 2026-05-09 00:24:09 +00:00
docs: update-v86.js rewrite + SMB/v86/testing knowledge (#348)
* Rewrite update-v86.js for the current build pipeline Both v86 fixes we've been carrying are now real branches on the fork (PR #1540 electron-renderer-fs-loader, PR #1541 ide-shared-registers) combined as felixrieseberg/v86:windows95-base. update-v86.js no longer needs to patch sources at build time — it just builds whatever's checked out and copies the result. Gone: the fallback-to-copy.sh path, the skew-day check, the structural regex patches for load_file/exportSymbol/fetch-bind, the phantom-slave guard (both are in the branch), the --js-only flag. If you don't have cargo/clang/java/closure, the script fails loudly — no silent fallbacks. Added: sanity checks against the installed libv86.js for the invariants our SMB integration and parcel-build shim depend on, so if upstream changes something load-bearing we see it as a WARN at update time instead of a runtime failure. Tested end-to-end: 5/5 sanity checks, fresh boot SUCCESS in 32s. * docs and skills: capture SMB/v86/testing knowledge from the session - docs/smb-share.md: user-facing SMB integration overview (how to mount in Win95, what's implemented, what's not). Points at the protocol-level README inside src/renderer/smb/ for wire-level gotchas. - .claude/skills/probe-win95: how to boot and test the VM without a human. Env vars, file locations, failure modes, the XT scancode keyboard trick, bisect rules of thumb. - .claude/skills/update-v86: how to pull upstream v86 changes, what the five sanity-check WARNs mean, how to retire the fork branches when the PRs merge upstream. .gitignore narrowed to exclude only the runtime dirs (scheduled_tasks.lock, worktrees) instead of the whole .claude/ tree, so skills can be committed.
This commit is contained in:
113
.claude/skills/probe-win95/SKILL.md
Normal file
113
.claude/skills/probe-win95/SKILL.md
Normal file
@@ -0,0 +1,113 @@
|
||||
---
|
||||
name: probe-win95
|
||||
description: Boot Windows 95 in Electron under Claude's control, without a human clicking anything. Use when testing v86 updates, SMB changes, keyboard input, boot stability, or bisecting regressions.
|
||||
---
|
||||
|
||||
# Probing Windows 95 autonomously
|
||||
|
||||
You can run and test the Win95 VM yourself. The harness is already wired
|
||||
up — three pieces:
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `src/renderer/debug-harness.ts` | Activated by `WIN95_PROBE=1`. Boots fresh automatically, samples CPU + VGA + text screen every 5s, writes `/tmp/win95-probe.json` + `/tmp/win95-screen.png`, detects SUCCESS vs FAIL modes, optionally drives keyboard input. |
|
||||
| `src/renderer/smb/index.ts` | Wraps `console.log` so `[smb]` and `[nbns]` lines tee to `$TMPDIR/windows95-smb.log` (outside Electron, readable by any polling script — no CDP needed). |
|
||||
| `tools/probe-boot.sh` | One-shot: kill leftovers → parcel build → launch Electron → poll `/tmp/win95-probe.done` → report → kill. |
|
||||
|
||||
## One-shot boot test
|
||||
|
||||
```sh
|
||||
tools/probe-boot.sh
|
||||
```
|
||||
|
||||
Prints SUCCESS or a FAIL verdict. ~40s on a clean run.
|
||||
|
||||
## Boot + type into Run
|
||||
|
||||
```sh
|
||||
pkill -9 -f "windows95.*electron"; sleep 2
|
||||
rm -f "$HOME/Library/Application Support/windows95/state-v4.bin"
|
||||
rm -f /tmp/win95-probe.json /tmp/win95-probe.done \
|
||||
"$TMPDIR/windows95-smb.log"
|
||||
|
||||
WIN95_PROBE=1 \
|
||||
WIN95_PROBE_SCRIPT='HOST/HOST' \
|
||||
WIN95_SMB_SHARE="$HOME/Downloads" \
|
||||
./node_modules/.bin/electron . > /tmp/win95-electron.log 2>&1 &
|
||||
```
|
||||
|
||||
`WIN95_PROBE_SCRIPT='HOST/HOST'` types `\\HOST\HOST` into Start → Run on
|
||||
desktop. `/` → `\` substitution (env var / shell quoting, pragmatism). The
|
||||
harness drives it via XT scancodes — Win95 doesn't have Win+R (Win98+
|
||||
only), so the sequence is Esc, Esc, Ctrl+Esc, R, backslashes + text,
|
||||
Enter.
|
||||
|
||||
## Reading results
|
||||
|
||||
| File | What |
|
||||
|---|---|
|
||||
| `/tmp/win95-probe.json` | Live status: `phase` (`init`/`text-mode`/`splash`/`desktop`), `gfxW/H`, `textScreen`, `instructionDelta`, `verdict` |
|
||||
| `/tmp/win95-probe.done` | Written once when verdict is decided |
|
||||
| `/tmp/win95-screen.png` | Canvas screenshot, refreshed each tick |
|
||||
| `$TMPDIR/windows95-smb.log` | SMB/NBNS protocol trace |
|
||||
| `/tmp/win95-electron.log` | Electron stderr |
|
||||
|
||||
## Verdicts
|
||||
|
||||
| Verdict | Meaning | Action |
|
||||
|---|---|---|
|
||||
| `SUCCESS` | Canvas ≥640×480, CPU active, uptime >30s | desktop reached |
|
||||
| `FAIL_VXDLINK` | "Invalid VxD dynamic link call" | flaky — retry |
|
||||
| `FAIL_IOS` / `FAIL_PROTECTION` | IOS subsystem protection error | usually driver/BIOS mismatch |
|
||||
| `FAIL_KRNL386` | "Cannot find KRNL386.EXE" in safe mode | disk reads returning garbage — wasm/BIOS drift |
|
||||
| `FAIL_SPLASH_HANG` | Canvas stuck 320×400 for >70s | IRQ starvation — if you're on v86 master, check the IDE register fix |
|
||||
| `FAIL_HUNG` | CPU stopped advancing or text screen frozen 40s | hard hang |
|
||||
|
||||
## Rules of the road
|
||||
|
||||
- **Sporadic bluescreens are normal** on all v86 versions. One FAIL_VXDLINK
|
||||
or FAIL_HUNG doesn't prove anything — retry up to 3×.
|
||||
- **Always clean state** (`state-v4.bin`) before a probe. `pkill` on a
|
||||
wedged Electron triggers `onbeforeunload`, saving the *corrupted* state.
|
||||
Deleting it forces fallback to `images/default-state.bin`.
|
||||
- **Don't trust the text buffer in graphics mode.** After desktop (≥640×480)
|
||||
the stale BIOS text lingers in the buffer. The harness's `phase` field
|
||||
accounts for this; don't re-read `textScreen` in a `desktop` phase and
|
||||
think you hit a BSOD.
|
||||
- **Kill Electron when done.** Background processes pile up, each holding
|
||||
the disk image lock. `pkill -f "windows95.*electron"` on every path out.
|
||||
|
||||
## Bisecting v86
|
||||
|
||||
`tools/bisect-v86.sh <commit>` handles one step. The harness retries 3×
|
||||
per commit. Hard-won lessons:
|
||||
|
||||
1. **Validate bounds against a known-good binary.** Source-built wasm can
|
||||
drift from prod due to cargo/rustc version differences. We hit this:
|
||||
the "GOOD" bound produced a wasm that couldn't read the disk at all.
|
||||
2. **JS-only when toolchain drifts.** Keep the prod wasm, rebuild only
|
||||
libv86.js at each commit. Closure is deterministic enough; cargo
|
||||
isn't always. Works until you cross a commit that changes the JS↔wasm
|
||||
ABI (for v86, the APIC→Rust port in Aug 2025).
|
||||
3. **Retry on FAIL, never on SUCCESS.** One SUCCESS = commit is good.
|
||||
Three different FAILs at the same commit = commit is bad.
|
||||
4. **State cleanup between runs** (see above). Skipping this is the #1
|
||||
cause of spurious "bad" verdicts during bisect.
|
||||
|
||||
## Extending the harness
|
||||
|
||||
- New verdicts: add to the chain in `collectStatus` in `debug-harness.ts`
|
||||
- New keyboard actions: extend `runScript` (current types: `keys`, `chord`,
|
||||
`text`, `wait`)
|
||||
- New probe signals: add to `ProbeStatus` interface
|
||||
|
||||
Gate everything new on `process.env.WIN95_PROBE === "1"` so it stays out
|
||||
of the normal app.
|
||||
|
||||
## Common failure diagnostics
|
||||
|
||||
| Symptom | Check |
|
||||
|---|---|
|
||||
| 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. |
|
||||
127
.claude/skills/update-v86/SKILL.md
Normal file
127
.claude/skills/update-v86/SKILL.md
Normal file
@@ -0,0 +1,127 @@
|
||||
---
|
||||
name: update-v86
|
||||
description: Build and install v86 (wasm + libv86.js + BIOS) into windows95. Use when pulling upstream v86 changes, fixing a broken build, verifying the fork branches are still in sync, or setting up a fresh v86 checkout.
|
||||
---
|
||||
|
||||
# Updating v86
|
||||
|
||||
windows95 builds v86 from source — not from copy.sh. Two small bugfix
|
||||
patches ride along on a fork branch until the upstream PRs land.
|
||||
|
||||
## Sources
|
||||
|
||||
| File | Built from |
|
||||
|---|---|
|
||||
| `src/renderer/lib/libv86.js` | `make build/libv86.js` in `../v86` |
|
||||
| `src/renderer/lib/build/v86.wasm` | `make build/v86.wasm` |
|
||||
| `bios/seabios.bin`, `bios/vgabios.bin` | copied from `../v86/bios/` |
|
||||
|
||||
`tools/update-v86.js` runs those targets, copies the artifacts, runs 5
|
||||
sanity checks, and fails loudly if any prerequisite is missing. No
|
||||
fallbacks, no fetching from copy.sh.
|
||||
|
||||
## The fork branch
|
||||
|
||||
v86 should be checked out on **`felixrieseberg/v86:windows95-base`**.
|
||||
That branch merges two bugfix branches, each with an open upstream PR:
|
||||
|
||||
- **`electron-renderer-fs-loader`** (PR #1540) — `src/lib.js` uses
|
||||
`require("fs")` instead of `await import("node:fs/promises")`. Dynamic
|
||||
import of `node:` URLs doesn't work in an Electron renderer.
|
||||
- **`ide-shared-registers`** (PR #1541) — `src/ide.js` writes ATA Command
|
||||
Block registers (Features, Sector Count, LBA Low/Mid/High) to both
|
||||
master and slave. Without this, Win95/98 hang at the splash screen on
|
||||
any disk >~535MiB. Root cause: v86 commit `1b90d2e7` changed those
|
||||
writes to target only `current_interface`, but per ATA spec they're
|
||||
channel-shared (one register file on the IDE cable; both drives latch
|
||||
the same value).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
```sh
|
||||
rustup target add wasm32-unknown-unknown
|
||||
brew install openjdk
|
||||
# one-time: fetch the Closure compiler v86's Makefile pins to
|
||||
curl -sL https://repo1.maven.org/maven2/com/google/javascript/closure-compiler/v20210601/closure-compiler-v20210601.jar \
|
||||
-o ../v86/closure-compiler/compiler.jar
|
||||
```
|
||||
|
||||
Closure **must** be v20210601 — newer versions hit
|
||||
[closure-compiler#3972](https://github.com/google/closure-compiler/issues/3972)
|
||||
on v86's source. The pin is in v86's Makefile.
|
||||
|
||||
## Steps
|
||||
|
||||
```sh
|
||||
cd ../v86
|
||||
git fetch fork origin
|
||||
git checkout windows95-base
|
||||
git rebase fork/windows95-base # in case fork was updated elsewhere
|
||||
cd ../windows95
|
||||
node tools/update-v86.js
|
||||
```
|
||||
|
||||
That's it. Script runs both `make` targets, copies, verifies.
|
||||
|
||||
## Sanity-check WARNs
|
||||
|
||||
The 5 checks assert invariants `src/renderer/smb/index.ts` and
|
||||
`tools/parcel-build.js` depend on. A WARN means upstream changed
|
||||
something load-bearing — don't ignore it:
|
||||
|
||||
1. **`await import("node:...")` still present** → PR #1540 was reverted
|
||||
or the pattern moved. Electron renderer will fail to load disk images.
|
||||
2. **`master.features_reg=` missing in minified** → PR #1541 was reverted
|
||||
or `windows95-base` lost the commit. Win95 will hang at splash on
|
||||
disks >535MiB. Check `cd ../v86 && git log --oneline windows95-base`.
|
||||
3. **Export pattern changed** → `tools/parcel-build.js` shim needs
|
||||
updating. Look for `module.exports.V86=` and `window.V86=`.
|
||||
4. **`tcp-connection` event gone** → SMB falls back to the old-API theft
|
||||
hack in `src/renderer/smb/index.ts` — still works, but surprising.
|
||||
5. **`on_tcp_connection` gone** → old-API fallback is dead. SMB integration
|
||||
only works via the `tcp-connection` bus event now. Harmless; update
|
||||
the comment in `index.ts` and retire the theft code.
|
||||
|
||||
## After updating, probe-test
|
||||
|
||||
```sh
|
||||
node tools/update-v86.js && tools/probe-boot.sh
|
||||
```
|
||||
|
||||
Should land SUCCESS in ~40s. If FAIL_SPLASH_HANG, the IDE fix didn't
|
||||
take — check `grep master.features_reg src/renderer/lib/libv86.js`. If
|
||||
FAIL_VXDLINK, retry — sporadic bluescreens are normal (see the
|
||||
`probe-win95` skill).
|
||||
|
||||
## When a PR merges upstream
|
||||
|
||||
Rebase `windows95-base` to drop the now-redundant commit:
|
||||
|
||||
```sh
|
||||
cd ../v86
|
||||
git fetch origin
|
||||
git checkout windows95-base
|
||||
git rebase origin/master # drops the merged commit cleanly
|
||||
git push fork windows95-base --force-with-lease
|
||||
```
|
||||
|
||||
If **both** PRs are upstream, retire the fork branch entirely:
|
||||
|
||||
1. Point `tools/update-v86.js` default at `origin/master` (it already
|
||||
uses `../v86`, so just `git checkout master` there)
|
||||
2. Delete `fork/windows95-base`
|
||||
3. Remove this skill's "The fork branch" section
|
||||
4. Confirm the 5 sanity checks still pass — they're version-agnostic
|
||||
|
||||
## Integration contract with SMB
|
||||
|
||||
The SMB server sits on top of v86's network adapter. Details in
|
||||
`src/renderer/smb/README.md`. Short version: the new path uses the
|
||||
`tcp-connection` bus event; the fallback path uses
|
||||
`adapter.on_tcp_connection` callback + connection-theft (stealing a
|
||||
`TCPConnection` the HTTP probe builds for us). Both use `.on_data` on
|
||||
the conn, not `.on("data")`, because Closure dead-code-eliminates the
|
||||
event emitter plumbing.
|
||||
|
||||
If any v86 update breaks these assumptions, `src/renderer/smb/index.ts`
|
||||
needs updating, not just `tools/update-v86.js`.
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -15,4 +15,5 @@ trusted-signing-metadata.json
|
||||
.env
|
||||
electron-windows-sign.log
|
||||
.npmrc
|
||||
/.claude/
|
||||
/.claude/scheduled_tasks.lock
|
||||
/.claude/worktrees/
|
||||
|
||||
47
docs/smb-share.md
Normal file
47
docs/smb-share.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# Host folder over SMB
|
||||
|
||||
Windows 95 can mount a host folder as a network drive. The server lives in
|
||||
`src/renderer/smb/` — ~1500 lines, zero dependencies, read-only. Defaults to
|
||||
`~/Downloads`, configurable in Settings.
|
||||
|
||||
## Inside Win95
|
||||
|
||||
- **Browse:** Start → Run → `\\HOST\HOST`
|
||||
- **Map a letter:** in Explorer, Tools → Map Network Drive → `Z:` →
|
||||
`\\HOST\HOST` → ☑ Reconnect at logon
|
||||
- **Batch shortcut:** the share root exposes a virtual `_MAPZ.BAT` that runs
|
||||
`NET USE Z: \\HOST\HOST`. Double-click once, or copy it to
|
||||
`C:\WINDOWS\STARTM~1\PROGRAMS\STARTUP` to reconnect every boot.
|
||||
|
||||
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
|
||||
|
||||
```sh
|
||||
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.js
|
||||
```
|
||||
|
||||
35 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
|
||||
@@ -664,7 +664,7 @@ O.prototype.init=function(a,b){this.create_memory(a.memory_size||67108864,a.init
|
||||
this,function(){return d});c.register_write(146,this,function(e){d=e});c.register_read(1297,this,function(){return this.fw_pointer<this.fw_value.length?this.fw_value[this.fw_pointer++]:0});c.register_write(1296,this,void 0,function(e){function f(l){return new Uint8Array(Int32Array.of(l).buffer)}function g(l){return l>>8|l<<8&65280}function h(l){return l<<24|l<<8&16711680|l>>8&65280|l>>>24}ua("bios config port, index="+y(e));this.fw_pointer=0;if(0===e)this.fw_value=f(1431127377);else if(1===e)this.fw_value=
|
||||
f(0);else if(3===e)this.fw_value=f(this.memory_size[0]);else if(5===e)this.fw_value=f(1);else if(15===e)this.fw_value=f(1);else if(13===e)this.fw_value=new Uint8Array(16);else if(25===e){e=new Int32Array(4+64*this.option_roms.length);const l=new Uint8Array(e.buffer);e[0]=h(this.option_roms.length);for(let m=0;m<this.option_roms.length;m++){const {name:n,data:p}=this.option_roms[m],q=4+64*m;e[q+0>>2]=h(p.length);e[q+4>>2]=g(49152+m);for(let r=0;r<n.length;r++)l[q+8+r]=n.charCodeAt(r)}this.fw_value=
|
||||
l}else 32768<=e&&49152>e?this.fw_value=f(0):49152<=e&&e-49152<this.option_roms.length?this.fw_value=this.option_roms[e-49152].data:(ua("Warning: Unimplemented fw index: "+y(e)),this.fw_value=f(0))});this.devices={};a.load_devices&&(this.devices.pci=new Ac(this),this.acpi_enabled[0]&&(this.devices.acpi=new yc(this)),this.devices.rtc=new gb(this),this.fill_cmos(this.devices.rtc,a),this.devices.dma=new B(this),this.devices.vga=new X(this,b,a.screen,a.vga_memory_size||8388608),this.devices.ps2=new Gc(this,
|
||||
b),this.devices.uart0=new zc(this,1016,b),a.uart1&&(this.devices.uart1=new zc(this,760,b)),a.uart2&&(this.devices.uart2=new zc(this,1E3,b)),a.uart3&&(this.devices.uart3=new zc(this,744,b)),this.devices.fdc=new V(this,a.fda,a.fdb),c=[[void 0,void 0],[void 0,void 0]],a.hda&&(c[0][0]={buffer:a.hda},a.hdb&&(c[0][1]={buffer:a.hdb})),c[1][0]={is_cdrom:!0,buffer:a.cdrom},this.devices.ide=new Uc(this,b,c),this.devices.cdrom=this.devices.ide.secondary.master,this.devices.pit=new hb(this,b),"ne2k"===a.net_device.type?
|
||||
b),this.devices.uart0=new zc(this,1016,b),a.uart1&&(this.devices.uart1=new zc(this,760,b)),a.uart2&&(this.devices.uart2=new zc(this,1E3,b)),a.uart3&&(this.devices.uart3=new zc(this,744,b)),this.devices.fdc=new V(this,a.fda,a.fdb),c=[[void 0,void 0],[void 0,void 0]],a.hda&&(c[0][0]={buffer:a.hda},c[0][1]={buffer:a.hdb}),c[1][0]={is_cdrom:!0,buffer:a.cdrom},this.devices.ide=new Uc(this,b,c),this.devices.cdrom=this.devices.ide.secondary.master,this.devices.pit=new hb(this,b),"ne2k"===a.net_device.type?
|
||||
this.devices.net=new Dc(this,b,a.preserve_mac_from_state_image,a.mac_address_translation):"virtio"===a.net_device.type&&(this.devices.virtio_net=new Wc(this,b,a.preserve_mac_from_state_image,a.net_device.mtu)),a.fs9p?this.devices.virtio_9p=new bd(a.fs9p,this,b):a.handle9p?this.devices.virtio_9p=new cd(a.handle9p,this):a.proxy9p&&(this.devices.virtio_9p=new dd(a.proxy9p,this)),a.virtio_console&&(this.devices.virtio_console=new Ec(this,b)),a.virtio_balloon&&(this.devices.virtio_balloon=new $c(this,
|
||||
b)),this.devices.sb16=new D(this,b));a.multiboot&&(a=this.load_multiboot_option_rom(a.multiboot,a.initrd,a.cmdline))&&(this.bios.main?this.option_roms.push(a):this.reg32[0]=this.io.port_read32(244));this.debug_init()};O.prototype.load_multiboot=function(a){this.load_multiboot_option_rom(a,void 0,"")&&(this.reg32[0]=this.io.port_read32(244))};
|
||||
O.prototype.load_multiboot_option_rom=function(a,b,c){if(8192>a.byteLength){var d=new Int32Array(2048);(new Uint8Array(d.buffer)).set(new Uint8Array(a))}else d=new Int32Array(a,0,2048);for(var e=0;8192>e;e+=4){if(464367618===d[e>>2]){var f=d[e+4>>2];if(464367618+f+d[e+8>>2]|0)continue}else continue;ua("Multiboot magic found, flags: "+y(f>>>0,8),2);var g=this;this.io.register_read(244,this,function(){return 0},function(){return 0},function(){var n,p=31860;let q=0;if(c){q|=4;g.write32(31760,p);c+="\x00";
|
||||
|
||||
@@ -1,189 +1,166 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Updates v86 by building the wasm from a local checkout. The libv86.js +
|
||||
* v86.wasm pair MUST be ABI-matched — copy.sh historically rebuilds the JS
|
||||
* without rebuilding the wasm, and a mismatch silently breaks fresh boot
|
||||
* (state restore still works because the CPU snapshot is opaque, so you
|
||||
* won't notice until Win95 BSODs at the splash screen with "Invalid VxD
|
||||
* dynamic link call").
|
||||
* Build and install v86 (wasm + libv86.js + BIOS) from a local checkout.
|
||||
*
|
||||
* Usage:
|
||||
* node tools/update-v86.js [path/to/v86] # builds wasm from source
|
||||
* node tools/update-v86.js --js-only # just download libv86.js
|
||||
* node tools/update-v86.js [path/to/v86]
|
||||
*
|
||||
* The wasm build needs `rustup target add wasm32-unknown-unknown` and clang.
|
||||
* libv86.js needs Java + Closure; if you don't have those, --js-only fetches
|
||||
* from copy.sh and warns if its Last-Modified is far from your wasm build.
|
||||
* Defaults to ../v86 relative to this repo. Expects the checkout to be on
|
||||
* `fork/windows95-base` (or a branch with both bug fixes applied):
|
||||
*
|
||||
* - `electron-renderer-fs-loader` — file loader uses require() instead of
|
||||
* dynamic import (needed for Electron renderer, PR #1540)
|
||||
* - `ide-shared-registers` — ATA Command Block register writes hit both
|
||||
* master and slave, as the spec says they should (fixes Win95/98 boot
|
||||
* on disks >535MiB, PR #1541)
|
||||
*
|
||||
* If either PR is merged into upstream, rebase windows95-base and drop it.
|
||||
*
|
||||
* Prereqs (all must be installed — no fallbacks):
|
||||
* cargo + rustup target add wasm32-unknown-unknown
|
||||
* clang
|
||||
* java (e.g. brew install openjdk)
|
||||
* <v86>/closure-compiler/compiler.jar (v20210601 — pinned by v86's Makefile)
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const https = require('https');
|
||||
const { execSync } = require('child_process');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const LIB_DIR = path.join(__dirname, '../src/renderer/lib');
|
||||
const V86_DIR = process.argv.find(a => a !== process.argv[0] && a !== process.argv[1] && !a.startsWith('--'))
|
||||
|| path.resolve(__dirname, '../../v86');
|
||||
const JS_ONLY = process.argv.includes('--js-only');
|
||||
const SKEW_DAYS = 14;
|
||||
const WINDOWS95_DIR = path.resolve(__dirname, '..');
|
||||
const V86_DIR = process.argv[2]
|
||||
? path.resolve(process.argv[2])
|
||||
: path.resolve(WINDOWS95_DIR, '../v86');
|
||||
|
||||
function head(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
https.request(url, { method: 'HEAD' }, (res) => {
|
||||
resolve({ status: res.statusCode, lastModified: res.headers['last-modified'] });
|
||||
}).on('error', reject).end();
|
||||
});
|
||||
const LIB_DIR = path.join(WINDOWS95_DIR, 'src/renderer/lib');
|
||||
const BIOS_DIR = path.join(WINDOWS95_DIR, 'bios');
|
||||
|
||||
const JAVA_BIN = '/opt/homebrew/opt/openjdk/bin/java';
|
||||
|
||||
function require_tool(cmd, desc) {
|
||||
try {
|
||||
execFileSync('sh', ['-c', `command -v ${cmd}`], { stdio: 'ignore' });
|
||||
} catch {
|
||||
throw new Error(`Missing prerequisite: ${desc} (${cmd} not on PATH)`);
|
||||
}
|
||||
}
|
||||
|
||||
function download(url, dest) {
|
||||
return new Promise((resolve, reject) => {
|
||||
https.get(url, (res) => {
|
||||
if (res.statusCode !== 200) return reject(new Error(`${url} → HTTP ${res.statusCode}`));
|
||||
const chunks = [];
|
||||
res.on('data', (c) => chunks.push(c));
|
||||
res.on('end', () => {
|
||||
const buf = Buffer.concat(chunks);
|
||||
fs.writeFileSync(dest, buf);
|
||||
console.log(` ${path.basename(dest)}: ${(buf.length / 1024).toFixed(0)} KB`);
|
||||
resolve(res.headers['last-modified']);
|
||||
});
|
||||
}).on('error', reject);
|
||||
});
|
||||
function run(cmd, args, opts = {}) {
|
||||
execFileSync(cmd, args, { stdio: 'inherit', ...opts });
|
||||
}
|
||||
|
||||
function check_prereqs() {
|
||||
require_tool('cargo', 'rust/cargo');
|
||||
require_tool('clang', 'clang');
|
||||
|
||||
// cargo needs the wasm32 target
|
||||
const targets = execFileSync('rustup', ['target', 'list', '--installed']).toString();
|
||||
if (!targets.includes('wasm32-unknown-unknown')) {
|
||||
throw new Error('Missing rust target. Run: rustup target add wasm32-unknown-unknown');
|
||||
}
|
||||
|
||||
// Java comes from homebrew openjdk on macOS — the v86 Makefile invokes `java`
|
||||
// directly, so we have to put the homebrew java on PATH for its make calls
|
||||
// (or install openjdk into the system). We check for an explicit binary so
|
||||
// the error is clear.
|
||||
if (!fs.existsSync(JAVA_BIN)) {
|
||||
throw new Error(`Missing java at ${JAVA_BIN}. Install with: brew install openjdk`);
|
||||
}
|
||||
|
||||
const closureJar = path.join(V86_DIR, 'closure-compiler', 'compiler.jar');
|
||||
if (!fs.existsSync(closureJar)) {
|
||||
throw new Error(
|
||||
`Missing Closure compiler at ${closureJar}.\n` +
|
||||
`Download v20210601 (pinned by v86's Makefile):\n` +
|
||||
` mkdir -p ${path.dirname(closureJar)}\n` +
|
||||
` curl -sL https://repo1.maven.org/maven2/com/google/javascript/closure-compiler/v20210601/closure-compiler-v20210601.jar -o ${closureJar}`
|
||||
);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(path.join(V86_DIR, 'Makefile'))) {
|
||||
throw new Error(`No v86 checkout at ${V86_DIR}. Pass a path as the first argument or clone copy/v86 there.`);
|
||||
}
|
||||
}
|
||||
|
||||
function build_v86() {
|
||||
const env = { ...process.env, PATH: `/opt/homebrew/opt/openjdk/bin:${process.env.PATH}` };
|
||||
console.log('Building v86.wasm…');
|
||||
run('make', ['build/v86.wasm'], { cwd: V86_DIR, env });
|
||||
console.log('Building libv86.js…');
|
||||
run('make', ['build/libv86.js'], { cwd: V86_DIR, env });
|
||||
}
|
||||
|
||||
function install() {
|
||||
const copies = [
|
||||
['build/v86.wasm', 'build/v86.wasm'],
|
||||
['build/libv86.js', 'libv86.js'],
|
||||
];
|
||||
for (const [src, dest] of copies) {
|
||||
fs.copyFileSync(path.join(V86_DIR, src), path.join(LIB_DIR, dest));
|
||||
const size = fs.statSync(path.join(LIB_DIR, dest)).size;
|
||||
console.log(` ${dest}: ${(size / 1024).toFixed(0)} KB`);
|
||||
}
|
||||
|
||||
for (const bios of ['seabios.bin', 'vgabios.bin']) {
|
||||
fs.copyFileSync(path.join(V86_DIR, 'bios', bios), path.join(BIOS_DIR, bios));
|
||||
}
|
||||
console.log(' seabios.bin + vgabios.bin');
|
||||
}
|
||||
|
||||
/**
|
||||
* v86 commit 1b90d2e7 (May 2025) changed ATA Command Block register writes
|
||||
* to only target current_interface instead of both master and slave. Those
|
||||
* registers (ports 0x1F1-0x1F6) are channel-shared per the ATA spec — both
|
||||
* drives on the cable see the same register file. Win95's ESDI_506.PDR
|
||||
* writes them, switches drive-select, expects them to still be there.
|
||||
* Result: IDE IRQ never fires, splash screen hang.
|
||||
*
|
||||
* Found via JS-only bisect: prod wasm + freshly-built libv86.js, parent
|
||||
* 3c944a02 boots, 1b90d2e7 hangs deterministically.
|
||||
* Sanity check the installed files for the invariants our SMB integration
|
||||
* and Electron renderer depend on. If any of these fail, v86 changed under us
|
||||
* and src/renderer/smb/index.ts probably needs updating — see the README at
|
||||
* src/renderer/smb/README.md for why.
|
||||
*/
|
||||
function patchIdeSharedRegisters(ideJsPath) {
|
||||
let s = fs.readFileSync(ideJsPath, 'utf-8');
|
||||
const re = /this\.current_interface\.(\w+_reg) = \(this\.current_interface\.\1 << 8 \| data\) & 0xFFFF;/g;
|
||||
const matches = [...s.matchAll(re)];
|
||||
if (matches.length === 0) {
|
||||
console.log(' ide.js: shared-register patch already applied or upstream fixed it');
|
||||
return;
|
||||
function sanity_check() {
|
||||
const js = fs.readFileSync(path.join(LIB_DIR, 'libv86.js'), 'utf-8');
|
||||
|
||||
const checks = [
|
||||
// The electron-renderer-fs-loader fix: don't use dynamic import for fs
|
||||
[!/await import\("node:/.test(js),
|
||||
'libv86.js uses `await import("node:...")` — the Electron renderer fs loader PR was reverted?'],
|
||||
|
||||
// The ide-shared-registers fix: writes go to both master and slave
|
||||
// (minified has no spaces: `this.master.features_reg=(this.master...`)
|
||||
[/this\.master\.features_reg=\(this\.master\.features_reg/.test(js),
|
||||
'libv86.js ide.js did not get the shared-register fix — is the windows95-base branch still in sync?'],
|
||||
|
||||
// Export pattern still shims the way parcel-build expects
|
||||
[js.includes('module.exports') && js.includes('window'),
|
||||
'libv86.js export pattern changed — check the runtime shim in parcel-build.js'],
|
||||
|
||||
// SMB integration needs the tcp-connection bus event (new API path in index.ts)
|
||||
[js.includes('tcp-connection'),
|
||||
'libv86.js no longer fires the tcp-connection bus event — SMB will fall back to the old-API theft hack'],
|
||||
|
||||
// Old-API fallback still present for defense in depth
|
||||
[js.includes('on_tcp_connection'),
|
||||
'libv86.js no longer has on_tcp_connection — harmless but surprising'],
|
||||
];
|
||||
|
||||
let passed = 0;
|
||||
for (const [ok, msg] of checks) {
|
||||
if (ok) passed++;
|
||||
else console.warn(' WARN:', msg);
|
||||
}
|
||||
if (matches.length < 5) {
|
||||
throw new Error(`ide.js: expected ≥5 register write sites, found ${matches.length} — pattern changed`);
|
||||
}
|
||||
s = s.replace(re, (_, reg) =>
|
||||
`this.master.${reg} = (this.master.${reg} << 8 | data) & 0xFFFF;\n` +
|
||||
` this.slave.${reg} = (this.slave.${reg} << 8 | data) & 0xFFFF;`
|
||||
);
|
||||
fs.writeFileSync(ideJsPath, s);
|
||||
console.log(` ide.js: restored shared-register writes (${matches.length} sites)`);
|
||||
console.log(` sanity: ${passed}/${checks.length} checks passed`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const jsDest = path.join(LIB_DIR, 'libv86.js');
|
||||
const wasmDest = path.join(LIB_DIR, 'build/v86.wasm');
|
||||
function main() {
|
||||
console.log(`v86 checkout: ${V86_DIR}`);
|
||||
const head = execFileSync('git', ['log', '-1', '--format=%h %s'], { cwd: V86_DIR }).toString().trim();
|
||||
console.log(` ${head}`);
|
||||
|
||||
// ─── source patch (before any build) ─────────────────────────────────────
|
||||
if (!JS_ONLY) {
|
||||
const ideJs = path.join(V86_DIR, 'src/ide.js');
|
||||
if (fs.existsSync(ideJs)) {
|
||||
patchIdeSharedRegisters(ideJs);
|
||||
}
|
||||
check_prereqs();
|
||||
build_v86();
|
||||
install();
|
||||
sanity_check();
|
||||
console.log('done');
|
||||
}
|
||||
|
||||
// ─── wasm ────────────────────────────────────────────────────────────────
|
||||
let wasmDate;
|
||||
if (JS_ONLY) {
|
||||
if (!fs.existsSync(wasmDest)) {
|
||||
throw new Error(`--js-only requires an existing wasm at ${wasmDest}`);
|
||||
try { main(); }
|
||||
catch (e) {
|
||||
console.error('✗', e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
wasmDate = fs.statSync(wasmDest).mtime;
|
||||
console.log(`Keeping existing wasm (${wasmDate.toISOString().slice(0, 10)})`);
|
||||
} else {
|
||||
if (!fs.existsSync(path.join(V86_DIR, 'Makefile'))) {
|
||||
throw new Error(`No v86 checkout at ${V86_DIR}. Clone copy/v86 there or pass a path.`);
|
||||
}
|
||||
const head = execSync('git log -1 --format="%h %ci"', { cwd: V86_DIR }).toString().trim();
|
||||
console.log(`Building wasm from ${V86_DIR} @ ${head}`);
|
||||
execSync('make build/v86.wasm', { cwd: V86_DIR, stdio: 'inherit' });
|
||||
fs.copyFileSync(path.join(V86_DIR, 'build/v86.wasm'), wasmDest);
|
||||
wasmDate = new Date();
|
||||
console.log(` v86.wasm: ${(fs.statSync(wasmDest).size / 1024).toFixed(0)} KB`);
|
||||
}
|
||||
|
||||
// ─── libv86.js ───────────────────────────────────────────────────────────
|
||||
// Build from source if Closure is available; otherwise fetch and check skew.
|
||||
const hasClosure = !JS_ONLY && fs.existsSync(path.join(V86_DIR, 'closure-compiler/compiler.jar'));
|
||||
if (hasClosure) {
|
||||
console.log('Building libv86.js (Closure)…');
|
||||
execSync('make build/libv86.js', { cwd: V86_DIR, stdio: 'inherit' });
|
||||
fs.copyFileSync(path.join(V86_DIR, 'build/libv86.js'), jsDest);
|
||||
console.log(` libv86.js: ${(fs.statSync(jsDest).size / 1024).toFixed(0)} KB`);
|
||||
} else {
|
||||
console.log('No Closure jar — fetching libv86.js from copy.sh');
|
||||
const lm = await download('https://copy.sh/v86/build/libv86.js', jsDest);
|
||||
const jsDate = new Date(lm);
|
||||
const skew = Math.abs(jsDate - wasmDate) / 86400000;
|
||||
console.log(` JS: ${jsDate.toISOString().slice(0, 10)}`);
|
||||
console.log(` wasm: ${wasmDate.toISOString().slice(0, 10)}`);
|
||||
if (skew > SKEW_DAYS) {
|
||||
throw new Error(
|
||||
`JS and wasm are ${skew.toFixed(0)} days apart. ` +
|
||||
`Either install Closure (java + v86/closure-compiler/compiler.jar) ` +
|
||||
`to build libv86.js from the same commit, or git-checkout v86 to a ` +
|
||||
`commit near ${jsDate.toISOString().slice(0, 10)} and rebuild the wasm.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── BIOS ────────────────────────────────────────────────────────────────
|
||||
// SeaBIOS sets up the interrupt controller for whatever the emulated
|
||||
// hardware presents. New v86 + old BIOS = APIC never armed = IDE IRQs
|
||||
// never fire = boot hangs at the splash screen with no disk activity.
|
||||
if (!JS_ONLY) {
|
||||
const biosDir = path.join(__dirname, '../bios');
|
||||
for (const f of ['seabios.bin', 'vgabios.bin']) {
|
||||
fs.copyFileSync(path.join(V86_DIR, 'bios', f), path.join(biosDir, f));
|
||||
console.log(` ${f}: ${(fs.statSync(path.join(biosDir, f)).size / 1024).toFixed(0)} KB`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── patch: phantom slave drive ──────────────────────────────────────────
|
||||
// v86 bug since 1b90d2e7 (May 2025 IDE refactor): cpu.js does
|
||||
// ide_config[0][1] = { buffer: settings.hdb }
|
||||
// unconditionally inside the `if(settings.hda)` block. When hdb is
|
||||
// undefined this creates a phantom 0-size HD on primary slave; Win95's
|
||||
// ESDI_506.PDR detects it, sends IDENTIFY, and spins forever waiting for
|
||||
// DRQ from a drive that has no sectors. State restore skips driver init,
|
||||
// so it only bites on fresh boot.
|
||||
//
|
||||
// The pattern is structurally stable: `buffer` and `hdb` are option keys
|
||||
// (externed, not mangled), `[0][1]=` is literal.
|
||||
let js = fs.readFileSync(jsDest, 'utf-8');
|
||||
const phantom = /(\w+)\[0\]\[1\]=\{buffer:(\w+)\.hdb\}/g;
|
||||
const matches = [...js.matchAll(phantom)];
|
||||
if (matches.length !== 1) {
|
||||
throw new Error(
|
||||
`phantom-slave patch: expected exactly 1 match, found ${matches.length}. ` +
|
||||
`Either v86 fixed this upstream (good — remove this patch) or the ` +
|
||||
`pattern changed. Check src/cpu.js around ide_config[0][1].`
|
||||
);
|
||||
}
|
||||
js = js.replace(phantom, '$2.hdb&&($1[0][1]={buffer:$2.hdb})');
|
||||
fs.writeFileSync(jsDest, js);
|
||||
console.log(' patched: phantom slave drive guard (1 site)');
|
||||
|
||||
// ─── sanity ──────────────────────────────────────────────────────────────
|
||||
if (!js.includes('process.versions.node'))
|
||||
throw new Error('libv86 lost the process.versions.node check (file loader regression)');
|
||||
if (!/this\.fetch=\([^)]*\)=>fetch\(/.test(js))
|
||||
throw new Error('libv86 lost the fetch arrow wrapper');
|
||||
if (!js.includes('window.V86=') && !js.includes('module.exports.V86='))
|
||||
throw new Error('libv86 export pattern changed — check the runtime shim');
|
||||
|
||||
console.log('✓ installed (sanity checks pass)');
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error('✗', e.message); process.exit(1); });
|
||||
|
||||
Reference in New Issue
Block a user