How to setup a Wasm API for a CHIP-8 emulator - HedgeDoc
  1945 views
<center> # How to setup a Wasm API for a CHIP-8 emulator <big> **In this guide, you’ll learn how to keep control over the lifecycle of the library, allow signals/commands, and callbacks – all while keeping the asynchronous style that you know and love!** </big> *Written by Igor Loskutov. Originally published 2022-06-21 on the [Monadical blog](https://monadical.com/blog.html). Cover image titled “Crab & Rusty Gears”, generated with [Kandinsky 12b + shonenkovAI](https://t.me/shonenkovAI).* </center> <br> Whether small or large, libraries are a familiar part of a developer’s toolkit. But have you ever found yourself struggling with a library interface that doesn’t meet your standards? For example, it’s not always possible to have these user-friendly interfaces for Wasm libraries because of their complexity and the current state of the technology. Well, struggle no more! :::info What is Wasm? Wasm stands for ‘WebAssembly’, a portable binary code format that is designed to run on the web. ::: In this post, I’ll show you a way to improve your Wasm library API, given certain parameters that I’ll outline in this post. I’ll do this using the example of building the interface of a Wasm library API specifically for simulations like CHIP-8[^first]. In short, you can build a user-friendly interface, but your library must meet certain requirements and conform to certain restrictions. We’ll take a more detailed look at these conditions and restrictions later, but let’s first see what benefits we can get from a Rust Wasm API. :::warning Quick disclaimer: Wasm is a fairly new tool that is rapidly developing. As a result, this post may become obsolete as this technology advances. I’ll try to write updated versions in the future if there are major changes, so stay tuned! Also, Wasm technology has to be used with JavaScript, so you’ll need a solid understanding of JS to fully understand this post. ::: [^first]:CHIP-8 is an interpreted programming language, developed to write programs that are run on a CHIP-8 virtual machine. For a CHIP-8 guide, I recommend [this](https://blog.scottlogic.com/2017/12/13/chip8-emulator-webassembly-rust.html) priceless article which also uses Rust/Wasm for implementation. It focuses specifically on CHIP-8 implementation. ## The benefits of making a non-blocking Rust Wasm API I’ll be showing you how to build a user-friendly interface for a Wasm library by making a non-blocking Rust Wasm API for a simulation or a state machine. Now, your first thought might be: Why not just use Web Workers? Yes, this API does also work in Web Workers, but the reason I’m not using it here is because our library has a requirement to be able to mutate DOM elements. You could use Web Workers though, if you don’t have this specific restriction. We’re going to improve the client code from something like this: ```javascript const runloop = () => { for (var i = 0; i < 10; i++) { run_cycle(); } } decrement_timers(); updateUI(); window.requestAnimationFrame(runloop); }; window.requestAnimationFrame(runloop); ``` ![crying gif](https://docs.monadical.com/uploads/38f58b29-7cb5-46f1-b19b-9ccdb47600c6.gif) To something like this: ```javascript cpu.run() ``` ![joy gif](https://docs.monadical.com/uploads/88a70cfd-c8d1-4650-b96e-e72608ae36a8.gif) From a simulation API, in this case CHIP-8, we usually expect the following: - Control over the lifecycle. i.e. “start()” and “stop()” that doesn’t block the main browser thread. - Signals/commands. That is, accepting, for example, keyboard events. - Callbacks, or a way to subscribe to events in the running simulation. You’ll see that we’ll be able to do all this, while still maintaining functionality and keeping the asynchronous style. ## Restrictions to our method Before we get into the technical aspects of how to build this API, it’s important to note the restrictions that accompany this method: 1. You’ll probably only need to use this method if you’re running a continuous asynchronous computation. As we’ll see, there are some limitations to the method I’ll describe, which might not be worth it if your tasks are synchronous. Synchronous code runs quite well using more standard techniques. 2. The method is more applicable (although still viable without it) if you want your computation in the main thread. Our CHIP-8 handles a passed Canvas DOM element internally and mutates it. As I mentioned before, we can’t do this kind of a mutation in Web Worker yet[^second]. 3. We want the library not to be very CPU-heavy and for it to yield thread control quite often. That is, CHIP-8 waits for a while between its computational loops. 4. We also want our library usable, without having to make serious adjustments when we run it on other, non-Wasm, platforms. You’ll see that the library from this article works with both Wasm rendering itself on a canvas and also on a console, printing the screen into `stdout` when run as a binary (a benefit! Woohoo!). 5. We need to use three third-party libraries. (Ok so not quite a restriction but worth noting anyway). 6. When the computation is running and not waiting for a delay period, we still have to lock the main thread. (Again, a technical point worth noting). This method is rather niche because of the restrictions mentioned; nonetheless, let’s look at how we could achieve all the above. [^second]: Hopefully this will be possible later, if the Rust+Wasm ecosystem [implements it](https://developer.chrome.com/blog/offscreen-canvas). ## A possible fix? First, we want to run the computation in the main thread but still keep it asynchronous. As it turns out, there are no commonly accepted methods for this. But, what’s the deal with the main thread? Can’t we just run our computation there? For example: ```javascript pub fn run() { loop { do_the_thing(); } } Import { run } from “./my-wasm-lib” run() ``` Turns out, it would block the user’s browser! ![Crab WASM flowchart](https://docs.monadical.com/uploads/f86b8c8c-0c6f-4bd3-9353-177b1cc27262.png) As you can see, the one and only browser thread gets overtaken and is never returned back. After calling run() we can’t do anything, nor stop it. A library user expects that the library doesn’t break the page: ```javascript // expected behavior cpu.run() setTimeout(() => cpu.stop(), 10000) // the library’s computation runs for 10 secs then stops browser thread is usable while UI isn’t frozen ``` So let’s look at a better way. ## A real fix ### Step one: Gather your libraries These are the crates that you’ll need: - [wasm_mutex](https://lib.rs/crates/wasm_mutex): A mutex structure implementation that works in the browser main thread. - [wasm_timer](https://docs.rs/wasm-timer/latest/wasm_timer/): A Delay implementation that frees the main thread and lets Javascript code take control when chip8 is between loops. Note: I use the `fluvio-wasm-timer = "0.2.5"` branch at the moment of writing this; it’s more up-to-date. - [wasm_bindgen_futures](https://rustwasm.github.io/wasm-bindgen/api/wasm_bindgen_futures/fn.spawn_local.html)): This gives us spawn_local to run our Rust async code in the main thread. ### Step two: Write your source code The entry point to the Rust program would take a Canvas instance and a ROM (the instructions for CHIP-8 to run[^third]). ```javascript #[wasm_bindgen] pub fn init_program(program: &[u8], canvas: JsValue) -> Result<WasmProgram, JsValue> { match canvas.dyn_into::<web_sys::CanvasRenderingContext2d>() { Ok(canvas) => { // in the code, screen is interchangeable; CPU constructor accepts also a ConsoleScreen! let mut cpu = CPU::new(Box::new(WasmCanvasScreen::new(canvas))); cpu.load_program(program.to_vec()); Ok(WasmProgram { cpu: Arc::new(wasm_mutex::Mutex::new(cpu)) }) } Err(_) => Err(JsValue::from_str("canvas argument not a HtmlCanvas")), } } ``` The first thing we’ll be calling in our JS is to initialize an instance of a processor. The crucial method in the CPU trait is, as expected, run(). ```javascript pub async fn run(this: Arc<wasm_mutex::Mutex<CPU>>) { let arc = this.clone(); loop { Delay::new(Duration::new(1 / SPEED, 0)).await; let mut guard = arc.lock().await; let guard: &mut CPU = guard.deref_mut(); // for compiler to understand splitting borrow later // is_done() will return true after we call cpu.stop() if guard.is_done() { break; } // we also call requestAnimationFrame https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame from inside the lib! guard.screen.request_animation_frame().await; // whereas CPU::cycle is chip8 specific method and doesn’t contain nothing we should focus on match CPU::cycle(&mut guard.state, &mut *guard.screen) { Ok(()) => (), Err(e) => { println!("Error during cycle, {}. STOPPING", e); guard.stop(); } } } } ``` [^third]: More about ROMs [here](https://cgmathprog.home.blog/2021/05/20/chip-8-and-emulator-overview/). Note that instead of “self” it takes `Arc<wasm_mutex::Mutext<CPU>>`. Wasm-bindgen doesn’t understand it so we have to additionally wrap the CPU into a Wasm-bindgen compatible trait: ```javascript #[wasm_bindgen] pub struct WasmProgram { // we want wasm_bindgen to skip this field as it’s not compatible with wasm_bindgen, i.e. our chip8 has some incompatibility with wasm_bindgen structures (notably, it contains a reference to Canvas). You could have better luck with your simulation! cpu: Arc<Mutex<CPU>>, } #[wasm_bindgen] impl WasmProgram { pub fn run(&self) { let clone = self.cpu.clone(); spawn_local(async move { CPU::run(clone).await; }) } pub fn stop(&mut self) { let clone = self.cpu.clone(); spawn_local(async move { let mut guard = clone.lock().await; guard.stop(); }) } } ``` Keyboard listeners can be added in a similar way: ```javascript impl WasmProgram { … // which is a bit boilerplaytey but could likely be fixed with macros pub fn key_down(&mut self, k: usize) { let clone = self.cpu.clone(); spawn_local(async move { let mut guard = clone.lock().await; guard.key_down(k); }) } pub fn key_up(&mut self, k: usize) { let clone = self.cpu.clone(); spawn_local(async move { let mut guard = clone.lock().await; guard.key_up(k); }) } … } ``` ### Step 3: Marvel at your API By building this library, we have an interface that looks like this: ```javascript import initWasm, { WasmProgram, init_program as initChip8 } from '@firfi/rust-wasm-chip8'; const init = async () => { await initWasm(); const romData = await loadRom(); const canvas = document.getElementById("canvas"); const cpu = initChip8(romData, canvas); cpu.run(); // will run asynchronously initKeyboardListeners(cpu); // wire up controls, listen to kb events etc // and at any point you call cpu.stop() which will clean up its resources } ``` Moreover, you can run several simulations at the same time. They’ll still be taking the same thread, so probably don’t run hundreds of them though! ```javascript const cpu1 = initChip8(romData1, canvas1); const cpu2 = initChip8(romData2, canvas2); const cpu3 = initChip8(romData3, canvas3); cpu1.run(); cpu2.run(); cpu3.run(); ``` ## Final words So there are indeed ways to code convenient interfaces for Wasm libraries, although there are many nuisances to it. Wasm is still a fairly green tool and there’s a lot that can be improved or added. One day, the approach I’ve described here will become obsolete. We had to use three third-party libraries to achieve our goal, and we still locked the main thread, neither of which is ideal. Nevertheless, this method is a nice improvement in API usability over the methods I've seen so far, specifically for the CHIP-8 emulator. Plus, this approach can be used more generally for different simulations provided that they aren’t too computation-heavy. #### References The full source code can be found [here](https://github.com/Firfi/chip8-rust-wasm). And the resulting NPM package [here](https://www.npmjs.com/package/@firfi/rust-wasm-chip8).



Recent posts:


Back to top