diff --git a/.claude/skills/update-v86/SKILL.md b/.claude/skills/update-v86/SKILL.md index 6dfbc29..10804e2 100644 --- a/.claude/skills/update-v86/SKILL.md +++ b/.claude/skills/update-v86/SKILL.md @@ -23,7 +23,9 @@ fallbacks, no fetching from copy.sh. ## The fork branch v86 should be checked out on **`felixrieseberg/v86:windows95-base`**. -That branch merges four feature branches, each upstreamable on its own: +That branch merges the feature branches tracked in +[`docs/v86-patches.md`](../../../docs/v86-patches.md) — keep that table +in sync with this list. Each is upstreamable on its own: - **`electron-renderer-fs-loader`** (PR #1540) — `src/lib.js` uses `require("fs")` instead of `await import("node:fs/promises")`. Dynamic @@ -42,6 +44,14 @@ That branch merges four feature branches, each upstreamable on its own: 6–9 so `W95TOOLS.EXE` (guest-tools/agent) can sync `CF_TEXT` with the host. Consumes `mouse-absolute` and `vmware-clipboard-host` bus events; emits `vmware-absolute-mouse` and `vmware-clipboard-guest`. +- **`fake-network-copy-tcp-addrs`** — `src/browser/fake_network.js` + copies the four address subarrays (`hsrc/hdest/psrc/pdest`) when a + `TCPConnection` is created from an inbound SYN. Upstream stores them + as zero-copy views into the NE2000 TX ring; once the guest's 12-slot + TX ring wraps (any concurrent traffic — SMB, NBNS, ping), `pump()` + builds segments with whatever IP now occupies that slot, the guest + RSTs them as belonging to no TCB, and `recv()` blocks forever. + Exercised by `tools/probe-tcp.sh`. - **`vga-defer-vbe-disable-v86`** — `src/vga.js` defers `dispi[4]=0` written from V86 mode until a legacy attribute-mode write reaches the hardware. Win9x's VDD virtualises ports 3B0–3DF for a windowed DOS VM diff --git a/docs/v86-patches.md b/docs/v86-patches.md new file mode 100644 index 0000000..6a980ce --- /dev/null +++ b/docs/v86-patches.md @@ -0,0 +1,25 @@ +# 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 what +`tools/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`](https://github.com/felixrieseberg/v86/tree/electron-renderer-fs-loader) | [copy/v86#1540](https://github.com/copy/v86/pull/1540) | ✅ | `await import("node:fs/promises")` fails in an Electron renderer; disk images don't load. | +| IDE shared Command Block registers | [`ide-shared-registers`](https://github.com/felixrieseberg/v86/tree/ide-shared-registers) | [copy/v86#1541](https://github.com/copy/v86/pull/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`](https://github.com/felixrieseberg/v86/tree/vmware-abspointer) | [copy/v86#1542](https://github.com/copy/v86/pull/1542) | ✅ | Port 0x5658 GETVERSION + ABSPOINTER_* so VBMOUSE can track the host cursor 1:1 without pointer lock. | +| VMware text-clipboard backdoor | [`vmware-clipboard`](https://github.com/felixrieseberg/v86/tree/vmware-clipboard) | — *(stacked on #1542)* | ✅ | Legacy backdoor commands 6–9 so `W95TOOLS.EXE` can sync `CF_TEXT` with the host. | +| Defer V86-mode VBE disable | [`vga-defer-vbe-disable-v86`](https://github.com/felixrieseberg/v86/tree/vga-defer-vbe-disable-v86) | [copy/v86#1543](https://github.com/copy/v86/pull/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`](https://github.com/felixrieseberg/v86/tree/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 + +1. Branch off `origin/master` in `../v86`, commit, push to `fork`. +2. Open the PR against `copy/v86`. +3. `git checkout windows95-base && git merge --no-ff && git push fork windows95-base` +4. `node tools/update-v86.js` in this repo to rebuild `libv86.js` / `v86.wasm`. +5. Add a row above and a bullet in `.claude/skills/update-v86/SKILL.md`. diff --git a/src/renderer/debug-harness.ts b/src/renderer/debug-harness.ts index 66d905c..49cc607 100644 --- a/src/renderer/debug-harness.ts +++ b/src/renderer/debug-harness.ts @@ -183,7 +183,10 @@ export function startProbe(emulator: any) { const scriptCmd = process.env.WIN95_PROBE_SCRIPT; // WIN95_PROBE_RUN='telnet 1.2.3.4 7777' → literal text into Start→Run, // Enter, then optional WIN95_PROBE_RUN_AFTER keystrokes after _RUN_WAIT ms. + // WIN95_PROBE_RUN2 fires a second Start→Run sequence after _RUN2_WAIT ms, + // for two-process scenarios (e.g., background ping + telnet). const runCmd = process.env.WIN95_PROBE_RUN; + const runCmd2 = process.env.WIN95_PROBE_RUN2; const runAfter = process.env.WIN95_PROBE_RUN_AFTER; // WIN95_PROBE_DOSBOX=1 → after desktop, open COMMAND.COM, type `dir`, // optionally Alt+Enter to fullscreen. Regression test for the windowed @@ -272,6 +275,19 @@ export function startProbe(emulator: any) { { type: "text", text: runCmd }, { type: "wait", ms: 400 }, { type: "keys", dn: SC.ENTER_DN, up: SC.ENTER_UP }, + ...(runCmd2 ? [ + { type: "wait", ms: Number(process.env.WIN95_PROBE_RUN2_WAIT) || 3000 }, + { type: "chord", keys: [ + { dn: SC.CTRL_DN, up: SC.CTRL_UP }, + { dn: SC.ESC_DN, up: SC.ESC_UP }, + ]}, + { type: "wait", ms: 1200 }, + { type: "keys", dn: SC.R_DN, up: SC.R_UP }, + { type: "wait", ms: 1000 }, + { type: "text", text: runCmd2 }, + { type: "wait", ms: 400 }, + { type: "keys", dn: SC.ENTER_DN, up: SC.ENTER_UP }, + ] : []), ...(runAfter ? [ { type: "wait", ms: Number(process.env.WIN95_PROBE_RUN_WAIT) || 6000 }, { type: "text", text: runAfter }, diff --git a/src/renderer/emulator.tsx b/src/renderer/emulator.tsx index 03b72a7..d6c98e2 100644 --- a/src/renderer/emulator.tsx +++ b/src/renderer/emulator.tsx @@ -18,6 +18,7 @@ import { Win95Window } from "./app"; import { resetState } from "./utils/reset-state"; import { setupSmbShare } from "./smb"; import { setupTcpRelay } from "./net/tcp-relay"; +import { setupTcpTrace } from "./net/tcp-trace"; import { setupDnsShim } from "./net/dns-shim"; import { setupClipboardSync } from "./clipboard"; import { startProbe } from "./debug-harness"; @@ -394,6 +395,7 @@ export class Emulator extends React.Component<{}, EmulatorState> { // Raw TCP egress for ports the fetch adapter ignores (everything but 80). setupTcpRelay(window["emulator"]); + setupTcpTrace(window["emulator"]); // Serve a host folder over SMB on port 139. Read-only, traversal/symlink // guarded. In Win95: Start → Run → \\HOST\HOST. The env var wins so the diff --git a/src/renderer/lib/libv86.js b/src/renderer/lib/libv86.js index d4a4684..f20f291 100644 --- a/src/renderer/lib/libv86.js +++ b/src/renderer/lib/libv86.js @@ -191,7 +191,7 @@ function Hb(a,b){let c={};var d=(new DataView(a.buffer,a.byteOffset,a.byteLength a.rst=!!(d&4),a.psh=!!(d&8),a.ack=!!(d&16),a.urg=!!(d&32),a.ece=!!(d&64),a.cwr=!!(d&128),c.tcp=a,c.tcp_data=e.subarray(4*a.doff);else if(17===d){a=new DataView(e.buffer,e.byteOffset,e.byteLength);a={sport:a.getUint16(0),dport:a.getUint16(2),len:a.getUint16(4),checksum:a.getUint16(6),data:e.subarray(8),data_s:(new TextDecoder).decode(e.subarray(8))};if(67===a.dport||67===a.sport){e=e.subarray(8);d=new DataView(e.buffer,e.byteOffset,e.byteLength);e.subarray(44,236);d={op:d.getUint8(0),htype:d.getUint8(1), hlen:d.getUint8(2),hops:d.getUint8(3),xid:d.getUint32(4),secs:d.getUint16(8),flags:d.getUint16(10),ciaddr:d.getUint32(12),yiaddr:d.getUint32(16),siaddr:d.getUint32(20),giaddr:d.getUint32(24),chaddr:e.subarray(28,44),magic:d.getUint32(236),options:[]};e=e.subarray(240);for(l=0;l { // Ports already claimed by other in-process handlers. const RESERVED_PORTS = new Set([80, 139]); +// WIN95_TCP_TEST_PORT= short-circuits that port to an in-process fake +// upstream that writes a banner asynchronously (i.e., from outside a CPU +// tick) and then echoes everything back. Lets the probe harness exercise +// the recv() path deterministically without a real network endpoint. +const TEST_PORT = Number(process.env.WIN95_TCP_TEST_PORT) || 0; +const TEST_BANNER_BYTES = Number(process.env.WIN95_TCP_TEST_BYTES) || 3000; + // Destinations we never relay to. The emulated LAN (192.168.86/87) has nothing // real behind it; loopback and link-local would let guest software poke at the // host's own services or cloud-metadata endpoints. The rest of RFC1918 is left @@ -71,6 +78,34 @@ export function setupTcpRelay(emulator: V86) { conn.accept(); log(`→ ${ip}:${port} (${conn.tuple})`); + if (TEST_PORT && port === TEST_PORT) { + const mode = process.env.WIN95_TCP_TEST_MODE || "banner"; + const banner = Buffer.alloc(TEST_BANNER_BYTES, 0x41); + banner.write(`HELLO from tcp-relay test, ${TEST_BANNER_BYTES}B\r\n`); + let n = 0; + conn.on("data", (d) => { + if (d.length === 0) return; + n += d.length; + log(`test ← guest ${d.length}B (total ${n})`); + if (mode === "after-data" && n >= 1) { + setTimeout(() => { + log(`test → guest ${banner.length}B banner (async, after-data)`); + conn.write(banner); + }, 10); + } + }); + if (mode === "banner") + setTimeout( + () => { + log(`test → guest ${banner.length}B banner (async)`); + conn.write(banner); + }, + Number(process.env.WIN95_TCP_TEST_DELAY) || 50, + ); + conn.on_shutdown = conn.on_close = () => log("test guest closed"); + return; + } + let connected = false; let pending: Buffer[] | null = []; let upstreamGone = false; diff --git a/src/renderer/net/tcp-trace.ts b/src/renderer/net/tcp-trace.ts new file mode 100644 index 0000000..3877163 --- /dev/null +++ b/src/renderer/net/tcp-trace.ts @@ -0,0 +1,118 @@ +// Deep packet trace for the v86 userspace TCP ↔ NE2000 path. Activated by +// WIN95_TCP_TRACE= — every guest-TX and host-RX TCP frame on that +// dest port is decoded and appended to /tmp/win95-tcp-trace.log, and the +// NE2000 receive() is wrapped so we can see whether frames injected from +// outside a CPU tick ever land in the RX ring (vs. being filtered out). + +import * as fs from "fs"; + +const TRACE_FILE = + process.env.WIN95_TCP_TRACE_FILE || "/tmp/win95-tcp-trace.log"; + +interface V86 { + bus: { register(name: string, fn: (arg: unknown) => void): void }; + v86?: { cpu?: { devices?: { net?: unknown } } }; +} + +function flags(b: number): string { + let s = ""; + if (b & 1) s += "F"; + if (b & 2) s += "S"; + if (b & 4) s += "R"; + if (b & 8) s += "P"; + if (b & 16) s += "A"; + return s || "."; +} + +function decodeTcp(f: Uint8Array, port: number) { + if (f.length < 54) return null; + if (((f[12] << 8) | f[13]) !== 0x0800) return null; + const ihl = (f[14] & 0x0f) * 4; + if (f[14 + 9] !== 6) return null; + const ipLen = (f[16] << 8) | f[17]; + const t = 14 + ihl; + const sport = (f[t] << 8) | f[t + 1]; + const dport = (f[t + 2] << 8) | f[t + 3]; + if (sport !== port && dport !== port) return null; + const seq = + (f[t + 4] * 0x1000000 + (f[t + 5] << 16) + (f[t + 6] << 8) + f[t + 7]) >>> + 0; + const ack = + (f[t + 8] * 0x1000000 + (f[t + 9] << 16) + (f[t + 10] << 8) + f[t + 11]) >>> + 0; + const doff = (f[t + 12] >> 4) * 4; + const fl = f[t + 13]; + const win = (f[t + 14] << 8) | f[t + 15]; + const dlen = ipLen - ihl - doff; + return { sport, dport, seq, ack, fl: flags(fl), win, dlen, frameLen: f.length }; +} + +export function setupTcpTrace(emulator: V86) { + const port = Number(process.env.WIN95_TCP_TRACE); + if (!port) return; + + const t0 = Date.now(); + fs.writeFileSync(TRACE_FILE, `--- trace port ${port} ---\n`); + const w = (s: string) => { + const line = `[${((Date.now() - t0) / 1000).toFixed(3)}s] ${s}\n`; + try { + fs.appendFileSync(TRACE_FILE, line); + } catch {} + }; + + emulator.bus.register("net0-send", (raw: unknown) => { + const f = raw as Uint8Array; + const r = decodeTcp(f, port); + if (r) + w( + `guest→ ${r.sport}>${r.dport} seq=${r.seq} ack=${r.ack} ${r.fl} win=${r.win} len=${r.dlen}`, + ); + else if (f.length >= 14) { + const et = (f[12] << 8) | f[13]; + const dst = + et === 0x0800 ? f.subarray(30, 34).join(".") : et === 0x0806 ? "arp" : "?"; + w( + `guest→ [other] len=${f.length}@${f.byteOffset} et=0x${et.toString(16)} dst=${dst}`, + ); + } + }); + + // Wrap NE2000 receive() to see if/why a frame is dropped at the NIC layer. + const arm = () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const ne2k = (emulator as any).v86?.cpu?.devices?.net; + if (!ne2k) return false; + const orig = ne2k.receive.bind(ne2k); + ne2k.receive = function (a: Uint8Array) { + const r = decodeTcp(a, port); + if (r) { + const pages = 1 + ((Math.max(60, a.length) + 4) >> 8); + const avail = + this.boundary > this.curpg + ? this.boundary - this.curpg + : this.pstop - this.curpg + this.boundary - this.pstart; + const macOk = + a[0] === this.mac[0] && a[1] === this.mac[1] && a[2] === this.mac[2]; + const drop = + this.cr & 1 + ? "STP" + : !macOk && a[0] !== 0xff && !(this.rxcr & 16) + ? "MAC" + : avail < pages && this.boundary !== 0 + ? "FULL" + : ""; + w( + ` ne2k.recv frame=${a.length} sip=${a.slice(26, 30).join(".")} cr=0x${this.cr.toString(16)}` + + ` cur=${this.curpg} bnd=${this.boundary}${drop ? " DROP:" + drop : ""}`, + ); + } + return orig(a); + }; + w("ne2k.receive wrapped"); + return true; + }; + if (!arm()) { + const p = setInterval(() => arm() && clearInterval(p), 100); + setTimeout(() => clearInterval(p), 10000); + } +} diff --git a/tools/probe-tcp.sh b/tools/probe-tcp.sh new file mode 100755 index 0000000..1b67eff --- /dev/null +++ b/tools/probe-tcp.sh @@ -0,0 +1,65 @@ +#!/bin/bash +# Probe the tcp-relay recv() path: boot Win95, telnet to a fake upstream +# on port 7777, and dump the per-frame trace. PASS = guest ACKs the async +# banner (i.e., recv() returned). +set -e +cd "$(dirname "$0")/.." + +PORT=7777 +TRACE=/tmp/win95-tcp-trace.log +RELAY="${TMPDIR:-/tmp}/win95-tcp-relay.log" +STATUS=/tmp/win95-probe.json +TIMEOUT=${TIMEOUT:-90} + +pkill -9 -f "windows95.*electron" 2>/dev/null || true +sleep 1 +rm -f "$HOME/Library/Application Support/windows95/state-v4.bin" +rm -f "$STATUS" /tmp/win95-probe.done /tmp/win95-screen.png "$TRACE" "$RELAY" + +rm -rf dist +node tools/vite-build.js > /tmp/win95-build.log 2>&1 || { + echo "BUILD FAILED"; tail -30 /tmp/win95-build.log; exit 1 +} + +WIN95_PROBE=1 \ +WIN95_PROBE_RUN="${RUN:-ping -t 8.8.8.8}" \ +WIN95_PROBE_RUN2="${RUN2:-telnet 1.1.1.1 $PORT}" \ +WIN95_PROBE_RUN2_WAIT="${RUN2_WAIT:-3000}" \ +WIN95_PROBE_RUN_AFTER="${RUN_AFTER:-}" \ +WIN95_PROBE_RUN_WAIT="${RUN_WAIT:-6000}" \ +WIN95_TCP_TEST_PORT=$PORT \ +WIN95_TCP_TEST_MODE="${MODE:-banner}" \ +WIN95_TCP_TEST_DELAY="${DELAY:-50}" \ +WIN95_TCP_TEST_BYTES="${BYTES:-3000}" \ +WIN95_TCP_TRACE=$PORT \ +WIN95_SMB_SHARE="$HOME/Downloads" \ + ./node_modules/.bin/electron . > /tmp/win95-electron.log 2>&1 & +PID=$! +echo "electron pid=$PID, waiting up to ${TIMEOUT}s…" + +VERDICT=TIMEOUT +for i in $(seq 1 "$TIMEOUT"); do + kill -0 $PID 2>/dev/null || { VERDICT=CRASHED; break; } + if [ -f /tmp/win95-probe.done ] && grep -q FAIL /tmp/win95-probe.done; then + VERDICT="BOOT_$(cat /tmp/win95-probe.done)"; break + fi + if [ -f "$TRACE" ] && grep -q '→ guest .* banner' "$RELAY" 2>/dev/null; then + # Banner was written; give guest 8 s to ACK, then decide. + sleep 8 + if grep -Eq 'guest→.* ack=(279[8-9]|2[89][0-9]{2}|[3-9][0-9]{3}|[1-9][0-9]{4,}) ' "$TRACE"; then + VERDICT=PASS + else + VERDICT=FAIL + fi + break + fi + sleep 1 +done + +kill $PID 2>/dev/null || true +wait $PID 2>/dev/null || true + +echo "─── relay ───"; [ -f "$RELAY" ] && cat "$RELAY" +echo "─── trace ───"; [ -f "$TRACE" ] && cat "$TRACE" +echo "═══ $VERDICT ═══" +[ "$VERDICT" = PASS ]