Skip to main content

pixel8_runtime/
vm.rs

1//! WASM game execution: sandbox, host ABI, and lifecycle calls.
2//!
3//! Carts are `wasm32-unknown-unknown` modules executed with wasmi. The
4//! only way a cart can touch the outside world is through the small,
5//! C-like import set in the `"pixel8"` module — no WASI, no filesystem,
6//! no network. Fuel metering keeps runaway loops from hanging the
7//! console; they surface as a friendly error screen instead.
8
9use 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
22/// A cart's logical frames per second when it doesn't say otherwise.
23pub const DEFAULT_FPS: u32 = 60;
24
25/// The console's own tick rate: editors, menus and cart pickers. Independent
26/// of the cart rate, which the cart chooses via `pixel8_fps`.
27pub const UI_FPS: u32 = 30;
28
29/// Fuel budget for a single lifecycle call. wasmi charges ~1 fuel per
30/// instruction, so this is a hard cap of 131,072 (128 K) wasm instructions
31/// per call — one number shared with the memory and cart-size limits. A real
32/// frame uses a few thousand; exceeding this means the cart is stuck or doing
33/// far too much, and surfaces as a friendly error screen.
34const FUEL_PER_CALL: u64 = 131_072;
35
36/// Hard cap on a cart's total linear memory: 128 K, the same number as the
37/// fuel and cart-size limits. Covers static data, the shadow stack and the
38/// heap together (wasm cannot separate them). Carts default to a 32 KiB stack
39/// reserve (set per-cart in `.cargo/config.toml`), leaving up to ~96 KiB for
40/// static data and heap above it; carts may tune it.
41const MAX_MEMORY: usize = crate::cart::MEMORY_CAP;
42
43/// A loaded, running cart.
44pub 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    /// Load a cart module, wire up the ABI, and run `pixel8_init`.
61    ///
62    /// `storage` is the cart's persistent key-value store, loaded before
63    /// `pixel8_init` runs so the cart can read its save data from the first
64    /// frame. Frontends without persistence pass `Storage::default()`.
65    pub fn load(
66        wasm: &[u8],
67        assets: &Assets,
68        audio: AudioHandle,
69        storage: Storage,
70    ) -> Result<Self> {
71        // The single chokepoint every frontend runs a cart through: the
72        // desktop console, the standalone player, the web player and headless
73        // verify all land here. Reject mis-sized asset bundles before they
74        // reach the renderer, regardless of where the cart came from (a PNG
75        // cart, an on-disk project, or a hand-built module).
76        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            // MAX_BYTES caps the whole store at 128 K, so the length
423            // always fits an i32.
424            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    /// Read the cart's `pixel8_fps` export. The SDK emits it from every cart;
476    /// 30 and 60 are honored, and anything else (or a hand-written cart with
477    /// no such export) falls back to the default.
478    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    /// Run one logical frame: tick input, call `pixel8_update`.
528    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    /// Call `pixel8_draw`.
536    pub fn call_draw(&mut self) -> std::result::Result<(), RuntimeError> {
537        self.call("draw", self.draw)
538    }
539
540    /// The cart's logical frame rate: 30, or 60 if it opted in.
541    pub fn fps(&self) -> u32 {
542        self.store.data().fps
543    }
544
545    /// Fraction (0.0..1.0) of `update`'s fuel budget used last completed frame.
546    pub fn cpu_update(&self) -> f32 {
547        self.store.data().last_update_cpu
548    }
549
550    /// Fraction (0.0..1.0) of `draw`'s fuel budget used last completed frame.
551    pub fn cpu_draw(&self) -> f32 {
552        self.store.data().last_draw_cpu
553    }
554
555    /// Fraction (0.0..1.0) of the 128K memory cap currently in use.
556    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    /// The cart's committed-memory high-water in bytes (shadow-stack reserve +
564    /// statics + the highest the heap has reached), via its `pixel8_mem_used`
565    /// export, or 0 for carts without it (hand-written or allocation-free).
566    /// Tracks real pressure closely but is not an exact OOM line — the
567    /// allocator keeps a small reserve above the last allocation.
568    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
588/// Everything the host exposes to a running cart.
589pub struct HostState {
590    pub fb: Framebuffer,
591    pub input: InputState,
592    pub sprites: SpriteSheet,
593    pub map: MapData,
594    pub audio: AudioHandle,
595    /// The cart's persistent key-value store (the save file). The frontend
596    /// decides the backing: a cache-dir JSON file on the desktop console and
597    /// player, in-memory in the browser and headless `verify`.
598    pub storage: Storage,
599    /// Messages from the cart's `log` calls, drained by the console.
600    pub logs: Vec<String>,
601    /// Message from the cart's panic hook, captured just before the trap.
602    pub panic_message: Option<String>,
603    pub frame: u64,
604    /// The cart's logical frames per second (30 or 60), from its `pixel8_fps`
605    /// export. Drives `time()` and the host's update/draw cadence.
606    pub fps: u32,
607    /// Fraction (0.0..1.0) of `update`'s fuel budget used last completed frame.
608    last_update_cpu: f32,
609    /// Fraction (0.0..1.0) of `draw`'s fuel budget used last completed frame.
610    last_draw_cpu: f32,
611    /// Real frames per second measured by the host frontend; `0.0` until fed.
612    measured_fps: f32,
613    rng: u64,
614    /// Enforces `MAX_MEMORY` on linear-memory growth, including the initial
615    /// allocation at instantiation.
616    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        // xorshift64*; carts that need determinism can bring their own RNG.
645        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    /// Feed the host frontend's measured frame rate, surfaced to carts via `fps`.
655    pub fn set_measured_fps(&mut self, fps: f32) {
656        self.measured_fps = fps;
657    }
658
659    /// The measured frame rate, or the cart's target rate until a frontend
660    /// measures one. Keeps `fps()` sane on frontends that never measure.
661    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        // Force a nonzero xorshift state; all-zero is a fixed point.
671        self.rng = (((seed as u64) << 32) | (seed as u64)) | 1;
672    }
673}
674
675/// A cart-side runtime error, formatted for the error screen.
676#[derive(Debug, Clone)]
677pub struct RuntimeError {
678    /// Which lifecycle call failed: "init", "update" or "draw".
679    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
705/// Copy `bytes` into guest memory at `ptr`. Writes nothing when the
706/// destination range does not fit the guest's linear memory.
707fn 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    /// A minimal hand-written cart exercising the ABI from WAT.
729    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    /// Update loops ~10k times — well under the 131,072-fuel budget.
777    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    /// Update loops ~100k times — comfortably over the 131,072-fuel budget.
790    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    /// 1-page initial + grow by 1 page = 2 pages = exactly the 128 K cap (allowed).
803    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    /// Update grows linear memory far past the 128 K cap (denied -> trap).
812    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    /// Declares 3 pages (192 KiB) of initial memory — over the 128 K cap, so it
821    /// is denied at instantiation before the cart ever runs.
822    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        // sset wrote sprite-sheet pixel (0,0) = 9.
893        assert_eq!(vm.state().sprites.get(0, 0), 9);
894        // ellipse_fill drew color 8 after reset_palette, so no remap applies.
895        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        // Hold right; update should move the pixel.
909        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        // A cart with no pixel8_fps export (e.g. hand-written WAT) takes the
922        // default rate.
923        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        // A cart exporting pixel8_mem_used reports that many bytes used.
936        let mut vm = load_test_vm(MEM_EXPORT_CART).unwrap();
937        assert_eq!(vm.mem_used_bytes(), 32768);
938        // A cart without the export reports 0 (hand-written / allocation-free).
939        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        // BUDGET_OK_CART loops ~10k times in update and has an empty draw, so
1038        // the update phase must report a higher CPU fraction than draw.
1039        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        // TEST_CART declares one 64 KiB page of the 128 KiB cap.
1051        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    /// Exercises all four storage imports from a cart. Init proves a
1060    /// checked remove (pixel (2,0)), wipes the store with `storage_clear`,
1061    /// and leaves `"score" = 42` behind. Draw probes every `storage_get`
1062    /// branch: value length at (0,0), missing key at (1,0), cleared key at
1063    /// (3,0), cap-0 size query at (4,0), too-small buffer leaving memory
1064    /// untouched at (5,0), and an exact-fit write at (6,0).
1065    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        // The host sees what the cart stored, as canonical JSON — and only
1122        // that: storage_clear wiped the earlier "tmp" key.
1123        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            // Dropping the VM drops (and saves) the storage.
1150        }
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        // No frontend measurement yet: report the cart's target rate (30).
1159        let mut vm = load_test_vm(FPS30_CART).unwrap();
1160        assert_eq!(vm.state().measured_fps_or_target(), 30.0);
1161        // Once a frontend feeds a real rate, report that.
1162        vm.state_mut().set_measured_fps(58.0);
1163        assert_eq!(vm.state().measured_fps_or_target(), 58.0);
1164    }
1165}