use crate::{
assets::{Assets, MapData, SpriteSheet},
audio::AudioHandle,
fb::Framebuffer,
input::InputState,
storage::Storage,
};
use anyhow::{anyhow, Context as _, Result};
use wasmi::{
Caller, Config, Engine, Instance, Linker, Module, Store, StoreLimits, StoreLimitsBuilder,
TypedFunc,
};
pub const DEFAULT_FPS: u32 = 60;
pub const UI_FPS: u32 = 30;
const FUEL_PER_CALL: u64 = 131_072;
const MAX_MEMORY: usize = crate::cart::MEMORY_CAP;
pub struct GameVm {
store: Store<HostState>,
_instance: Instance,
update: TypedFunc<(), ()>,
draw: TypedFunc<(), ()>,
}
macro_rules! link {
($linker:expr, $name:literal, $f:expr) => {
$linker
.func_wrap("pixel8", $name, $f)
.with_context(|| format!("registering host fn {}", $name))?;
};
}
impl GameVm {
pub fn load(
wasm: &[u8],
assets: &Assets,
audio: AudioHandle,
storage: Storage,
) -> Result<Self> {
crate::assets::validate(assets)?;
let mut config = Config::default();
config.consume_fuel(true);
let engine = Engine::new(&config);
let module = Module::new(&engine, wasm).map_err(|e| anyhow!("Invalid cart wasm: {e}"))?;
audio.load(assets.sfx.clone(), assets.music.clone());
let mut store = Store::new(&engine, HostState::new(assets, audio, storage));
store.limiter(|state| &mut state.limits);
let mut linker = <Linker<HostState>>::new(&engine);
link!(linker, "clear", |mut c: Caller<'_, HostState>, col: i32| {
c.data_mut().fb.cls(col as u8)
});
link!(linker, "camera", |mut c: Caller<'_, HostState>,
x: i32,
y: i32| {
c.data_mut().fb.camera(x, y)
});
link!(linker, "clip", |mut c: Caller<'_, HostState>,
x: i32,
y: i32,
w: i32,
h: i32| {
c.data_mut().fb.clip(x, y, w, h)
});
link!(
linker,
"set_pixel",
|mut c: Caller<'_, HostState>, x: i32, y: i32, col: i32| {
c.data_mut().fb.pset(x, y, col as u8)
}
);
link!(linker, "pixel", |c: Caller<'_, HostState>,
x: i32,
y: i32|
-> i32 {
c.data().fb.pget(x, y) as i32
});
link!(linker, "line", |mut c: Caller<'_, HostState>,
x0: i32,
y0: i32,
x1: i32,
y1: i32,
col: i32| {
c.data_mut().fb.line(x0, y0, x1, y1, col as u8)
});
link!(linker, "rect", |mut c: Caller<'_, HostState>,
x0: i32,
y0: i32,
x1: i32,
y1: i32,
col: i32| {
c.data_mut().fb.rect(x0, y0, x1, y1, col as u8)
});
link!(
linker,
"rect_fill",
|mut c: Caller<'_, HostState>, x0: i32, y0: i32, x1: i32, y1: i32, col: i32| {
c.data_mut().fb.rectfill(x0, y0, x1, y1, col as u8)
}
);
link!(
linker,
"circle",
|mut c: Caller<'_, HostState>, x: i32, y: i32, r: i32, col: i32| {
c.data_mut().fb.circ(x, y, r, col as u8)
}
);
link!(
linker,
"circle_fill",
|mut c: Caller<'_, HostState>, x: i32, y: i32, r: i32, col: i32| {
c.data_mut().fb.circfill(x, y, r, col as u8)
}
);
link!(linker, "print", |mut c: Caller<'_, HostState>,
ptr: u32,
len: u32,
x: i32,
y: i32,
col: i32|
-> i32 {
let s = read_guest_str(&c, ptr, len);
c.data_mut().fb.print(&s, x, y, col as u8)
});
link!(linker, "is_button_down", |c: Caller<'_, HostState>,
b: u32|
-> i32 {
c.data().input.btn(b) as i32
});
link!(linker, "is_button_pressed", |c: Caller<'_, HostState>,
b: u32|
-> i32 {
c.data().input.btnp(b) as i32
});
link!(linker, "buttons_down", |c: Caller<'_, HostState>| -> i32 {
c.data().input.btn_mask() as i32
});
link!(
linker,
"buttons_pressed",
|c: Caller<'_, HostState>| -> i32 { c.data().input.btnp_mask() as i32 }
);
link!(
linker,
"sprite",
|mut c: Caller<'_, HostState>,
n: u32,
x: i32,
y: i32,
w: i32,
h: i32,
flip_x: i32,
flip_y: i32| {
let HostState { fb, sprites, .. } = c.data_mut();
fb.spr(sprites, n, x, y, w, h, flip_x != 0, flip_y != 0);
}
);
link!(linker, "map", |mut c: Caller<'_, HostState>,
cel_x: i32,
cel_y: i32,
sx: i32,
sy: i32,
cel_w: i32,
cel_h: i32,
layers: u32| {
let HostState {
fb, sprites, map, ..
} = c.data_mut();
fb.map(
map,
sprites,
cel_x,
cel_y,
sx,
sy,
cel_w,
cel_h,
layers as u8,
);
});
link!(linker, "map_tile", |c: Caller<'_, HostState>,
x: i32,
y: i32|
-> i32 {
c.data().map.get(x, y) as i32
});
link!(
linker,
"set_map_tile",
|mut c: Caller<'_, HostState>, x: i32, y: i32, v: u32| {
c.data_mut().map.set(x, y, v as u8)
}
);
link!(linker, "sprite_flags", |c: Caller<'_, HostState>,
n: u32|
-> i32 {
c.data().sprites.flags(n) as i32
});
link!(
linker,
"set_sprite_flags",
|mut c: Caller<'_, HostState>, n: u32, flags: u32| {
c.data_mut().sprites.flags[(n as usize) % crate::assets::SPRITE_COUNT] =
flags as u8;
}
);
link!(linker, "sfx", |c: Caller<'_, HostState>,
n: i32,
channel: i32| {
c.data().audio.play_sfx(n, channel)
});
link!(linker, "music", |c: Caller<'_, HostState>,
n: i32,
fade: i32,
mask: i32,
token: i32|
-> i32 {
c.data().audio.play_music(n, fade, mask, token)
});
link!(linker, "cpu_update", |c: Caller<'_, HostState>| -> f32 {
c.data().last_update_cpu
});
link!(linker, "cpu_draw", |c: Caller<'_, HostState>| -> f32 {
c.data().last_draw_cpu
});
link!(linker, "fps", |c: Caller<'_, HostState>| -> f32 {
c.data().measured_fps_or_target()
});
link!(linker, "time", |c: Caller<'_, HostState>| -> f32 {
let st = c.data();
st.frame as f32 / st.fps as f32
});
link!(linker, "rnd", |mut c: Caller<'_, HostState>| -> f32 {
c.data_mut().next_rand()
});
link!(linker, "log", |mut c: Caller<'_, HostState>,
ptr: u32,
len: u32| {
let s = read_guest_str(&c, ptr, len);
c.data_mut().logs.push(s);
});
link!(linker, "panic", |mut c: Caller<'_, HostState>,
ptr: u32,
len: u32| {
let s = read_guest_str(&c, ptr, len);
c.data_mut().panic_message = Some(s);
});
link!(
linker,
"seed_rng",
|mut c: Caller<'_, HostState>, seed: u32| { c.data_mut().seed_rand(seed) }
);
link!(linker, "sprite_pixel", |c: Caller<'_, HostState>,
x: i32,
y: i32|
-> i32 {
c.data().sprites.get(x, y) as i32
});
link!(
linker,
"set_sprite_pixel",
|mut c: Caller<'_, HostState>, x: i32, y: i32, col: i32| {
c.data_mut().sprites.set(x, y, col as u8)
}
);
link!(
linker,
"sprite_stretch",
|mut c: Caller<'_, HostState>,
sx: i32,
sy: i32,
sw: i32,
sh: i32,
dx: i32,
dy: i32,
dw: i32,
dh: i32,
flip_x: i32,
flip_y: i32| {
let HostState { fb, sprites, .. } = c.data_mut();
fb.sspr(
sprites,
sx,
sy,
sw,
sh,
dx,
dy,
dw,
dh,
flip_x != 0,
flip_y != 0,
);
}
);
link!(
linker,
"ellipse",
|mut c: Caller<'_, HostState>, x0: i32, y0: i32, x1: i32, y1: i32, col: i32| {
c.data_mut().fb.oval(x0, y0, x1, y1, col as u8)
}
);
link!(
linker,
"ellipse_fill",
|mut c: Caller<'_, HostState>, x0: i32, y0: i32, x1: i32, y1: i32, col: i32| {
c.data_mut().fb.ovalfill(x0, y0, x1, y1, col as u8)
}
);
link!(
linker,
"set_transparent_color",
|mut c: Caller<'_, HostState>, col: i32, t: i32| {
c.data_mut().fb.set_transparent_color(col as u8, t != 0)
}
);
link!(linker, "reset_transparency", |mut c: Caller<
'_,
HostState,
>| {
c.data_mut().fb.reset_transparency()
});
link!(
linker,
"remap_color",
|mut c: Caller<'_, HostState>, from: i32, to: i32, mode: i32| {
let fb = &mut c.data_mut().fb;
if mode == 0 {
fb.remap_color(from as u8, to as u8);
} else {
fb.remap_display_color(from as u8, to as u8);
}
}
);
link!(linker, "reset_palette", |mut c: Caller<'_, HostState>| {
c.data_mut().fb.reset_palette()
});
link!(
linker,
"set_fill_pattern",
|mut c: Caller<'_, HostState>, pattern: i32, secondary: i32, transparent: i32| {
c.data_mut()
.fb
.set_fill_pattern(pattern as u16, secondary as u8, transparent != 0)
}
);
link!(
linker,
"set_pen_color",
|mut c: Caller<'_, HostState>, col: i32| { c.data_mut().fb.set_pen_color(col as u8) }
);
link!(
linker,
"set_cursor",
|mut c: Caller<'_, HostState>, x: i32, y: i32| { c.data_mut().fb.set_cursor(x, y) }
);
link!(linker, "print_pen", |mut c: Caller<'_, HostState>,
ptr: u32,
len: u32|
-> i32 {
let s = read_guest_str(&c, ptr, len);
c.data_mut().fb.print_pen(&s)
});
link!(linker, "storage_set", |mut c: Caller<'_, HostState>,
key_ptr: u32,
key_len: u32,
val_ptr: u32,
val_len: u32|
-> i32 {
let key = read_guest_str(&c, key_ptr, key_len);
let val = read_guest_str(&c, val_ptr, val_len);
c.data_mut().storage.set_json(&key, &val) as i32
});
link!(linker, "storage_get", |mut c: Caller<'_, HostState>,
key_ptr: u32,
key_len: u32,
buf_ptr: u32,
buf_cap: u32|
-> i32 {
let key = read_guest_str(&c, key_ptr, key_len);
let Some(json) = c.data().storage.get_json(&key) else {
return -1;
};
if json.len() <= buf_cap as usize {
write_guest_bytes(&mut c, buf_ptr, json.as_bytes());
}
json.len() as i32
});
link!(linker, "storage_remove", |mut c: Caller<'_, HostState>,
key_ptr: u32,
key_len: u32|
-> i32 {
let key = read_guest_str(&c, key_ptr, key_len);
c.data_mut().storage.remove(&key) as i32
});
link!(linker, "storage_clear", |mut c: Caller<'_, HostState>| {
c.data_mut().storage.clear()
});
store
.set_fuel(FUEL_PER_CALL)
.map_err(|e| anyhow!("Fuel setup: {e}"))?;
let instance = linker
.instantiate_and_start(&mut store, &module)
.map_err(|e| {
let s = e.to_string();
if s.contains("resource limiter denied") {
anyhow!("Cart needs more than 128K of memory to start")
} else {
anyhow!("Cart does not match the Pixel8 ABI: {e}")
}
})?;
let init = instance
.get_typed_func::<(), ()>(&store, "pixel8_init")
.map_err(|e| anyhow!("Cart is missing pixel8_init: {e}"))?;
let update = instance
.get_typed_func::<(), ()>(&store, "pixel8_update")
.map_err(|e| anyhow!("Cart is missing pixel8_update: {e}"))?;
let draw = instance
.get_typed_func::<(), ()>(&store, "pixel8_draw")
.map_err(|e| anyhow!("Cart is missing pixel8_draw: {e}"))?;
let mut vm = Self {
store,
_instance: instance,
update,
draw,
};
vm.call("init", init).map_err(|e| anyhow!(e.to_string()))?;
vm.store.data_mut().fps = vm.query_fps();
Ok(vm)
}
fn query_fps(&mut self) -> u32 {
let Ok(func) = self
._instance
.get_typed_func::<(), u32>(&self.store, "pixel8_fps")
else {
return DEFAULT_FPS;
};
self.store.set_fuel(FUEL_PER_CALL).ok();
match func.call(&mut self.store, ()) {
Ok(30) => 30,
Ok(60) => 60,
_ => DEFAULT_FPS,
}
}
fn call(
&mut self,
phase: &'static str,
func: TypedFunc<(), ()>,
) -> std::result::Result<(), RuntimeError> {
self.store.set_fuel(FUEL_PER_CALL).ok();
let result = func.call(&mut self.store, ()).map_err(|err| {
let message = match self.store.data_mut().panic_message.take() {
Some(panic) => panic,
None => {
let s = err.to_string();
if s.contains("fuel") {
format!("{phase}() ran too long\n(infinite loop?)")
} else if s.contains("growth operation limited") {
format!("{phase}() ran out of memory\n(128K limit)")
} else {
s
}
}
};
RuntimeError { phase, message }
});
if result.is_ok() {
let remaining = self.store.get_fuel().unwrap_or(0);
let frac = FUEL_PER_CALL.saturating_sub(remaining) as f32 / FUEL_PER_CALL as f32;
match phase {
"update" => self.store.data_mut().last_update_cpu = frac,
"draw" => self.store.data_mut().last_draw_cpu = frac,
_ => {}
}
}
result
}
pub fn call_update(&mut self) -> std::result::Result<(), RuntimeError> {
self.store.data_mut().input.tick();
let r = self.call("update", self.update);
self.store.data_mut().frame += 1;
r
}
pub fn call_draw(&mut self) -> std::result::Result<(), RuntimeError> {
self.call("draw", self.draw)
}
pub fn fps(&self) -> u32 {
self.store.data().fps
}
pub fn cpu_update(&self) -> f32 {
self.store.data().last_update_cpu
}
pub fn cpu_draw(&self) -> f32 {
self.store.data().last_draw_cpu
}
pub fn memory_used_fraction(&self) -> f32 {
let Some(mem) = self._instance.get_memory(&self.store, "memory") else {
return 0.0;
};
mem.data_size(&self.store) as f32 / MAX_MEMORY as f32
}
pub fn mem_used_bytes(&mut self) -> u32 {
let Ok(func) = self
._instance
.get_typed_func::<(), u32>(&self.store, "pixel8_mem_used")
else {
return 0;
};
self.store.set_fuel(FUEL_PER_CALL).ok();
func.call(&mut self.store, ()).unwrap_or(0)
}
pub fn state(&self) -> &HostState {
self.store.data()
}
pub fn state_mut(&mut self) -> &mut HostState {
self.store.data_mut()
}
}
pub struct HostState {
pub fb: Framebuffer,
pub input: InputState,
pub sprites: SpriteSheet,
pub map: MapData,
pub audio: AudioHandle,
pub storage: Storage,
pub logs: Vec<String>,
pub panic_message: Option<String>,
pub frame: u64,
pub fps: u32,
last_update_cpu: f32,
last_draw_cpu: f32,
measured_fps: f32,
rng: u64,
limits: StoreLimits,
}
impl HostState {
fn new(assets: &Assets, audio: AudioHandle, storage: Storage) -> Self {
Self {
fb: Framebuffer::new(),
input: InputState::default(),
sprites: assets.sprites.clone(),
map: assets.map.clone(),
audio,
storage,
logs: Vec::new(),
panic_message: None,
frame: 0,
fps: DEFAULT_FPS,
last_update_cpu: 0.0,
last_draw_cpu: 0.0,
measured_fps: 0.0,
rng: 0x2545_f491_4f6c_dd1d,
limits: StoreLimitsBuilder::new()
.memory_size(MAX_MEMORY)
.trap_on_grow_failure(true)
.build(),
}
}
fn next_rand(&mut self) -> f32 {
let mut x = self.rng;
x ^= x >> 12;
x ^= x << 25;
x ^= x >> 27;
self.rng = x;
let bits = (x.wrapping_mul(0x2545_f491_4f6c_dd1d) >> 40) as u32;
bits as f32 / (1u32 << 24) as f32
}
pub fn set_measured_fps(&mut self, fps: f32) {
self.measured_fps = fps;
}
pub fn measured_fps_or_target(&self) -> f32 {
if self.measured_fps > 0.0 {
self.measured_fps
} else {
self.fps as f32
}
}
fn seed_rand(&mut self, seed: u32) {
self.rng = (((seed as u64) << 32) | (seed as u64)) | 1;
}
}
#[derive(Debug, Clone)]
pub struct RuntimeError {
pub phase: &'static str,
pub message: String,
}
impl std::fmt::Display for RuntimeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Runtime error in {}:\n{}", self.phase, self.message)
}
}
fn read_guest_str(caller: &Caller<'_, HostState>, ptr: u32, len: u32) -> String {
let Some(mem) = caller
.get_export("memory")
.and_then(wasmi::Extern::into_memory)
else {
return String::new();
};
let data = mem.data(caller);
let start = ptr as usize;
let end = start.saturating_add(len as usize).min(data.len());
if start >= end {
return String::new();
}
String::from_utf8_lossy(&data[start..end]).into_owned()
}
fn write_guest_bytes(caller: &mut Caller<'_, HostState>, ptr: u32, bytes: &[u8]) {
let Some(mem) = caller
.get_export("memory")
.and_then(wasmi::Extern::into_memory)
else {
return;
};
let data = mem.data_mut(caller);
let start = ptr as usize;
let Some(end) = start.checked_add(bytes.len()) else {
return;
};
if end <= data.len() {
data[start..end].copy_from_slice(bytes);
}
}
#[cfg(test)]
mod tests {
use super::*;
const TEST_CART: &str = r#"
(module
(import "pixel8" "clear" (func $cls (param i32)))
(import "pixel8" "set_pixel" (func $pset (param i32 i32 i32)))
(import "pixel8" "pixel" (func $pget (param i32 i32) (result i32)))
(import "pixel8" "is_button_down" (func $btn (param i32) (result i32)))
(import "pixel8" "print" (func $print (param i32 i32 i32 i32 i32) (result i32)))
(import "pixel8" "log" (func $log (param i32 i32)))
(memory (export "memory") 1)
(data (i32.const 16) "hi from cart")
(global $x (mut i32) (i32.const 5))
(func (export "pixel8_init")
(call $log (i32.const 16) (i32.const 12)))
(func (export "pixel8_update")
(if (i32.ne (call $btn (i32.const 1)) (i32.const 0))
(then (global.set $x (i32.add (global.get $x) (i32.const 1))))))
(func (export "pixel8_draw")
(call $cls (i32.const 1))
(call $pset (global.get $x) (i32.const 7) (i32.const 8))
(drop (call $print (i32.const 16) (i32.const 2) (i32.const 0) (i32.const 0) (i32.const 7))))
)
"#;
const LOOPING_CART: &str = r#"
(module
(func (export "pixel8_init"))
(func (export "pixel8_update") (loop $l (br $l)))
(func (export "pixel8_draw"))
)
"#;
const FPS30_CART: &str = r#"
(module
(func (export "pixel8_init"))
(func (export "pixel8_fps") (result i32) (i32.const 30))
(func (export "pixel8_update"))
(func (export "pixel8_draw")))
"#;
const MEM_EXPORT_CART: &str = r#"
(module
(func (export "pixel8_init"))
(func (export "pixel8_update"))
(func (export "pixel8_draw"))
(func (export "pixel8_mem_used") (result i32) (i32.const 32768)))
"#;
const BUDGET_OK_CART: &str = r#"
(module
(func (export "pixel8_init"))
(func (export "pixel8_update")
(local $i i32)
(local.set $i (i32.const 10000))
(loop $l
(local.set $i (i32.add (local.get $i) (i32.const -1)))
(br_if $l (local.get $i))))
(func (export "pixel8_draw")))
"#;
const BUDGET_OVER_CART: &str = r#"
(module
(func (export "pixel8_init"))
(func (export "pixel8_update")
(local $i i32)
(local.set $i (i32.const 100000))
(loop $l
(local.set $i (i32.add (local.get $i) (i32.const -1)))
(br_if $l (local.get $i))))
(func (export "pixel8_draw")))
"#;
const GROW_TO_CAP_CART: &str = r#"
(module
(memory (export "memory") 1)
(func (export "pixel8_init"))
(func (export "pixel8_update") (drop (memory.grow (i32.const 1))))
(func (export "pixel8_draw")))
"#;
const GROW_PAST_CAP_CART: &str = r#"
(module
(memory (export "memory") 1)
(func (export "pixel8_init"))
(func (export "pixel8_update") (drop (memory.grow (i32.const 10))))
(func (export "pixel8_draw")))
"#;
const HUGE_INITIAL_MEMORY_CART: &str = r#"
(module
(memory (export "memory") 3)
(func (export "pixel8_init"))
(func (export "pixel8_update"))
(func (export "pixel8_draw")))
"#;
const PARITY_CART: &str = r#"
(module
(import "pixel8" "ellipse" (func $ovalo (param i32 i32 i32 i32 i32)))
(import "pixel8" "ellipse_fill" (func $oval (param i32 i32 i32 i32 i32)))
(import "pixel8" "set_transparent_color" (func $palt (param i32 i32)))
(import "pixel8" "reset_transparency" (func $paltr))
(import "pixel8" "remap_color" (func $pal (param i32 i32 i32)))
(import "pixel8" "reset_palette" (func $palr))
(import "pixel8" "set_fill_pattern" (func $fillp (param i32 i32 i32)))
(import "pixel8" "set_sprite_pixel" (func $sset (param i32 i32 i32)))
(import "pixel8" "sprite_pixel" (func $sget (param i32 i32) (result i32)))
(import "pixel8" "sprite_stretch"
(func $sspr (param i32 i32 i32 i32 i32 i32 i32 i32 i32 i32)))
(import "pixel8" "seed_rng" (func $srand (param i32)))
(import "pixel8" "set_pen_color" (func $color (param i32)))
(import "pixel8" "set_cursor" (func $cursor (param i32 i32)))
(import "pixel8" "print_pen" (func $printp (param i32 i32) (result i32)))
(import "pixel8" "cpu_update" (func $cpuu (result f32)))
(import "pixel8" "cpu_draw" (func $cpud (result f32)))
(import "pixel8" "fps" (func $fps (result f32)))
(memory (export "memory") 1)
(data (i32.const 0) "hi")
(func (export "pixel8_init"))
(func (export "pixel8_update")
(call $srand (i32.const 42))
(call $sset (i32.const 0) (i32.const 0) (i32.const 9))
(drop (call $sget (i32.const 0) (i32.const 0))))
(func (export "pixel8_draw")
(call $pal (i32.const 8) (i32.const 12) (i32.const 0))
(call $palt (i32.const 0) (i32.const 1))
(call $paltr)
(call $fillp (i32.const 0) (i32.const 0) (i32.const 0))
(call $color (i32.const 7))
(call $cursor (i32.const 0) (i32.const 0))
(drop (call $printp (i32.const 0) (i32.const 2)))
(call $sspr (i32.const 0) (i32.const 0) (i32.const 8) (i32.const 8)
(i32.const 64) (i32.const 0) (i32.const 8) (i32.const 8)
(i32.const 0) (i32.const 0))
(call $ovalo (i32.const 20) (i32.const 20) (i32.const 28) (i32.const 28)
(i32.const 7))
(call $palr)
(call $oval (i32.const 0) (i32.const 0) (i32.const 8) (i32.const 8) (i32.const 8))
(drop (call $cpuu))
(drop (call $cpud))
(drop (call $fps))))
"#;
fn load_test_vm(wat_src: &str) -> Result<GameVm> {
let wasm = wat::parse_str(wat_src).unwrap();
GameVm::load(
&wasm,
&Assets::default(),
AudioHandle::dummy(),
Storage::default(),
)
}
#[test]
fn parity_imports_link_and_run() {
let mut vm = load_test_vm(PARITY_CART).unwrap();
vm.call_update().unwrap();
vm.call_draw().unwrap();
assert_eq!(vm.state().sprites.get(0, 0), 9);
assert_eq!(vm.state().fb.pget(4, 4), 8, "oval filled the box center");
}
#[test]
fn abi_lifecycle_and_drawing() {
let mut vm = load_test_vm(TEST_CART).unwrap();
assert_eq!(vm.state_mut().logs.pop().as_deref(), Some("hi from cart"));
vm.call_update().unwrap();
vm.call_draw().unwrap();
assert_eq!(vm.state().fb.pget(5, 7), 8, "set_pixel through ABI");
assert_eq!(vm.state().fb.pget(0, 0), 7, "print drew a glyph pixel");
vm.state_mut().input.set_button(1, true);
vm.call_update().unwrap();
vm.call_draw().unwrap();
assert_eq!(
vm.state().fb.pget(6, 7),
8,
"is_button_down(right) moved pixel"
);
}
#[test]
fn default_fps_is_60() {
let vm = load_test_vm(TEST_CART).unwrap();
assert_eq!(vm.fps(), 60);
}
#[test]
fn cart_can_select_30fps() {
let vm = load_test_vm(FPS30_CART).unwrap();
assert_eq!(vm.fps(), 30);
}
#[test]
fn mem_used_reads_export_else_zero() {
let mut vm = load_test_vm(MEM_EXPORT_CART).unwrap();
assert_eq!(vm.mem_used_bytes(), 32768);
let mut vm2 = load_test_vm(TEST_CART).unwrap();
assert_eq!(vm2.mem_used_bytes(), 0);
}
#[test]
fn infinite_loop_is_trapped() {
let mut vm = load_test_vm(LOOPING_CART).unwrap();
let err = vm.call_update().unwrap_err();
assert_eq!(err.phase, "update");
assert!(err.message.contains("ran too long"), "{}", err.message);
}
#[test]
fn missing_exports_is_a_load_error() {
let wasm = wat::parse_str("(module)").unwrap();
let err = match GameVm::load(
&wasm,
&Assets::default(),
AudioHandle::dummy(),
Storage::default(),
) {
Err(e) => e,
Ok(_) => panic!("empty module should not load"),
};
assert!(err.to_string().contains("pixel8_init"));
}
#[test]
fn unknown_imports_are_rejected() {
let wasm = wat::parse_str(
r#"(module (import "env" "evil" (func))
(func (export "pixel8_init"))
(func (export "pixel8_update"))
(func (export "pixel8_draw")))"#,
)
.unwrap();
assert!(GameVm::load(
&wasm,
&Assets::default(),
AudioHandle::dummy(),
Storage::default()
)
.is_err());
}
#[test]
fn fuel_budget_allows_modest_work() {
let mut vm = load_test_vm(BUDGET_OK_CART).unwrap();
assert!(
vm.call_update().is_ok(),
"10k-iteration frame must fit the 128K-fuel budget"
);
}
#[test]
fn fuel_budget_traps_runaway_work() {
let mut vm = load_test_vm(BUDGET_OVER_CART).unwrap();
let err = vm.call_update().unwrap_err();
assert!(err.message.contains("ran too long"), "got: {}", err.message);
}
#[test]
fn memory_growth_up_to_cap_is_allowed() {
let mut vm = load_test_vm(GROW_TO_CAP_CART).unwrap();
assert!(
vm.call_update().is_ok(),
"growing to exactly 128 K must succeed"
);
}
#[test]
fn memory_growth_past_cap_is_a_friendly_error() {
let mut vm = load_test_vm(GROW_PAST_CAP_CART).unwrap();
let err = vm.call_update().unwrap_err();
assert!(
err.message.contains("out of memory"),
"got: {}",
err.message
);
}
#[test]
fn oversized_initial_memory_is_rejected_at_load() {
let wasm = wat::parse_str(HUGE_INITIAL_MEMORY_CART).unwrap();
let err = match GameVm::load(
&wasm,
&Assets::default(),
AudioHandle::dummy(),
Storage::default(),
) {
Err(e) => e,
Ok(_) => panic!("oversized cart should not load"),
};
assert!(err.to_string().contains("128K of memory"), "got: {err}");
}
#[test]
fn reports_cpu_usage_per_phase() {
let mut vm = load_test_vm(BUDGET_OK_CART).unwrap();
vm.call_update().unwrap();
vm.call_draw().unwrap();
let u = vm.cpu_update();
let d = vm.cpu_draw();
assert!(u > 0.0 && u < 1.0, "update cpu fraction in range: {u}");
assert!(u > d, "heavy update beats empty draw: {u} vs {d}");
}
#[test]
fn reports_memory_usage() {
let vm = load_test_vm(TEST_CART).unwrap();
let frac = vm.memory_used_fraction();
assert!(
(frac - 0.5).abs() < 0.01,
"one page is half the cap: {frac}"
);
}
const STORAGE_CART: &str = r#"
(module
(import "pixel8" "storage_set" (func $sset (param i32 i32 i32 i32) (result i32)))
(import "pixel8" "storage_get" (func $sget (param i32 i32 i32 i32) (result i32)))
(import "pixel8" "storage_remove" (func $srem (param i32 i32) (result i32)))
(import "pixel8" "storage_clear" (func $sclr))
(import "pixel8" "set_pixel" (func $pset (param i32 i32 i32)))
(memory (export "memory") 1)
(data (i32.const 0) "score")
(data (i32.const 8) "42")
(data (i32.const 16) "gone")
(data (i32.const 24) "tmp")
(data (i32.const 28) "1")
(data (i32.const 63) "\05")
(func (export "pixel8_init")
;; Removing an existing key returns 1 -> (2,0) = 5.
(drop (call $sset (i32.const 24) (i32.const 3) (i32.const 28) (i32.const 1)))
(if (i32.eq (call $srem (i32.const 24) (i32.const 3)) (i32.const 1))
(then (call $pset (i32.const 2) (i32.const 0) (i32.const 5))))
;; Re-add "tmp", wipe everything, then store the real value.
(drop (call $sset (i32.const 24) (i32.const 3) (i32.const 28) (i32.const 1)))
(call $sclr)
(drop (call $sset (i32.const 0) (i32.const 5) (i32.const 8) (i32.const 2))))
(func (export "pixel8_update"))
(func (export "pixel8_draw")
;; (0,0) = the JSON length of the "score" value (2).
(call $pset (i32.const 0) (i32.const 0)
(call $sget (i32.const 0) (i32.const 5) (i32.const 64) (i32.const 16)))
;; A key never stored returns -1 -> (1,0) = 7.
(if (i32.eq (call $sget (i32.const 16) (i32.const 4) (i32.const 64) (i32.const 16))
(i32.const -1))
(then (call $pset (i32.const 1) (i32.const 0) (i32.const 7))))
;; "tmp" was wiped by storage_clear -> (3,0) = 7.
(if (i32.eq (call $sget (i32.const 24) (i32.const 3) (i32.const 64) (i32.const 16))
(i32.const -1))
(then (call $pset (i32.const 3) (i32.const 0) (i32.const 7))))
;; Cap 0 still reports the length -> (4,0) = 2.
(call $pset (i32.const 4) (i32.const 0)
(call $sget (i32.const 0) (i32.const 5) (i32.const 64) (i32.const 0)))
;; A too-small buffer gets nothing written: the sentinel byte at
;; 63 survives a cap-1 read of the 2-byte value -> (5,0) = 5.
(drop (call $sget (i32.const 0) (i32.const 5) (i32.const 63) (i32.const 1)))
(call $pset (i32.const 5) (i32.const 0) (i32.load8_u (i32.const 63)))
;; An exactly-sized buffer is filled: "42" lands at 80..82 -> (6,0) = 7.
(drop (call $sget (i32.const 0) (i32.const 5) (i32.const 80) (i32.const 2)))
(if (i32.and
(i32.eq (i32.load8_u (i32.const 80)) (i32.const 52))
(i32.eq (i32.load8_u (i32.const 81)) (i32.const 50)))
(then (call $pset (i32.const 6) (i32.const 0) (i32.const 7))))))
"#;
#[test]
fn storage_abi_set_get_remove_clear() {
let mut vm = load_test_vm(STORAGE_CART).unwrap();
vm.call_update().unwrap();
vm.call_draw().unwrap();
assert_eq!(vm.state().storage.get_json("score").as_deref(), Some("42"));
assert_eq!(vm.state().storage.get_json("tmp"), None);
let px = |x| vm.state().fb.pget(x, 0);
assert_eq!(px(0), 2, "storage_get returned the value length");
assert_eq!(px(1), 7, "missing key returned -1");
assert_eq!(px(2), 5, "removing an existing key returned 1");
assert_eq!(px(3), 7, "storage_clear wiped the store");
assert_eq!(px(4), 2, "cap-0 call sized the read");
assert_eq!(px(5), 5, "too-small buffer left guest memory untouched");
assert_eq!(px(6), 7, "exact-fit buffer was filled");
}
#[test]
fn storage_persists_across_vm_loads() {
let path =
std::env::temp_dir().join(format!("pixel8_vm_storage_{}.json", std::process::id()));
let _ = std::fs::remove_file(&path);
let wasm = wat::parse_str(STORAGE_CART).unwrap();
{
let _vm = GameVm::load(
&wasm,
&Assets::default(),
AudioHandle::dummy(),
Storage::at_path(path.clone()),
)
.unwrap();
}
let reloaded = Storage::at_path(path.clone());
assert_eq!(reloaded.get_json("score").as_deref(), Some("42"));
std::fs::remove_file(&path).unwrap();
}
#[test]
fn fps_falls_back_to_target_until_measured() {
let mut vm = load_test_vm(FPS30_CART).unwrap();
assert_eq!(vm.state().measured_fps_or_target(), 30.0);
vm.state_mut().set_measured_fps(58.0);
assert_eq!(vm.state().measured_fps_or_target(), 58.0);
}
}