3 Commits

Author SHA1 Message Date
Felix Rieseberg
d0090e5569 Cap SMB TCP burst at 4×MSS to stay under NE2000 RX ring (#371) 2026-04-14 16:50:19 -07:00
Felix Rieseberg
b14ea86ced Speed up SMB transfers: TCP windowing, hot-path assembly, gated diagnostics (#370) 2026-04-14 16:42:22 -07:00
Felix Rieseberg
41394e4d8e Add Machine menu items to swap floppy/CD-ROM/shared folder at runtime (#369)
v86 already exposes set_fda/eject_fda and devices.cdrom.set_cdrom/eject,
but the app only let you pick media from the pre-boot Settings card. This
adds Machine ▸ Floppy Drive, Machine ▸ CD-ROM Drive, and Machine ▸ Change
Shared Folder so you can swap media while Windows is running.

- Floppy: read the .img into memory and call set_fda({buffer}).
- CD-ROM: call devices.cdrom.set_cdrom(new SyncFileBuffer(path)) directly
  so the ATAPI BSY workaround in sync-file-buffer.ts still applies; the
  public set_cdrom() routes through v86's async loaders and re-introduces
  the race.
- SMB: setupSmbShare now installs the port-139 hook unconditionally,
  accepts a null initial path (RSTs incoming SYNs until one is set), and
  returns {setHostPath} so the menu can re-aim the share without a
  restart. New TCP sessions pick up the new folder; open ones keep the
  old root until Win95 reconnects.
2026-04-14 14:15:30 -07:00
6 changed files with 250 additions and 70 deletions

View File

@@ -36,6 +36,10 @@ export const IPC_COMMANDS = {
MACHINE_ESC: "MACHINE_ESC",
MACHINE_ALT_ENTER: "MACHINE_ALT_ENTER",
MACHINE_CTRL_ALT_DEL: "MACHINE_CTRL_ALT_DEL",
// Runtime media swap (main → renderer, payload = host path or undefined=eject)
MACHINE_SET_FLOPPY: "MACHINE_SET_FLOPPY",
MACHINE_SET_CDROM: "MACHINE_SET_CDROM",
MACHINE_SET_SMB_SHARE: "MACHINE_SET_SMB_SHARE",
// Machine events
MACHINE_STARTED: "MACHINE_STARTED",
MACHINE_STOPPED: "MACHINE_STOPPED",

View File

@@ -23,17 +23,34 @@ export async function setupMenu() {
);
}
function send(cmd: string) {
function send(cmd: string, ...args: unknown[]) {
const windows = BrowserWindow.getAllWindows();
if (windows[0]) {
log(`Sending "${cmd}"`);
windows[0].webContents.send(cmd);
windows[0].webContents.send(cmd, ...args);
} else {
log(`Tried to send "${cmd}", but could not find window`);
}
}
async function pickFile(filters: Electron.FileFilter[]) {
const win = BrowserWindow.getAllWindows()[0];
const result = await dialog.showOpenDialog(win, {
properties: ["openFile"],
filters,
});
return result.canceled ? null : result.filePaths[0];
}
async function pickFolder() {
const win = BrowserWindow.getAllWindows()[0];
const result = await dialog.showOpenDialog(win, {
properties: ["openDirectory"],
});
return result.canceled ? null : result.filePaths[0];
}
async function createMenu({ isRunning } = { isRunning: false }) {
const template: Array<Electron.MenuItemConstructorOptions> = [
{
@@ -188,6 +205,61 @@ async function createMenu({ isRunning } = { isRunning: false }) {
{
type: "separator",
},
// Hot-swap removable media. v86 always instantiates the FDC and the
// ATAPI secondary-master, so insert/eject work even if nothing was
// mounted at boot. The pre-boot Settings card covers the !isRunning
// case, so these are runtime-only.
{
label: "Floppy Drive",
enabled: isRunning,
submenu: [
{
label: "Insert Disk Image…",
click: async () => {
const p = await pickFile([
{ name: "Floppy image", extensions: ["img", "ima", "vfd"] },
{ name: "All Files", extensions: ["*"] },
]);
if (p) send(IPC_COMMANDS.MACHINE_SET_FLOPPY, p);
},
},
{
label: "Eject",
click: () => send(IPC_COMMANDS.MACHINE_SET_FLOPPY, null),
},
],
},
{
label: "CD-ROM Drive",
enabled: isRunning,
submenu: [
{
label: "Insert Disc Image…",
click: async () => {
const p = await pickFile([
{ name: "ISO image", extensions: ["iso"] },
{ name: "All Files", extensions: ["*"] },
]);
if (p) send(IPC_COMMANDS.MACHINE_SET_CDROM, p);
},
},
{
label: "Eject",
click: () => send(IPC_COMMANDS.MACHINE_SET_CDROM, null),
},
],
},
{
label: "Change Shared Folder…",
enabled: isRunning,
click: async () => {
const p = await pickFolder();
if (p) send(IPC_COMMANDS.MACHINE_SET_SMB_SHARE, p);
},
},
{
type: "separator",
},
{
label: "Go to Disk Image",
click: () => send(IPC_COMMANDS.SHOW_DISK_IMAGE),

View File

@@ -58,6 +58,7 @@ export interface EmulatorState {
export class Emulator extends React.Component<{}, EmulatorState> {
private isQuitting = false;
private isResetting = false;
private smbShare?: ReturnType<typeof setupSmbShare>;
// Mirrors state.hasAbsoluteMouse but updated synchronously — setState is
// batched, and the lock/unlock decisions can't wait for a render.
private absoluteMouse = false;
@@ -229,6 +230,48 @@ export class Emulator extends React.Component<{}, EmulatorState> {
this.showDiskImage();
});
ipcRenderer.on(IPC_COMMANDS.MACHINE_SET_FLOPPY, (_e, p: string | null) => {
const emu = this.state.emulator;
if (!emu) return;
if (p) {
// Floppies are ≤2.88MB — load whole image into memory and hand v86 a
// plain ArrayBuffer so its SyncBuffer path is used.
const buf = fs.readFileSync(p);
const ab = buf.buffer.slice(
buf.byteOffset,
buf.byteOffset + buf.byteLength,
);
emu.set_fda({ buffer: ab });
console.log(`💾 floppy ← ${p}`);
} else {
emu.eject_fda();
console.log(`💾 floppy ejected`);
}
});
ipcRenderer.on(IPC_COMMANDS.MACHINE_SET_CDROM, (_e, p: string | null) => {
const emu = this.state.emulator;
// The public emu.set_cdrom() routes through v86's async loaders, which
// re-introduce the ATAPI BSY race documented in sync-file-buffer.ts.
// Go straight to the device with our fs-backed synchronous buffer —
// same object the boot path hands to the `cdrom:` option.
const dev = emu?.v86?.cpu?.devices?.cdrom;
if (!dev) return;
if (p) {
dev.set_cdrom(new SyncFileBuffer(p));
console.log(`💿 cdrom ← ${p}`);
} else {
dev.eject();
console.log(`💿 cdrom ejected`);
}
});
ipcRenderer.on(IPC_COMMANDS.MACHINE_SET_SMB_SHARE, (_e, p: string) => {
this.smbShare?.setHostPath(p);
this.setState({ smbSharePath: p });
ipcRenderer.invoke(IPC_COMMANDS.SET_SMB_SHARE_PATH, p);
});
ipcRenderer.on(IPC_COMMANDS.ZOOM_IN, () => {
this.setScale(this.state.scale * 1.2);
});
@@ -450,10 +493,15 @@ export class Emulator extends React.Component<{}, EmulatorState> {
// 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
// probe harness can point at a fixture dir without touching settings.
const smbRoot = process.env.WIN95_SMB_SHARE || this.state.smbSharePath;
if (smbRoot) {
setupSmbShare(window["emulator"], smbRoot, CONSTANTS.TOOLS_PATH);
}
// The hook is installed unconditionally so the Machine ▸ Change Shared
// Folder menu can point it at a directory later without a restart.
const smbRoot =
process.env.WIN95_SMB_SHARE || this.state.smbSharePath || null;
this.smbShare = setupSmbShare(
window["emulator"],
smbRoot,
CONSTANTS.TOOLS_PATH,
);
if (PROBE) {
startProbe(window["emulator"]);

View File

@@ -8,26 +8,28 @@
// Then inside Win95: Start → Run → \\192.168.86.1\host
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import { NetBIOSFramer, nbPositiveResponse, nbWrap } from "./netbios";
import { setupNbns } from "./nbns";
import { SmbSession, shareNameFor, TOOLS_SHARE } from "./server";
// SPIKE diagnostics: tee everything to a file so we can debug without DevTools
const LOG_FILE = process.env.WIN95_SMB_LOG || path.join(os.tmpdir(), "windows95-smb.log");
try { fs.writeFileSync(LOG_FILE, `--- ${new Date().toISOString()} ---\n`); } catch {}
const origLog = console.log;
console.log = (...args: unknown[]) => {
origLog(...args);
const tag = String(args[0] ?? "");
if (tag === "[smb]" || tag === "[nbns]") {
try {
fs.appendFileSync(LOG_FILE, args.map(a =>
typeof a === "string" ? a : JSON.stringify(a)).join(" ") + "\n");
} catch {}
}
};
// Diagnostics tee — opt-in via WIN95_SMB_LOG. The console.log override and
// per-frame counter below sit on the hot path; don't pay for them unless
// someone is actually watching.
const LOG_FILE = process.env.WIN95_SMB_LOG;
if (LOG_FILE) {
try { fs.writeFileSync(LOG_FILE, `--- ${new Date().toISOString()} ---\n`); } catch {}
const origLog = console.log;
console.log = (...args: unknown[]) => {
origLog(...args);
const tag = String(args[0] ?? "");
if (tag === "[smb]" || tag === "[nbns]") {
try {
fs.appendFileSync(LOG_FILE, args.map(a =>
typeof a === "string" ? a : JSON.stringify(a)).join(" ") + "\n");
} catch {}
}
};
}
interface TCPConnection {
sport: number;
@@ -56,33 +58,42 @@ interface V86 {
const log = (...a: unknown[]) => console.log("[smb]", ...a);
export function setupSmbShare(emulator: V86, hostPath: string, toolsRoot?: string) {
log(`serving ${hostPath} on \\\\HOST\\${shareNameFor(hostPath)} ` +
`(+ \\\\HOST\\${TOOLS_SHARE}${toolsRoot ? `${toolsRoot}` : ""}) port 139`);
export function setupSmbShare(emulator: V86, hostPath: string | null, toolsRoot?: string) {
// hostPath is read on every new TCP 139 connection, so the menu can re-aim
// the share at a different folder without restarting. Existing SmbSessions
// keep their old root until Win95 reconnects (close the Explorer window or
// `net use z: /delete` then re-map).
const announce = () => hostPath
? log(`serving ${hostPath} on \\\\HOST\\${shareNameFor(hostPath)} ` +
`(+ \\\\HOST\\${TOOLS_SHARE}${toolsRoot ? `${toolsRoot}` : ""}) port 139`)
: log(`port 139 hooked, no host folder shared yet`);
announce();
// SPIKE diagnostic: count every ethernet frame so we know if the NIC is
// emitting anything at all (DHCP, ARP, anything). Logged on a timer so
// we don't flood — and so the absence of a tick proves the bus is dead.
let frameStats = { total: 0, arp: 0, ip: 0, udp: 0, tcp: 0, other: 0 };
emulator.bus.register("net0-send", (raw: unknown) => {
const f = raw as Uint8Array;
frameStats.total++;
if (f.length < 14) { frameStats.other++; return; }
const et = (f[12] << 8) | f[13];
if (et === 0x0806) frameStats.arp++;
else if (et === 0x0800) {
frameStats.ip++;
const proto = f[14 + 9];
if (proto === 6) frameStats.tcp++;
else if (proto === 17) frameStats.udp++;
} else frameStats.other++;
});
setInterval(() => {
if (frameStats.total > 0) {
log("frames:", JSON.stringify(frameStats));
frameStats = { total: 0, arp: 0, ip: 0, udp: 0, tcp: 0, other: 0 };
}
}, 5000);
if (LOG_FILE) {
// Count every ethernet frame so we know if the NIC is emitting anything
// at all. Logged on a timer so the absence of a tick proves the bus is
// dead. Opt-in: this hook fires once per TX frame during a file copy.
let frameStats = { total: 0, arp: 0, ip: 0, udp: 0, tcp: 0, other: 0 };
emulator.bus.register("net0-send", (raw: unknown) => {
const f = raw as Uint8Array;
frameStats.total++;
if (f.length < 14) { frameStats.other++; return; }
const et = (f[12] << 8) | f[13];
if (et === 0x0806) frameStats.arp++;
else if (et === 0x0800) {
frameStats.ip++;
const proto = f[14 + 9];
if (proto === 6) frameStats.tcp++;
else if (proto === 17) frameStats.udp++;
} else frameStats.other++;
});
setInterval(() => {
if (frameStats.total > 0) {
log("frames:", JSON.stringify(frameStats));
frameStats = { total: 0, arp: 0, ip: 0, udp: 0, tcp: 0, other: 0 };
}
}, 5000);
}
// Win95 won't even try TCP 139 until UDP 137 answers a Node Status query
setupNbns(emulator as Parameters<typeof setupNbns>[0]);
@@ -100,6 +111,12 @@ export function setupSmbShare(emulator: V86, hostPath: string, toolsRoot?: strin
const wireConn = (conn: TCPConnection) => {
log(`← TCP SYN ${conn.tuple}`);
if (!hostPath) {
// No folder picked yet — caller declines the SYN so the guest sees a
// clean RST instead of a half-open NetBIOS session.
log("no share configured → RST");
return false;
}
const framer = new NetBIOSFramer();
const session = new SmbSession(hostPath, toolsRoot);
@@ -123,14 +140,39 @@ export function setupSmbShare(emulator: V86, hostPath: string, toolsRoot?: strin
} else {
(conn as any).on_data = handler;
}
// v86's TCP is stop-and-wait (one MSS, wait for ACK). The link is lossless
// and has no retransmit anyway, so keep a small window in flight by sliding
// the ring-buffer view under the original pump(). The burst lands in the
// NE2000 RX ring before the guest CPU runs, so the cap is the ring (5258
// pages for Win95's driver) — 4×MSS + our ACK ≈ 36 pages, 8 overflowed it.
const c = conn as any, sb = c.send_buffer;
const mss: number = c.send_chunk_buf?.length ?? 1460;
const pump1 = Object.getPrototypeOf(c)?.pump;
if (pump1 && sb?.buffer) {
let hi: number | undefined;
c.pump = function () {
if (this.pending || !sb.length) return pump1.call(this);
const cap = sb.buffer.length, t0 = sb.tail, l0 = sb.length, s0 = this.seq;
const win = 4 * mss;
let off = hi === undefined ? 0 : Math.max(0, Math.min(hi - s0, l0));
for (; off < l0 && off < win; off += Math.min(mss, l0 - off)) {
sb.tail = (t0 + off) % cap; sb.length = l0 - off;
this.seq = s0 + off; this.pending = false;
pump1.call(this);
}
hi = s0 + off;
sb.tail = t0; sb.length = l0; this.seq = s0; this.pending = true;
};
}
return true;
};
// New API: bus event (no-op on old v86 — event never fires)
emulator.bus.register("tcp-connection", (c: unknown) => {
const conn = c as TCPConnection;
if (conn.sport !== 139) return;
wireConn(conn);
conn.accept();
if (wireConn(conn)) conn.accept();
});
// Old API: monkey-patch adapter.on_tcp_connection. The adapter is created
@@ -177,7 +219,7 @@ export function setupSmbShare(emulator: V86, hostPath: string, toolsRoot?: strin
// "established" and we want a fresh handshake.
conn.tuple = tuple;
conn.state = "syn-received";
wireConn(conn);
if (!wireConn(conn)) return false;
try {
conn.accept(packet);
} catch (e) {
@@ -195,4 +237,11 @@ export function setupSmbShare(emulator: V86, hostPath: string, toolsRoot?: strin
const poll = setInterval(() => { if (tryHook()) clearInterval(poll); }, 100);
setTimeout(() => clearInterval(poll), 10000);
}
return {
setHostPath(p: string) {
hostPath = p;
announce();
},
};
}

View File

@@ -670,7 +670,11 @@ export class SmbSession {
new Uint8Array(0), new Uint8Array(0));
}
const words = new Writer().u16(data.length).zero(8).build();
const bytes = new Writer().u8(0x01).u16(data.length).bytes(data).build();
const bytes = new Uint8Array(3 + data.length);
bytes[0] = 0x01;
bytes[1] = data.length & 0xff;
bytes[2] = (data.length >> 8) & 0xff;
bytes.set(data, 3);
return buildSmb(req, CMD_READ, 0, words, bytes);
}

View File

@@ -4,7 +4,7 @@
// SMB1 message = 32-byte header + word block + byte block.
// Header is at a fixed offset; word/byte blocks vary by command.
import { Reader, Writer } from "./wire";
import { Reader } from "./wire";
export const SMB_MAGIC = [0xff, 0x53, 0x4d, 0x42]; // \xFF SMB
@@ -106,25 +106,28 @@ export function buildSmb(
bytes: Uint8Array,
overrides?: { tid?: number; uid?: number; flags2?: number }
): Uint8Array {
const w = new Writer();
w.bytes(SMB_MAGIC);
w.u8(cmd);
w.u32(status);
w.u8(FLAGS_REPLY | FLAGS_CASELESS | FLAGS_CANONICAL);
if (words.length % 2 !== 0) throw new Error("word block must be even");
// Hot path for READ replies (bytes can be ~16K) — assemble directly instead
// of pushing byte-by-byte through Writer.
const out = new Uint8Array(32 + 1 + words.length + 2 + bytes.length);
const v = new DataView(out.buffer);
out[0] = 0xff; out[1] = 0x53; out[2] = 0x4d; out[3] = 0x42;
out[4] = cmd;
v.setUint32(5, status, true);
out[9] = FLAGS_REPLY | FLAGS_CASELESS | FLAGS_CANONICAL;
// mirror long-name capability so the client keeps sending long names; never
// claim NT status or unicode (we reply in ASCII)
w.u16((overrides?.flags2 ?? req.flags2) & FLAGS2_LONG_NAMES);
w.zero(12);
w.u16(overrides?.tid ?? req.tid);
w.u16(req.pid);
w.u16(overrides?.uid ?? req.uid);
w.u16(req.mid);
if (words.length % 2 !== 0) throw new Error("word block must be even");
w.u8(words.length / 2);
w.bytes(words);
w.u16(bytes.length);
w.bytes(bytes);
return w.build();
v.setUint16(10, (overrides?.flags2 ?? req.flags2) & FLAGS2_LONG_NAMES, true);
// 12 bytes reserved already zero
v.setUint16(24, overrides?.tid ?? req.tid, true);
v.setUint16(26, req.pid, true);
v.setUint16(28, overrides?.uid ?? req.uid, true);
v.setUint16(30, req.mid, true);
out[32] = words.length / 2;
out.set(words, 33);
v.setUint16(33 + words.length, bytes.length, true);
out.set(bytes, 35 + words.length);
return out;
}
export function dosError(errClass: number, errCode: number): number {