v86
v86 emulates a complete 32-bit x86 PC in JavaScript/WebAssembly, booting MS-DOS, Windows, Linux, ReactOS and more from disk images entirely client-side. A striking demonstration of full-system emulation on the open web.
Runs on: Web browser
v86 Online Emulator
Play v86 using JavaScript directly in your browser.
Controls
Configurations
| Configuration | Emulator | Machine | OS | Legal | |
|---|---|---|---|---|---|
| FreeDOS | v86 | IBM PC/AT | DOS (MS-DOS / DR-DOS) | grey | Open ⛶ |
| Windows 3.1 | v86 | IBM PC/AT | Windows 3.x | grey | Open ⛶ |
| Windows 3.0 | v86 | IBM PC/AT | Windows 3.x | grey | Open ⛶ |
| Windows 98 | v86 | IBM PC/AT | Windows 9x | grey | Open ⛶ |
| Windows 95 | v86 | IBM PC/AT | Windows 9x | grey | Open ⛶ |
| ReactOS | v86 | IBM PC/AT | ReactOS | grey | Open ⛶ |
| Buildroot Linux | v86 | IBM PC/AT | Linux | open | Open ⛶ |
Machines emulated
Operating systems
Chips
Notes
Embedding
v86 emulates a whole x86 PC — CPU, chipset, BIOS, VGA, PS/2 keyboard — with the CPU compiled to WebAssembly. You construct one V86 object, point it at self-hosted BIOS + a boot disk, and give it a screen container; it fetches and instantiates v86.wasm and boots. We self-host everything (no CDN): libv86.js, v86.wasm, SeaBIOS + VGABIOS, and a 720 KB FreeDOS floppy.
var emulator = new V86({
wasm_path: "/emulator/v86/src/v86.wasm",
bios: { url: "/emulator/v86/src/seabios.bin" },
vga_bios: { url: "/emulator/v86/src/vgabios.bin" },
fda: { url: "/emulator/v86/src/freedos722.img" }, // floppy A: -> A:\>
memory_size: 32 * 1024 * 1024,
vga_memory_size: 2 * 1024 * 1024,
screen_container: screenContainer, // a <div> holding a text <div> + a <canvas>
autostart: true
});
The screen is two elements, not one. v86 renders VGA text mode into an inner <div> (real DOM text, white-space:pre) and graphics mode into a <canvas>, toggling their visibility. The debugger re-homes one screen element between the play view and its Screen window, so we hand it the whole screen_container — both children travel together and DOS text keeps rendering wherever the container lands.
| Member | Kind | What it does |
|---|---|---|
emulator.run() / emulator.stop() | method | Start / stop the WASM CPU loop. Our transport's resume / pause. |
emulator.restart() | method | Reboot the machine. |
emulator.v86.cpu.main_loop() | method | Run ONE time-slice of the CPU (the only step primitive a JIT-in-WASM core exposes). Our step / stepInsn. |
emulator.keyboard_send_scancodes([..]) | method | Inject Set-1 keyboard scancodes over the PS/2 bus — drives the on-screen keyboard. |
emulator.v86.cpu | object | The live CPU: typed-array views onto the WASM memory (registers + RAM) and a handful of bound wasm exports. |
Debugger integration
This is the interesting part: how a WebAssembly CPU gets the same debugger as the pure-JS cores. v86 does not hide its state inside the WASM sandbox — it keeps the entire architectural state in one linear WASM memory and, at construction, wraps typed-array VIEWS around fixed byte offsets in that memory. Those views are ordinary JavaScript objects, so reading and writing them reads and writes the exact bytes the CPU executes on next cycle. No message passing, no serialization.
| What the debugger needs | Where it lives in the WASM core |
|---|---|
| EAX ECX EDX EBX ESP EBP ESI EDI | cpu.reg32 — an Int32Array(8) view; writable (poking an element writes WASM memory). |
| Instruction pointer (for disasm) | cpu.instruction_pointer[0] — the linear EIP (CS base + IP); cpu.get_real_eip() gives the IP offset inside CS. |
| Segment selectors CS DS SS ES FS GS | cpu.sreg — a Uint16Array(8) view (order ES CS SS DS FS GS). Shown read-only: writing a selector alone would not recompute the cached segment base the JIT uses. |
| EFLAGS + condition flags | cpu.get_eflags() — a bound wasm export that recombines cpu.flags with the lazily-evaluated flags_changed. Read-only for the same lazy-eval reason. |
| Physical RAM | cpu.mem8 — a Uint8Array over guest physical memory; mem8[phys] is a side-effect-free byte read/write (it is the backing store, so it never touches an I/O device). |
Reaching the views. The public V86 object hides the machine one level down: emulator.v86 is the machine and emulator.v86.cpu is the CPU carrying the views above. A few wasm functions are already bound onto that JS object (get_eflags, get_real_eip, get_seg_cs, main_loop), so we call them directly. The boot script grabs emulator.v86.cpu after construction and publishes it, so the plug-in never has to know about WASM at all.
Play / pause / step — and the honest limit. Pause is emulator.stop(), resume is emulator.run(), and we track an isPaused flag the shell's Play button and the debugger both read. The hard part is single-instruction step. A JIT-in-WASM core has no clean one-instruction primitive: the only export that advances the CPU is main_loop(), which runs a whole time-slice (many instructions, until a HLT or the slice's time budget). We inspected every published build — release, fallback (interpreter) and debug — and NONE exports a per-instruction cycle_internal / do_many_cycles. So our step is a burst step: while stopped we call cpu.main_loop() once, which advances EIP and the registers by one slice. That is enough to watch the registers and memory change live, but it is not one instruction, and we say so. (A true single step would need the x86 Trap Flag routed to a host trap — v86 does not surface that — or a patched debug WASM that exports the interpreter's inner cycle.)
Breakpoints / watchpoints are surfaced (the gutter and lists work) but best-effort: because normal execution happens inside v86's own WASM loop and a burst runs many instructions, a host-side PC check can only fire at burst boundaries, not before each instruction. They are honestly labelled as coarse rather than pretended exact.
What to copy for another WASM core (js-dos, np2kai, px68k, vAmigaWeb, infinite-mac, MartyPC). The whole technique is: (1) find the object that holds typed-array VIEWS onto the core's linear memory — if the core exposes registers/RAM as views, you are done, just read/write them each refresh; (2) map the debugger's register set and memory read/write straight onto those views; (3) wire pause/resume to whatever stop/run the core has; (4) for step, use the smallest "advance" the core exports (ideally one instruction; a time-slice if not) and be explicit about the granularity. A core that keeps state in views is fully debuggable from JS; a core that keeps state private inside WASM locals would need a patched build that copies its state out — that is the dividing line for the rest of the Tier-4 rollout.
Architecture
v86 is a full-system PC emulator. The x86 CPU is compiled to WebAssembly (with a self-hosted JIT that translates hot basic blocks to more WASM at run time); the surrounding machine — PIC, PIT, PS/2 controller, VGA, floppy/IDE, RTC — is JavaScript talking to the CPU over a bus. It boots real software from disk images entirely client-side.
libv86.js— the JavaScript machine: theV86façade, the device models, the screen/keyboard adapters, and the loader that instantiates the WASM.v86.wasm— the x86 CPU core + JIT, holding all CPU state in one linear memory (and the zstd decoder that inflates the boot-state snapshots).seabios.bin/vgabios.bin— the self-hosted system + video BIOS (SeaBIOS / VGABIOS, LGPL, freely redistributable).- A menu of self-hosted OS disks, one per config: a FreeDOS floppy, Windows 3.0 / 3.1 / 95 hard disks (cold-boot to the desktop), Windows 98 and ReactOS hard disks that resume from a pre-booted
.bin.zstRAM snapshot (straight to the desktop), and a Buildroot Linux live CD. Each config in the embed'sconfigs[]carries a smallv86spec — memory size, which drive (fda / hda / cdrom), and how the image loads.
Loading a big disk without a big download. The small images (Win 3.x, DOS, Linux) load whole. The large ones (Win 95/98 ~300-450 MB, ReactOS ~700 MB) are split into fixed-size part files (async + use_parts): v86 fetches only the blocks the guest actually reads, each as a plain whole-file GET, so it self-hosts on any static server with no HTTP Range support. Paired with a state snapshot, only the desktop's working set ever downloads.
DOS and Linux boot in real mode / text mode, so the shared 16-bit x86 disassembler drives the disasm view exactly. The Windows and ReactOS guests run in 32-bit protected mode, where the same register / memory / step machinery is fully live (EAX..EDI, EIP, segments, EFLAGS, physical RAM) and the disasm view is approximate (the decoder is an 8086/16-bit model).
Sound
v86 already models real PC sound hardware — a Sound Blaster 16 (its DSP/DAC plus an OPL FM block and the SB mixer) and the PC speaker (PIT channel 2 gating a square wave) — and it drives them through its own WebAudio pipeline. So this is a native-audio integration: we do not route samples through the site's shared audio sink. We reuse v86's pipeline as-is and only add a master mute in front of the speakers.
The pipeline. When the machine is built, v86 constructs a SpeakerAdapter that owns one AudioContext and a small graph: the SB16 DAC (an AudioWorklet, or a ScriptProcessor fallback) and the PC-speaker OscillatorNode feed a mixer whose treble/bass/gain stages meet at a ChannelMerger, and that merger connects to the context's destination. The adapter also suspends the context when the CPU stops and resumes it when the CPU runs.
The handle. The context is emulator.speaker_adapter.audio_context and the mixer's final node is emulator.speaker_adapter.mixer.node_merger. On emulator-ready the boot script splices one GainNode into that last hop — it disconnects the merger from the destination and reconnects node_merger -> masterGain -> destination — so a single gain controls everything the guest can make audible.
Mute contract. The transport exposes the standard pair, both starting muted:
var muted = true; // browsers block audio before a gesture
transport.isMuted = function(){ return muted; };
transport.setMute = function(m){
muted = !!m;
if(muted){ master.gain.value = 0; actx.suspend(); } // silence + park the context
else { master.gain.value = 1; actx.resume(); } // unmuted from the button's real click
};
The master gain is the reliable mute: v86 re-resumes its context whenever the CPU runs (including when you un-pause in the debugger), so holding masterGain.gain = 0 guarantees silence no matter what the adapter does with the context. The suspend() is a belt-and-braces extra for the muted state; unmuting sets gain to 1 and calls resume() from the real click.
Sound plays when the software makes it. Each machine boots to its natural state and stays silent until the guest programs the sound hardware. Sound is fully supported through the pipeline above. The page starts muted, so click the Sound button to hear it.