1use crate::{
10 assets::{Assets, MapData, SpriteSheet},
11 audio::AudioHandle,
12 fb::Framebuffer,
13 input::InputState,
14 storage::Storage,
15};
16use anyhow::{anyhow, Context as _, Result};
17use wasmi::{
18 Caller, Config, Engine, Instance, Linker, Module, Store, StoreLimits, StoreLimitsBuilder,
19 TypedFunc,
20};
21
22pub const DEFAULT_FPS: u32 = 60;
24
25pub const UI_FPS: u32 = 30;
28
29const FUEL_PER_CALL: u64 = 131_072;
35
36const MAX_MEMORY: usize = crate::cart::MEMORY_CAP;
42
43pub struct GameVm {
45 store: Store<HostState>,
46 _instance: Instance,
47 update: TypedFunc<(), ()>,
48 draw: TypedFunc<(), ()>,
49}
50
51macro_rules! link {
52 ($linker:expr, $name:literal, $f:expr) => {
53 $linker
54 .func_wrap("pixel8", $name, $f)
55 .with_context(|| format!("registering host fn {}", $name))?;
56 };
57}
58
59impl GameVm {
60 pub fn load(
66 wasm: &[u8],
67 assets: &Assets,
68 audio: AudioHandle,
69 storage: Storage,
70 ) -> Result<Self> {
71 crate::assets::validate(assets)?;
77
78 let mut config = Config::default();
79 config.consume_fuel(true);
80 let engine = Engine::new(&config);
81 let module = Module::new(&engine, wasm).map_err(|e| anyhow!("Invalid cart wasm: {e}"))?;
82
83 audio.load(assets.sfx.clone(), assets.music.clone());
84 let mut store = Store::new(&engine, HostState::new(assets, audio, storage));
85 store.limiter(|state| &mut state.limits);
86 let mut linker = <Linker<HostState>>::new(&engine);
87
88 link!(linker, "clear", |mut c: Caller<'_, HostState>, col: i32| {
89 c.data_mut().fb.cls(col as u8)
90 });
91 link!(linker, "camera", |mut c: Caller<'_, HostState>,
92 x: i32,
93 y: i32| {
94 c.data_mut().fb.camera(x, y)
95 });
96 link!(linker, "clip", |mut c: Caller<'_, HostState>,
97 x: i32,
98 y: i32,
99 w: i32,
100 h: i32| {
101 c.data_mut().fb.clip(x, y, w, h)
102 });
103 link!(
104 linker,
105 "set_pixel",
106 |mut c: Caller<'_, HostState>, x: i32, y: i32, col: i32| {
107 c.data_mut().fb.pset(x, y, col as u8)
108 }
109 );
110 link!(linker, "pixel", |c: Caller<'_, HostState>,
111 x: i32,
112 y: i32|
113 -> i32 {
114 c.data().fb.pget(x, y) as i32
115 });
116 link!(linker, "line", |mut c: Caller<'_, HostState>,
117 x0: i32,
118 y0: i32,
119 x1: i32,
120 y1: i32,
121 col: i32| {
122 c.data_mut().fb.line(x0, y0, x1, y1, col as u8)
123 });
124 link!(linker, "rect", |mut c: Caller<'_, HostState>,
125 x0: i32,
126 y0: i32,
127 x1: i32,
128 y1: i32,
129 col: i32| {
130 c.data_mut().fb.rect(x0, y0, x1, y1, col as u8)
131 });
132 link!(
133 linker,
134 "rect_fill",
135 |mut c: Caller<'_, HostState>, x0: i32, y0: i32, x1: i32, y1: i32, col: i32| {
136 c.data_mut().fb.rectfill(x0, y0, x1, y1, col as u8)
137 }
138 );
139 link!(
140 linker,
141 "circle",
142 |mut c: Caller<'_, HostState>, x: i32, y: i32, r: i32, col: i32| {
143 c.data_mut().fb.circ(x, y, r, col as u8)
144 }
145 );
146 link!(
147 linker,
148 "circle_fill",
149 |mut c: Caller<'_, HostState>, x: i32, y: i32, r: i32, col: i32| {
150 c.data_mut().fb.circfill(x, y, r, col as u8)
151 }
152 );
153 link!(linker, "print", |mut c: Caller<'_, HostState>,
154 ptr: u32,
155 len: u32,
156 x: i32,
157 y: i32,
158 col: i32|
159 -> i32 {
160 let s = read_guest_str(&c, ptr, len);
161 c.data_mut().fb.print(&s, x, y, col as u8)
162 });
163 link!(linker, "is_button_down", |c: Caller<'_, HostState>,
164 b: u32|
165 -> i32 {
166 c.data().input.btn(b) as i32
167 });
168 link!(linker, "is_button_pressed", |c: Caller<'_, HostState>,
169 b: u32|
170 -> i32 {
171 c.data().input.btnp(b) as i32
172 });
173 link!(linker, "buttons_down", |c: Caller<'_, HostState>| -> i32 {
174 c.data().input.btn_mask() as i32
175 });
176 link!(
177 linker,
178 "buttons_pressed",
179 |c: Caller<'_, HostState>| -> i32 { c.data().input.btnp_mask() as i32 }
180 );
181 link!(
182 linker,
183 "sprite",
184 |mut c: Caller<'_, HostState>,
185 n: u32,
186 x: i32,
187 y: i32,
188 w: i32,
189 h: i32,
190 flip_x: i32,
191 flip_y: i32| {
192 let HostState { fb, sprites, .. } = c.data_mut();
193 fb.spr(sprites, n, x, y, w, h, flip_x != 0, flip_y != 0);
194 }
195 );
196 link!(linker, "map", |mut c: Caller<'_, HostState>,
197 cel_x: i32,
198 cel_y: i32,
199 sx: i32,
200 sy: i32,
201 cel_w: i32,
202 cel_h: i32,
203 layers: u32| {
204 let HostState {
205 fb, sprites, map, ..
206 } = c.data_mut();
207 fb.map(
208 map,
209 sprites,
210 cel_x,
211 cel_y,
212 sx,
213 sy,
214 cel_w,
215 cel_h,
216 layers as u8,
217 );
218 });
219 link!(linker, "map_tile", |c: Caller<'_, HostState>,
220 x: i32,
221 y: i32|
222 -> i32 {
223 c.data().map.get(x, y) as i32
224 });
225 link!(
226 linker,
227 "set_map_tile",
228 |mut c: Caller<'_, HostState>, x: i32, y: i32, v: u32| {
229 c.data_mut().map.set(x, y, v as u8)
230 }
231 );
232 link!(linker, "sprite_flags", |c: Caller<'_, HostState>,
233 n: u32|
234 -> i32 {
235 c.data().sprites.flags(n) as i32
236 });
237 link!(
238 linker,
239 "set_sprite_flags",
240 |mut c: Caller<'_, HostState>, n: u32, flags: u32| {
241 c.data_mut().sprites.flags[(n as usize) % crate::assets::SPRITE_COUNT] =
242 flags as u8;
243 }
244 );
245 link!(linker, "sfx", |c: Caller<'_, HostState>,
246 n: i32,
247 channel: i32| {
248 c.data().audio.play_sfx(n, channel)
249 });
250 link!(linker, "music", |c: Caller<'_, HostState>,
251 n: i32,
252 fade: i32,
253 mask: i32,
254 token: i32|
255 -> i32 {
256 c.data().audio.play_music(n, fade, mask, token)
257 });
258 link!(linker, "cpu_update", |c: Caller<'_, HostState>| -> f32 {
259 c.data().last_update_cpu
260 });
261 link!(linker, "cpu_draw", |c: Caller<'_, HostState>| -> f32 {
262 c.data().last_draw_cpu
263 });
264 link!(linker, "fps", |c: Caller<'_, HostState>| -> f32 {
265 c.data().measured_fps_or_target()
266 });
267 link!(linker, "time", |c: Caller<'_, HostState>| -> f32 {
268 let st = c.data();
269 st.frame as f32 / st.fps as f32
270 });
271 link!(linker, "rnd", |mut c: Caller<'_, HostState>| -> f32 {
272 c.data_mut().next_rand()
273 });
274 link!(linker, "log", |mut c: Caller<'_, HostState>,
275 ptr: u32,
276 len: u32| {
277 let s = read_guest_str(&c, ptr, len);
278 c.data_mut().logs.push(s);
279 });
280 link!(linker, "panic", |mut c: Caller<'_, HostState>,
281 ptr: u32,
282 len: u32| {
283 let s = read_guest_str(&c, ptr, len);
284 c.data_mut().panic_message = Some(s);
285 });
286 link!(
287 linker,
288 "seed_rng",
289 |mut c: Caller<'_, HostState>, seed: u32| { c.data_mut().seed_rand(seed) }
290 );
291 link!(linker, "sprite_pixel", |c: Caller<'_, HostState>,
292 x: i32,
293 y: i32|
294 -> i32 {
295 c.data().sprites.get(x, y) as i32
296 });
297 link!(
298 linker,
299 "set_sprite_pixel",
300 |mut c: Caller<'_, HostState>, x: i32, y: i32, col: i32| {
301 c.data_mut().sprites.set(x, y, col as u8)
302 }
303 );
304 link!(
305 linker,
306 "sprite_stretch",
307 |mut c: Caller<'_, HostState>,
308 sx: i32,
309 sy: i32,
310 sw: i32,
311 sh: i32,
312 dx: i32,
313 dy: i32,
314 dw: i32,
315 dh: i32,
316 flip_x: i32,
317 flip_y: i32| {
318 let HostState { fb, sprites, .. } = c.data_mut();
319 fb.sspr(
320 sprites,
321 sx,
322 sy,
323 sw,
324 sh,
325 dx,
326 dy,
327 dw,
328 dh,
329 flip_x != 0,
330 flip_y != 0,
331 );
332 }
333 );
334 link!(
335 linker,
336 "ellipse",
337 |mut c: Caller<'_, HostState>, x0: i32, y0: i32, x1: i32, y1: i32, col: i32| {
338 c.data_mut().fb.oval(x0, y0, x1, y1, col as u8)
339 }
340 );
341 link!(
342 linker,
343 "ellipse_fill",
344 |mut c: Caller<'_, HostState>, x0: i32, y0: i32, x1: i32, y1: i32, col: i32| {
345 c.data_mut().fb.ovalfill(x0, y0, x1, y1, col as u8)
346 }
347 );
348 link!(
349 linker,
350 "set_transparent_color",
351 |mut c: Caller<'_, HostState>, col: i32, t: i32| {
352 c.data_mut().fb.set_transparent_color(col as u8, t != 0)
353 }
354 );
355 link!(linker, "reset_transparency", |mut c: Caller<
356 '_,
357 HostState,
358 >| {
359 c.data_mut().fb.reset_transparency()
360 });
361 link!(
362 linker,
363 "remap_color",
364 |mut c: Caller<'_, HostState>, from: i32, to: i32, mode: i32| {
365 let fb = &mut c.data_mut().fb;
366 if mode == 0 {
367 fb.remap_color(from as u8, to as u8);
368 } else {
369 fb.remap_display_color(from as u8, to as u8);
370 }
371 }
372 );
373 link!(linker, "reset_palette", |mut c: Caller<'_, HostState>| {
374 c.data_mut().fb.reset_palette()
375 });
376 link!(
377 linker,
378 "set_fill_pattern",
379 |mut c: Caller<'_, HostState>, pattern: i32, secondary: i32, transparent: i32| {
380 c.data_mut()
381 .fb
382 .set_fill_pattern(pattern as u16, secondary as u8, transparent != 0)
383 }
384 );
385 link!(
386 linker,
387 "set_pen_color",
388 |mut c: Caller<'_, HostState>, col: i32| { c.data_mut().fb.set_pen_color(col as u8) }
389 );
390 link!(
391 linker,
392 "set_cursor",
393 |mut c: Caller<'_, HostState>, x: i32, y: i32| { c.data_mut().fb.set_cursor(x, y) }
394 );
395 link!(linker, "print_pen", |mut c: Caller<'_, HostState>,
396 ptr: u32,
397 len: u32|
398 -> i32 {
399 let s = read_guest_str(&c, ptr, len);
400 c.data_mut().fb.print_pen(&s)
401 });
402 link!(linker, "storage_set", |mut c: Caller<'_, HostState>,
403 key_ptr: u32,
404 key_len: u32,
405 val_ptr: u32,
406 val_len: u32|
407 -> i32 {
408 let key = read_guest_str(&c, key_ptr, key_len);
409 let val = read_guest_str(&c, val_ptr, val_len);
410 c.data_mut().storage.set_json(&key, &val) as i32
411 });
412 link!(linker, "storage_get", |mut c: Caller<'_, HostState>,
413 key_ptr: u32,
414 key_len: u32,
415 buf_ptr: u32,
416 buf_cap: u32|
417 -> i32 {
418 let key = read_guest_str(&c, key_ptr, key_len);
419 let Some(json) = c.data().storage.get_json(&key) else {
420 return -1;
421 };
422 if json.len() <= buf_cap as usize {
425 write_guest_bytes(&mut c, buf_ptr, json.as_bytes());
426 }
427 json.len() as i32
428 });
429 link!(linker, "storage_remove", |mut c: Caller<'_, HostState>,
430 key_ptr: u32,
431 key_len: u32|
432 -> i32 {
433 let key = read_guest_str(&c, key_ptr, key_len);
434 c.data_mut().storage.remove(&key) as i32
435 });
436 link!(linker, "storage_clear", |mut c: Caller<'_, HostState>| {
437 c.data_mut().storage.clear()
438 });
439
440 store
441 .set_fuel(FUEL_PER_CALL)
442 .map_err(|e| anyhow!("Fuel setup: {e}"))?;
443 let instance = linker
444 .instantiate_and_start(&mut store, &module)
445 .map_err(|e| {
446 let s = e.to_string();
447 if s.contains("resource limiter denied") {
448 anyhow!("Cart needs more than 128K of memory to start")
449 } else {
450 anyhow!("Cart does not match the Pixel8 ABI: {e}")
451 }
452 })?;
453
454 let init = instance
455 .get_typed_func::<(), ()>(&store, "pixel8_init")
456 .map_err(|e| anyhow!("Cart is missing pixel8_init: {e}"))?;
457 let update = instance
458 .get_typed_func::<(), ()>(&store, "pixel8_update")
459 .map_err(|e| anyhow!("Cart is missing pixel8_update: {e}"))?;
460 let draw = instance
461 .get_typed_func::<(), ()>(&store, "pixel8_draw")
462 .map_err(|e| anyhow!("Cart is missing pixel8_draw: {e}"))?;
463
464 let mut vm = Self {
465 store,
466 _instance: instance,
467 update,
468 draw,
469 };
470 vm.call("init", init).map_err(|e| anyhow!(e.to_string()))?;
471 vm.store.data_mut().fps = vm.query_fps();
472 Ok(vm)
473 }
474
475 fn query_fps(&mut self) -> u32 {
479 let Ok(func) = self
480 ._instance
481 .get_typed_func::<(), u32>(&self.store, "pixel8_fps")
482 else {
483 return DEFAULT_FPS;
484 };
485 self.store.set_fuel(FUEL_PER_CALL).ok();
486 match func.call(&mut self.store, ()) {
487 Ok(30) => 30,
488 Ok(60) => 60,
489 _ => DEFAULT_FPS,
490 }
491 }
492
493 fn call(
494 &mut self,
495 phase: &'static str,
496 func: TypedFunc<(), ()>,
497 ) -> std::result::Result<(), RuntimeError> {
498 self.store.set_fuel(FUEL_PER_CALL).ok();
499 let result = func.call(&mut self.store, ()).map_err(|err| {
500 let message = match self.store.data_mut().panic_message.take() {
501 Some(panic) => panic,
502 None => {
503 let s = err.to_string();
504 if s.contains("fuel") {
505 format!("{phase}() ran too long\n(infinite loop?)")
506 } else if s.contains("growth operation limited") {
507 format!("{phase}() ran out of memory\n(128K limit)")
508 } else {
509 s
510 }
511 }
512 };
513 RuntimeError { phase, message }
514 });
515 if result.is_ok() {
516 let remaining = self.store.get_fuel().unwrap_or(0);
517 let frac = FUEL_PER_CALL.saturating_sub(remaining) as f32 / FUEL_PER_CALL as f32;
518 match phase {
519 "update" => self.store.data_mut().last_update_cpu = frac,
520 "draw" => self.store.data_mut().last_draw_cpu = frac,
521 _ => {}
522 }
523 }
524 result
525 }
526
527 pub fn call_update(&mut self) -> std::result::Result<(), RuntimeError> {
529 self.store.data_mut().input.tick();
530 let r = self.call("update", self.update);
531 self.store.data_mut().frame += 1;
532 r
533 }
534
535 pub fn call_draw(&mut self) -> std::result::Result<(), RuntimeError> {
537 self.call("draw", self.draw)
538 }
539
540 pub fn fps(&self) -> u32 {
542 self.store.data().fps
543 }
544
545 pub fn cpu_update(&self) -> f32 {
547 self.store.data().last_update_cpu
548 }
549
550 pub fn cpu_draw(&self) -> f32 {
552 self.store.data().last_draw_cpu
553 }
554
555 pub fn memory_used_fraction(&self) -> f32 {
557 let Some(mem) = self._instance.get_memory(&self.store, "memory") else {
558 return 0.0;
559 };
560 mem.data_size(&self.store) as f32 / MAX_MEMORY as f32
561 }
562
563 pub fn mem_used_bytes(&mut self) -> u32 {
569 let Ok(func) = self
570 ._instance
571 .get_typed_func::<(), u32>(&self.store, "pixel8_mem_used")
572 else {
573 return 0;
574 };
575 self.store.set_fuel(FUEL_PER_CALL).ok();
576 func.call(&mut self.store, ()).unwrap_or(0)
577 }
578
579 pub fn state(&self) -> &HostState {
580 self.store.data()
581 }
582
583 pub fn state_mut(&mut self) -> &mut HostState {
584 self.store.data_mut()
585 }
586}
587
588pub struct HostState {
590 pub fb: Framebuffer,
591 pub input: InputState,
592 pub sprites: SpriteSheet,
593 pub map: MapData,
594 pub audio: AudioHandle,
595 pub storage: Storage,
599 pub logs: Vec<String>,
601 pub panic_message: Option<String>,
603 pub frame: u64,
604 pub fps: u32,
607 last_update_cpu: f32,
609 last_draw_cpu: f32,
611 measured_fps: f32,
613 rng: u64,
614 limits: StoreLimits,
617}
618
619impl HostState {
620 fn new(assets: &Assets, audio: AudioHandle, storage: Storage) -> Self {
621 Self {
622 fb: Framebuffer::new(),
623 input: InputState::default(),
624 sprites: assets.sprites.clone(),
625 map: assets.map.clone(),
626 audio,
627 storage,
628 logs: Vec::new(),
629 panic_message: None,
630 frame: 0,
631 fps: DEFAULT_FPS,
632 last_update_cpu: 0.0,
633 last_draw_cpu: 0.0,
634 measured_fps: 0.0,
635 rng: 0x2545_f491_4f6c_dd1d,
636 limits: StoreLimitsBuilder::new()
637 .memory_size(MAX_MEMORY)
638 .trap_on_grow_failure(true)
639 .build(),
640 }
641 }
642
643 fn next_rand(&mut self) -> f32 {
644 let mut x = self.rng;
646 x ^= x >> 12;
647 x ^= x << 25;
648 x ^= x >> 27;
649 self.rng = x;
650 let bits = (x.wrapping_mul(0x2545_f491_4f6c_dd1d) >> 40) as u32;
651 bits as f32 / (1u32 << 24) as f32
652 }
653
654 pub fn set_measured_fps(&mut self, fps: f32) {
656 self.measured_fps = fps;
657 }
658
659 pub fn measured_fps_or_target(&self) -> f32 {
662 if self.measured_fps > 0.0 {
663 self.measured_fps
664 } else {
665 self.fps as f32
666 }
667 }
668
669 fn seed_rand(&mut self, seed: u32) {
670 self.rng = (((seed as u64) << 32) | (seed as u64)) | 1;
672 }
673}
674
675#[derive(Debug, Clone)]
677pub struct RuntimeError {
678 pub phase: &'static str,
680 pub message: String,
681}
682
683impl std::fmt::Display for RuntimeError {
684 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
685 write!(f, "Runtime error in {}:\n{}", self.phase, self.message)
686 }
687}
688
689fn read_guest_str(caller: &Caller<'_, HostState>, ptr: u32, len: u32) -> String {
690 let Some(mem) = caller
691 .get_export("memory")
692 .and_then(wasmi::Extern::into_memory)
693 else {
694 return String::new();
695 };
696 let data = mem.data(caller);
697 let start = ptr as usize;
698 let end = start.saturating_add(len as usize).min(data.len());
699 if start >= end {
700 return String::new();
701 }
702 String::from_utf8_lossy(&data[start..end]).into_owned()
703}
704
705fn write_guest_bytes(caller: &mut Caller<'_, HostState>, ptr: u32, bytes: &[u8]) {
708 let Some(mem) = caller
709 .get_export("memory")
710 .and_then(wasmi::Extern::into_memory)
711 else {
712 return;
713 };
714 let data = mem.data_mut(caller);
715 let start = ptr as usize;
716 let Some(end) = start.checked_add(bytes.len()) else {
717 return;
718 };
719 if end <= data.len() {
720 data[start..end].copy_from_slice(bytes);
721 }
722}
723
724#[cfg(test)]
725mod tests {
726 use super::*;
727
728 const TEST_CART: &str = r#"
730 (module
731 (import "pixel8" "clear" (func $cls (param i32)))
732 (import "pixel8" "set_pixel" (func $pset (param i32 i32 i32)))
733 (import "pixel8" "pixel" (func $pget (param i32 i32) (result i32)))
734 (import "pixel8" "is_button_down" (func $btn (param i32) (result i32)))
735 (import "pixel8" "print" (func $print (param i32 i32 i32 i32 i32) (result i32)))
736 (import "pixel8" "log" (func $log (param i32 i32)))
737 (memory (export "memory") 1)
738 (data (i32.const 16) "hi from cart")
739 (global $x (mut i32) (i32.const 5))
740 (func (export "pixel8_init")
741 (call $log (i32.const 16) (i32.const 12)))
742 (func (export "pixel8_update")
743 (if (i32.ne (call $btn (i32.const 1)) (i32.const 0))
744 (then (global.set $x (i32.add (global.get $x) (i32.const 1))))))
745 (func (export "pixel8_draw")
746 (call $cls (i32.const 1))
747 (call $pset (global.get $x) (i32.const 7) (i32.const 8))
748 (drop (call $print (i32.const 16) (i32.const 2) (i32.const 0) (i32.const 0) (i32.const 7))))
749 )
750 "#;
751
752 const LOOPING_CART: &str = r#"
753 (module
754 (func (export "pixel8_init"))
755 (func (export "pixel8_update") (loop $l (br $l)))
756 (func (export "pixel8_draw"))
757 )
758 "#;
759
760 const FPS30_CART: &str = r#"
761 (module
762 (func (export "pixel8_init"))
763 (func (export "pixel8_fps") (result i32) (i32.const 30))
764 (func (export "pixel8_update"))
765 (func (export "pixel8_draw")))
766 "#;
767
768 const MEM_EXPORT_CART: &str = r#"
769 (module
770 (func (export "pixel8_init"))
771 (func (export "pixel8_update"))
772 (func (export "pixel8_draw"))
773 (func (export "pixel8_mem_used") (result i32) (i32.const 32768)))
774 "#;
775
776 const BUDGET_OK_CART: &str = r#"
778 (module
779 (func (export "pixel8_init"))
780 (func (export "pixel8_update")
781 (local $i i32)
782 (local.set $i (i32.const 10000))
783 (loop $l
784 (local.set $i (i32.add (local.get $i) (i32.const -1)))
785 (br_if $l (local.get $i))))
786 (func (export "pixel8_draw")))
787 "#;
788
789 const BUDGET_OVER_CART: &str = r#"
791 (module
792 (func (export "pixel8_init"))
793 (func (export "pixel8_update")
794 (local $i i32)
795 (local.set $i (i32.const 100000))
796 (loop $l
797 (local.set $i (i32.add (local.get $i) (i32.const -1)))
798 (br_if $l (local.get $i))))
799 (func (export "pixel8_draw")))
800 "#;
801
802 const GROW_TO_CAP_CART: &str = r#"
804 (module
805 (memory (export "memory") 1)
806 (func (export "pixel8_init"))
807 (func (export "pixel8_update") (drop (memory.grow (i32.const 1))))
808 (func (export "pixel8_draw")))
809 "#;
810
811 const GROW_PAST_CAP_CART: &str = r#"
813 (module
814 (memory (export "memory") 1)
815 (func (export "pixel8_init"))
816 (func (export "pixel8_update") (drop (memory.grow (i32.const 10))))
817 (func (export "pixel8_draw")))
818 "#;
819
820 const HUGE_INITIAL_MEMORY_CART: &str = r#"
823 (module
824 (memory (export "memory") 3)
825 (func (export "pixel8_init"))
826 (func (export "pixel8_update"))
827 (func (export "pixel8_draw")))
828 "#;
829
830 const PARITY_CART: &str = r#"
831 (module
832 (import "pixel8" "ellipse" (func $ovalo (param i32 i32 i32 i32 i32)))
833 (import "pixel8" "ellipse_fill" (func $oval (param i32 i32 i32 i32 i32)))
834 (import "pixel8" "set_transparent_color" (func $palt (param i32 i32)))
835 (import "pixel8" "reset_transparency" (func $paltr))
836 (import "pixel8" "remap_color" (func $pal (param i32 i32 i32)))
837 (import "pixel8" "reset_palette" (func $palr))
838 (import "pixel8" "set_fill_pattern" (func $fillp (param i32 i32 i32)))
839 (import "pixel8" "set_sprite_pixel" (func $sset (param i32 i32 i32)))
840 (import "pixel8" "sprite_pixel" (func $sget (param i32 i32) (result i32)))
841 (import "pixel8" "sprite_stretch"
842 (func $sspr (param i32 i32 i32 i32 i32 i32 i32 i32 i32 i32)))
843 (import "pixel8" "seed_rng" (func $srand (param i32)))
844 (import "pixel8" "set_pen_color" (func $color (param i32)))
845 (import "pixel8" "set_cursor" (func $cursor (param i32 i32)))
846 (import "pixel8" "print_pen" (func $printp (param i32 i32) (result i32)))
847 (import "pixel8" "cpu_update" (func $cpuu (result f32)))
848 (import "pixel8" "cpu_draw" (func $cpud (result f32)))
849 (import "pixel8" "fps" (func $fps (result f32)))
850 (memory (export "memory") 1)
851 (data (i32.const 0) "hi")
852 (func (export "pixel8_init"))
853 (func (export "pixel8_update")
854 (call $srand (i32.const 42))
855 (call $sset (i32.const 0) (i32.const 0) (i32.const 9))
856 (drop (call $sget (i32.const 0) (i32.const 0))))
857 (func (export "pixel8_draw")
858 (call $pal (i32.const 8) (i32.const 12) (i32.const 0))
859 (call $palt (i32.const 0) (i32.const 1))
860 (call $paltr)
861 (call $fillp (i32.const 0) (i32.const 0) (i32.const 0))
862 (call $color (i32.const 7))
863 (call $cursor (i32.const 0) (i32.const 0))
864 (drop (call $printp (i32.const 0) (i32.const 2)))
865 (call $sspr (i32.const 0) (i32.const 0) (i32.const 8) (i32.const 8)
866 (i32.const 64) (i32.const 0) (i32.const 8) (i32.const 8)
867 (i32.const 0) (i32.const 0))
868 (call $ovalo (i32.const 20) (i32.const 20) (i32.const 28) (i32.const 28)
869 (i32.const 7))
870 (call $palr)
871 (call $oval (i32.const 0) (i32.const 0) (i32.const 8) (i32.const 8) (i32.const 8))
872 (drop (call $cpuu))
873 (drop (call $cpud))
874 (drop (call $fps))))
875 "#;
876
877 fn load_test_vm(wat_src: &str) -> Result<GameVm> {
878 let wasm = wat::parse_str(wat_src).unwrap();
879 GameVm::load(
880 &wasm,
881 &Assets::default(),
882 AudioHandle::dummy(),
883 Storage::default(),
884 )
885 }
886
887 #[test]
888 fn parity_imports_link_and_run() {
889 let mut vm = load_test_vm(PARITY_CART).unwrap();
890 vm.call_update().unwrap();
891 vm.call_draw().unwrap();
892 assert_eq!(vm.state().sprites.get(0, 0), 9);
894 assert_eq!(vm.state().fb.pget(4, 4), 8, "oval filled the box center");
896 }
897
898 #[test]
899 fn abi_lifecycle_and_drawing() {
900 let mut vm = load_test_vm(TEST_CART).unwrap();
901 assert_eq!(vm.state_mut().logs.pop().as_deref(), Some("hi from cart"));
902
903 vm.call_update().unwrap();
904 vm.call_draw().unwrap();
905 assert_eq!(vm.state().fb.pget(5, 7), 8, "set_pixel through ABI");
906 assert_eq!(vm.state().fb.pget(0, 0), 7, "print drew a glyph pixel");
907
908 vm.state_mut().input.set_button(1, true);
910 vm.call_update().unwrap();
911 vm.call_draw().unwrap();
912 assert_eq!(
913 vm.state().fb.pget(6, 7),
914 8,
915 "is_button_down(right) moved pixel"
916 );
917 }
918
919 #[test]
920 fn default_fps_is_60() {
921 let vm = load_test_vm(TEST_CART).unwrap();
924 assert_eq!(vm.fps(), 60);
925 }
926
927 #[test]
928 fn cart_can_select_30fps() {
929 let vm = load_test_vm(FPS30_CART).unwrap();
930 assert_eq!(vm.fps(), 30);
931 }
932
933 #[test]
934 fn mem_used_reads_export_else_zero() {
935 let mut vm = load_test_vm(MEM_EXPORT_CART).unwrap();
937 assert_eq!(vm.mem_used_bytes(), 32768);
938 let mut vm2 = load_test_vm(TEST_CART).unwrap();
940 assert_eq!(vm2.mem_used_bytes(), 0);
941 }
942
943 #[test]
944 fn infinite_loop_is_trapped() {
945 let mut vm = load_test_vm(LOOPING_CART).unwrap();
946 let err = vm.call_update().unwrap_err();
947 assert_eq!(err.phase, "update");
948 assert!(err.message.contains("ran too long"), "{}", err.message);
949 }
950
951 #[test]
952 fn missing_exports_is_a_load_error() {
953 let wasm = wat::parse_str("(module)").unwrap();
954 let err = match GameVm::load(
955 &wasm,
956 &Assets::default(),
957 AudioHandle::dummy(),
958 Storage::default(),
959 ) {
960 Err(e) => e,
961 Ok(_) => panic!("empty module should not load"),
962 };
963 assert!(err.to_string().contains("pixel8_init"));
964 }
965
966 #[test]
967 fn unknown_imports_are_rejected() {
968 let wasm = wat::parse_str(
969 r#"(module (import "env" "evil" (func))
970 (func (export "pixel8_init"))
971 (func (export "pixel8_update"))
972 (func (export "pixel8_draw")))"#,
973 )
974 .unwrap();
975 assert!(GameVm::load(
976 &wasm,
977 &Assets::default(),
978 AudioHandle::dummy(),
979 Storage::default()
980 )
981 .is_err());
982 }
983
984 #[test]
985 fn fuel_budget_allows_modest_work() {
986 let mut vm = load_test_vm(BUDGET_OK_CART).unwrap();
987 assert!(
988 vm.call_update().is_ok(),
989 "10k-iteration frame must fit the 128K-fuel budget"
990 );
991 }
992
993 #[test]
994 fn fuel_budget_traps_runaway_work() {
995 let mut vm = load_test_vm(BUDGET_OVER_CART).unwrap();
996 let err = vm.call_update().unwrap_err();
997 assert!(err.message.contains("ran too long"), "got: {}", err.message);
998 }
999
1000 #[test]
1001 fn memory_growth_up_to_cap_is_allowed() {
1002 let mut vm = load_test_vm(GROW_TO_CAP_CART).unwrap();
1003 assert!(
1004 vm.call_update().is_ok(),
1005 "growing to exactly 128 K must succeed"
1006 );
1007 }
1008
1009 #[test]
1010 fn memory_growth_past_cap_is_a_friendly_error() {
1011 let mut vm = load_test_vm(GROW_PAST_CAP_CART).unwrap();
1012 let err = vm.call_update().unwrap_err();
1013 assert!(
1014 err.message.contains("out of memory"),
1015 "got: {}",
1016 err.message
1017 );
1018 }
1019
1020 #[test]
1021 fn oversized_initial_memory_is_rejected_at_load() {
1022 let wasm = wat::parse_str(HUGE_INITIAL_MEMORY_CART).unwrap();
1023 let err = match GameVm::load(
1024 &wasm,
1025 &Assets::default(),
1026 AudioHandle::dummy(),
1027 Storage::default(),
1028 ) {
1029 Err(e) => e,
1030 Ok(_) => panic!("oversized cart should not load"),
1031 };
1032 assert!(err.to_string().contains("128K of memory"), "got: {err}");
1033 }
1034
1035 #[test]
1036 fn reports_cpu_usage_per_phase() {
1037 let mut vm = load_test_vm(BUDGET_OK_CART).unwrap();
1040 vm.call_update().unwrap();
1041 vm.call_draw().unwrap();
1042 let u = vm.cpu_update();
1043 let d = vm.cpu_draw();
1044 assert!(u > 0.0 && u < 1.0, "update cpu fraction in range: {u}");
1045 assert!(u > d, "heavy update beats empty draw: {u} vs {d}");
1046 }
1047
1048 #[test]
1049 fn reports_memory_usage() {
1050 let vm = load_test_vm(TEST_CART).unwrap();
1052 let frac = vm.memory_used_fraction();
1053 assert!(
1054 (frac - 0.5).abs() < 0.01,
1055 "one page is half the cap: {frac}"
1056 );
1057 }
1058
1059 const STORAGE_CART: &str = r#"
1066 (module
1067 (import "pixel8" "storage_set" (func $sset (param i32 i32 i32 i32) (result i32)))
1068 (import "pixel8" "storage_get" (func $sget (param i32 i32 i32 i32) (result i32)))
1069 (import "pixel8" "storage_remove" (func $srem (param i32 i32) (result i32)))
1070 (import "pixel8" "storage_clear" (func $sclr))
1071 (import "pixel8" "set_pixel" (func $pset (param i32 i32 i32)))
1072 (memory (export "memory") 1)
1073 (data (i32.const 0) "score")
1074 (data (i32.const 8) "42")
1075 (data (i32.const 16) "gone")
1076 (data (i32.const 24) "tmp")
1077 (data (i32.const 28) "1")
1078 (data (i32.const 63) "\05")
1079 (func (export "pixel8_init")
1080 ;; Removing an existing key returns 1 -> (2,0) = 5.
1081 (drop (call $sset (i32.const 24) (i32.const 3) (i32.const 28) (i32.const 1)))
1082 (if (i32.eq (call $srem (i32.const 24) (i32.const 3)) (i32.const 1))
1083 (then (call $pset (i32.const 2) (i32.const 0) (i32.const 5))))
1084 ;; Re-add "tmp", wipe everything, then store the real value.
1085 (drop (call $sset (i32.const 24) (i32.const 3) (i32.const 28) (i32.const 1)))
1086 (call $sclr)
1087 (drop (call $sset (i32.const 0) (i32.const 5) (i32.const 8) (i32.const 2))))
1088 (func (export "pixel8_update"))
1089 (func (export "pixel8_draw")
1090 ;; (0,0) = the JSON length of the "score" value (2).
1091 (call $pset (i32.const 0) (i32.const 0)
1092 (call $sget (i32.const 0) (i32.const 5) (i32.const 64) (i32.const 16)))
1093 ;; A key never stored returns -1 -> (1,0) = 7.
1094 (if (i32.eq (call $sget (i32.const 16) (i32.const 4) (i32.const 64) (i32.const 16))
1095 (i32.const -1))
1096 (then (call $pset (i32.const 1) (i32.const 0) (i32.const 7))))
1097 ;; "tmp" was wiped by storage_clear -> (3,0) = 7.
1098 (if (i32.eq (call $sget (i32.const 24) (i32.const 3) (i32.const 64) (i32.const 16))
1099 (i32.const -1))
1100 (then (call $pset (i32.const 3) (i32.const 0) (i32.const 7))))
1101 ;; Cap 0 still reports the length -> (4,0) = 2.
1102 (call $pset (i32.const 4) (i32.const 0)
1103 (call $sget (i32.const 0) (i32.const 5) (i32.const 64) (i32.const 0)))
1104 ;; A too-small buffer gets nothing written: the sentinel byte at
1105 ;; 63 survives a cap-1 read of the 2-byte value -> (5,0) = 5.
1106 (drop (call $sget (i32.const 0) (i32.const 5) (i32.const 63) (i32.const 1)))
1107 (call $pset (i32.const 5) (i32.const 0) (i32.load8_u (i32.const 63)))
1108 ;; An exactly-sized buffer is filled: "42" lands at 80..82 -> (6,0) = 7.
1109 (drop (call $sget (i32.const 0) (i32.const 5) (i32.const 80) (i32.const 2)))
1110 (if (i32.and
1111 (i32.eq (i32.load8_u (i32.const 80)) (i32.const 52))
1112 (i32.eq (i32.load8_u (i32.const 81)) (i32.const 50)))
1113 (then (call $pset (i32.const 6) (i32.const 0) (i32.const 7))))))
1114 "#;
1115
1116 #[test]
1117 fn storage_abi_set_get_remove_clear() {
1118 let mut vm = load_test_vm(STORAGE_CART).unwrap();
1119 vm.call_update().unwrap();
1120 vm.call_draw().unwrap();
1121 assert_eq!(vm.state().storage.get_json("score").as_deref(), Some("42"));
1124 assert_eq!(vm.state().storage.get_json("tmp"), None);
1125 let px = |x| vm.state().fb.pget(x, 0);
1126 assert_eq!(px(0), 2, "storage_get returned the value length");
1127 assert_eq!(px(1), 7, "missing key returned -1");
1128 assert_eq!(px(2), 5, "removing an existing key returned 1");
1129 assert_eq!(px(3), 7, "storage_clear wiped the store");
1130 assert_eq!(px(4), 2, "cap-0 call sized the read");
1131 assert_eq!(px(5), 5, "too-small buffer left guest memory untouched");
1132 assert_eq!(px(6), 7, "exact-fit buffer was filled");
1133 }
1134
1135 #[test]
1136 fn storage_persists_across_vm_loads() {
1137 let path =
1138 std::env::temp_dir().join(format!("pixel8_vm_storage_{}.json", std::process::id()));
1139 let _ = std::fs::remove_file(&path);
1140 let wasm = wat::parse_str(STORAGE_CART).unwrap();
1141 {
1142 let _vm = GameVm::load(
1143 &wasm,
1144 &Assets::default(),
1145 AudioHandle::dummy(),
1146 Storage::at_path(path.clone()),
1147 )
1148 .unwrap();
1149 }
1151 let reloaded = Storage::at_path(path.clone());
1152 assert_eq!(reloaded.get_json("score").as_deref(), Some("42"));
1153 std::fs::remove_file(&path).unwrap();
1154 }
1155
1156 #[test]
1157 fn fps_falls_back_to_target_until_measured() {
1158 let mut vm = load_test_vm(FPS30_CART).unwrap();
1160 assert_eq!(vm.state().measured_fps_or_target(), 30.0);
1161 vm.state_mut().set_measured_fps(58.0);
1163 assert_eq!(vm.state().measured_fps_or_target(), 58.0);
1164 }
1165}