aquova.net Github's logo Mastodon's logo Blue Sky's logo Backloggd's logo Pico-8's logo The RSS logo


Chapter XIV. WebAssembly Frontend Setup

Return to Index

Previous Chapter

With our desktop frontend in a good place, we shall turn our attention to the wasm frontend and complete many of the same procedures. If you are skipping to here without reading the previous chapter, I would recommend you go back and at least skim it, as it provides the justification for some of the decisions we're about to make, as well as performs some changes to the core backend that we will again rely upon here.

We'll begin by adding in some new dependencies to our wasm/Cargo.toml configuration file. We already have the entry pointing us to the core backend module, but we will need to include three third party libraries. The first two, js-sys and web-sys allow Rust modules access to JavaScript and HTML DOM elements respectively, both of which we will need when interfacing with the webpage. The third, wasm-bindgen, provides support for interacting between Rust and JavaScript more easily. I've set them to be the most up-to-date versions at the time of writing, but you should cross-reference with the online versions to see if any new changes are available — https://crates.io/.


# In wasm/Cargo.toml

[dependencies]
gb_core = { path = "../core" }
js-sys = "0.3.67"
wasm-bindgen = "0.2.90"

[dependencies.web-sys]
version = "0.3.67"
features = [
    "CanvasRenderingContext2d",
    "Document",
    "Element",
    "HtmlCanvasElement",
    "ImageData",
    "KeyboardEvent",
    "Window",
]

[lib]
crate-type = ["cdylib"]
            

You'll notice the syntax for web-sys is rather unusual. There are a lot of HTML elements in the world, but we will need very few of them for our project. Rather than import them all, I've only called out the specific ones that we will use; a list that hopefully seems pretty logical. We also set the crate-type of this module to be "cdylib". This is a compatibility feature that specifies that this module is meant to be utilized by another programming language — in this case JavaScript.

