From 89c0a8575d0b77a42c9e054d9e24576989cc153b Mon Sep 17 00:00:00 2001 From: Felix Rieseberg Date: Sat, 11 Apr 2026 09:33:51 -0700 Subject: [PATCH] docs: update-v86.js rewrite + SMB/v86/testing knowledge (#348) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- .claude/skills/probe-win95/SKILL.md | 113 ++++++++++ .claude/skills/update-v86/SKILL.md | 127 ++++++++++++ .gitignore | 3 +- docs/smb-share.md | 47 +++++ src/renderer/lib/libv86.js | 2 +- tools/update-v86.js | 309 +++++++++++++--------------- 6 files changed, 433 insertions(+), 168 deletions(-) create mode 100644 .claude/skills/probe-win95/SKILL.md create mode 100644 .claude/skills/update-v86/SKILL.md create mode 100644 docs/smb-share.md diff --git a/.claude/skills/probe-win95/SKILL.md b/.claude/skills/probe-win95/SKILL.md new file mode 100644 index 0000000..1a8c71a --- /dev/null +++ b/.claude/skills/probe-win95/SKILL.md @@ -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 ` 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. | diff --git a/.claude/skills/update-v86/SKILL.md b/.claude/skills/update-v86/SKILL.md new file mode 100644 index 0000000..2a38149 --- /dev/null +++ b/.claude/skills/update-v86/SKILL.md @@ -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`. diff --git a/.gitignore b/.gitignore index d5373f8..5d77128 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,5 @@ trusted-signing-metadata.json .env electron-windows-sign.log .npmrc -/.claude/ +/.claude/scheduled_tasks.lock +/.claude/worktrees/ diff --git a/docs/smb-share.md b/docs/smb-share.md new file mode 100644 index 0000000..8a046c9 --- /dev/null +++ b/docs/smb-share.md @@ -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 diff --git a/src/renderer/lib/libv86.js b/src/renderer/lib/libv86.js index 5c0ccfa..ba29af1 100644 --- a/src/renderer/lib/libv86.js +++ b/src/renderer/lib/libv86.js @@ -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>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>2]=h(p.length);e[q+4>>2]=g(49152+m);for(let r=0;re?this.fw_value=f(0):49152<=e&&e-49152a.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"; diff --git a/tools/update-v86.js b/tools/update-v86.js index a6d008a..6a8680a 100644 --- a/tools/update-v86.js +++ b/tools/update-v86.js @@ -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) + * /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); - } - } - - // ─── wasm ──────────────────────────────────────────────────────────────── - let wasmDate; - if (JS_ONLY) { - if (!fs.existsSync(wasmDest)) { - throw new Error(`--js-only requires an existing wasm at ${wasmDest}`); - } - 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)'); + check_prereqs(); + build_v86(); + install(); + sanity_check(); + console.log('done'); } -main().catch((e) => { console.error('✗', e.message); process.exit(1); }); +try { main(); } +catch (e) { + console.error('✗', e.message); + process.exit(1); +}