use crate::{
assets::{Assets, MapData, SpriteSheet},
audio::AudioHandle,
fb::Framebuffer,
input::InputState,
storage::Storage,
};
use anyhow::{anyhow, Context as _, Result};
use pixel8::physics::wire::{Record, CAP, EMPTY, RECORD};
use wasmi::{
Caller, Config, Engine, Instance, Linker, Module, OperatorCost, 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);
config.compilation_mode(wasmi::CompilationMode::Eager);
config.operator_cost(Self::operator_cost());
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,
"step_cast",
|mut c: Caller<'_, HostState>, ptr: u32, len: u32, config: u32| {
step_the_cast(&mut c, ptr, len, config)
}
);
link!(
linker,
"draw_cast",
|mut c: Caller<'_, HostState>, ptr: u32, len: u32, layers: u32| {
draw_the_cast(&mut c, ptr, len, layers)
}
);
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()
}
fn operator_cost() -> OperatorCost {
OperatorCost {
local_get: 0,
local_set: 0,
local_tee: 0,
i32_const: 0,
i64_const: 0,
f32_const: 0,
f64_const: 0,
..Default::default()
}
}
}
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 step_the_cast(caller: &mut Caller<'_, HostState>, ptr: u32, len: u32, config: u32) {
use pixel8::{
physics::{wire::Recast, Kinetic, World},
BitFlags, SpriteFlag,
};
let Some(mut records) = read_cast(caller, ptr, len) else {
return;
};
let members = len as usize;
let mut cast: Vec<Recast> = records[..members].iter().map(Recast::of).collect();
{
let state = caller.data();
let flags = |sprite: u32| {
BitFlags::<SpriteFlag>::from_bits(state.sprites.flags(sprite))
.expect("all eight sprite-flag bits are flags")
};
let tiles = |x: i16, y: i16| {
if x < 0
|| y < 0
|| x as usize >= crate::assets::MAP_W
|| y as usize >= crate::assets::MAP_H
{
return BitFlags::empty();
}
flags(state.map.get(x as i32, y as i32) as u32)
};
let world: World<0> = if config & 1 != 0 {
World::new()
} else {
World::mapless()
};
let mut entities: Vec<&mut dyn Kinetic> =
cast.iter_mut().map(|e| e as &mut dyn Kinetic).collect();
world.step_hosted(&mut entities, tiles, |sprite| flags(sprite.0 as u32));
}
for (recast, record) in cast.iter().zip(records.iter_mut()) {
recast.report(record);
}
let Some(memory) = caller
.get_export("memory")
.and_then(wasmi::Extern::into_memory)
else {
return;
};
let data = memory.data_mut(&mut *caller);
let start = ptr as usize;
if start + members * RECORD > data.len() {
return;
}
for (slot, record) in records[..members].iter().enumerate() {
let at = start + slot * RECORD;
record.write(
(&mut data[at..at + RECORD])
.try_into()
.expect("sized just above"),
);
}
}
fn draw_the_cast(caller: &mut Caller<'_, HostState>, ptr: u32, len: u32, layers: u32) {
use pixel8::{physics::wire::looks, BitFlags, SpriteFlag, SpriteId};
let Some(records) = read_cast(caller, ptr, len) else {
return;
};
let layers = BitFlags::<SpriteFlag>::from_bits(layers as u8)
.expect("all eight sprite-flag bits are flags");
let HostState { fb, sprites, .. } = caller.data_mut();
let sheet = &*sprites;
let carried = |sprite: SpriteId| {
BitFlags::<SpriteFlag>::from_bits(sheet.flags(sprite.0 as u32))
.expect("all eight sprite-flag bits are flags")
};
for look in looks(&records[..len as usize], layers, carried) {
fb.spr(
sheet,
u32::from(look.sprite.0),
i32::from(look.x),
i32::from(look.y),
i32::from(look.width),
i32::from(look.height),
look.flip_x,
look.flip_y,
);
}
}
fn read_cast(caller: &Caller<'_, HostState>, ptr: u32, len: u32) -> Option<[Record; CAP]> {
let members = len as usize;
if members > CAP {
return None;
}
let memory = caller
.get_export("memory")
.and_then(wasmi::Extern::into_memory)?;
let data = memory.data(caller);
let start = ptr as usize;
let end = start.checked_add(members * RECORD)?;
if end > data.len() {
return None;
}
let mut records = [EMPTY; CAP];
for (slot, record) in records[..members].iter_mut().enumerate() {
let at = start + slot * RECORD;
*record = Record::read(data[at..at + RECORD].try_into().expect("sized just above"));
}
Some(records)
}
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 fuel_costs;
#[cfg(test)]
mod tests {
use super::*;
use pixel8::physics::wire::{FLIP_X, FLIP_Y, HIDDEN, UNWORN};
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 STEP_CAST_CART: &str = r#"
(module
(import "pixel8" "step_cast" (func $step (param i32 i32 i32)))
(import "pixel8" "set_pixel" (func $pset (param i32 i32 i32)))
(memory (export "memory") 1)
(data (i32.const 64)
"\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\05\00\00\00\00\00\00\00\00\00\00\00\09\00\03\00\01\00\00\00")
(func (export "pixel8_init"))
(func (export "pixel8_update")
(call $step (i32.const 64) (i32.const 1) (i32.const 1)))
(func (export "pixel8_draw")
(call $pset (i32.const 0) (i32.const 0) (i32.load8_u (i32.const 88)))
(call $pset (i32.const 1) (i32.const 0) (i32.load8_u (i32.const 100)))
(call $pset (i32.const 2) (i32.const 0) (i32.load8_u (i32.const 102)))
(call $pset (i32.const 3) (i32.const 0) (i32.load8_u (i32.const 104))))
)
"#;
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 bulky_update_cart(reps: usize) -> String {
let body = "(global.set $n (i32.add (global.get $n) (i32.const 1)))".repeat(reps);
format!(
r#"(module
(global $n (mut i32) (i32.const 0))
(func (export "pixel8_init"))
(func (export "pixel8_update") {body})
(func (export "pixel8_draw")))"#
)
}
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(),
)
}
fn draw_record(rx: i16, ry: i16, sprite: u16, meta: u8, span: u8) -> [u8; RECORD] {
let mut bytes = [0u8; RECORD];
bytes[16..18].copy_from_slice(&rx.to_le_bytes());
bytes[18..20].copy_from_slice(&ry.to_le_bytes());
bytes[36..38].copy_from_slice(&sprite.to_le_bytes());
bytes[40] = meta;
bytes[43] = span;
bytes
}
fn draw_cast_wat(
records: &[[u8; RECORD]],
layers: u32,
camera_xy: Option<(i32, i32)>,
) -> String {
let data: String = records
.iter()
.flatten()
.map(|b| format!("\\{b:02x}"))
.collect();
let camera_call = match camera_xy {
Some((x, y)) => format!("(call $camera (i32.const {x}) (i32.const {y}))"),
None => String::new(),
};
format!(
r#"(module
(import "pixel8" "draw_cast" (func $draw (param i32 i32 i32)))
(import "pixel8" "camera" (func $camera (param i32 i32)))
(memory (export "memory") 1)
(data (i32.const 64) "{data}")
(func (export "pixel8_init"))
(func (export "pixel8_update"))
(func (export "pixel8_draw")
{camera_call}
(call $draw (i32.const 64) (i32.const {count}) (i32.const {layers}))))"#,
count = records.len(),
)
}
fn load_draw_vm(wat_src: &str, assets: &Assets) -> GameVm {
let wasm = wat::parse_str(wat_src).unwrap();
GameVm::load(&wasm, assets, AudioHandle::dummy(), Storage::default()).unwrap()
}
#[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 step_cast_answers_in_place_and_leaves_the_cart_s_bytes_alone() {
let mut vm = load_test_vm(STEP_CAST_CART).unwrap();
for _ in 0..3 {
vm.call_update().unwrap();
}
vm.call_draw().unwrap();
let fb = &vm.state().fb;
assert_eq!(fb.pget(0, 0), 5, "bw was not preserved");
assert_eq!(fb.pget(1, 0), 9, "sprite was not preserved");
assert_eq!(fb.pget(2, 0), 3, "solid was not preserved");
assert_eq!(fb.pget(3, 0), 1, "meta was not preserved");
}
#[test]
fn a_record_draws_its_cell_at_its_drawn_pixel() {
let mut assets = Assets::default();
assets.sprites.set(3, 4, 7); let record = draw_record(50, 60, 0, 0, 0);
let mut vm = load_draw_vm(&draw_cast_wat(&[record], 0, None), &assets);
vm.call_draw().unwrap();
assert_eq!(
vm.state().fb.pget(53, 64),
7,
"the painted pixel landed at rx+3, ry+4"
);
assert_eq!(
vm.state().fb.pget(50, 60),
0,
"an unpainted pixel of the cell stayed clear"
);
}
#[test]
fn flip_x_and_flip_y_mirror_the_cell() {
let mut assets = Assets::default();
assets.sprites.set(8, 0, 8); assets.sprites.set(15, 7, 12);
let flipped_x = draw_record(0, 0, 1, FLIP_X, 0);
let mut vm = load_draw_vm(&draw_cast_wat(&[flipped_x], 0, None), &assets);
vm.call_draw().unwrap();
assert_eq!(
vm.state().fb.pget(7, 0),
8,
"FLIP_X moved local (0,0) to the right edge"
);
assert_eq!(
vm.state().fb.pget(0, 7),
12,
"FLIP_X moved local (7,7) to the left edge"
);
let flipped_y = draw_record(0, 0, 1, FLIP_Y, 0);
let mut vm = load_draw_vm(&draw_cast_wat(&[flipped_y], 0, None), &assets);
vm.call_draw().unwrap();
assert_eq!(
vm.state().fb.pget(0, 7),
8,
"FLIP_Y moved local (0,0) to the bottom edge"
);
assert_eq!(
vm.state().fb.pget(7, 0),
12,
"FLIP_Y moved local (7,7) to the top edge"
);
}
#[test]
fn a_span_of_one_extra_column_draws_the_neighbour_to_the_right() {
let mut assets = Assets::default();
assets.sprites.set(8, 0, 6); let record = draw_record(0, 0, 0, 0, 0x01);
let mut vm = load_draw_vm(&draw_cast_wat(&[record], 0, None), &assets);
vm.call_draw().unwrap();
assert_eq!(
vm.state().fb.pget(8, 0),
6,
"the block's second column is cell 1"
);
}
#[test]
fn a_span_of_one_extra_row_draws_the_neighbour_below() {
let mut assets = Assets::default();
assets.sprites.set(0, 8, 6); let record = draw_record(0, 0, 0, 0, 0x10);
let mut vm = load_draw_vm(&draw_cast_wat(&[record], 0, None), &assets);
vm.call_draw().unwrap();
assert_eq!(
vm.state().fb.pget(0, 8),
6,
"the block's second row is cell 16"
);
}
#[test]
fn hidden_and_unworn_records_draw_nothing() {
let mut assets = Assets::default();
assets.sprites.set(0, 0, 9); assets.sprites.set(120, 120, 9);
let hidden = draw_record(10, 10, 0, HIDDEN, 0);
let unworn = draw_record(20, 20, UNWORN, 0, 0);
let mut vm = load_draw_vm(&draw_cast_wat(&[hidden, unworn], 0, None), &assets);
vm.call_draw().unwrap();
assert_eq!(
vm.state().fb.pget(10, 10),
0,
"a hidden record is never drawn"
);
assert_eq!(
vm.state().fb.pget(20, 20),
0,
"an unworn record is never drawn"
);
}
#[test]
fn layers_zero_draws_everything_and_a_mask_only_flagged_cells() {
let mut assets = Assets::default();
assets.sprites.set(0, 0, 4); assets.sprites.set(8, 0, 5); assets.sprites.set_flag(1, 0, true);
let plain = draw_record(0, 0, 0, 0, 0);
let flagged = draw_record(20, 0, 1, 0, 0);
let mut all = load_draw_vm(&draw_cast_wat(&[plain, flagged], 0, None), &assets);
all.call_draw().unwrap();
assert_eq!(
all.state().fb.pget(0, 0),
4,
"layers 0 draws the unflagged cell too"
);
assert_eq!(
all.state().fb.pget(20, 0),
5,
"layers 0 draws the flagged cell too"
);
let mut masked = load_draw_vm(&draw_cast_wat(&[plain, flagged], 1, None), &assets);
masked.call_draw().unwrap();
assert_eq!(
masked.state().fb.pget(0, 0),
0,
"an unflagged cell is never picked by a mask"
);
assert_eq!(
masked.state().fb.pget(20, 0),
5,
"the flagged cell survives the mask"
);
}
#[test]
fn a_later_record_is_drawn_over_an_earlier_one() {
let mut assets = Assets::default();
for py in 0..8 {
for px in 0..8 {
assets.sprites.set(16 + px, py, 5); }
}
assets.sprites.set(24, 0, 9); let under = draw_record(40, 40, 2, 0, 0);
let over = draw_record(40, 40, 3, 0, 0);
let mut vm = load_draw_vm(&draw_cast_wat(&[under, over], 0, None), &assets);
vm.call_draw().unwrap();
assert_eq!(
vm.state().fb.pget(40, 40),
9,
"the later record's opaque pixel wins"
);
assert_eq!(
vm.state().fb.pget(41, 40),
5,
"the earlier record shows through elsewhere"
);
}
#[test]
fn the_camera_offsets_the_whole_cast() {
let mut assets = Assets::default();
assets.sprites.set(0, 0, 6);
let record = draw_record(20, 20, 0, 0, 0);
let mut vm = load_draw_vm(&draw_cast_wat(&[record], 0, Some((5, 5))), &assets);
vm.call_draw().unwrap();
assert_eq!(
vm.state().fb.pget(15, 15),
6,
"the camera shifted the whole block"
);
assert_eq!(
vm.state().fb.pget(20, 20),
0,
"nothing was drawn at the unshifted position"
);
}
#[test]
fn a_malformed_cast_draws_nothing_without_trapping() {
let mut assets = Assets::default();
assets.sprites.set(0, 0, 9);
let data: String = draw_record(10, 10, 0, 0, 0)
.iter()
.map(|b| format!("\\{b:02x}"))
.collect();
let bad_len = format!(
r#"(module
(import "pixel8" "draw_cast" (func $draw (param i32 i32 i32)))
(memory (export "memory") 1)
(data (i32.const 64) "{data}")
(func (export "pixel8_init"))
(func (export "pixel8_update"))
(func (export "pixel8_draw")
(call $draw (i32.const 64) (i32.const 65) (i32.const 0))))"#
);
let mut vm = load_draw_vm(&bad_len, &assets);
assert!(vm.call_draw().is_ok(), "a length past CAP must not trap");
assert_eq!(
vm.state().fb.pget(10, 10),
0,
"a length past CAP must draw nothing, not the records that would fit"
);
let bad_ptr = r#"(module
(import "pixel8" "draw_cast" (func $draw (param i32 i32 i32)))
(memory (export "memory") 1)
(func (export "pixel8_init"))
(func (export "pixel8_update"))
(func (export "pixel8_draw")
(call $draw (i32.const 65500) (i32.const 1) (i32.const 0))))"#;
let mut vm = load_draw_vm(bad_ptr, &Assets::default());
assert!(
vm.call_draw().is_ok(),
"a range off the end of memory must not trap"
);
}
#[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 oversized_function_body_still_runs() {
let mut vm = load_test_vm(&bulky_update_cart(12_000)).unwrap();
assert!(
vm.call_update().is_ok(),
"an 84 KB function body must be callable, not billed as a runaway loop"
);
let spent = vm.cpu_update() * FUEL_PER_CALL as f32;
assert!(
spent > 12_000.0,
"all 12 K repetitions should have run, but the frame cost {spent} fuel"
);
}
#[test]
fn first_frame_costs_the_same_as_later_frames() {
let mut vm = load_test_vm(&bulky_update_cart(1_400)).unwrap();
vm.call_update().unwrap();
let first = vm.cpu_update();
vm.call_update().unwrap();
let steady = vm.cpu_update();
assert!(steady > 0.0, "the frame has to cost something to compare");
assert!(
(first - steady).abs() < 1e-6,
"frame 0 used {first} of the budget against a steady state of {steady}"
);
}
#[test]
fn eager_translation_of_a_cart_sized_module_is_quick() {
let wasm = wat::parse_str(bulky_update_cart(128 * 1024 / 7).as_str()).unwrap();
assert!(wasm.len() >= 128 * 1024, "the probe fills the cart cap");
let started = std::time::Instant::now();
let vm = GameVm::load(
&wasm,
&Assets::default(),
AudioHandle::dummy(),
Storage::default(),
);
let elapsed = started.elapsed();
assert!(vm.is_ok(), "a cart-sized module must load");
assert!(
elapsed < std::time::Duration::from_secs(5),
"translating a cart-sized module took {elapsed:?}"
);
}
#[test]
fn bad_cart_bytes_are_a_friendly_load_error() {
let valid = wat::parse_str(TEST_CART).unwrap();
let cases = [
("not wasm at all", b"definitely not a wasm module".to_vec()),
("a module cut in half", valid[..valid.len() / 2].to_vec()),
(
"a module missing its last byte",
valid[..valid.len() - 1].to_vec(),
),
];
for (name, bytes) in cases {
let err = match GameVm::load(
&bytes,
&Assets::default(),
AudioHandle::dummy(),
Storage::default(),
) {
Err(e) => e,
Ok(_) => panic!("{name} should not load"),
};
assert!(
err.to_string().contains("Invalid cart wasm"),
"{name}: got {err}"
);
}
}
#[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();
}
const FPS_FROM_INIT_CART: &str = r#"
(module
(global $rate (mut i32) (i32.const 0))
(func (export "pixel8_init") (global.set $rate (i32.const 30)))
(func (export "pixel8_fps") (result i32) (global.get $rate))
(func (export "pixel8_update"))
(func (export "pixel8_draw")))
"#;
#[test]
fn a_carts_rate_may_be_worked_out_by_its_init() {
let vm = load_test_vm(FPS_FROM_INIT_CART).unwrap();
assert_eq!(vm.state().measured_fps_or_target(), 30.0);
}
#[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);
}
}