Search › A-Z › N › Nintendo DS
Nintendo DS
This is the Nintendo DS running in your browser: the DeSmuME core compiled to WebAssembly with Emscripten and self-hosted, no CDN. It boots an openly-licensed homebrew .nds under an HLE BIOS (no firmware files needed), renders both DS screens stacked with the lower screen as a live touchscreen, and plugs into the shared in-frame debugger for the ARM946E-S (ARM9) main CPU and the ARM7TDMI co-processor.
Runs on: Web browser
Nintendo DS Online Emulator
Play Nintendo DS using JavaScript directly in your browser.
Controls
Configurations
| Configuration | Emulator | Machine | OS | Legal | |
|---|---|---|---|---|---|
| DS Homebrew | Nintendo DS | Nintendo DS | open | Open ⛶ |
Machines emulated
Chips
Notes
Embedding
The Nintendo DS core is DeSmuME (the TASEmulators DS emulator), compiled to WebAssembly with Emscripten. DeSmuME normally ships a Qt/Cocoa/SDL desktop app; rather than pull in any of that we wrote a tiny standalone Emscripten frontend, nds_wasm.cpp, that talks to DeSmuME's OWN core API (NDS_Init / NDS_LoadROM / NDS_Reset / NDS_exec) with the software rasterizer and a dummy sound core — no SDL, no OpenGL, no libretro — and exposes it to JavaScript through a handful of flat functions. The module is built with -sMODULARIZE=1 so nds.js is a factory you instantiate. We self-host everything (no CDN): the rebuilt nds.js / nds.wasm and each homebrew .nds.
// nds.js defines a global Module factory; instantiate it, stage the ROM, boot.
var ds = await Module({ locateFile: f => SRC + f }); // finds nds.wasm
ds._wasm_boot(); // NDS_Init + soft rasterizer + HLE BIOS
var p = ds._wasm_rom_buf(rom.length); ds.HEAPU8.set(rom, p); // stage the .nds
ds._wasm_load(p, rom.length); // write to MEMFS, NDS_LoadROM, direct-boot
Rendering is manual and DUAL-SCREEN. DeSmuME rasters two 256×192 screens into one 16-bit BGR555 buffer; the frontend converts the stacked 256×384 picture to RGBA in the WASM heap. Each frame we blit that buffer to an off-screen canvas and draw it to the visible canvas — the top half is the main screen, the bottom half is the touchscreen:
var rgba = new Uint8ClampedArray(ds.HEAPU8.buffer, ds._wasm_fb_ptr(), 256*384*4);
img.data.set(rgba); bctx.putImageData(img, 0, 0);
ctx.drawImage(back, 0,0, canvas.width, canvas.height); // both screens, stacked
Input is the DS key latch plus the touchscreen. Keys are pushed as one bitmask (D-pad + A/B/X/Y + L/R + Start/Select) through _wasm_set_pad; the lower screen's pointer events map to touch coordinates and call _wasm_set_touch(x,y) / _wasm_release_touch(), which drive DeSmuME's NDS_setTouchPos / NDS_releaseTouch.
| Member | Kind | What it does |
|---|---|---|
_wasm_boot() | export | NDS_Init, software 3D rasterizer, dummy sound, HLE-BIOS direct-boot config. |
_wasm_rom_buf(n) / _wasm_load(p,n) | export | Allocate a staging pointer for the .nds; write it to MEMFS, NDS_LoadROM, NDS_Reset (direct-boots). |
_wasm_tick() | export | Run exactly one video frame (NDS_exec) and convert both screens to RGBA. Our frame-step and the loop's advance. |
_wasm_fb_ptr() | export | Pointer to the stacked 256×384 RGBA framebuffer in the heap. |
_wasm_set_pad(bits) | export | Write the DS key latch (D-pad + A/B/X/Y + L/R + Start/Select). |
_wasm_set_touch(x,y) / _wasm_release_touch() | export | Set / clear the touchscreen position on the lower display. |
_wasm_dbg_* | export | The debug sampling API over BOTH ARM cores (registers, memory, step, reset) — see below. |
Debugger integration
This is a WebAssembly core, yet it gets the same live debugger as the pure-JavaScript machines — real registers, real memory, a real single-instruction step, plus execution breakpoints and memory watchpoints. The DS has TWO ARM CPUs; DeSmuME keeps both in plain C structs (NDS_ARM9 and NDS_ARM7, of type armcpu_t), whose R[16] / CPSR / instruct_adr are directly reachable, and whose armcpu_exec<PROCNUM>() runs exactly one instruction. We expose these through a small sampling API from our standalone frontend.
1 · Exactly what was added. One new file, nds_wasm.cpp, is the whole frontend: it drives the core and appends a block of EMSCRIPTEN_KEEPALIVE functions that read live state off the two armcpu_t structs. They are sampling functions: the debugger calls them ~10×/second while its window is open, and once per Step. There is no per-instruction or per-cycle callback anywhere — the emulator hot path (NDS_exec) is byte-for-byte unchanged (the golden performance rule). A second tiny file, task_stub.cpp, replaces DeSmuME's pthread-based Task with a synchronous one so the build is single-threaded (no SharedArrayBuffer, hosts anywhere).
// nds_wasm.cpp — sampling only, no hot-path hook. proc: 0 = ARM9, 1 = ARM7.
static armcpu_t *cpu(int proc){ return proc ? &NDS_ARM7 : &NDS_ARM9; }
u32 wasm_dbg_pc(int p){ return cpu(p)->instruct_adr; }
u32 wasm_dbg_reg(int p,int i){ return cpu(p)->R[i&15]; }
u32 wasm_dbg_cpsr(int p){ return cpu(p)->CPSR.val; }
u32 wasm_dbg_read(int p,u32 a){ // side-effect-free (MMU_AT_DEBUG)
return p ? _MMU_read08<ARMCPU_ARM7,MMU_AT_DEBUG>(a)
: _MMU_read08<ARMCPU_ARM9,MMU_AT_DEBUG>(a); }
void wasm_dbg_step(int p){ p ? armcpu_exec<1>() : armcpu_exec<0>(); } // ONE instruction
2 · What is REAL here.
- Real registers, both cores — R0-R15 and CPSR (N Z C V + the T/mode bits) for the ARM9 (ARM946E-S) main CPU and the ARM7TDMI co-processor, read and written live off the two
armcpu_tstructs. The ARM9 is the primary; the ARM7's PC and registers appear as a secondary group so you can watch both. - Real single-instruction step —
Step icallswasm_dbg_step(0)=armcpu_exec<0>(), which executes exactly one ARM9 instruction; PC and the registers change by one instruction per click. - Side-effect-free memory — reads go through
_MMU_read08<…,MMU_AT_DEBUG>, the DS's debug access path that bypasses I/O side effects, plus a direct window on the 4 MB main RAM (MMU.MAIN_MEM), so auto-polling the hex/disasm view never acknowledges an interrupt or clears a latch. - Execution breakpoints & memory watchpoints — implemented host-side in the run loop (see below), so they cost nothing when the debugger is closed.
3 · The reused decoder. The ARM9 and ARM7 are both ARM cores, so the machine plug-in points its cpu.decoder at the shared arm7 ARM disassembler — no new decoder was written. (It decodes 32-bit ARM state; when a core is in Thumb state the disassembly is best-effort, which the Notes call out.)
4 · Breakpoints and watchpoints without a hot-path hook. When nothing is armed, the loop runs a whole frame at native speed with _wasm_tick() (NDS_exec). As soon as a breakpoint or watchpoint is set, the loop switches to stepping the ARM9 one instruction at a time with wasm_dbg_step(0), comparing the live PC against the breakpoint set and the watched addresses against their last sampled value, and pausing on a hit. This is a pure host-side check that only runs while a guard is armed. (While single-stepping the ARM9 the ARM7/video do not advance in lockstep, so the picture holds on its last frame until you resume.)
Architecture
The Nintendo DS (Nintendo, 2004) is a dual-screen handheld — the first mainstream console with a touchscreen — built around two ARM CPUs sharing 4 MB of main RAM. DeSmuME emulates the whole machine; our build is a thin standalone Emscripten port of its core:
- ARM946E-S (ARM9) — the ARMv5TE main processor at ~67 MHz, with instruction/data caches and TCM. This is the primary CPU the debugger samples;
armcpu_exec<0>()is the run-one-instruction primitive used for single-stepping. - ARM7TDMI (ARM7) — the ARMv4T co-processor at ~33 MHz, handling sound, Wi-Fi, the touchscreen/firmware SPI and general I/O. Exposed as a secondary core (its PC and registers) — the same ARM7TDMI as the Game Boy Advance.
- 2D + 3D video — two 2D engines (main + sub) each drive one 256×192 screen; a hardware 3D geometry + rasterizer pipeline feeds one of them. DeSmuME renders 3D with a software rasterizer in this build (no WebGL needed).
- Touchscreen — a resistive panel over the lower screen, read by the ARM7 through the TSC; pointer events on the lower half of the canvas set the touch position.
- Memory map — 4 MB main RAM at
0x02000000(shared by both CPUs), plus per-CPU TCM/WRAM, the I/O register block, palette/VRAM/OAM, and the cartridge. Homebrew.ndsimages direct-boot from the cartridge header under an HLE BIOS, so no external BIOS or firmware files are needed.
How to build this exact artefact. Toolchain: Homebrew emscripten (emcc on PATH).
git clone https://github.com/TASEmulators/desmume.git
# add nds_wasm.cpp (core-API frontend + wasm_dbg_* hooks) and task_stub.cpp,
# then compile the core (desmume/src/*.cpp, minus the SSE/AVX/NEON/GL/JIT/Lua
# variants) + the shim with the software rasterizer:
emcc -O2 -std=c++17 -sUSE_ZLIB=1 <core .cpp files> nds_wasm.cpp task_stub.cpp -o nds.js -sMODULARIZE=1 -sEXPORT_NAME=Module -sALLOW_MEMORY_GROWTH=1 -sEXPORTED_RUNTIME_METHODS=ccall,cwrap,HEAPU8,HEAPU32 -sEXPORTED_FUNCTIONS=_wasm_boot,_wasm_load,_wasm_tick,_wasm_dbg_pc,... # -> nds.js + .wasm
The build is single-threaded (the pthread Task is stubbed synchronous), so it hosts anywhere with no SharedArrayBuffer / COOP-COEP headers. The interpreter cores run in plain C++ with no dynarec, so nothing needs writable-executable memory.
Sound
The Nintendo DS sound is DeSmuME's own 16-channel SPU emulation — the real thing, mixing PCM8 / PCM16 / IMA-ADPCM samples and PSG square/noise voices exactly as the ARM7 programs them through the sound registers at 0x04000400–0x04000520. DeSmuME already mixes a full frame of stereo output every NDS_exec (its SPU_Emulate_user path); the stock frontend simply threw those samples away with a dummy sound core. This is therefore a vendored-core, re-route (V-stub) integration: no new sound chip, we just capture the samples the core was already computing and feed them to the shared browser sink.
Capture core. The Emscripten frontend (nds_wasm.cpp) registers a tiny SoundInterface_struct (SNDCapture) in place of SNDDummy and selects it with SPU_ChangeSoundCore(SNDCORE_CAPTURE, 2048). Its UpdateAudio(buffer, num_samples) callback memcpys that frame's interleaved-stereo s16 (at DeSmuME's native DESMUME_SAMPLE_RATE = 44100 Hz) into a heap buffer. Three EMSCRIPTEN_KEEPALIVE exports hand it to JavaScript — wasm_audio_ptr(), wasm_audio_count() (stereo pairs this frame, ~735–743), and wasm_audio_rate(). The emulator hot path is otherwise unchanged.
Resample & sink. The DS runs at 44100 Hz but the browser AudioContext is usually 48000 Hz, so the boot script (embed.js) linearly resamples each frame's capture through a small FIFO (carrying the fractional phase and last sample across frames for continuity, with the backlog capped at ~4 frames so latency stays bounded) and pushes exactly Math.round(EmuAudio.sampleRate/60) interleaved-stereo Int16 pairs per video frame to EmuAudio.push() — the shared sink in debugger/src/audio.js.
Mute contract. Audio starts muted (browsers block audio before a gesture); the transport exposes isMuted() / setMute(m) which delegate straight to EmuAudio, so the Sound button's real click resumes the AudioContext and unmutes. While muted the FIFO is dropped so no stale backlog plays on unmute.
Sound. The bundled homebrew's ARM7 payload programs SPU channel 8 as a 50%-duty PSG square wave (SOUNDCNT master-enable + SOUND8CNT key-on) at roughly 440 Hz, so it drives a continuous tone as it runs, while the ARM9 fills the top screen with its colour gradient. Sound plays when the software makes it; the page starts muted, so click Sound to hear it.