With this in place, we can turn our attention to the WebAssembly implementation. Let's take a moment and first describe what we're actually going to create. The user will directly interact with a HTML webpage with a canvas element within it. This canvas will display the Game Boy screen data provided up from the backend, and the page will provide the loaded ROM data and keyboard presses down to it. Where the WebAssembly module fits in is it provides public API functions for JavaScript to utilize that accesses functions within core. Think of it (and an accompanying auto-generated JavaScript file you'll see in a moment) as the connective "glue" between the webpage and the core backend. It should be noted that the compiled .wasm file will be a single binary which includes both all of core and wasm/src/lib.rs together as one.

In order to get a better sense of how this will fit together, let's create the webpage first. This page won't be anything special (or be good looking), and should be simple to anyone with even an introductory web background. We'll create this folder space outside of our wasm directory. Back up at the root of the project, create a fourth directory called html with an index.html file inside it.


<!DOCTYPE html>
<html>
    <head>
        <title>Game Boy Emulator</title>
        <meta charset="utf-8">
        <style>
            html {
                text-align: center;
                font-family: "Arial", "Helvetica", sans-serif;
                max-width: 1000px;
                margin: 0 auto;
            }

            canvas {
                padding-left: 0;
                padding-right: 0;
                margin-left: auto;
                margin-right: auto;
            }
        </style>
    </head>
    <body>
        <h1>My Game Boy Emulator</h1>
        <label for="fileinput">Select a GB game: </label>
        <input type="file" id="fileinput" accept=".gb,.dmg" autocomplete="off"/>
        <br/><br/>
        <canvas id="canvas" width="160px" height="144px">If you can see this, then your browser doesn't support HTML5 and is old.</canvas>
    </body>
    <script type="module" src="index.js"></script>
</html>
            

Beautiful. This is about as barebones as they come. Feel free to edit the CSS styling as you wish, I'm a terrible graphic designer. The page includes only some header text, the canvas element — which defaults to the familiar 160x144 Game Boy resolution — and a button which will prompt the user to select their Game Boy ROM. Just like with the desktop frontend, we don't do any verification to determine if the selected file is a valid Game Boy game, but here we at least limit the selection to the most well-used file extensions (I have very rarely seen other extensions used for GB titles, such as .bin, or .cgb and .gbc for Color titles. You're free to edit the list or remove the accept limitation altogether). At the end of the page, an index.js file is referenced. Let's create it and populate it now.


// In html/index.js

const SCALE = 3
const WIDTH = 160
const HEIGHT = 144

let canvas = document.getElementById("canvas")
canvas.width = WIDTH * SCALE
canvas.height = HEIGHT * SCALE

let ctx = canvas.getContext("2d")
ctx.fillStyle = "#FFFFFF"
ctx.fillRect(0, 0, canvas.width, canvas.height)

async function run() {
    document.getElementById("fileinput").addEventListener("change", function (e) {
        let file = e.target.files[0]
        if (!file) {
            alert("Failed to read file")
            return
        }

        let fr = new FileReader()
        fr.onload = function (fre) {
            let buffer = fre.result
            const rom = new Uint8Array(buffer)
            // TODO: Load ROM
        }

        fr.readAsArrayBuffer(file)
    }, false)
}

run().catch(console.error)
            

We'll start with a few things present. First, the script grabs the canvas UI element, scales its size, and colors it completely white to start. Next is the run function which includes an event handler for the file select button. When it's clicked, the page attempts to read the specified file in as a Uint8Array buffer, but right now has no where to put it. At this point, if you've been following along with the entire tutorial your project structure should look like this.


.
├── core
│   ├── Cargo.toml
│   └── src
│       ├── bus.rs
│       ├── cpu
│       │   ├── mod.rs
│       │   └── opcodes.rs
│       ├── lib.rs
│       └── utils.rs
├── desktop
│   ├── Cargo.toml
│   └── src
│       └── main.rs
├── wasm
│   ├── Cargo.toml
│   └── src
│       └── lib.rs
└── html
    ├── index.html
    └── index.js
            

It's at this point that we're stuck with regards to what we can do on the html side. We now must return to the wasm module and implement the functions that this JavaScript program will need to continue. These are the same that we needed in the desktop frontend — a constructor for the Cpu object and a function to pass in the ROM data. This is where wasm_bindgen comes in. It has declarators for functions and constructors so that they can properly be interfaced with. In wasm/src/lib.rs, we're going to create an object with two member functions — our core CPU object and a reference to the HTML canvas element, which will be useful to have later.


// In wasm/src/lib.rs

use gb_core::cpu::Cpu;

use js_sys::Uint8Array;
use wasm_bindgen::prelude::*;
use web_sys::{CanvasRenderingContext2d, HtmlCanvasElement};

#[wasm_bindgen]
pub struct GB {
    cpu: Cpu,
    ctx: CanvasRenderingContext2d,
}

#[wasm_bindgen]
impl GB {
    #[wasm_bindgen(constructor)]
    pub fn new() -> Result<GB, JsValue> {
        let cpu = Cpu::new();

        let document = web_sys::window().unwrap().document().unwrap();
        let canvas = document.get_element_by_id("canvas").unwrap();
        let canvas: HtmlCanvasElement = canvas.dyn_into::<HtmlCanvasElement>()
            .map_err(|_| ())
            .unwrap();

        let ctx = canvas.get_context("2d")
            .unwrap()
            .unwrap()
            .dyn_into::<CanvasRenderingContext2d>()
            .unwrap();

        let gb = GB { cpu, ctx };
        Ok(gb)
    }

    #[wasm_bindgen]
    pub fn load_rom(&mut self, data: Uint8Array) {
        let mut rom: Vec<u8> = Vec::new();

        for i in 0..data.byte_length() {
            rom.push(data.get_index(i));
        }
        self.cpu.load_rom(&rom);
    }
}
            

With the exception of the wasm_bindgen everywhere, this should look pretty familiar. The creation of the cpu field only requires us to call Cpu::new(), no surprises there. Getting the canvas ctx from the webpage is very similar to the steps one would take in JavaScript, but with a lot more unwrap at every step. Once we have both of them we bundle them in a Result, which is required for constructors. The load_rom function also looks very similar to what we did in the desktop module. We receive the data as a Uint8Array and will need to iterate across it to convert each byte into a Rust-style u8, at which point we can pass it into core via the load_rom function we added in the previous chapter.

Compilation

Compiling a WebAssembly module requires a bit of setup beyond what you'll typically get out of the box with a Rust install. Rather than go through the trouble of setting up all the toolchains ourselves, I'm going to rely on a third party tool known as wasm-pack to handle this for us. I personally like to rely on as few mysterious tools as I can, but given it's from the same group that develops wasm-bindgen, I'm willing to make an exception. You can install wasm-pack via cargo if you haven't yet.


$ cargo install wasm-pack
            

Once it's finished installing, enter the wasm directory and build our project via the following command.


$ wasm-pack build --target=web
            

It will complain a bit about some unused items, but should complete successfully. Once it does, there are two newly generated files we are interested in — pkg/wasm_bg.wasm and pkg/wasm.js. pkg/wasm_bg.wasm is the WebAssembly binary I've been promising, containing both the wasm/src/lib.rs file we just finished editing as well as the entirety of core, together in a single binary file. pkg/wasm.js is a surprisingly short and surprisingly readable helper file, created to assist in loading and setting up the API we defined in the wasm module. Both of these files need to be copied over into the html directory, and will need to be replaced each time we recompile wasm. With them in place, all that remains is to access them in html/index.js.


// In html/index.js

import init, * as wasm from "./wasm.js"

// Unchanged code omitted

async function run() {
    await init()
    let gb = new wasm.GB()

    document.getElementById("fileinput").addEventListener("change", function (e) {
        let file = e.target.files[0]
        if (!file) {
            alert("Failed to read file")
            return
        }

        let fr = new FileReader()
        fr.onload = function () {
            let buffer = fr.result
            const rom = new Uint8Array(buffer)
            gb.load_rom(rom)
        }

        fr.readAsArrayBuffer(file)
    }, false)
}

run().catch(console.error)
            

The wasm_bg.wasm file is loaded by the generated wasm.js file once its init function is called, which we need to do before anything else. After that, we have access to the functions we defined in wasm/src/lib.rs. These are our constructor, which we save into the gb variable, and the load_rom function, which we use to send our Uint8Array of ROM data. If you start up a local webserver and load this webpage, you'll notice... not too much. Unlike the desktop frontend where a window appears, the html page doesn't really do much since there's no emulation happening. We'll work to resolve that soon, but at this point both our frontends are in the position to read in a ROM data file and pass it into the core backend. Our next job is to return to core and create something to receive it.

Next Chapter