NP2kai
NP2kai is a modernized fork of Neko Project II that adds cross-platform builds and a libretro core for RetroArch, including an Emscripten/WebAssembly version. It emulates the PC-9801 and PC-9821 hardware with expanded sound and peripheral support.
Runs on: Windows, macOS, Linux, Android, RetroArch, Web browser
NP2kai Online Emulator
Play NP2kai using JavaScript directly in your browser.
Controls
Configurations
| Configuration | Emulator | Machine | OS | Legal | |
|---|---|---|---|---|---|
| FreeDOS(98) | NP2kai | NEC PC-9801 | DOS (MS-DOS / DR-DOS) | grey | Open ⛶ |
Machines emulated
Chips
Notes
Embedding
np2kai (AZO234's actively-maintained fork of Neko Project II) emulates a whole NEC PC-98 — an Intel i386-class CPU, the µPD765 floppy controller, the text/graphics GDCs and the YM2203 sound chip — with the machine compiled to WebAssembly by Emscripten (its SDL2 target). We self-host everything (no CDN): the rebuilt np2kai.js + np2kai.wasm, and a GPL FreeDOS(98) boot floppy. NP2kai carries its own clean-room ITF/BIOS and an embedded font, so no NEC ROM is needed to reach a DOS prompt.
The build is a plain (non-modularised) Emscripten program, so you configure it by defining the global Module before the script loads — hand it the canvas, the boot disk as a command-line argument, and a preRun hook that drops the disk into the in-memory filesystem:
var Module = {
canvas: screenCanvas,
arguments: ["/fd98.hdm"], // mounted to FDD1, booted by the ITF
locateFile: function(p){ return p === "emnp21kai_sdl2.wasm" ? SRC+"np2kai.wasm" : SRC+p; },
preRun: [function(){
Module.addRunDependency("disk");
fetch(DISK).then(r => r.arrayBuffer()).then(function(b){
Module.FS.writeFile("/fd98.hdm", new Uint8Array(b));
Module.removeRunDependency("disk");
});
}]
};
Once onRuntimeInitialized fires we publish a small transport plus the debug hooks on window.EMU_BOOT. SDL2 keeps a live reference to the canvas element, so the debugger can freely re-home it between the play view and its Screen window and the PC-98 keeps drawing wherever it lands.
| Handle | Kind | What it does |
|---|---|---|
Module._emudbg_pause(1|0) | hook | Set / clear the paused flag the frame loop checks once per frame. Our transport pause / resume. |
Module._emudbg_step() | hook | Queue exactly one pccore_exec() frame while paused. Our step / stepInsn (a frame burst). |
Module._emudbg_reg(i) / _emudbg_read(a) | hook | Sample one register / one guest RAM byte on demand. |
Module._emudbg_key(ref,down) | hook | Inject a PC-98 key by matrix code — drives the on-screen keyboard. |
Module._emudbg_reset() | hook | pccore_reset() — reboots the machine off the mounted disk. |
Debugger integration
How a WebAssembly core with PRIVATE state still gets the full debugger. v86 hands JavaScript typed-array views onto its CPU, so JS can read it directly. NP2kai does not: its i386c core keeps the architectural state in a C global (CPU_STATSAVE, i.e. i386core.s) that lives inside the wasm sandbox with no JS view. The dividing line the Tier-4 rollout drew is exactly this — a core that hides state needs a patched build that copies its state out. So we rebuilt NP2kai from source with one new file, emudbg.c, holding a handful of EMSCRIPTEN_KEEPALIVE functions that SAMPLE the state when the debugger asks (~10×/s + once per step). There is no per-instruction or per-cycle hook — the emulator's hot path is untouched, so the cost is paid only while the debug window is open.
Exactly what was changed (against AZO234/NP2kai, tag 0.86):
// NEW FILE: emudbg.c — reads i386c/ia32/cpu.h macros over CPU_STATSAVE
uint32_t emudbg_reg(int i) // CPU_REGS_DWORD(i) EAX ECX EDX EBX ESP EBP ESI EDI
void emudbg_set_reg(i,v) // write-back (poke the core's reg)
uint32_t emudbg_sreg(int i) // CPU_REGS_SREG(i) ES CS SS DS FS GS
uint32_t emudbg_eip() / emudbg_eflags() // CPU_EIP / REAL_EFLAGREG (recombines lazy CPU_OV)
uint32_t emudbg_pc() // CS_BASE + IP — real-mode linear PC for disasm
uint32_t emudbg_read(a) / emudbg_read_block(a,out,len) // mem[] backing store, side-effect-free
void emudbg_pause(on) / emudbg_step() / emudbg_reset()
void emudbg_key(ref,down) // keystat_keydown/keyup — PC-98 key matrix
// EDIT 1: sdl/np2.c np2exec() — honour the paused flag ONCE PER FRAME:
if (np2dbg_is_paused()) { if(np2dbg_take_step()){ joymng_sync(); pccore_exec(TRUE); } emscripten_sleep(16); continue; }
// EDIT 2: CMakeLists.txt — link the exports + memory growth (see build note).
| What the debugger needs | Where it lives in the i386c core |
|---|---|
| EAX ECX EDX EBX ESP EBP ESI EDI | CPU_STATSAVE.cpu_regs.reg[i].d — sampled by emudbg_reg; writable via emudbg_set_reg. |
| EIP (drives disasm) | CPU_EIP; the disasm PC is the real-mode linear CS_BASE + IP from emudbg_pc. |
| Segment selectors CS DS SS ES FS GS | CPU_REGS_SREG(i). Read-only: writing a selector alone would not recompute the cached segment base. |
| EFLAGS + condition flags | REAL_EFLAGREG, which recombines CPU_FLAG with the lazily-evaluated overflow (CPU_OV). Read-only for that reason. |
| Physical RAM | mem[] (i386c/cpumem.c, the 2 MB backing store) read directly by emudbg_read — side-effect-free, never an I/O port. |
Play / pause / step — the honest limit. Pause and resume set a flag that NP2kai's own frame loop (np2exec) tests once per frame, so the machine idles cheaply while stopped. Step queues exactly one pccore_exec() — one emulated FRAME (many instructions). That is the smallest advance we can make without adding a per-instruction check to the hot loop, which the performance rule forbids. It is enough to watch the registers and RAM change live, but it is a frame burst, not one instruction, and we say so. Breakpoints / watchpoints are therefore NOT enforced: catching a PC or a RAM write needs a per-instruction hook that would slow the emulator even with the debugger closed, so we do not fake them.
How to rebuild this emulator (reproducible from scratch):
# toolchain: Homebrew emscripten 6.0.3 (emcc/em++/emcmake on PATH), cmake, make
git clone https://github.com/AZO234/NP2kai # tag 0.86
# add emudbg.c (above); patch np2exec() (above); in CMakeLists.txt provide a
# PNG::PNG interface target using -sUSE_LIBPNG=1, drop ssl/crypto from the em
# base libs, add emudbg.c to the emnp21kai_sdl2 target + these link flags.
cd NP2kai && mkdir build && cd build
NP2KAI_VERSION=0.86 NP2KAI_HASH=$(git rev-parse --short HEAD) emcmake cmake .. -D__EMSCRIPTEN__=ON -DUSE_NETWORK=OFF -DUSE_TICKCOUNT=OFF -DCMAKE_BUILD_TYPE=Release
make emnp21kai_sdl2 -j8 # default target: IA-32 core, SDL2
# link flags added: -sALLOW_MEMORY_GROWTH=1 -sINITIAL_MEMORY=134217728
# -sEMULATE_FUNCTION_POINTER_CASTS=1 -sASYNCIFY=1 -sFORCE_FILESYSTEM=1
# -sEXPORTED_FUNCTIONS=_main,_emudbg_*,_malloc,_free
# -sEXPORTED_RUNTIME_METHODS=ccall,cwrap,getValue,setValue,HEAPU8,HEAPU32,FS,addRunDependency,removeRunDependency
# out: emnp21kai_sdl2.{js,wasm} -> vendored as np2kai.{js,wasm}
Two build quirks worth noting: NP2kai's function-pointer I/O tables trip the strict wasm indirect-call type check, fixed with EMULATE_FUNCTION_POINTER_CASTS (which then needs an explicit ASYNCIFY=1 because it disables the auto-detection that emscripten_sleep relies on); and the default 64 MB heap OOMs during the PC-98 memory sizing, fixed with memory growth.
Architecture
NP2kai is a full-system NEC PC-98 emulator. The Intel i386-class CPU (the i386c/ia32 core) is compiled to WebAssembly; the surrounding machine — the two GDCs (text + graphics), the µPD765 floppy controller, the µPD8255 keyboard/mouse ports, the RTC and the YM2203 (OPN) sound chip — is C talking to the CPU over the bus, all client-side.
np2kai.js/np2kai.wasm— the whole PC-98 (CPU + devices) as the Emscripten SDL2 build, rebuilt with theemudbg.cexport hooks.- Clean-room ITF / BIOS and an embedded font are built into NP2kai itself (BSD-3), so booting needs no NEC ROM.
freedos98_2hd.hdm— a 2HD (1232 KB) FreeDOS(98) boot floppy (GPL, freely redistributable) that boots straight toA:\>.
The default machine boots FreeDOS(98) in real mode, so the shared 16-bit x86 disassembler drives the disasm view correctly. Code that switches to 32-bit protected mode disassembles approximately (the decoder is a 16-bit model).
Sound
Pattern V-native. np2kai already emulates the PC-98's sound hardware — the YM2608 (OPNA) FM synthesiser with its ADPCM / rhythm channels, the PC-98 beep speaker, and PCM — and the Emscripten SDL2 build already renders that mix through its OWN WebAudio graph. So we do not re-route audio through the site's shared EmuAudio sink (that would risk double output or phase corruption); we drive the core's own context directly and leave its mixer untouched.
The graph. SDL2 lazily creates a single AudioContext at window.Module.SDL2.audioContext and feeds it from Module.SDL2.audio.scriptProcessorNode, which pulls the core's rendered samples out of the wasm heap each callback. Left alone, SDL2 wires that node straight to audioContext.destination and auto-resumes on any page gesture — so it could wake without our button. To keep it silent until the user asks, the boot shim splices a GainNode between the two:
var node = Module.SDL2.audio.scriptProcessorNode;
node.disconnect();
node.connect(gain); // gain.gain.value = muted ? 0 : 1
gain.connect(ctx.destination);
Mute / unmute. While muted we hold gain.gain.value = 0 AND keep the context suspend()ed, and a ~300 ms guard re-asserts that (and re-splices the gain) as soon as SDL2 opens audio, defeating the auto-resume. setMute(false), called from the Sound button's real click, sets gain = 1 and ctx.resume()s; setMute(true) zeroes the gain and suspends again. The contract is published on window.EMU_BOOT.transport as isMuted() / setMute(m), and it starts muted because browsers block audio before a user gesture.
Plays when the software does. The machine boots to its natural resting state at A:\> and makes no sound on its own. Sound is fully supported and plays whenever the running software drives the PC-98 hardware. The page starts muted, so click the Sound button to hear it.
Caveat. There is one AudioContext (the core's), so mute is enforced by gain + suspend rather than by a shared mixer; when muted the context is suspended, so the scriptProcessor callback stops firing and the destination is fully silent.