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 pixel8::physics::wire::{Record, CAP, EMPTY, RECORD};
18use wasmi::{
19 Caller, Config, Engine, Instance, Linker, Module, OperatorCost, Store, StoreLimits,
20 StoreLimitsBuilder, TypedFunc,
21};
22
23pub const DEFAULT_FPS: u32 = 60;
25
26pub const UI_FPS: u32 = 30;
29
30const FUEL_PER_CALL: u64 = 131_072;
37
38const MAX_MEMORY: usize = crate::cart::MEMORY_CAP;
44
45pub struct GameVm {
47 store: Store<HostState>,
48 _instance: Instance,
49 update: TypedFunc<(), ()>,
50 draw: TypedFunc<(), ()>,
51}
52
53macro_rules! link {
54 ($linker:expr, $name:literal, $f:expr) => {
55 $linker
56 .func_wrap("pixel8", $name, $f)
57 .with_context(|| format!("registering host fn {}", $name))?;
58 };
59}
60
61impl GameVm {
62 pub fn load(
68 wasm: &[u8],
69 assets: &Assets,
70 audio: AudioHandle,
71 storage: Storage,
72 ) -> Result<Self> {
73 crate::assets::validate(assets)?;
79
80 let mut config = Config::default();
81 config.consume_fuel(true);
82 config.compilation_mode(wasmi::CompilationMode::Eager);
94 config.operator_cost(Self::operator_cost());
95 let engine = Engine::new(&config);
96 let module = Module::new(&engine, wasm).map_err(|e| anyhow!("Invalid cart wasm: {e}"))?;
97
98 audio.load(assets.sfx.clone(), assets.music.clone());
99 let mut store = Store::new(&engine, HostState::new(assets, audio, storage));
100 store.limiter(|state| &mut state.limits);
101 let mut linker = <Linker<HostState>>::new(&engine);
102
103 link!(linker, "clear", |mut c: Caller<'_, HostState>, col: i32| {
104 c.data_mut().fb.cls(col as u8)
105 });
106 link!(linker, "camera", |mut c: Caller<'_, HostState>,
107 x: i32,
108 y: i32| {
109 c.data_mut().fb.camera(x, y)
110 });
111 link!(linker, "clip", |mut c: Caller<'_, HostState>,
112 x: i32,
113 y: i32,
114 w: i32,
115 h: i32| {
116 c.data_mut().fb.clip(x, y, w, h)
117 });
118 link!(
119 linker,
120 "set_pixel",
121 |mut c: Caller<'_, HostState>, x: i32, y: i32, col: i32| {
122 c.data_mut().fb.pset(x, y, col as u8)
123 }
124 );
125 link!(linker, "pixel", |c: Caller<'_, HostState>,
126 x: i32,
127 y: i32|
128 -> i32 {
129 c.data().fb.pget(x, y) as i32
130 });
131 link!(linker, "line", |mut c: Caller<'_, HostState>,
132 x0: i32,
133 y0: i32,
134 x1: i32,
135 y1: i32,
136 col: i32| {
137 c.data_mut().fb.line(x0, y0, x1, y1, col as u8)
138 });
139 link!(linker, "rect", |mut c: Caller<'_, HostState>,
140 x0: i32,
141 y0: i32,
142 x1: i32,
143 y1: i32,
144 col: i32| {
145 c.data_mut().fb.rect(x0, y0, x1, y1, col as u8)
146 });
147 link!(
148 linker,
149 "rect_fill",
150 |mut c: Caller<'_, HostState>, x0: i32, y0: i32, x1: i32, y1: i32, col: i32| {
151 c.data_mut().fb.rectfill(x0, y0, x1, y1, col as u8)
152 }
153 );
154 link!(
155 linker,
156 "circle",
157 |mut c: Caller<'_, HostState>, x: i32, y: i32, r: i32, col: i32| {
158 c.data_mut().fb.circ(x, y, r, col as u8)
159 }
160 );
161 link!(
162 linker,
163 "circle_fill",
164 |mut c: Caller<'_, HostState>, x: i32, y: i32, r: i32, col: i32| {
165 c.data_mut().fb.circfill(x, y, r, col as u8)
166 }
167 );
168 link!(linker, "print", |mut c: Caller<'_, HostState>,
169 ptr: u32,
170 len: u32,
171 x: i32,
172 y: i32,
173 col: i32|
174 -> i32 {
175 let s = read_guest_str(&c, ptr, len);
176 c.data_mut().fb.print(&s, x, y, col as u8)
177 });
178 link!(linker, "is_button_down", |c: Caller<'_, HostState>,
179 b: u32|
180 -> i32 {
181 c.data().input.btn(b) as i32
182 });
183 link!(linker, "is_button_pressed", |c: Caller<'_, HostState>,
184 b: u32|
185 -> i32 {
186 c.data().input.btnp(b) as i32
187 });
188 link!(linker, "buttons_down", |c: Caller<'_, HostState>| -> i32 {
189 c.data().input.btn_mask() as i32
190 });
191 link!(
192 linker,
193 "buttons_pressed",
194 |c: Caller<'_, HostState>| -> i32 { c.data().input.btnp_mask() as i32 }
195 );
196 link!(
197 linker,
198 "sprite",
199 |mut c: Caller<'_, HostState>,
200 n: u32,
201 x: i32,
202 y: i32,
203 w: i32,
204 h: i32,
205 flip_x: i32,
206 flip_y: i32| {
207 let HostState { fb, sprites, .. } = c.data_mut();
208 fb.spr(sprites, n, x, y, w, h, flip_x != 0, flip_y != 0);
209 }
210 );
211 link!(linker, "map", |mut c: Caller<'_, HostState>,
212 cel_x: i32,
213 cel_y: i32,
214 sx: i32,
215 sy: i32,
216 cel_w: i32,
217 cel_h: i32,
218 layers: u32| {
219 let HostState {
220 fb, sprites, map, ..
221 } = c.data_mut();
222 fb.map(
223 map,
224 sprites,
225 cel_x,
226 cel_y,
227 sx,
228 sy,
229 cel_w,
230 cel_h,
231 layers as u8,
232 );
233 });
234 link!(linker, "map_tile", |c: Caller<'_, HostState>,
235 x: i32,
236 y: i32|
237 -> i32 {
238 c.data().map.get(x, y) as i32
239 });
240 link!(
241 linker,
242 "set_map_tile",
243 |mut c: Caller<'_, HostState>, x: i32, y: i32, v: u32| {
244 c.data_mut().map.set(x, y, v as u8)
245 }
246 );
247 link!(
248 linker,
249 "step_cast",
250 |mut c: Caller<'_, HostState>, ptr: u32, len: u32, config: u32| {
251 step_the_cast(&mut c, ptr, len, config)
252 }
253 );
254 link!(
255 linker,
256 "draw_cast",
257 |mut c: Caller<'_, HostState>, ptr: u32, len: u32, layers: u32| {
258 draw_the_cast(&mut c, ptr, len, layers)
259 }
260 );
261 link!(linker, "sprite_flags", |c: Caller<'_, HostState>,
262 n: u32|
263 -> i32 {
264 c.data().sprites.flags(n) as i32
265 });
266 link!(
267 linker,
268 "set_sprite_flags",
269 |mut c: Caller<'_, HostState>, n: u32, flags: u32| {
270 c.data_mut().sprites.flags[(n as usize) % crate::assets::SPRITE_COUNT] =
271 flags as u8;
272 }
273 );
274 link!(linker, "sfx", |c: Caller<'_, HostState>,
275 n: i32,
276 channel: i32| {
277 c.data().audio.play_sfx(n, channel)
278 });
279 link!(linker, "music", |c: Caller<'_, HostState>,
280 n: i32,
281 fade: i32,
282 mask: i32,
283 token: i32|
284 -> i32 {
285 c.data().audio.play_music(n, fade, mask, token)
286 });
287 link!(linker, "cpu_update", |c: Caller<'_, HostState>| -> f32 {
288 c.data().last_update_cpu
289 });
290 link!(linker, "cpu_draw", |c: Caller<'_, HostState>| -> f32 {
291 c.data().last_draw_cpu
292 });
293 link!(linker, "fps", |c: Caller<'_, HostState>| -> f32 {
294 c.data().measured_fps_or_target()
295 });
296 link!(linker, "time", |c: Caller<'_, HostState>| -> f32 {
297 let st = c.data();
298 st.frame as f32 / st.fps as f32
299 });
300 link!(linker, "rnd", |mut c: Caller<'_, HostState>| -> f32 {
301 c.data_mut().next_rand()
302 });
303 link!(linker, "log", |mut c: Caller<'_, HostState>,
304 ptr: u32,
305 len: u32| {
306 let s = read_guest_str(&c, ptr, len);
307 c.data_mut().logs.push(s);
308 });
309 link!(linker, "panic", |mut c: Caller<'_, HostState>,
310 ptr: u32,
311 len: u32| {
312 let s = read_guest_str(&c, ptr, len);
313 c.data_mut().panic_message = Some(s);
314 });
315 link!(
316 linker,
317 "seed_rng",
318 |mut c: Caller<'_, HostState>, seed: u32| { c.data_mut().seed_rand(seed) }
319 );
320 link!(linker, "sprite_pixel", |c: Caller<'_, HostState>,
321 x: i32,
322 y: i32|
323 -> i32 {
324 c.data().sprites.get(x, y) as i32
325 });
326 link!(
327 linker,
328 "set_sprite_pixel",
329 |mut c: Caller<'_, HostState>, x: i32, y: i32, col: i32| {
330 c.data_mut().sprites.set(x, y, col as u8)
331 }
332 );
333 link!(
334 linker,
335 "sprite_stretch",
336 |mut c: Caller<'_, HostState>,
337 sx: i32,
338 sy: i32,
339 sw: i32,
340 sh: i32,
341 dx: i32,
342 dy: i32,
343 dw: i32,
344 dh: i32,
345 flip_x: i32,
346 flip_y: i32| {
347 let HostState { fb, sprites, .. } = c.data_mut();
348 fb.sspr(
349 sprites,
350 sx,
351 sy,
352 sw,
353 sh,
354 dx,
355 dy,
356 dw,
357 dh,
358 flip_x != 0,
359 flip_y != 0,
360 );
361 }
362 );
363 link!(
364 linker,
365 "ellipse",
366 |mut c: Caller<'_, HostState>, x0: i32, y0: i32, x1: i32, y1: i32, col: i32| {
367 c.data_mut().fb.oval(x0, y0, x1, y1, col as u8)
368 }
369 );
370 link!(
371 linker,
372 "ellipse_fill",
373 |mut c: Caller<'_, HostState>, x0: i32, y0: i32, x1: i32, y1: i32, col: i32| {
374 c.data_mut().fb.ovalfill(x0, y0, x1, y1, col as u8)
375 }
376 );
377 link!(
378 linker,
379 "set_transparent_color",
380 |mut c: Caller<'_, HostState>, col: i32, t: i32| {
381 c.data_mut().fb.set_transparent_color(col as u8, t != 0)
382 }
383 );
384 link!(linker, "reset_transparency", |mut c: Caller<
385 '_,
386 HostState,
387 >| {
388 c.data_mut().fb.reset_transparency()
389 });
390 link!(
391 linker,
392 "remap_color",
393 |mut c: Caller<'_, HostState>, from: i32, to: i32, mode: i32| {
394 let fb = &mut c.data_mut().fb;
395 if mode == 0 {
396 fb.remap_color(from as u8, to as u8);
397 } else {
398 fb.remap_display_color(from as u8, to as u8);
399 }
400 }
401 );
402 link!(linker, "reset_palette", |mut c: Caller<'_, HostState>| {
403 c.data_mut().fb.reset_palette()
404 });
405 link!(
406 linker,
407 "set_fill_pattern",
408 |mut c: Caller<'_, HostState>, pattern: i32, secondary: i32, transparent: i32| {
409 c.data_mut()
410 .fb
411 .set_fill_pattern(pattern as u16, secondary as u8, transparent != 0)
412 }
413 );
414 link!(
415 linker,
416 "set_pen_color",
417 |mut c: Caller<'_, HostState>, col: i32| { c.data_mut().fb.set_pen_color(col as u8) }
418 );
419 link!(
420 linker,
421 "set_cursor",
422 |mut c: Caller<'_, HostState>, x: i32, y: i32| { c.data_mut().fb.set_cursor(x, y) }
423 );
424 link!(linker, "print_pen", |mut c: Caller<'_, HostState>,
425 ptr: u32,
426 len: u32|
427 -> i32 {
428 let s = read_guest_str(&c, ptr, len);
429 c.data_mut().fb.print_pen(&s)
430 });
431 link!(linker, "storage_set", |mut c: Caller<'_, HostState>,
432 key_ptr: u32,
433 key_len: u32,
434 val_ptr: u32,
435 val_len: u32|
436 -> i32 {
437 let key = read_guest_str(&c, key_ptr, key_len);
438 let val = read_guest_str(&c, val_ptr, val_len);
439 c.data_mut().storage.set_json(&key, &val) as i32
440 });
441 link!(linker, "storage_get", |mut c: Caller<'_, HostState>,
442 key_ptr: u32,
443 key_len: u32,
444 buf_ptr: u32,
445 buf_cap: u32|
446 -> i32 {
447 let key = read_guest_str(&c, key_ptr, key_len);
448 let Some(json) = c.data().storage.get_json(&key) else {
449 return -1;
450 };
451 if json.len() <= buf_cap as usize {
454 write_guest_bytes(&mut c, buf_ptr, json.as_bytes());
455 }
456 json.len() as i32
457 });
458 link!(linker, "storage_remove", |mut c: Caller<'_, HostState>,
459 key_ptr: u32,
460 key_len: u32|
461 -> i32 {
462 let key = read_guest_str(&c, key_ptr, key_len);
463 c.data_mut().storage.remove(&key) as i32
464 });
465 link!(linker, "storage_clear", |mut c: Caller<'_, HostState>| {
466 c.data_mut().storage.clear()
467 });
468
469 store
470 .set_fuel(FUEL_PER_CALL)
471 .map_err(|e| anyhow!("Fuel setup: {e}"))?;
472 let instance = linker
473 .instantiate_and_start(&mut store, &module)
474 .map_err(|e| {
475 let s = e.to_string();
476 if s.contains("resource limiter denied") {
477 anyhow!("Cart needs more than 128K of memory to start")
478 } else {
479 anyhow!("Cart does not match the Pixel8 ABI: {e}")
480 }
481 })?;
482
483 let init = instance
484 .get_typed_func::<(), ()>(&store, "pixel8_init")
485 .map_err(|e| anyhow!("Cart is missing pixel8_init: {e}"))?;
486 let update = instance
487 .get_typed_func::<(), ()>(&store, "pixel8_update")
488 .map_err(|e| anyhow!("Cart is missing pixel8_update: {e}"))?;
489 let draw = instance
490 .get_typed_func::<(), ()>(&store, "pixel8_draw")
491 .map_err(|e| anyhow!("Cart is missing pixel8_draw: {e}"))?;
492
493 let mut vm = Self {
494 store,
495 _instance: instance,
496 update,
497 draw,
498 };
499 vm.call("init", init).map_err(|e| anyhow!(e.to_string()))?;
500 vm.store.data_mut().fps = vm.query_fps();
501 Ok(vm)
502 }
503
504 fn query_fps(&mut self) -> u32 {
508 let Ok(func) = self
509 ._instance
510 .get_typed_func::<(), u32>(&self.store, "pixel8_fps")
511 else {
512 return DEFAULT_FPS;
513 };
514 self.store.set_fuel(FUEL_PER_CALL).ok();
515 match func.call(&mut self.store, ()) {
516 Ok(30) => 30,
517 Ok(60) => 60,
518 _ => DEFAULT_FPS,
519 }
520 }
521
522 fn call(
523 &mut self,
524 phase: &'static str,
525 func: TypedFunc<(), ()>,
526 ) -> std::result::Result<(), RuntimeError> {
527 self.store.set_fuel(FUEL_PER_CALL).ok();
528 let result = func.call(&mut self.store, ()).map_err(|err| {
529 let message = match self.store.data_mut().panic_message.take() {
530 Some(panic) => panic,
531 None => {
532 let s = err.to_string();
533 if s.contains("fuel") {
534 format!("{phase}() ran too long\n(infinite loop?)")
535 } else if s.contains("growth operation limited") {
536 format!("{phase}() ran out of memory\n(128K limit)")
537 } else {
538 s
539 }
540 }
541 };
542 RuntimeError { phase, message }
543 });
544 if result.is_ok() {
545 let remaining = self.store.get_fuel().unwrap_or(0);
546 let frac = FUEL_PER_CALL.saturating_sub(remaining) as f32 / FUEL_PER_CALL as f32;
547 match phase {
548 "update" => self.store.data_mut().last_update_cpu = frac,
549 "draw" => self.store.data_mut().last_draw_cpu = frac,
550 _ => {}
551 }
552 }
553 result
554 }
555
556 pub fn call_update(&mut self) -> std::result::Result<(), RuntimeError> {
558 self.store.data_mut().input.tick();
559 let r = self.call("update", self.update);
560 self.store.data_mut().frame += 1;
561 r
562 }
563
564 pub fn call_draw(&mut self) -> std::result::Result<(), RuntimeError> {
566 self.call("draw", self.draw)
567 }
568
569 pub fn fps(&self) -> u32 {
571 self.store.data().fps
572 }
573
574 pub fn cpu_update(&self) -> f32 {
576 self.store.data().last_update_cpu
577 }
578
579 pub fn cpu_draw(&self) -> f32 {
581 self.store.data().last_draw_cpu
582 }
583
584 pub fn memory_used_fraction(&self) -> f32 {
586 let Some(mem) = self._instance.get_memory(&self.store, "memory") else {
587 return 0.0;
588 };
589 mem.data_size(&self.store) as f32 / MAX_MEMORY as f32
590 }
591
592 pub fn mem_used_bytes(&mut self) -> u32 {
598 let Ok(func) = self
599 ._instance
600 .get_typed_func::<(), u32>(&self.store, "pixel8_mem_used")
601 else {
602 return 0;
603 };
604 self.store.set_fuel(FUEL_PER_CALL).ok();
605 func.call(&mut self.store, ()).unwrap_or(0)
606 }
607
608 pub fn state(&self) -> &HostState {
609 self.store.data()
610 }
611
612 pub fn state_mut(&mut self) -> &mut HostState {
613 self.store.data_mut()
614 }
615
616 fn operator_cost() -> OperatorCost {
636 OperatorCost {
637 local_get: 0,
638 local_set: 0,
639 local_tee: 0,
640 i32_const: 0,
641 i64_const: 0,
642 f32_const: 0,
643 f64_const: 0,
644 ..Default::default()
645 }
646 }
647}
648
649pub struct HostState {
651 pub fb: Framebuffer,
652 pub input: InputState,
653 pub sprites: SpriteSheet,
654 pub map: MapData,
655 pub audio: AudioHandle,
656 pub storage: Storage,
660 pub logs: Vec<String>,
662 pub panic_message: Option<String>,
664 pub frame: u64,
665 pub fps: u32,
668 last_update_cpu: f32,
670 last_draw_cpu: f32,
672 measured_fps: f32,
674 rng: u64,
675 limits: StoreLimits,
678}
679
680impl HostState {
681 fn new(assets: &Assets, audio: AudioHandle, storage: Storage) -> Self {
682 Self {
683 fb: Framebuffer::new(),
684 input: InputState::default(),
685 sprites: assets.sprites.clone(),
686 map: assets.map.clone(),
687 audio,
688 storage,
689 logs: Vec::new(),
690 panic_message: None,
691 frame: 0,
692 fps: DEFAULT_FPS,
693 last_update_cpu: 0.0,
694 last_draw_cpu: 0.0,
695 measured_fps: 0.0,
696 rng: 0x2545_f491_4f6c_dd1d,
697 limits: StoreLimitsBuilder::new()
698 .memory_size(MAX_MEMORY)
699 .trap_on_grow_failure(true)
700 .build(),
701 }
702 }
703
704 fn next_rand(&mut self) -> f32 {
705 let mut x = self.rng;
707 x ^= x >> 12;
708 x ^= x << 25;
709 x ^= x >> 27;
710 self.rng = x;
711 let bits = (x.wrapping_mul(0x2545_f491_4f6c_dd1d) >> 40) as u32;
712 bits as f32 / (1u32 << 24) as f32
713 }
714
715 pub fn set_measured_fps(&mut self, fps: f32) {
717 self.measured_fps = fps;
718 }
719
720 pub fn measured_fps_or_target(&self) -> f32 {
723 if self.measured_fps > 0.0 {
724 self.measured_fps
725 } else {
726 self.fps as f32
727 }
728 }
729
730 fn seed_rand(&mut self, seed: u32) {
731 self.rng = (((seed as u64) << 32) | (seed as u64)) | 1;
733 }
734}
735
736#[derive(Debug, Clone)]
738pub struct RuntimeError {
739 pub phase: &'static str,
741 pub message: String,
742}
743
744impl std::fmt::Display for RuntimeError {
745 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
746 write!(f, "Runtime error in {}:\n{}", self.phase, self.message)
747 }
748}
749
750fn step_the_cast(caller: &mut Caller<'_, HostState>, ptr: u32, len: u32, config: u32) {
760 use pixel8::{
761 physics::{wire::Recast, Kinetic, World},
762 BitFlags, SpriteFlag,
763 };
764
765 let Some(mut records) = read_cast(caller, ptr, len) else {
766 return;
767 };
768 let members = len as usize;
769
770 let mut cast: Vec<Recast> = records[..members].iter().map(Recast::of).collect();
771 {
772 let state = caller.data();
773 let flags = |sprite: u32| {
775 BitFlags::<SpriteFlag>::from_bits(state.sprites.flags(sprite))
776 .expect("all eight sprite-flag bits are flags")
777 };
778 let tiles = |x: i16, y: i16| {
781 if x < 0
782 || y < 0
783 || x as usize >= crate::assets::MAP_W
784 || y as usize >= crate::assets::MAP_H
785 {
786 return BitFlags::empty();
787 }
788 flags(state.map.get(x as i32, y as i32) as u32)
789 };
790 let world: World<0> = if config & 1 != 0 {
793 World::new()
794 } else {
795 World::mapless()
796 };
797 let mut entities: Vec<&mut dyn Kinetic> =
798 cast.iter_mut().map(|e| e as &mut dyn Kinetic).collect();
799 world.step_hosted(&mut entities, tiles, |sprite| flags(sprite.0 as u32));
800 }
801
802 for (recast, record) in cast.iter().zip(records.iter_mut()) {
803 recast.report(record);
804 }
805 let Some(memory) = caller
810 .get_export("memory")
811 .and_then(wasmi::Extern::into_memory)
812 else {
813 return;
814 };
815 let data = memory.data_mut(&mut *caller);
816 let start = ptr as usize;
817 if start + members * RECORD > data.len() {
818 return;
819 }
820 for (slot, record) in records[..members].iter().enumerate() {
821 let at = start + slot * RECORD;
822 record.write(
823 (&mut data[at..at + RECORD])
824 .try_into()
825 .expect("sized just above"),
826 );
827 }
828}
829
830fn draw_the_cast(caller: &mut Caller<'_, HostState>, ptr: u32, len: u32, layers: u32) {
840 use pixel8::{physics::wire::looks, BitFlags, SpriteFlag, SpriteId};
841
842 let Some(records) = read_cast(caller, ptr, len) else {
843 return;
844 };
845 let layers = BitFlags::<SpriteFlag>::from_bits(layers as u8)
848 .expect("all eight sprite-flag bits are flags");
849
850 let HostState { fb, sprites, .. } = caller.data_mut();
851 let sheet = &*sprites;
852 let carried = |sprite: SpriteId| {
853 BitFlags::<SpriteFlag>::from_bits(sheet.flags(sprite.0 as u32))
854 .expect("all eight sprite-flag bits are flags")
855 };
856 for look in looks(&records[..len as usize], layers, carried) {
857 fb.spr(
858 sheet,
859 u32::from(look.sprite.0),
860 i32::from(look.x),
861 i32::from(look.y),
862 i32::from(look.width),
863 i32::from(look.height),
864 look.flip_x,
865 look.flip_y,
866 );
867 }
868}
869
870fn read_cast(caller: &Caller<'_, HostState>, ptr: u32, len: u32) -> Option<[Record; CAP]> {
876 let members = len as usize;
877 if members > CAP {
878 return None;
879 }
880 let memory = caller
881 .get_export("memory")
882 .and_then(wasmi::Extern::into_memory)?;
883 let data = memory.data(caller);
884 let start = ptr as usize;
885 let end = start.checked_add(members * RECORD)?;
886 if end > data.len() {
887 return None;
888 }
889 let mut records = [EMPTY; CAP];
890 for (slot, record) in records[..members].iter_mut().enumerate() {
891 let at = start + slot * RECORD;
892 *record = Record::read(data[at..at + RECORD].try_into().expect("sized just above"));
893 }
894 Some(records)
895}
896
897fn read_guest_str(caller: &Caller<'_, HostState>, ptr: u32, len: u32) -> String {
898 let Some(mem) = caller
899 .get_export("memory")
900 .and_then(wasmi::Extern::into_memory)
901 else {
902 return String::new();
903 };
904 let data = mem.data(caller);
905 let start = ptr as usize;
906 let end = start.saturating_add(len as usize).min(data.len());
907 if start >= end {
908 return String::new();
909 }
910 String::from_utf8_lossy(&data[start..end]).into_owned()
911}
912
913fn write_guest_bytes(caller: &mut Caller<'_, HostState>, ptr: u32, bytes: &[u8]) {
916 let Some(mem) = caller
917 .get_export("memory")
918 .and_then(wasmi::Extern::into_memory)
919 else {
920 return;
921 };
922 let data = mem.data_mut(caller);
923 let start = ptr as usize;
924 let Some(end) = start.checked_add(bytes.len()) else {
925 return;
926 };
927 if end <= data.len() {
928 data[start..end].copy_from_slice(bytes);
929 }
930}
931
932#[cfg(test)]
934mod fuel_costs;
935
936#[cfg(test)]
937mod tests {
938 use super::*;
939 use pixel8::physics::wire::{FLIP_X, FLIP_Y, HIDDEN, UNWORN};
940
941 const TEST_CART: &str = r#"
943 (module
944 (import "pixel8" "clear" (func $cls (param i32)))
945 (import "pixel8" "set_pixel" (func $pset (param i32 i32 i32)))
946 (import "pixel8" "pixel" (func $pget (param i32 i32) (result i32)))
947 (import "pixel8" "is_button_down" (func $btn (param i32) (result i32)))
948 (import "pixel8" "print" (func $print (param i32 i32 i32 i32 i32) (result i32)))
949 (import "pixel8" "log" (func $log (param i32 i32)))
950 (memory (export "memory") 1)
951 (data (i32.const 16) "hi from cart")
952 (global $x (mut i32) (i32.const 5))
953 (func (export "pixel8_init")
954 (call $log (i32.const 16) (i32.const 12)))
955 (func (export "pixel8_update")
956 (if (i32.ne (call $btn (i32.const 1)) (i32.const 0))
957 (then (global.set $x (i32.add (global.get $x) (i32.const 1))))))
958 (func (export "pixel8_draw")
959 (call $cls (i32.const 1))
960 (call $pset (global.get $x) (i32.const 7) (i32.const 8))
961 (drop (call $print (i32.const 16) (i32.const 2) (i32.const 0) (i32.const 0) (i32.const 7))))
962 )
963 "#;
964
965 const LOOPING_CART: &str = r#"
966 (module
967 (func (export "pixel8_init"))
968 (func (export "pixel8_update") (loop $l (br $l)))
969 (func (export "pixel8_draw"))
970 )
971 "#;
972
973 const STEP_CAST_CART: &str = r#"
980 (module
981 (import "pixel8" "step_cast" (func $step (param i32 i32 i32)))
982 (import "pixel8" "set_pixel" (func $pset (param i32 i32 i32)))
983 (memory (export "memory") 1)
984 (data (i32.const 64)
985 "\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")
986 (func (export "pixel8_init"))
987 (func (export "pixel8_update")
988 (call $step (i32.const 64) (i32.const 1) (i32.const 1)))
989 (func (export "pixel8_draw")
990 (call $pset (i32.const 0) (i32.const 0) (i32.load8_u (i32.const 88)))
991 (call $pset (i32.const 1) (i32.const 0) (i32.load8_u (i32.const 100)))
992 (call $pset (i32.const 2) (i32.const 0) (i32.load8_u (i32.const 102)))
993 (call $pset (i32.const 3) (i32.const 0) (i32.load8_u (i32.const 104))))
994 )
995 "#;
996
997 const FPS30_CART: &str = r#"
998 (module
999 (func (export "pixel8_init"))
1000 (func (export "pixel8_fps") (result i32) (i32.const 30))
1001 (func (export "pixel8_update"))
1002 (func (export "pixel8_draw")))
1003 "#;
1004
1005 const MEM_EXPORT_CART: &str = r#"
1006 (module
1007 (func (export "pixel8_init"))
1008 (func (export "pixel8_update"))
1009 (func (export "pixel8_draw"))
1010 (func (export "pixel8_mem_used") (result i32) (i32.const 32768)))
1011 "#;
1012
1013 const BUDGET_OK_CART: &str = r#"
1015 (module
1016 (func (export "pixel8_init"))
1017 (func (export "pixel8_update")
1018 (local $i i32)
1019 (local.set $i (i32.const 10000))
1020 (loop $l
1021 (local.set $i (i32.add (local.get $i) (i32.const -1)))
1022 (br_if $l (local.get $i))))
1023 (func (export "pixel8_draw")))
1024 "#;
1025
1026 const BUDGET_OVER_CART: &str = r#"
1028 (module
1029 (func (export "pixel8_init"))
1030 (func (export "pixel8_update")
1031 (local $i i32)
1032 (local.set $i (i32.const 100000))
1033 (loop $l
1034 (local.set $i (i32.add (local.get $i) (i32.const -1)))
1035 (br_if $l (local.get $i))))
1036 (func (export "pixel8_draw")))
1037 "#;
1038
1039 const GROW_TO_CAP_CART: &str = r#"
1041 (module
1042 (memory (export "memory") 1)
1043 (func (export "pixel8_init"))
1044 (func (export "pixel8_update") (drop (memory.grow (i32.const 1))))
1045 (func (export "pixel8_draw")))
1046 "#;
1047
1048 const GROW_PAST_CAP_CART: &str = r#"
1050 (module
1051 (memory (export "memory") 1)
1052 (func (export "pixel8_init"))
1053 (func (export "pixel8_update") (drop (memory.grow (i32.const 10))))
1054 (func (export "pixel8_draw")))
1055 "#;
1056
1057 const HUGE_INITIAL_MEMORY_CART: &str = r#"
1060 (module
1061 (memory (export "memory") 3)
1062 (func (export "pixel8_init"))
1063 (func (export "pixel8_update"))
1064 (func (export "pixel8_draw")))
1065 "#;
1066
1067 const PARITY_CART: &str = r#"
1068 (module
1069 (import "pixel8" "ellipse" (func $ovalo (param i32 i32 i32 i32 i32)))
1070 (import "pixel8" "ellipse_fill" (func $oval (param i32 i32 i32 i32 i32)))
1071 (import "pixel8" "set_transparent_color" (func $palt (param i32 i32)))
1072 (import "pixel8" "reset_transparency" (func $paltr))
1073 (import "pixel8" "remap_color" (func $pal (param i32 i32 i32)))
1074 (import "pixel8" "reset_palette" (func $palr))
1075 (import "pixel8" "set_fill_pattern" (func $fillp (param i32 i32 i32)))
1076 (import "pixel8" "set_sprite_pixel" (func $sset (param i32 i32 i32)))
1077 (import "pixel8" "sprite_pixel" (func $sget (param i32 i32) (result i32)))
1078 (import "pixel8" "sprite_stretch"
1079 (func $sspr (param i32 i32 i32 i32 i32 i32 i32 i32 i32 i32)))
1080 (import "pixel8" "seed_rng" (func $srand (param i32)))
1081 (import "pixel8" "set_pen_color" (func $color (param i32)))
1082 (import "pixel8" "set_cursor" (func $cursor (param i32 i32)))
1083 (import "pixel8" "print_pen" (func $printp (param i32 i32) (result i32)))
1084 (import "pixel8" "cpu_update" (func $cpuu (result f32)))
1085 (import "pixel8" "cpu_draw" (func $cpud (result f32)))
1086 (import "pixel8" "fps" (func $fps (result f32)))
1087 (memory (export "memory") 1)
1088 (data (i32.const 0) "hi")
1089 (func (export "pixel8_init"))
1090 (func (export "pixel8_update")
1091 (call $srand (i32.const 42))
1092 (call $sset (i32.const 0) (i32.const 0) (i32.const 9))
1093 (drop (call $sget (i32.const 0) (i32.const 0))))
1094 (func (export "pixel8_draw")
1095 (call $pal (i32.const 8) (i32.const 12) (i32.const 0))
1096 (call $palt (i32.const 0) (i32.const 1))
1097 (call $paltr)
1098 (call $fillp (i32.const 0) (i32.const 0) (i32.const 0))
1099 (call $color (i32.const 7))
1100 (call $cursor (i32.const 0) (i32.const 0))
1101 (drop (call $printp (i32.const 0) (i32.const 2)))
1102 (call $sspr (i32.const 0) (i32.const 0) (i32.const 8) (i32.const 8)
1103 (i32.const 64) (i32.const 0) (i32.const 8) (i32.const 8)
1104 (i32.const 0) (i32.const 0))
1105 (call $ovalo (i32.const 20) (i32.const 20) (i32.const 28) (i32.const 28)
1106 (i32.const 7))
1107 (call $palr)
1108 (call $oval (i32.const 0) (i32.const 0) (i32.const 8) (i32.const 8) (i32.const 8))
1109 (drop (call $cpuu))
1110 (drop (call $cpud))
1111 (drop (call $fps))))
1112 "#;
1113
1114 fn bulky_update_cart(reps: usize) -> String {
1120 let body = "(global.set $n (i32.add (global.get $n) (i32.const 1)))".repeat(reps);
1121 format!(
1122 r#"(module
1123 (global $n (mut i32) (i32.const 0))
1124 (func (export "pixel8_init"))
1125 (func (export "pixel8_update") {body})
1126 (func (export "pixel8_draw")))"#
1127 )
1128 }
1129
1130 fn load_test_vm(wat_src: &str) -> Result<GameVm> {
1131 let wasm = wat::parse_str(wat_src).unwrap();
1132 GameVm::load(
1133 &wasm,
1134 &Assets::default(),
1135 AudioHandle::dummy(),
1136 Storage::default(),
1137 )
1138 }
1139
1140 fn draw_record(rx: i16, ry: i16, sprite: u16, meta: u8, span: u8) -> [u8; RECORD] {
1144 let mut bytes = [0u8; RECORD];
1145 bytes[16..18].copy_from_slice(&rx.to_le_bytes());
1146 bytes[18..20].copy_from_slice(&ry.to_le_bytes());
1147 bytes[36..38].copy_from_slice(&sprite.to_le_bytes());
1148 bytes[40] = meta;
1149 bytes[43] = span;
1150 bytes
1151 }
1152
1153 fn draw_cast_wat(
1157 records: &[[u8; RECORD]],
1158 layers: u32,
1159 camera_xy: Option<(i32, i32)>,
1160 ) -> String {
1161 let data: String = records
1162 .iter()
1163 .flatten()
1164 .map(|b| format!("\\{b:02x}"))
1165 .collect();
1166 let camera_call = match camera_xy {
1167 Some((x, y)) => format!("(call $camera (i32.const {x}) (i32.const {y}))"),
1168 None => String::new(),
1169 };
1170 format!(
1171 r#"(module
1172 (import "pixel8" "draw_cast" (func $draw (param i32 i32 i32)))
1173 (import "pixel8" "camera" (func $camera (param i32 i32)))
1174 (memory (export "memory") 1)
1175 (data (i32.const 64) "{data}")
1176 (func (export "pixel8_init"))
1177 (func (export "pixel8_update"))
1178 (func (export "pixel8_draw")
1179 {camera_call}
1180 (call $draw (i32.const 64) (i32.const {count}) (i32.const {layers}))))"#,
1181 count = records.len(),
1182 )
1183 }
1184
1185 fn load_draw_vm(wat_src: &str, assets: &Assets) -> GameVm {
1188 let wasm = wat::parse_str(wat_src).unwrap();
1189 GameVm::load(&wasm, assets, AudioHandle::dummy(), Storage::default()).unwrap()
1190 }
1191
1192 #[test]
1193 fn parity_imports_link_and_run() {
1194 let mut vm = load_test_vm(PARITY_CART).unwrap();
1195 vm.call_update().unwrap();
1196 vm.call_draw().unwrap();
1197 assert_eq!(vm.state().sprites.get(0, 0), 9);
1199 assert_eq!(vm.state().fb.pget(4, 4), 8, "oval filled the box center");
1201 }
1202
1203 #[test]
1204 fn step_cast_answers_in_place_and_leaves_the_cart_s_bytes_alone() {
1205 let mut vm = load_test_vm(STEP_CAST_CART).unwrap();
1210 for _ in 0..3 {
1211 vm.call_update().unwrap();
1212 }
1213 vm.call_draw().unwrap();
1214 let fb = &vm.state().fb;
1215 assert_eq!(fb.pget(0, 0), 5, "bw was not preserved");
1217 assert_eq!(fb.pget(1, 0), 9, "sprite was not preserved");
1218 assert_eq!(fb.pget(2, 0), 3, "solid was not preserved");
1219 assert_eq!(fb.pget(3, 0), 1, "meta was not preserved");
1220 }
1221
1222 #[test]
1223 fn a_record_draws_its_cell_at_its_drawn_pixel() {
1224 let mut assets = Assets::default();
1225 assets.sprites.set(3, 4, 7); let record = draw_record(50, 60, 0, 0, 0);
1227 let mut vm = load_draw_vm(&draw_cast_wat(&[record], 0, None), &assets);
1228 vm.call_draw().unwrap();
1229 assert_eq!(
1230 vm.state().fb.pget(53, 64),
1231 7,
1232 "the painted pixel landed at rx+3, ry+4"
1233 );
1234 assert_eq!(
1235 vm.state().fb.pget(50, 60),
1236 0,
1237 "an unpainted pixel of the cell stayed clear"
1238 );
1239 }
1240
1241 #[test]
1242 fn flip_x_and_flip_y_mirror_the_cell() {
1243 let mut assets = Assets::default();
1245 assets.sprites.set(8, 0, 8); assets.sprites.set(15, 7, 12); let flipped_x = draw_record(0, 0, 1, FLIP_X, 0);
1249 let mut vm = load_draw_vm(&draw_cast_wat(&[flipped_x], 0, None), &assets);
1250 vm.call_draw().unwrap();
1251 assert_eq!(
1252 vm.state().fb.pget(7, 0),
1253 8,
1254 "FLIP_X moved local (0,0) to the right edge"
1255 );
1256 assert_eq!(
1257 vm.state().fb.pget(0, 7),
1258 12,
1259 "FLIP_X moved local (7,7) to the left edge"
1260 );
1261
1262 let flipped_y = draw_record(0, 0, 1, FLIP_Y, 0);
1263 let mut vm = load_draw_vm(&draw_cast_wat(&[flipped_y], 0, None), &assets);
1264 vm.call_draw().unwrap();
1265 assert_eq!(
1266 vm.state().fb.pget(0, 7),
1267 8,
1268 "FLIP_Y moved local (0,0) to the bottom edge"
1269 );
1270 assert_eq!(
1271 vm.state().fb.pget(7, 0),
1272 12,
1273 "FLIP_Y moved local (7,7) to the top edge"
1274 );
1275 }
1276
1277 #[test]
1278 fn a_span_of_one_extra_column_draws_the_neighbour_to_the_right() {
1279 let mut assets = Assets::default();
1280 assets.sprites.set(8, 0, 6); let record = draw_record(0, 0, 0, 0, 0x01);
1282 let mut vm = load_draw_vm(&draw_cast_wat(&[record], 0, None), &assets);
1283 vm.call_draw().unwrap();
1284 assert_eq!(
1285 vm.state().fb.pget(8, 0),
1286 6,
1287 "the block's second column is cell 1"
1288 );
1289 }
1290
1291 #[test]
1292 fn a_span_of_one_extra_row_draws_the_neighbour_below() {
1293 let mut assets = Assets::default();
1294 assets.sprites.set(0, 8, 6); let record = draw_record(0, 0, 0, 0, 0x10);
1296 let mut vm = load_draw_vm(&draw_cast_wat(&[record], 0, None), &assets);
1297 vm.call_draw().unwrap();
1298 assert_eq!(
1299 vm.state().fb.pget(0, 8),
1300 6,
1301 "the block's second row is cell 16"
1302 );
1303 }
1304
1305 #[test]
1306 fn hidden_and_unworn_records_draw_nothing() {
1307 let mut assets = Assets::default();
1308 assets.sprites.set(0, 0, 9); assets.sprites.set(120, 120, 9);
1314 let hidden = draw_record(10, 10, 0, HIDDEN, 0);
1315 let unworn = draw_record(20, 20, UNWORN, 0, 0);
1316 let mut vm = load_draw_vm(&draw_cast_wat(&[hidden, unworn], 0, None), &assets);
1317 vm.call_draw().unwrap();
1318 assert_eq!(
1319 vm.state().fb.pget(10, 10),
1320 0,
1321 "a hidden record is never drawn"
1322 );
1323 assert_eq!(
1324 vm.state().fb.pget(20, 20),
1325 0,
1326 "an unworn record is never drawn"
1327 );
1328 }
1329
1330 #[test]
1331 fn layers_zero_draws_everything_and_a_mask_only_flagged_cells() {
1332 let mut assets = Assets::default();
1333 assets.sprites.set(0, 0, 4); assets.sprites.set(8, 0, 5); assets.sprites.set_flag(1, 0, true);
1336 let plain = draw_record(0, 0, 0, 0, 0);
1337 let flagged = draw_record(20, 0, 1, 0, 0);
1338
1339 let mut all = load_draw_vm(&draw_cast_wat(&[plain, flagged], 0, None), &assets);
1340 all.call_draw().unwrap();
1341 assert_eq!(
1342 all.state().fb.pget(0, 0),
1343 4,
1344 "layers 0 draws the unflagged cell too"
1345 );
1346 assert_eq!(
1347 all.state().fb.pget(20, 0),
1348 5,
1349 "layers 0 draws the flagged cell too"
1350 );
1351
1352 let mut masked = load_draw_vm(&draw_cast_wat(&[plain, flagged], 1, None), &assets);
1353 masked.call_draw().unwrap();
1354 assert_eq!(
1355 masked.state().fb.pget(0, 0),
1356 0,
1357 "an unflagged cell is never picked by a mask"
1358 );
1359 assert_eq!(
1360 masked.state().fb.pget(20, 0),
1361 5,
1362 "the flagged cell survives the mask"
1363 );
1364 }
1365
1366 #[test]
1367 fn a_later_record_is_drawn_over_an_earlier_one() {
1368 let mut assets = Assets::default();
1369 for py in 0..8 {
1370 for px in 0..8 {
1371 assets.sprites.set(16 + px, py, 5); }
1373 }
1374 assets.sprites.set(24, 0, 9); let under = draw_record(40, 40, 2, 0, 0);
1376 let over = draw_record(40, 40, 3, 0, 0);
1377 let mut vm = load_draw_vm(&draw_cast_wat(&[under, over], 0, None), &assets);
1378 vm.call_draw().unwrap();
1379 assert_eq!(
1380 vm.state().fb.pget(40, 40),
1381 9,
1382 "the later record's opaque pixel wins"
1383 );
1384 assert_eq!(
1385 vm.state().fb.pget(41, 40),
1386 5,
1387 "the earlier record shows through elsewhere"
1388 );
1389 }
1390
1391 #[test]
1392 fn the_camera_offsets_the_whole_cast() {
1393 let mut assets = Assets::default();
1394 assets.sprites.set(0, 0, 6);
1395 let record = draw_record(20, 20, 0, 0, 0);
1396 let mut vm = load_draw_vm(&draw_cast_wat(&[record], 0, Some((5, 5))), &assets);
1397 vm.call_draw().unwrap();
1398 assert_eq!(
1399 vm.state().fb.pget(15, 15),
1400 6,
1401 "the camera shifted the whole block"
1402 );
1403 assert_eq!(
1404 vm.state().fb.pget(20, 20),
1405 0,
1406 "nothing was drawn at the unshifted position"
1407 );
1408 }
1409
1410 #[test]
1411 fn a_malformed_cast_draws_nothing_without_trapping() {
1412 let mut assets = Assets::default();
1415 assets.sprites.set(0, 0, 9);
1416 let data: String = draw_record(10, 10, 0, 0, 0)
1417 .iter()
1418 .map(|b| format!("\\{b:02x}"))
1419 .collect();
1420 let bad_len = format!(
1421 r#"(module
1422 (import "pixel8" "draw_cast" (func $draw (param i32 i32 i32)))
1423 (memory (export "memory") 1)
1424 (data (i32.const 64) "{data}")
1425 (func (export "pixel8_init"))
1426 (func (export "pixel8_update"))
1427 (func (export "pixel8_draw")
1428 (call $draw (i32.const 64) (i32.const 65) (i32.const 0))))"#
1429 );
1430 let mut vm = load_draw_vm(&bad_len, &assets);
1431 assert!(vm.call_draw().is_ok(), "a length past CAP must not trap");
1432 assert_eq!(
1433 vm.state().fb.pget(10, 10),
1434 0,
1435 "a length past CAP must draw nothing, not the records that would fit"
1436 );
1437
1438 let bad_ptr = r#"(module
1441 (import "pixel8" "draw_cast" (func $draw (param i32 i32 i32)))
1442 (memory (export "memory") 1)
1443 (func (export "pixel8_init"))
1444 (func (export "pixel8_update"))
1445 (func (export "pixel8_draw")
1446 (call $draw (i32.const 65500) (i32.const 1) (i32.const 0))))"#;
1447 let mut vm = load_draw_vm(bad_ptr, &Assets::default());
1448 assert!(
1449 vm.call_draw().is_ok(),
1450 "a range off the end of memory must not trap"
1451 );
1452 }
1453
1454 #[test]
1455 fn abi_lifecycle_and_drawing() {
1456 let mut vm = load_test_vm(TEST_CART).unwrap();
1457 assert_eq!(vm.state_mut().logs.pop().as_deref(), Some("hi from cart"));
1458
1459 vm.call_update().unwrap();
1460 vm.call_draw().unwrap();
1461 assert_eq!(vm.state().fb.pget(5, 7), 8, "set_pixel through ABI");
1462 assert_eq!(vm.state().fb.pget(0, 0), 7, "print drew a glyph pixel");
1463
1464 vm.state_mut().input.set_button(1, true);
1466 vm.call_update().unwrap();
1467 vm.call_draw().unwrap();
1468 assert_eq!(
1469 vm.state().fb.pget(6, 7),
1470 8,
1471 "is_button_down(right) moved pixel"
1472 );
1473 }
1474
1475 #[test]
1476 fn default_fps_is_60() {
1477 let vm = load_test_vm(TEST_CART).unwrap();
1480 assert_eq!(vm.fps(), 60);
1481 }
1482
1483 #[test]
1484 fn cart_can_select_30fps() {
1485 let vm = load_test_vm(FPS30_CART).unwrap();
1486 assert_eq!(vm.fps(), 30);
1487 }
1488
1489 #[test]
1490 fn mem_used_reads_export_else_zero() {
1491 let mut vm = load_test_vm(MEM_EXPORT_CART).unwrap();
1493 assert_eq!(vm.mem_used_bytes(), 32768);
1494 let mut vm2 = load_test_vm(TEST_CART).unwrap();
1496 assert_eq!(vm2.mem_used_bytes(), 0);
1497 }
1498
1499 #[test]
1500 fn infinite_loop_is_trapped() {
1501 let mut vm = load_test_vm(LOOPING_CART).unwrap();
1502 let err = vm.call_update().unwrap_err();
1503 assert_eq!(err.phase, "update");
1504 assert!(err.message.contains("ran too long"), "{}", err.message);
1505 }
1506
1507 #[test]
1508 fn missing_exports_is_a_load_error() {
1509 let wasm = wat::parse_str("(module)").unwrap();
1510 let err = match GameVm::load(
1511 &wasm,
1512 &Assets::default(),
1513 AudioHandle::dummy(),
1514 Storage::default(),
1515 ) {
1516 Err(e) => e,
1517 Ok(_) => panic!("empty module should not load"),
1518 };
1519 assert!(err.to_string().contains("pixel8_init"));
1520 }
1521
1522 #[test]
1523 fn unknown_imports_are_rejected() {
1524 let wasm = wat::parse_str(
1525 r#"(module (import "env" "evil" (func))
1526 (func (export "pixel8_init"))
1527 (func (export "pixel8_update"))
1528 (func (export "pixel8_draw")))"#,
1529 )
1530 .unwrap();
1531 assert!(GameVm::load(
1532 &wasm,
1533 &Assets::default(),
1534 AudioHandle::dummy(),
1535 Storage::default()
1536 )
1537 .is_err());
1538 }
1539
1540 #[test]
1541 fn fuel_budget_allows_modest_work() {
1542 let mut vm = load_test_vm(BUDGET_OK_CART).unwrap();
1543 assert!(
1544 vm.call_update().is_ok(),
1545 "10k-iteration frame must fit the 128K-fuel budget"
1546 );
1547 }
1548
1549 #[test]
1550 fn fuel_budget_traps_runaway_work() {
1551 let mut vm = load_test_vm(BUDGET_OVER_CART).unwrap();
1552 let err = vm.call_update().unwrap_err();
1553 assert!(err.message.contains("ran too long"), "got: {}", err.message);
1554 }
1555
1556 #[test]
1557 fn oversized_function_body_still_runs() {
1558 let mut vm = load_test_vm(&bulky_update_cart(12_000)).unwrap();
1565 assert!(
1566 vm.call_update().is_ok(),
1567 "an 84 KB function body must be callable, not billed as a runaway loop"
1568 );
1569 let spent = vm.cpu_update() * FUEL_PER_CALL as f32;
1572 assert!(
1573 spent > 12_000.0,
1574 "all 12 K repetitions should have run, but the frame cost {spent} fuel"
1575 );
1576 }
1577
1578 #[test]
1579 fn first_frame_costs_the_same_as_later_frames() {
1580 let mut vm = load_test_vm(&bulky_update_cart(1_400)).unwrap();
1586 vm.call_update().unwrap();
1587 let first = vm.cpu_update();
1588 vm.call_update().unwrap();
1589 let steady = vm.cpu_update();
1590 assert!(steady > 0.0, "the frame has to cost something to compare");
1591 assert!(
1592 (first - steady).abs() < 1e-6,
1593 "frame 0 used {first} of the budget against a steady state of {steady}"
1594 );
1595 }
1596
1597 #[test]
1598 fn eager_translation_of_a_cart_sized_module_is_quick() {
1599 let wasm = wat::parse_str(bulky_update_cart(128 * 1024 / 7).as_str()).unwrap();
1605 assert!(wasm.len() >= 128 * 1024, "the probe fills the cart cap");
1606 let started = std::time::Instant::now();
1607 let vm = GameVm::load(
1608 &wasm,
1609 &Assets::default(),
1610 AudioHandle::dummy(),
1611 Storage::default(),
1612 );
1613 let elapsed = started.elapsed();
1614 assert!(vm.is_ok(), "a cart-sized module must load");
1615 assert!(
1616 elapsed < std::time::Duration::from_secs(5),
1617 "translating a cart-sized module took {elapsed:?}"
1618 );
1619 }
1620
1621 #[test]
1622 fn bad_cart_bytes_are_a_friendly_load_error() {
1623 let valid = wat::parse_str(TEST_CART).unwrap();
1627 let cases = [
1628 ("not wasm at all", b"definitely not a wasm module".to_vec()),
1629 ("a module cut in half", valid[..valid.len() / 2].to_vec()),
1630 (
1631 "a module missing its last byte",
1632 valid[..valid.len() - 1].to_vec(),
1633 ),
1634 ];
1635 for (name, bytes) in cases {
1636 let err = match GameVm::load(
1637 &bytes,
1638 &Assets::default(),
1639 AudioHandle::dummy(),
1640 Storage::default(),
1641 ) {
1642 Err(e) => e,
1643 Ok(_) => panic!("{name} should not load"),
1644 };
1645 assert!(
1646 err.to_string().contains("Invalid cart wasm"),
1647 "{name}: got {err}"
1648 );
1649 }
1650 }
1651
1652 #[test]
1653 fn memory_growth_up_to_cap_is_allowed() {
1654 let mut vm = load_test_vm(GROW_TO_CAP_CART).unwrap();
1655 assert!(
1656 vm.call_update().is_ok(),
1657 "growing to exactly 128 K must succeed"
1658 );
1659 }
1660
1661 #[test]
1662 fn memory_growth_past_cap_is_a_friendly_error() {
1663 let mut vm = load_test_vm(GROW_PAST_CAP_CART).unwrap();
1664 let err = vm.call_update().unwrap_err();
1665 assert!(
1666 err.message.contains("out of memory"),
1667 "got: {}",
1668 err.message
1669 );
1670 }
1671
1672 #[test]
1673 fn oversized_initial_memory_is_rejected_at_load() {
1674 let wasm = wat::parse_str(HUGE_INITIAL_MEMORY_CART).unwrap();
1675 let err = match GameVm::load(
1676 &wasm,
1677 &Assets::default(),
1678 AudioHandle::dummy(),
1679 Storage::default(),
1680 ) {
1681 Err(e) => e,
1682 Ok(_) => panic!("oversized cart should not load"),
1683 };
1684 assert!(err.to_string().contains("128K of memory"), "got: {err}");
1685 }
1686
1687 #[test]
1688 fn reports_cpu_usage_per_phase() {
1689 let mut vm = load_test_vm(BUDGET_OK_CART).unwrap();
1692 vm.call_update().unwrap();
1693 vm.call_draw().unwrap();
1694 let u = vm.cpu_update();
1695 let d = vm.cpu_draw();
1696 assert!(u > 0.0 && u < 1.0, "update cpu fraction in range: {u}");
1697 assert!(u > d, "heavy update beats empty draw: {u} vs {d}");
1698 }
1699
1700 #[test]
1701 fn reports_memory_usage() {
1702 let vm = load_test_vm(TEST_CART).unwrap();
1704 let frac = vm.memory_used_fraction();
1705 assert!(
1706 (frac - 0.5).abs() < 0.01,
1707 "one page is half the cap: {frac}"
1708 );
1709 }
1710
1711 const STORAGE_CART: &str = r#"
1718 (module
1719 (import "pixel8" "storage_set" (func $sset (param i32 i32 i32 i32) (result i32)))
1720 (import "pixel8" "storage_get" (func $sget (param i32 i32 i32 i32) (result i32)))
1721 (import "pixel8" "storage_remove" (func $srem (param i32 i32) (result i32)))
1722 (import "pixel8" "storage_clear" (func $sclr))
1723 (import "pixel8" "set_pixel" (func $pset (param i32 i32 i32)))
1724 (memory (export "memory") 1)
1725 (data (i32.const 0) "score")
1726 (data (i32.const 8) "42")
1727 (data (i32.const 16) "gone")
1728 (data (i32.const 24) "tmp")
1729 (data (i32.const 28) "1")
1730 (data (i32.const 63) "\05")
1731 (func (export "pixel8_init")
1732 ;; Removing an existing key returns 1 -> (2,0) = 5.
1733 (drop (call $sset (i32.const 24) (i32.const 3) (i32.const 28) (i32.const 1)))
1734 (if (i32.eq (call $srem (i32.const 24) (i32.const 3)) (i32.const 1))
1735 (then (call $pset (i32.const 2) (i32.const 0) (i32.const 5))))
1736 ;; Re-add "tmp", wipe everything, then store the real value.
1737 (drop (call $sset (i32.const 24) (i32.const 3) (i32.const 28) (i32.const 1)))
1738 (call $sclr)
1739 (drop (call $sset (i32.const 0) (i32.const 5) (i32.const 8) (i32.const 2))))
1740 (func (export "pixel8_update"))
1741 (func (export "pixel8_draw")
1742 ;; (0,0) = the JSON length of the "score" value (2).
1743 (call $pset (i32.const 0) (i32.const 0)
1744 (call $sget (i32.const 0) (i32.const 5) (i32.const 64) (i32.const 16)))
1745 ;; A key never stored returns -1 -> (1,0) = 7.
1746 (if (i32.eq (call $sget (i32.const 16) (i32.const 4) (i32.const 64) (i32.const 16))
1747 (i32.const -1))
1748 (then (call $pset (i32.const 1) (i32.const 0) (i32.const 7))))
1749 ;; "tmp" was wiped by storage_clear -> (3,0) = 7.
1750 (if (i32.eq (call $sget (i32.const 24) (i32.const 3) (i32.const 64) (i32.const 16))
1751 (i32.const -1))
1752 (then (call $pset (i32.const 3) (i32.const 0) (i32.const 7))))
1753 ;; Cap 0 still reports the length -> (4,0) = 2.
1754 (call $pset (i32.const 4) (i32.const 0)
1755 (call $sget (i32.const 0) (i32.const 5) (i32.const 64) (i32.const 0)))
1756 ;; A too-small buffer gets nothing written: the sentinel byte at
1757 ;; 63 survives a cap-1 read of the 2-byte value -> (5,0) = 5.
1758 (drop (call $sget (i32.const 0) (i32.const 5) (i32.const 63) (i32.const 1)))
1759 (call $pset (i32.const 5) (i32.const 0) (i32.load8_u (i32.const 63)))
1760 ;; An exactly-sized buffer is filled: "42" lands at 80..82 -> (6,0) = 7.
1761 (drop (call $sget (i32.const 0) (i32.const 5) (i32.const 80) (i32.const 2)))
1762 (if (i32.and
1763 (i32.eq (i32.load8_u (i32.const 80)) (i32.const 52))
1764 (i32.eq (i32.load8_u (i32.const 81)) (i32.const 50)))
1765 (then (call $pset (i32.const 6) (i32.const 0) (i32.const 7))))))
1766 "#;
1767
1768 #[test]
1769 fn storage_abi_set_get_remove_clear() {
1770 let mut vm = load_test_vm(STORAGE_CART).unwrap();
1771 vm.call_update().unwrap();
1772 vm.call_draw().unwrap();
1773 assert_eq!(vm.state().storage.get_json("score").as_deref(), Some("42"));
1776 assert_eq!(vm.state().storage.get_json("tmp"), None);
1777 let px = |x| vm.state().fb.pget(x, 0);
1778 assert_eq!(px(0), 2, "storage_get returned the value length");
1779 assert_eq!(px(1), 7, "missing key returned -1");
1780 assert_eq!(px(2), 5, "removing an existing key returned 1");
1781 assert_eq!(px(3), 7, "storage_clear wiped the store");
1782 assert_eq!(px(4), 2, "cap-0 call sized the read");
1783 assert_eq!(px(5), 5, "too-small buffer left guest memory untouched");
1784 assert_eq!(px(6), 7, "exact-fit buffer was filled");
1785 }
1786
1787 #[test]
1788 fn storage_persists_across_vm_loads() {
1789 let path =
1790 std::env::temp_dir().join(format!("pixel8_vm_storage_{}.json", std::process::id()));
1791 let _ = std::fs::remove_file(&path);
1792 let wasm = wat::parse_str(STORAGE_CART).unwrap();
1793 {
1794 let _vm = GameVm::load(
1795 &wasm,
1796 &Assets::default(),
1797 AudioHandle::dummy(),
1798 Storage::at_path(path.clone()),
1799 )
1800 .unwrap();
1801 }
1803 let reloaded = Storage::at_path(path.clone());
1804 assert_eq!(reloaded.get_json("score").as_deref(), Some("42"));
1805 std::fs::remove_file(&path).unwrap();
1806 }
1807
1808 const FPS_FROM_INIT_CART: &str = r#"
1811 (module
1812 (global $rate (mut i32) (i32.const 0))
1813 (func (export "pixel8_init") (global.set $rate (i32.const 30)))
1814 (func (export "pixel8_fps") (result i32) (global.get $rate))
1815 (func (export "pixel8_update"))
1816 (func (export "pixel8_draw")))
1817 "#;
1818
1819 #[test]
1820 fn a_carts_rate_may_be_worked_out_by_its_init() {
1821 let vm = load_test_vm(FPS_FROM_INIT_CART).unwrap();
1825 assert_eq!(vm.state().measured_fps_or_target(), 30.0);
1826 }
1827
1828 #[test]
1829 fn fps_falls_back_to_target_until_measured() {
1830 let mut vm = load_test_vm(FPS30_CART).unwrap();
1832 assert_eq!(vm.state().measured_fps_or_target(), 30.0);
1833 vm.state_mut().set_measured_fps(58.0);
1835 assert_eq!(vm.state().measured_fps_or_target(), 58.0);
1836 }
1837}