diff --git a/.claude/skills/update-v86/SKILL.md b/.claude/skills/update-v86/SKILL.md index 32ff1eb..6dfbc29 100644 --- a/.claude/skills/update-v86/SKILL.md +++ b/.claude/skills/update-v86/SKILL.md @@ -35,11 +35,13 @@ That branch merges four feature branches, each upstreamable on its own: 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). -- **`vmware-abspointer`** — `src/vmware.js` implements the VMware mouse - backdoor (port `0x5658`, GETVERSION + ABSPOINTER_*) so a guest driver +- **`vmware-abspointer`** — `src/vmware.js` implements the VMware + backdoor (port `0x5658`): GETVERSION + ABSPOINTER_* so a guest driver (VBADOS VBMOUSE) can read absolute cursor position and track the host - cursor 1:1 without pointer lock. Consumes the `mouse-absolute` bus - event that `MouseAdapter` already emits. + cursor 1:1 without pointer lock, and the legacy text-clipboard commands + 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`. - **`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/guest-tools/README.md b/guest-tools/README.md index cb58cf6..ed69af1 100644 --- a/guest-tools/README.md +++ b/guest-tools/README.md @@ -21,3 +21,23 @@ Install inside the guest: absolute mouse driver**. 3. 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 does one thing: bridges +Windows 95's `CF_TEXT` clipboard to the host (legacy backdoor commands +6–9; host side is `src/renderer/clipboard.ts`, which polls Electron's +clipboard). It's also where time sync, host-initiated shutdown, and a +tray icon will live when those land. + +Install inside the guest: + +1. Copy `\\HOST\TOOLS\agent\W95TOOLS.EXE` to `C:\WINDOWS\`. +2. Drop a shortcut to it in + `C:\WINDOWS\Start Menu\Programs\StartUp` so 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). diff --git a/guest-tools/agent/Dockerfile b/guest-tools/agent/Dockerfile new file mode 100644 index 0000000..8337c94 --- /dev/null +++ b/guest-tools/agent/Dockerfile @@ -0,0 +1,12 @@ +FROM --platform=linux/amd64 debian:bookworm-slim +RUN apt-get update && \ + apt-get install -y --no-install-recommends curl xz-utils ca-certificates make && \ + rm -rf /var/lib/apt/lists/* +RUN mkdir -p /opt/watcom && \ + curl -fsSL https://github.com/open-watcom/open-watcom-v2/releases/download/Current-build/ow-snapshot.tar.xz \ + | tar -xJ -C /opt/watcom +ENV WATCOM=/opt/watcom +ENV PATH=$WATCOM/binl64:$PATH +ENV INCLUDE=$WATCOM/h:$WATCOM/h/nt +ENV LIB=$WATCOM/lib386:$WATCOM/lib386/nt +WORKDIR /work diff --git a/guest-tools/agent/Makefile b/guest-tools/agent/Makefile new file mode 100644 index 0000000..6435c04 --- /dev/null +++ b/guest-tools/agent/Makefile @@ -0,0 +1,30 @@ +# Build W95TOOLS.EXE for Windows 95 with Open Watcom v2, inside Docker. +# +# Watcom is the only readily-available cross-compiler that emits a PE binary +# Win95 RTM will load (subsystem 4.0, no msvcrt). mingw-w64 targets NT. The +# macOS-native Watcom binaries are unsigned and Gatekeeper kills them, so we +# run the linux/amd64 build under Docker instead. + +IMAGE := windows95-ow2 +# Docker Desktop's bind-mount file sync races with recently-edited files; work +# around it by piping the source on stdin and building in /tmp inside the +# container, then dumping the EXE bytes back over stdout. +DOCKER := docker run --rm -i --platform linux/amd64 $(IMAGE) +CFLAGS := -bt=nt -3r -zq -wx -we -os -s + +.PHONY: all image clean + +all: W95TOOLS.EXE + +image: + docker build --platform linux/amd64 -t $(IMAGE) . + +W95TOOLS.EXE: w95tools.c image + $(DOCKER) sh -c 'cd /tmp && cat >w95tools.c && \ + wcc386 $(CFLAGS) w95tools.c && \ + wlink system nt_win option quiet name W95TOOLS.EXE \ + file w95tools.o library kernel32,user32 && \ + cat W95TOOLS.EXE' $@ + +clean: + rm -f W95TOOLS.EXE diff --git a/guest-tools/agent/W95TOOLS.EXE b/guest-tools/agent/W95TOOLS.EXE new file mode 100644 index 0000000..d8903de Binary files /dev/null and b/guest-tools/agent/W95TOOLS.EXE differ diff --git a/guest-tools/agent/w95tools.c b/guest-tools/agent/w95tools.c new file mode 100644 index 0000000..29edaad --- /dev/null +++ b/guest-tools/agent/w95tools.c @@ -0,0 +1,181 @@ +/* + * W95TOOLS — guest-side integration agent for the windows95 emulator. + * + * Currently: bidirectional text clipboard. Talks to the emulator over the + * legacy VMware backdoor (port 0x5658; implemented in v86's vmware.js). + * Joins the Win32 clipboard-viewer chain so guest copies are pushed + * immediately, and polls the backdoor on a timer so host copies show up + * within ~250 ms. + * + * Win9x runs ring-3 code with the I/O bitmap wide open, so a plain IN works + * from a user process — no driver needed. On NT this would #GP; we don't run + * there. + * + * Build with Open Watcom v2 (see Makefile). Links USER32/KERNEL32 only, + * runs on Win95 RTM. + */ + +#define WIN32_LEAN_AND_MEAN +#include + +#define VMW_MAGIC 0x564D5868UL +#define VMW_PORT 0x5658 +#define CMD_GETLEN 6 +#define CMD_GETDATA 7 +#define CMD_SETLEN 8 +#define CMD_SETDATA 9 +#define CMD_VERSION 10 + +#define POLL_MS 250 +#define MAX_CLIP 0xFFFF + +extern unsigned long bd(unsigned long cmd, unsigned long arg); +#pragma aux bd = \ + "mov eax, 564D5868h" \ + "mov edx, 5658h" \ + "in eax, dx" \ + parm [ecx] [ebx] \ + value [eax] \ + modify [edx]; + +extern unsigned long bd_ebx(unsigned long cmd, unsigned long arg); +#pragma aux bd_ebx = \ + "mov eax, 564D5868h" \ + "mov edx, 5658h" \ + "in eax, dx" \ + parm [ecx] [ebx] \ + value [ebx] \ + modify [eax edx]; + +static HWND g_next; +static int g_ignore; + +static void push_to_host(HWND hwnd) +{ + HANDLE h; + char *p; + unsigned long len, i, w; + + if (!IsClipboardFormatAvailable(CF_TEXT)) { + bd(CMD_SETLEN, 0); + return; + } + if (!OpenClipboard(hwnd)) return; + h = GetClipboardData(CF_TEXT); + if (h && (p = (char *)GlobalLock(h)) != 0) { + len = lstrlen(p); + if (len > MAX_CLIP) len = MAX_CLIP; + bd(CMD_SETLEN, len); + for (i = 0; i < len; i += 4) { + w = (unsigned char)p[i]; + if (i + 1 < len) w |= (unsigned long)(unsigned char)p[i+1] << 8; + if (i + 2 < len) w |= (unsigned long)(unsigned char)p[i+2] << 16; + if (i + 3 < len) w |= (unsigned long)(unsigned char)p[i+3] << 24; + bd(CMD_SETDATA, w); + } + GlobalUnlock(h); + } + CloseClipboard(); +} + +static void pull_from_host(HWND hwnd) +{ + long len; + unsigned long i, w; + HGLOBAL h; + char *p; + + len = (long)bd(CMD_GETLEN, 0); + if (len < 0) return; + if (len > MAX_CLIP) len = MAX_CLIP; + + h = GlobalAlloc(GMEM_MOVEABLE | GMEM_DDESHARE, (DWORD)len + 1); + if (!h) return; + p = (char *)GlobalLock(h); + for (i = 0; i < (unsigned long)len; i += 4) { + w = bd(CMD_GETDATA, 0); + p[i] = (char)w; + if (i + 1 < (unsigned long)len) p[i+1] = (char)(w >> 8); + if (i + 2 < (unsigned long)len) p[i+2] = (char)(w >> 16); + if (i + 3 < (unsigned long)len) p[i+3] = (char)(w >> 24); + } + p[len] = 0; + GlobalUnlock(h); + + if (!OpenClipboard(hwnd)) { GlobalFree(h); return; } + g_ignore++; + EmptyClipboard(); + SetClipboardData(CF_TEXT, h); + CloseClipboard(); +} + +static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) +{ + switch (msg) { + case WM_CREATE: + g_next = SetClipboardViewer(hwnd); + SetTimer(hwnd, 1, POLL_MS, 0); + return 0; + + case WM_DRAWCLIPBOARD: + if (g_ignore > 0) g_ignore--; + else push_to_host(hwnd); + if (g_next) SendMessage(g_next, msg, wp, lp); + return 0; + + case WM_CHANGECBCHAIN: + if ((HWND)wp == g_next) g_next = (HWND)lp; + else if (g_next) SendMessage(g_next, msg, wp, lp); + return 0; + + case WM_TIMER: + pull_from_host(hwnd); + return 0; + + case WM_DESTROY: + ChangeClipboardChain(hwnd, g_next); + KillTimer(hwnd, 1); + PostQuitMessage(0); + return 0; + } + return DefWindowProc(hwnd, msg, wp, lp); +} + +int PASCAL WinMain(HINSTANCE hi, HINSTANCE hp, LPSTR cmd, int show) +{ + WNDCLASS wc; + HWND hwnd; + MSG msg; + + (void)hp; (void)cmd; (void)show; + + if (CreateMutex(0, FALSE, "W95Tools") && GetLastError() == ERROR_ALREADY_EXISTS) + return 0; + + if (bd_ebx(CMD_VERSION, 0) != VMW_MAGIC) { + MessageBox(0, "VMware backdoor not present.", "W95Tools", MB_OK | MB_ICONSTOP); + return 1; + } + + wc.style = 0; + wc.lpfnWndProc = WndProc; + wc.cbClsExtra = 0; + wc.cbWndExtra = 0; + wc.hInstance = hi; + wc.hIcon = 0; + wc.hCursor = 0; + wc.hbrBackground = 0; + wc.lpszMenuName = 0; + wc.lpszClassName = "W95Tools"; + RegisterClass(&wc); + + hwnd = CreateWindow("W95Tools", "W95Tools", WS_OVERLAPPED, + 0, 0, 0, 0, 0, 0, hi, 0); + if (!hwnd) return 1; + + while (GetMessage(&msg, 0, 0, 0) > 0) { + TranslateMessage(&msg); + DispatchMessage(&msg); + } + return 0; +} diff --git a/src/renderer/clipboard.ts b/src/renderer/clipboard.ts new file mode 100644 index 0000000..33ea9a5 --- /dev/null +++ b/src/renderer/clipboard.ts @@ -0,0 +1,62 @@ +// Bidirectional text clipboard between the host and the Win95 guest. +// +// Transport is the legacy VMware backdoor clipboard protocol (port 0x5658, +// commands 6–9) implemented in v86's vmware.js. Inside the guest, W95TOOLS.EXE +// (guest-tools/agent) polls the backdoor and bridges it to CF_TEXT via the +// Win32 clipboard-viewer chain. Out here we poll Electron's clipboard — there +// is no change event — and translate between host UTF-8/LF and guest +// Windows-1252/CRLF. + +import { clipboard } from "electron"; + +const CP1252 = new TextDecoder("windows-1252"); +// v86's vmware.js and the guest agent both clamp to 64 KB; clamp here too so +// a huge host clipboard never even gets allocated/encoded. +const CLIP_MAX = 0x10000; + +function fromGuest(bytes: Uint8Array): string { + return CP1252.decode(bytes).replace(/\r\n/g, "\n"); +} + +function toGuest(text: string): Uint8Array { + const s = text.slice(0, CLIP_MAX).replace(/\r\n|\n/g, "\r\n"); + const n = Math.min(s.length, CLIP_MAX); + const out = new Uint8Array(n); + for (let i = 0; i < n; i++) { + const c = s.charCodeAt(i); + out[i] = c < 256 ? c : 0x3f; + } + return out; +} + +export function setupClipboardSync(emulator: any): () => void { + // Track the last value seen on each side so a value we just wrote doesn't + // bounce back as a "change" from the other side. + let lastHost = clipboard.readText(); + let lastGuest = ""; + + emulator.add_listener("vmware-clipboard-guest", (bytes: Uint8Array) => { + const text = fromGuest(bytes); + if (text === lastHost || text === lastGuest) return; + lastGuest = text; + lastHost = text; + clipboard.writeText(text); + console.log("[clip] guest → host", text.length, "chars"); + }); + + const poll = () => { + const text = clipboard.readText(); + if (text === lastHost) return; + lastHost = text; + if (text === lastGuest) return; + emulator.bus.send("vmware-clipboard-host", toGuest(text)); + console.log("[clip] host → guest", text.length, "chars"); + }; + + const id = window.setInterval(poll, 500); + window.addEventListener("focus", poll); + return () => { + window.clearInterval(id); + window.removeEventListener("focus", poll); + }; +} diff --git a/src/renderer/emulator.tsx b/src/renderer/emulator.tsx index 9d3fdb1..814db53 100644 --- a/src/renderer/emulator.tsx +++ b/src/renderer/emulator.tsx @@ -19,6 +19,7 @@ import { resetState } from "./utils/reset-state"; import { setupSmbShare } from "./smb"; import { setupTcpRelay } from "./net/tcp-relay"; import { setupDnsShim } from "./net/dns-shim"; +import { setupClipboardSync } from "./clipboard"; import { startProbe } from "./debug-harness"; import { SyncFileBuffer } from "./sync-file-buffer"; @@ -406,6 +407,10 @@ export class Emulator extends React.Component<{}, EmulatorState> { startProbe(window["emulator"]); } + // Host ↔ guest text clipboard. The guest side is W95TOOLS.EXE on the + // TOOLS share; until that's running this is a no-op poller. + setupClipboardSync(window["emulator"]); + // New v86 instance // Mouse stays disabled until either the pointer is captured or the guest's // VMware-backdoor mouse driver requests absolute mode. diff --git a/src/renderer/lib/libv86.js b/src/renderer/lib/libv86.js index c76c6f6..d4a4684 100644 --- a/src/renderer/lib/libv86.js +++ b/src/renderer/lib/libv86.js @@ -426,13 +426,13 @@ this.mouse_clicks=this.mouse_delta_x=this.mouse_delta_y=0;break;case 245:this.en 0;break;default:y(a)}this.mouse_irq()}}else if(this.read_controller_output_port)this.read_controller_output_port=!1,this.controller_output_port=a;else{y(a);this.mouse_buffer.clear();this.kbd_buffer.clear();this.kbd_buffer.push(250);switch(a){case 237:this.next_read_led=!0;break;case 240:this.next_handle_scan_code_set=!0;break;case 242:this.kbd_buffer.push(171);this.kbd_buffer.push(131);break;case 243:this.next_read_rate=!0;break;case 244:this.enable_keyboard_stream=!0;break;case 245:this.enable_keyboard_stream= !1;break;case 246:break;case 255:this.kbd_buffer.clear();this.kbd_buffer.push(250);this.kbd_buffer.push(170);this.kbd_buffer.push(0);break;default:y(a)}this.kbd_irq()}}; Gc.prototype.port64_write=function(a){y(a);switch(a){case 32:this.kbd_buffer.clear();this.mouse_buffer.clear();this.kbd_buffer.push(this.command_register);this.kbd_irq();break;case 96:this.read_command_register=!0;break;case 209:this.read_controller_output_port=!0;break;case 211:this.read_output_register=!0;break;case 212:this.next_is_mouse_command=!0;break;case 167:this.command_register|=32;break;case 168:this.command_register&=-33;break;case 169:this.kbd_buffer.clear();this.mouse_buffer.clear(); -this.kbd_buffer.push(0);this.kbd_irq();break;case 170:this.kbd_buffer.clear();this.mouse_buffer.clear();this.kbd_buffer.push(85);this.kbd_irq();break;case 171:this.kbd_buffer.clear();this.mouse_buffer.clear();this.kbd_buffer.push(0);this.kbd_irq();break;case 173:this.command_register|=16;break;case 174:this.command_register&=-17;break;case 254:this.cpu.reboot_internal();break;default:y(a)}};function Hc(a,b){this.cpu=a;this.bus=b;this.absolute=this.enabled=!1;this.queue=[];this.buttons=0;this.last_y=this.last_x=-1;this.tail_is_move=!1;this.clip_out=new Uint8Array(0);this.clip_out_cursor=0;this.clip_out_fresh=!1;this.clip_in=new Uint8Array(0);this.clip_in_cursor=0;this.bus.register("vmware-clipboard-host",function(c){this.clip_out=65536this.last_x||(b&&this.tail_is_move&&4<=this.queue.length?(this.queue[this.queue.length-3]=this.last_x,this.queue[this.queue.length-2]=this.last_y):1024>>0,65536),this.clip_in=new Uint8Array(a),this.clip_in_cursor=0,0===a&&this.bus.send("vmware-clipboard-guest", +this.kbd_buffer.push(0);this.kbd_irq();break;case 170:this.kbd_buffer.clear();this.mouse_buffer.clear();this.kbd_buffer.push(85);this.kbd_irq();break;case 171:this.kbd_buffer.clear();this.mouse_buffer.clear();this.kbd_buffer.push(0);this.kbd_irq();break;case 173:this.command_register|=16;break;case 174:this.command_register&=-17;break;case 254:this.cpu.reboot_internal();break;default:y(a)}};function Hc(a,b){function c(){}this.cpu=a;this.bus=b;this.absolute=this.enabled=!1;this.queue=[];this.buttons=0;this.last_y=this.last_x=-1;this.tail_is_move=!1;this.clip_out=new Uint8Array(0);this.clip_out_cursor=0;this.clip_out_fresh=!1;this.clip_in=new Uint8Array(0);this.clip_in_cursor=0;this.bus.register("vmware-clipboard-host",function(d){this.clip_out=65536this.last_x||(b&&this.tail_is_move&&4<=this.queue.length?(this.queue[this.queue.length-3]=this.last_x,this.queue[this.queue.length-2]=this.last_y):1024>>0,65536),this.clip_in=new Uint8Array(a),this.clip_in_cursor=0,0===a&&this.bus.send("vmware-clipboard-guest", this.clip_in),0;case 9:b=this.clip_in;a=a[3]>>>0;var c=this.clip_in_cursor;c>>8);c>>16);c>>24);this.clip_in_cursor=c;c>=b.length&&(this.bus.send("vmware-clipboard-guest",b),this.clip_in=new Uint8Array(0),this.clip_in_cursor=0);return 0;case 40:return this.enabled?this.queue.length:-65536;case 39:b=Math.min(a[3]>>>0,4,this.queue.length);c=[0,0,0,0];for(let d=0;d-1}}(7)},{type:Kc},{machine:Kc},{version1:U},{entry:U},{phoff:U},{shoff:U},{flags:U},{ehsize:Kc},{phentsize:Kc},{phnum:Kc},{shentsize:Kc},{shnum:Kc},{shstrndx:Kc}]);console.assert(52===Mc.reduce((a,b)=>a+b.size,0)); +c[3];return c[0];case 41:switch(a[3]){case 1161905490:this.enabled=!0;this.queue.length=0;this.tail_is_move=!1;this.queue.push(876762442);break;case 245:this.absolute=this.enabled=!1;this.queue.length=0;this.bus.send("vmware-absolute-mouse",!1);break;case 1396851026:this.absolute=!0;this.bus.send("vmware-absolute-mouse",!0);break;case 1279611474:this.absolute=!1,this.bus.send("vmware-absolute-mouse",!1)}return 0}return-1};Hc.prototype.get_state=function(){return[this.enabled,this.absolute]}; +Hc.prototype.set_state=function(a){this.enabled=a[0];this.absolute=a[1];this.bus.send("vmware-absolute-mouse",this.absolute)};const Ic=DataView.prototype,Jc={size:1,get:Ic.getUint8,set:Ic.setUint8},Kc={size:2,get:Ic.getUint16,set:Ic.setUint16},U={size:4,get:Ic.getUint32,set:Ic.setUint32},Mc=Lc([{magic:U},{class:Jc},{data:Jc},{version0:Jc},{osabi:Jc},{abiversion:Jc},{pad0:function(a){return{size:a,get:()=>-1}}(7)},{type:Kc},{machine:Kc},{version1:U},{entry:U},{phoff:U},{shoff:U},{flags:U},{ehsize:Kc},{phentsize:Kc},{phnum:Kc},{shentsize:Kc},{shnum:Kc},{shstrndx:Kc}]);console.assert(52===Mc.reduce((a,b)=>a+b.size,0)); const Nc=Lc([{type:U},{offset:U},{vaddr:U},{paddr:U},{filesz:U},{memsz:U},{flags:U},{align:U}]);console.assert(32===Nc.reduce((a,b)=>a+b.size,0));const Oc=Lc([{name:U},{type:U},{flags:U},{addr:U},{offset:U},{size:U},{link:U},{info:U},{addralign:U},{entsize:U}]);console.assert(40===Oc.reduce((a,b)=>a+b.size,0));function Lc(a){return a.map(function(b){var c=Object.keys(b);console.assert(1===c.length);c=c[0];b=b[c];console.assert(0