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 pixel8::physics::wire::{Record, CAP, EMPTY, RECORD};
18use wasmi::{
19    Caller, Config, Engine, Instance, Linker, Module, OperatorCost, Store, StoreLimits,
20    StoreLimitsBuilder, TypedFunc,
21};
22
23/// A cart's logical frames per second when it doesn't say otherwise.
24pub const DEFAULT_FPS: u32 = 60;
25
26/// The console's own tick rate: editors, menus and cart pickers. Independent
27/// of the cart rate, which the cart chooses via `pixel8_fps`.
28pub const UI_FPS: u32 = 30;
29
30/// Fuel budget for a single lifecycle call: a hard cap of 131,072 (128 K)
31/// wasm instructions per call — one number shared with the memory and
32/// cart-size limits. What counts as an instruction is set by
33/// [`GameVm::operator_cost`]. A real frame uses a few thousand; exceeding this
34/// means the cart is stuck or doing far too much, and surfaces as a friendly
35/// error screen.
36const FUEL_PER_CALL: u64 = 131_072;
37
38/// Hard cap on a cart's total linear memory: 128 K, the same number as the
39/// fuel and cart-size limits. Covers static data, the shadow stack and the
40/// heap together (wasm cannot separate them). Carts default to a 32 KiB stack
41/// reserve (set per-cart in `.cargo/config.toml`), leaving up to ~96 KiB for
42/// static data and heap above it; carts may tune it.
43const MAX_MEMORY: usize = crate::cart::MEMORY_CAP;
44
45/// A loaded, running cart.
46pub 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    /// Load a cart module, wire up the ABI, and run `pixel8_init`.
63    ///
64    /// `storage` is the cart's persistent key-value store, loaded before
65    /// `pixel8_init` runs so the cart can read its save data from the first
66    /// frame. Frontends without persistence pass `Storage::default()`.
67    pub fn load(
68        wasm: &[u8],
69        assets: &Assets,
70        audio: AudioHandle,
71        storage: Storage,
72    ) -> Result<Self> {
73        // The single chokepoint every frontend runs a cart through: the
74        // desktop console, the standalone player, the web player and headless
75        // verify all land here. Reject mis-sized asset bundles before they
76        // reach the renderer, regardless of where the cart came from (a PNG
77        // cart, an on-disk project, or a hand-built module).
78        crate::assets::validate(assets)?;
79
80        let mut config = Config::default();
81        config.consume_fuel(true);
82        // Translate every function body up front. wasmi's default lazy mode defers
83        // translation to a function's first call and bills it to whatever fuel budget
84        // happens to be armed then — so the cart pays for the console's compiler out of
85        // its frame budget, frame 0 spikes, and a function body over ~18 KiB can never
86        // be entered at all (it exhausts the budget before executing an instruction, and
87        // surfaces as the bogus "ran too long (infinite loop?)" screen). Eager
88        // translation happens in `Module::new` below, which has no store and so is not
89        // fuel-metered. That moves the cost to load time, where it is bounded by the
90        // 128 K cart-size cap: a module filling that cap translates in about a
91        // millisecond in a release build, well inside one frame, so there is nothing
92        // here for a hostile cart to stretch.
93        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            // MAX_BYTES caps the whole store at 128 K, so the length
452            // always fits an i32.
453            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    /// Read the cart's `pixel8_fps` export. The SDK emits it from every cart;
505    /// 30 and 60 are honored, and anything else (or a hand-written cart with
506    /// no such export) falls back to the default.
507    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    /// Run one logical frame: tick input, call `pixel8_update`.
557    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    /// Call `pixel8_draw`.
565    pub fn call_draw(&mut self) -> std::result::Result<(), RuntimeError> {
566        self.call("draw", self.draw)
567    }
568
569    /// The cart's logical frame rate: 30, or 60 if it opted in.
570    pub fn fps(&self) -> u32 {
571        self.store.data().fps
572    }
573
574    /// Fraction (0.0..1.0) of `update`'s fuel budget used last completed frame.
575    pub fn cpu_update(&self) -> f32 {
576        self.store.data().last_update_cpu
577    }
578
579    /// Fraction (0.0..1.0) of `draw`'s fuel budget used last completed frame.
580    pub fn cpu_draw(&self) -> f32 {
581        self.store.data().last_draw_cpu
582    }
583
584    /// Fraction (0.0..1.0) of the 128K memory cap currently in use.
585    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    /// The cart's committed-memory high-water in bytes (shadow-stack reserve +
593    /// statics + the highest the heap has reached), via its `pixel8_mem_used`
594    /// export, or 0 for carts without it (hand-written or allocation-free).
595    /// Tracks real pressure closely but is not an exact OOM line — the
596    /// allocator keeps a small reserve above the last allocation.
597    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    /// What one unit of fuel buys, as a price per wasm operator.
617    ///
618    /// wasmi meters the cart's input operators, and by default charges one
619    /// fuel for every one of them except a few free structural markers
620    /// (`block`, `loop`, `end`, `nop`...). Counted that way, reading a local
621    /// costs the same as a multiply, so a loop like `acc = acc * 31 + i`
622    /// costs 18 fuel an iteration for 6 operations of real work, and a host
623    /// call with ten arguments costs eleven times a call with none.
624    ///
625    /// wasmi 0.51, which this budget was calibrated against, was a register
626    /// machine and priced translated instructions instead. Locals and
627    /// constants were operands there, not instructions, and cost nothing;
628    /// that loop cost 7 fuel, and every host call one. Zero-pricing the same
629    /// operand plumbing here keeps "one fuel, one instruction" true, and with
630    /// it the stress cart's trip point and every figure in `docs/LIMITS.md`.
631    ///
632    /// Nothing that does work or repeats work is free: arithmetic, memory
633    /// access, globals, `select`, every branch and every call still cost one,
634    /// so a runaway loop cannot iterate without spending fuel.
635    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
649/// Everything the host exposes to a running cart.
650pub struct HostState {
651    pub fb: Framebuffer,
652    pub input: InputState,
653    pub sprites: SpriteSheet,
654    pub map: MapData,
655    pub audio: AudioHandle,
656    /// The cart's persistent key-value store (the save file). The frontend
657    /// decides the backing: a cache-dir JSON file on the desktop console and
658    /// player, in-memory in the browser and headless `verify`.
659    pub storage: Storage,
660    /// Messages from the cart's `log` calls, drained by the console.
661    pub logs: Vec<String>,
662    /// Message from the cart's panic hook, captured just before the trap.
663    pub panic_message: Option<String>,
664    pub frame: u64,
665    /// The cart's logical frames per second (30 or 60), from its `pixel8_fps`
666    /// export. Drives `time()` and the host's update/draw cadence.
667    pub fps: u32,
668    /// Fraction (0.0..1.0) of `update`'s fuel budget used last completed frame.
669    last_update_cpu: f32,
670    /// Fraction (0.0..1.0) of `draw`'s fuel budget used last completed frame.
671    last_draw_cpu: f32,
672    /// Real frames per second measured by the host frontend; `0.0` until fed.
673    measured_fps: f32,
674    rng: u64,
675    /// Enforces `MAX_MEMORY` on linear-memory growth, including the initial
676    /// allocation at instantiation.
677    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        // xorshift64*; carts that need determinism can bring their own RNG.
706        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    /// Feed the host frontend's measured frame rate, surfaced to carts via `fps`.
716    pub fn set_measured_fps(&mut self, fps: f32) {
717        self.measured_fps = fps;
718    }
719
720    /// The measured frame rate, or the cart's target rate until a frontend
721    /// measures one. Keeps `fps()` sane on frontends that never measure.
722    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        // Force a nonzero xorshift state; all-zero is a fixed point.
732        self.rng = (((seed as u64) << 32) | (seed as u64)) | 1;
733    }
734}
735
736/// A cart-side runtime error, formatted for the error screen.
737#[derive(Debug, Clone)]
738pub struct RuntimeError {
739    /// Which lifecycle call failed: "init", "update" or "draw".
740    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
750/// The console's half of the SDK's `World::step` — the `step_cast` import.
751///
752/// The cast arrives as `pixel8::physics::wire` records in cart memory. The engine that steps them
753/// is the SDK's own — this crate depends on the SDK precisely so the two sides of the wire are one
754/// implementation — run here natively, with the console's map and sprite sheet bound in directly
755/// instead of through a host call apiece. The answers go back into the same records.
756///
757/// Anything malformed — a range off the end of cart memory, a length past the wire's capacity —
758/// steps nothing, exactly as the rest of the ABI shrugs off bad arguments rather than trapping.
759fn 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        // Every one of the eight bits names a flag, so the sheet's byte always converts.
774        let flags = |sprite: u32| {
775            BitFlags::<SpriteFlag>::from_bits(state.sprites.flags(sprite))
776                .expect("all eight sprite-flag bits are flags")
777        };
778        // The SDK's own reading of the map, natively: off the map is nothing, and an on-map tile
779        // answers with its cell's flags — cell 0 included, exactly as `Context::map_tile` has it.
780        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        // A world of no seats of its own: the cast is the one handed in, decoded out of cart
791        // memory, so the host's world is nothing but the two words of configuration below.
792        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    // The answers go back into the very bytes each record arrived in, and only the answers:
806    // everything the cart wrote and the step never decides — bounds, confines, flags — must come
807    // back exactly as it went, for a raw ABI caller that reuses its buffer as much as for the
808    // SDK. `Record::write` touches nothing but the output fields.
809    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
830/// The console's half of the SDK's `World::draw` — the `draw_cast` import.
831///
832/// The very same records `step_cast` answers into, read the same way and never written back — a
833/// draw has nothing to report. What each one shows, and in which order, is the SDK's own
834/// `wire::looks`; every look it walks over goes through the very blit the `sprite` import uses, so
835/// the camera, the clip, the transparency and the draw palette hold for a cast exactly as they do
836/// for a sprite drawn by hand.
837///
838/// Anything malformed draws nothing, exactly as `step_cast` steps nothing.
839fn 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    // Every one of the eight bits names a flag, so the raw mask always converts — `map` takes its
846    // layers the very same way.
847    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
870/// The records out of cart memory, decoded: the read half `step_the_cast` and `draw_the_cast`
871/// share, so what counts as a malformed call can never differ between the two imports.
872///
873/// `None` for a length past `CAP`, a missing `memory` export, or a range off the end of it —
874/// nothing rather than a trap, exactly as the rest of the ABI answers a malformed call.
875fn 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
913/// Copy `bytes` into guest memory at `ptr`. Writes nothing when the
914/// destination range does not fit the guest's linear memory.
915fn 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/// What the frame budget buys, pinned against the figures `docs/LIMITS.md` quotes.
933#[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    /// A minimal hand-written cart exercising the ABI from WAT.
942    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    /// A raw ABI client of `step_cast`: one record at address 64, a prop with distinctive bytes
974    /// in the input-only fields, stepped every update. `draw` copies four of those bytes into the
975    /// framebuffer's top row, which is how the test reads them back out.
976    ///
977    /// The record: 24 zero bytes (position, velocity, drawn pixel, bounds corner), then bw = 5 at
978    /// offset 24, zeros to sprite = 9 at 36, solid = 3 at 38, heeds = 0, meta = PROP at 40.
979    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    /// Update loops ~10k times — well under the 131,072-fuel budget.
1014    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    /// Update loops ~100k times — comfortably over the 131,072-fuel budget.
1027    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    /// 1-page initial + grow by 1 page = 2 pages = exactly the 128 K cap (allowed).
1040    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    /// Update grows linear memory far past the 128 K cap (denied -> trap).
1049    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    /// Declares 3 pages (192 KiB) of initial memory — over the 128 K cap, so it
1058    /// is denied at instantiation before the cart ever runs.
1059    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    /// A cart whose `pixel8_update` body is `reps` copies of a counter bump: a knob
1115    /// for the size of a function body in BYTES, which is what the lazy translator
1116    /// bills for. The bump survives translation — a folded-away no-op would leave
1117    /// the body costing nothing to run and the tests below measuring nothing — while
1118    /// staying far cheaper per byte than the 7 fuel translating it would cost.
1119    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    /// One record's wire bytes, touching only what a draw reads — `rx`/`ry` at 16/18 (`i16` LE),
1141    /// `sprite` at 36 (`u16` LE), `meta` at 40, `span` at 43 — and zero everywhere else, the way
1142    /// a raw client that only ever sets a member's look would leave it.
1143    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    /// A raw ABI client of `draw_cast`: `records`' bytes concatenated into one data segment at
1154    /// address 64, drawn every frame with `layers`. `camera_xy`, given, is set with one `camera`
1155    /// call right before the draw.
1156    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    /// Like [`load_test_vm`], but with a caller-supplied `Assets` (a painted or flagged sheet)
1186    /// instead of the all-blank default.
1187    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        // sset wrote sprite-sheet pixel (0,0) = 9.
1198        assert_eq!(vm.state().sprites.get(0, 0), 9);
1199        // ellipse_fill drew color 8 after reset_palette, so no remap applies.
1200        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        // The wire contract: only the answers — body, velocity, contacts — are written back, and
1206        // every input-only byte comes back exactly as the cart wrote it. A raw client that reuses
1207        // its buffer across updates depends on that; zeroing the inputs would turn its entity
1208        // into a zero-sized, unworn nothing on the second call.
1209        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        // The bytes draw copied out: bw at offset 24, sprite at 36, solid at 38, meta at 40.
1216        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); // cell 0's local (3, 4)
1226        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        // An asymmetric cell: only diagonal corners painted, so a flip is unmistakable.
1244        let mut assets = Assets::default();
1245        assets.sprites.set(8, 0, 8); // cell 1's local (0, 0)
1246        assets.sprites.set(15, 7, 12); // cell 1's local (7, 7)
1247
1248        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); // cell 1's local (0, 0), immediately right of cell 0
1281        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); // cell 16's local (0, 0), immediately below cell 0
1295        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); // cell 0's local (0, 0): would be visible if drawn
1309                                     // And the cell an unworn record's `0xFFFF` would wrap to, were
1310                                     // it ever taken for a cell:
1311                                     // the blit reads its index modulo the sheet, which lands on
1312                                     // cell 255.
1313        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); // cell 0: unflagged
1334        assets.sprites.set(8, 0, 5); // cell 1: flagged
1335        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); // cell 2: solid color 5
1372            }
1373        }
1374        assets.sprites.set(24, 0, 9); // cell 3's local (0, 0); the rest of cell 3 stays 0
1375        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        // A drawable record leads the buffer, so a host that clamped the length instead of
1413        // refusing it would draw it.
1414        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        // 1 page = 65536 bytes; a record is 44, so a pointer with fewer than 44 bytes left is a
1439        // range off the end of memory.
1440        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        // Hold right; update should move the pixel.
1465        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        // A cart with no pixel8_fps export (e.g. hand-written WAT) takes the
1478        // default rate.
1479        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        // A cart exporting pixel8_mem_used reports that many bytes used.
1492        let mut vm = load_test_vm(MEM_EXPORT_CART).unwrap();
1493        assert_eq!(vm.mem_used_bytes(), 32768);
1494        // A cart without the export reports 0 (hand-written / allocation-free).
1495        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        // Lazy translation bills 7 fuel per byte of a function body the first time
1559        // it is entered, so any body over 131_072 / 7 = 18_724 bytes could never be
1560        // called: it burned the whole budget before executing an instruction and
1561        // surfaced as "ran too long". This body is ~84 KB — 588 K fuel to translate,
1562        // four and a half frame budgets — but only 36 K fuel to run, so with
1563        // translation off the meter it fits with room to spare.
1564        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        // Three fuel a repetition, so this also says the body was entered and run
1570        // rather than merely not trapping.
1571        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        // A ~10 KB update body costs 4.2 K fuel to run but 69 K — over half the frame
1581        // budget — to translate at the lazy 7 fuel a byte, and lazily that charge
1582        // landed on whichever frame called it first. Translation now happens in
1583        // `Module::new`, off the meter, so frame 0 costs what every frame after it
1584        // does.
1585        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        // Eager translation moves the compiler off the frame budget and onto load,
1600        // which is only a good trade if load stays quick. A cart's wasm is capped at
1601        // 128 KiB, and translating a body that size measures in the low milliseconds
1602        // — the deadline here is three orders of magnitude above that, so it catches
1603        // a pathological translator without flaking on a loaded machine.
1604        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        // `Module::new` is where a cart's wasm is checked and, now that translation
1624        // is eager, where its function bodies are compiled as well. Whichever stage
1625        // rejects the bytes, the user gets one sentence rather than wasmi internals.
1626        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        // BUDGET_OK_CART loops ~10k times in update and has an empty draw, so
1690        // the update phase must report a higher CPU fraction than draw.
1691        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        // TEST_CART declares one 64 KiB page of the 128 KiB cap.
1703        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    /// Exercises all four storage imports from a cart. Init proves a
1712    /// checked remove (pixel (2,0)), wipes the store with `storage_clear`,
1713    /// and leaves `"score" = 42` behind. Draw probes every `storage_get`
1714    /// branch: value length at (0,0), missing key at (1,0), cleared key at
1715    /// (3,0), cap-0 size query at (4,0), too-small buffer leaving memory
1716    /// untouched at (5,0), and an exact-fit write at (6,0).
1717    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        // The host sees what the cart stored, as canonical JSON — and only
1774        // that: storage_clear wiped the earlier "tmp" key.
1775        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            // Dropping the VM drops (and saves) the storage.
1802        }
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    /// A hand-written cart that only works out its rate while `pixel8_init` runs: what the
1809    /// documented query order — `pixel8_fps` once, after init — exists for.
1810    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        // The raw-ABI contract: `pixel8_fps` is asked once, after `pixel8_init`, so a cart is
1822        // free to compute its rate from state init builds. (The SDK's `boot` is answered the
1823        // target cart-side, from the game's own constant, precisely so this order can stand.)
1824        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        // No frontend measurement yet: report the cart's target rate (30).
1831        let mut vm = load_test_vm(FPS30_CART).unwrap();
1832        assert_eq!(vm.state().measured_fps_or_target(), 30.0);
1833        // Once a frontend feeds a real rate, report that.
1834        vm.state_mut().set_measured_fps(58.0);
1835        assert_eq!(vm.state().measured_fps_or_target(), 58.0);
1836    }
1837}