diff --git a/README.md b/README.md
index c1230f2..f23c52d 100644
--- a/README.md
+++ b/README.md
@@ -15,6 +15,15 @@ network multiplayer feature, so itโs not completely useless.
+## WebAssembly Build
+
+WebAssembly version for modern browsers is available at https://maxpoletaev.github.io/dendy/.
+It runs smoothly in modern browsers, though it does not support netplay in its
+current form. (there was an [experimental][wasm-netplay] implementation of
+netplay over WebRTC, but it was too slow and unreliable to be usable).
+
+[wasm-netplay]: https://drive.google.com/file/d/1r3ZY20L168u3djRMWA_KLMrY0eIr1ify/view?usp=sharing
+
## Download
You can download the latest pre-built binaries for Windows, macOS, and Linux
diff --git a/apu/apu.go b/apu/apu.go
index 25871c1..8d77fe5 100644
--- a/apu/apu.go
+++ b/apu/apu.go
@@ -134,7 +134,7 @@ func (a *APU) Output() float32 {
out = f.do(out)
}
- return out * 5.0
+ return clamp(out, -1, 1)
}
func (a *APU) Tick() {
@@ -230,3 +230,12 @@ func (a *APU) LoadState(r *binario.Reader) error {
r.ReadBoolTo(&a.frameIRQ),
)
}
+
+func clamp(v float32, min, max float32) float32 {
+ if v < min {
+ return min
+ } else if v > max {
+ return max
+ }
+ return v
+}
diff --git a/cmd/dendy-wasm/main.go b/cmd/dendy-wasm/main.go
index 2389bbe..17d6162 100644
--- a/cmd/dendy-wasm/main.go
+++ b/cmd/dendy-wasm/main.go
@@ -4,22 +4,21 @@ import (
_ "embed"
"fmt"
"log"
- "runtime"
"syscall/js"
- "time"
"unsafe"
+ "github.com/maxpoletaev/dendy/consts"
"github.com/maxpoletaev/dendy/ines"
"github.com/maxpoletaev/dendy/input"
"github.com/maxpoletaev/dendy/system"
)
const (
- debugFrameTime = false
+ audioBufferSize = 512
)
//go:embed nestest.nes
-var bootROM []byte
+var nestestROM []byte
func create(joy *input.Joystick, romData []byte) (*system.System, error) {
rom, err := ines.NewFromBuffer(romData)
@@ -40,51 +39,60 @@ func create(joy *input.Joystick, romData []byte) (*system.System, error) {
func main() {
log.SetFlags(0) // disable timestamps
joystick := input.NewJoystick()
+ audioBuf := make([]float32, audioBufferSize)
- nes, err := create(joystick, bootROM)
+ nes, err := create(joystick, nestestROM)
if err != nil {
log.Fatalf("[ERROR] failed to initialize: %v", err)
}
+ global := js.Global()
+ jsapi := global.Get("go")
+ jsapi.Set("AudioBufferSize", audioBufferSize)
+ jsapi.Set("AudioSampleRate", consts.AudioSamplesPerSecond)
+
var (
- mem runtime.MemStats
- frameTimeSum time.Duration
- frameCount uint
+ ticksCount int
+ sampleCount int
)
- js.Global().Set("runFrame", js.FuncOf(func(this js.Value, args []js.Value) any {
+ jsapi.Set("RunFrame", js.FuncOf(func(this js.Value, args []js.Value) any {
buttons := args[0].Int()
- start := time.Now()
- var framePtr uintptr
-
- for {
- nes.Tick()
- if nes.FrameReady() {
- frame := nes.Frame()
- joystick.SetButtons(uint8(buttons))
- framePtr = uintptr(unsafe.Pointer(&frame[0]))
- break
+ frameReady := false
+
+ for sampleCount < len(audioBuf) {
+ for ticksCount < consts.TicksPerAudioSample {
+ nes.Tick()
+ ticksCount++
+
+ if nes.FrameReady() {
+ joystick.SetButtons(uint8(buttons))
+ frameReady = true
+ }
}
- }
- if debugFrameTime {
- frameTime := time.Since(start)
- frameTimeSum += frameTime
- frameCount++
-
- if frameCount%120 == 0 {
- runtime.ReadMemStats(&mem)
- avgFrameTime := frameTimeSum / time.Duration(frameCount)
- log.Printf("[INFO] frame time: %v, memory: %d", avgFrameTime, mem.HeapAlloc)
- frameTimeSum = 0
- frameCount = 0
+ audioBuf[sampleCount] = nes.AudioSample()
+ sampleCount++
+ ticksCount = 0
+
+ if frameReady {
+ return true
}
}
- return framePtr
+ sampleCount = 0
+ return false
+ }))
+
+ jsapi.Set("GetFrameBufferPtr", js.FuncOf(func(this js.Value, args []js.Value) any {
+ return uintptr(unsafe.Pointer(&nes.Frame()[0]))
+ }))
+
+ jsapi.Set("GetAudioBufferPtr", js.FuncOf(func(this js.Value, args []js.Value) any {
+ return uintptr(unsafe.Pointer(&audioBuf[0]))
}))
- js.Global().Set("uploadROM", js.FuncOf(func(this js.Value, args []js.Value) any {
+ jsapi.Set("LoadROM", js.FuncOf(func(this js.Value, args []js.Value) any {
data := js.Global().Get("Uint8Array").New(args[0])
romData := make([]byte, data.Length())
js.CopyBytesToGo(romData, data)
diff --git a/web/audio.js b/web/audio.js
new file mode 100644
index 0000000..3d9a106
--- /dev/null
+++ b/web/audio.js
@@ -0,0 +1,40 @@
+class AudioProcessor extends AudioWorkletProcessor {
+ constructor() {
+ super();
+ this.position = 0;
+ this.buffers = [];
+ this.current = null;
+
+ this.port.onmessage = (e) => {
+ this.buffers.push(e.data);
+ if (this.buffers.length > 3) {
+ this.buffers.shift();
+ }
+ };
+ }
+
+ process(inputs, outputs, parameters) {
+ let output = outputs[0];
+ let channel = output[0];
+
+ if (!this.current || this.position >= this.current.length) {
+ this.current = this.buffers.shift();
+ this.position = 0;
+
+ if (!this.current) {
+ for (let i = 0; i < channel.length; i++) {
+ channel[i] = 0;
+ }
+ return true;
+ }
+ }
+
+ for (let i = 0; i < channel.length; i++) {
+ channel[i] = this.current[this.position++];
+ }
+
+ return true;
+ }
+}
+
+registerProcessor("audio-processor", AudioProcessor);
\ No newline at end of file
diff --git a/web/index.html b/web/index.html
index e15954a..789a1f2 100644
--- a/web/index.html
+++ b/web/index.html
@@ -13,13 +13,15 @@
+
๐ Click anywhere to unmute
- Select ROM (.nes):
+ Select ROM (.nes):
+
diff --git a/web/main.js b/web/main.js
index 6a0448c..95cf1d9 100644
--- a/web/main.js
+++ b/web/main.js
@@ -6,27 +6,67 @@ const documentReady = new Promise((resolve) => {
}
});
-const go = new Go();
+window.go = new Go();
const wasmReady = WebAssembly.instantiateStreaming(fetch("dendy.wasm"), go.importObject).then((result) => {
go.run(result.instance);
});
-Promise.all([wasmReady, documentReady]).then(() => {
- const width = 256;
- const height = 240;
- const targetFPS = 60;
- const scale = 2;
+Promise.all([wasmReady, documentReady]).then(async () => {
+ const WIDTH = 256;
+ const HEIGHT = 240;
+ const TARGET_FPS = 60;
+ const SCALE = 2;
+
+ const audioBufferSize = go.AudioBufferSize;
+ const audioSampleRate = go.AudioSampleRate;
+
+ // ========================
+ // Canvas setup
+ // ========================
let canvas = document.getElementById("canvas");
- canvas.width = width;
- canvas.height = height;
- canvas.style.width = width*scale + "px";
- canvas.style.height = height*scale + "px";
+ canvas.width = WIDTH;
+ canvas.height = HEIGHT;
+ canvas.style.width = WIDTH * SCALE + "px";
+ canvas.style.height = HEIGHT * SCALE + "px";
canvas.style.imageRendering = "pixelated";
let ctx = canvas.getContext("2d");
ctx.imageSmoothingEnabled = false;
- let buttonsPressed = 0;
+
+ // ========================
+ // Audio setup
+ // ========================
+
+ console.log(`[INFO] audio sample rate: ${audioSampleRate}, buffer size: ${audioBufferSize}`);
+ let audioCtx = new AudioContext({
+ sampleRate: audioSampleRate,
+ });
+
+ await audioCtx.audioWorklet.addModule("audio.js");
+ let audioNode = new AudioWorkletNode(audioCtx, "audio-processor");
+ audioNode.connect(audioCtx.destination);
+
+ // ========================
+ // Mute/unmute button
+ // ========================
+
+ let unmuteButton = document.getElementById("unmute-button");
+ if (audioCtx.state === "suspended") {
+ unmuteButton.style.display = "block";
+ }
+
+ document.addEventListener("click", function() {
+ if (audioCtx.state === "suspended") {
+ unmuteButton.style.display = "none";
+ audioCtx.resume();
+ }
+ }, {once: true});
+
+
+ // ========================
+ // Input handling
+ // ========================
const BUTTON_A = 1 << 0;
const BUTTON_B = 1 << 1;
@@ -48,6 +88,8 @@ Promise.all([wasmReady, documentReady]).then(() => {
"KeyK": BUTTON_A,
};
+ let buttonsPressed = 0;
+
document.addEventListener("keydown", (event) => {
if (keyMap[event.code]) {
event.preventDefault();
@@ -62,52 +104,80 @@ Promise.all([wasmReady, documentReady]).then(() => {
}
});
+ // ========================
+ // ROM loading
+ // ========================
+
let fileInput = document.getElementById("file-input");
- fileInput.addEventListener("input", function() {
+ fileInput.addEventListener("input", function () {
this.files[0].arrayBuffer().then((buffer) => {
let rom = new Uint8Array(buffer);
- let ok = uploadROM(rom);
+ let ok = go.LoadROM(rom);
if (!ok) {
- alert("Invalid ROM file");
+ alert("Invalid or unsupported ROM file");
this.value = "";
}
});
- this.blur();
+ this.blur(); // Avoid re-opening file dialog when pressing Enter
});
if (fileInput.files.length > 0) {
fileInput.files[0].arrayBuffer().then((buffer) => {
let rom = new Uint8Array(buffer);
- let ok = uploadROM(rom);
+ let ok = go.LoadROM(rom);
if (!ok) {
fileInput.value = "";
}
});
}
+ // ========================
+ // Game loop
+ // ========================
+
function isInFocus() {
return document.hasFocus() && document.visibilityState === "visible";
}
- function gameLoop() {
- let nextFrame = () => {
- let start = performance.now();
+ function getMemoryBuffer() {
+ return go._inst.exports.mem?.buffer || go._inst.exports.memory.buffer; // latter is for TinyGo
+ }
+
+ function executeFrame() {
+ while (true) {
+ let frameReady = go.RunFrame(buttonsPressed);
- if (isInFocus()) {
- let framePtr = runFrame(buttonsPressed);
- let memPtr = go._inst.exports.mem?.buffer || go._inst.exports.memory.buffer; // latter is for TinyGo
- let image = new ImageData(new Uint8ClampedArray(memPtr, framePtr, width * height * 4), width, height);
+ if (frameReady) {
+ let framePtr = go.GetFrameBufferPtr();
+ let image = new ImageData(new Uint8ClampedArray(getMemoryBuffer(), framePtr, WIDTH * HEIGHT * 4), WIDTH, HEIGHT);
ctx.putImageData(image, 0, 0);
+ return
}
- let elapsed = performance.now() - start;
- let nextTimeout = Math.max(0, (1000 / targetFPS) - elapsed);
- setTimeout(nextFrame, nextTimeout);
- };
+ let audioBufPtr = go.GetAudioBufferPtr();
+ let audioBuf = new Float32Array(getMemoryBuffer(), audioBufPtr, go.AudioBufferSize);
+ audioNode.port.postMessage(audioBuf.slice());
+ }
+ }
+
+ let lastFrameTime = performance.now();
+ const frameTime = 1000 / TARGET_FPS;
+
+ function loop() {
+ requestAnimationFrame(loop)
- nextFrame();
+ const now = performance.now()
+ const elapsed = now - lastFrameTime
+ if (elapsed < frameTime) return
+
+ const excessTime = elapsed % frameTime
+ lastFrameTime = now - excessTime
+
+ if (isInFocus()) {
+ executeFrame();
+ }
}
- gameLoop();
+ requestAnimationFrame(loop);
});
diff --git a/web/style.css b/web/style.css
index 37de3c9..e63b7c3 100644
--- a/web/style.css
+++ b/web/style.css
@@ -16,7 +16,7 @@ body {
width: 512px;
border-radius: 12px;
overflow: hidden;
- box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2);
+ box-shadow: 0 10px 50px rgba(0, 0, 0, 0.3);
}
.header {
@@ -41,7 +41,7 @@ body {
.game-window {
height: 480px;
- border-bottom: 2px solid #2d3748;
+ border-bottom: 1px solid #2d3748;
}
.game-window canvas {
@@ -136,3 +136,17 @@ body {
.source-link a {
color: #4a5568;
}
+
+.unmute {
+ position: absolute;
+ background: rgba(0, 0, 0, 0.5);
+ backdrop-filter: blur(5px);
+ border-radius: 20px;
+ padding: 10px;
+ color: white;
+ left: 50%;
+ transform: translateX(-50%);
+ bottom: 40px;
+ font-size: 1.1em;
+ white-space: nowrap;
+}
\ No newline at end of file