Skip to main content

gasm/
native.rs

1//! In-process stub host for running a guest natively (non-wasm targets).
2//!
3//! It implements the same ABI as the runners' headless mode: no display or
4//! audio, virtual time, in-memory storage, and FNV-1a hashes over presented
5//! frames, audio and gfx uploads. It checks arguments like the runners do
6//! (frame rate, audio format, frame geometry, storage keys and limits, gfx
7//! handles and buffer ranges) and panics where a runner would trap. A harness
8//! sets assets/params/input, calls the guest's `gasm_init`/`gasm_frame`, then
9//! reads [`stats`]. Hashes match `gasm-run --headless` for deterministic guests.
10//!
11//! The state is process-wide: run one guest per process (or per test binary
12//! with `--test-threads=1`).
13
14use std::collections::BTreeMap;
15use std::sync::Mutex;
16
17struct State {
18    frame_rate: f64,
19    frames: u64,
20    clock: (f64, u64, f64),
21    now_ms: f64,
22    /// sorted by name (UTF-8 bytes)
23    assets: BTreeMap<String, Vec<u8>>,
24    params: BTreeMap<String, String>,
25    storage: BTreeMap<String, Vec<u8>>,
26    storage_used: usize,
27    pads: [u32; 4],
28    text: String,
29    input_mode: u32,
30    channels: u32,
31    hashing: bool,
32    video_hash: u32,
33    audio_hash: u32,
34    presented: u64,
35    size: (u32, u32),
36    audio_frames: u64,
37    /// gfx objects: Some(buffer size) for buffers, None for other kinds, in creation order
38    gfx: Vec<GfxObj>,
39}
40
41#[derive(Clone, Copy, PartialEq)]
42enum GfxObj {
43    Buffer(u64),
44    Other,
45    Destroyed,
46}
47
48static STATE: Mutex<State> = Mutex::new(State {
49    frame_rate: 60.0,
50    frames: 0,
51    clock: (0.0, 0, 60.0),
52    now_ms: 0.0,
53    assets: BTreeMap::new(),
54    params: BTreeMap::new(),
55    storage: BTreeMap::new(),
56    storage_used: 0,
57    pads: [0; 4],
58    text: String::new(),
59    input_mode: 0,
60    channels: 2,
61    hashing: true,
62    video_hash: FNV_INIT,
63    audio_hash: FNV_INIT,
64    presented: 0,
65    size: (0, 0),
66    audio_frames: 0,
67    gfx: Vec::new(),
68});
69
70const FNV_INIT: u32 = 0x811c_9dc5;
71const STORAGE_MAX_VALUE: usize = 1 << 20;
72const STORAGE_QUOTA: usize = 16 << 20;
73
74fn fnv(mut h: u32, bytes: &[u8]) -> u32 {
75    for &b in bytes {
76        h = (h ^ b as u32).wrapping_mul(0x0100_0193);
77    }
78    h
79}
80
81fn with<R>(f: impl FnOnce(&mut State) -> R) -> R {
82    f(&mut STATE.lock().unwrap_or_else(|e| e.into_inner()))
83}
84
85fn valid_key(k: &str) -> bool {
86    !k.is_empty()
87        && k.len() <= 128
88        && k.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'_' || b == b'-')
89        && k != "."
90        && k != ".."
91}
92
93/// Harness API.
94pub fn set_asset(name: &str, bytes: Vec<u8>) {
95    with(|s| s.assets.insert(name.into(), bytes));
96}
97pub fn set_param(name: &str, value: &str) {
98    with(|s| s.params.insert(name.into(), value.into()));
99}
100pub fn set_pad(player: usize, buttons: u32) {
101    with(|s| s.pads[player] = buttons);
102}
103/// Text returned by `text_input` until the next [`end_frame`].
104pub fn set_text(text: &str) {
105    with(|s| s.text = text.into());
106}
107pub fn set_hashing(on: bool) {
108    with(|s| s.hashing = on);
109}
110
111/// Advance the virtual clock to the start of frame `frames` (as the runners'
112/// `VirtualClock`: monotonic across frame rate changes).
113fn tick(s: &mut State) {
114    let (base_ms, base_frame, rate) = &mut s.clock;
115    if s.frame_rate != *rate {
116        *base_ms += (s.frames - *base_frame) as f64 * 1000.0 / *rate;
117        *base_frame = s.frames;
118        *rate = s.frame_rate;
119    }
120    s.now_ms = *base_ms + (s.frames - *base_frame) as f64 * 1000.0 / *rate;
121}
122
123/// Call before each gasm_frame: time_ms is the start of that frame.
124pub fn begin_frame() {
125    with(tick);
126}
127
128/// Call after each gasm_frame so time advances.
129pub fn end_frame() {
130    with(|s| {
131        s.frames += 1;
132        s.text.clear();
133        tick(s);
134    });
135}
136
137pub struct Stats {
138    pub frame_rate: f64,
139    pub presented: u64,
140    pub width: u32,
141    pub height: u32,
142    pub video_hash: u32,
143    pub audio_hash: u32,
144    pub audio_frames: u64,
145}
146
147pub fn stats() -> Stats {
148    with(|s| Stats {
149        frame_rate: s.frame_rate,
150        presented: s.presented,
151        width: s.size.0,
152        height: s.size.1,
153        video_hash: s.video_hash,
154        audio_hash: s.audio_hash,
155        audio_frames: s.audio_frames,
156    })
157}
158
159/// Same signatures as the wasm imports (see sys.rs).
160#[allow(clippy::missing_safety_doc)]
161pub mod abi {
162    use super::*;
163
164    unsafe fn bytes<'a>(ptr: *const u8, len: u32) -> &'a [u8] {
165        if len == 0 { &[] } else { unsafe { std::slice::from_raw_parts(ptr, len as usize) } }
166    }
167    /// A string argument; runners trap on invalid UTF-8.
168    unsafe fn text(ptr: *const u8, len: u32) -> String {
169        String::from_utf8(unsafe { bytes(ptr, len) }.to_vec()).expect("string argument is not UTF-8")
170    }
171    unsafe fn copy_out(src: &[u8], dst: *mut u8, cap: u32) -> i32 {
172        if src.len() <= cap as usize && !src.is_empty() {
173            unsafe { std::ptr::copy_nonoverlapping(src.as_ptr(), dst, src.len()) };
174        }
175        src.len() as i32
176    }
177
178    pub unsafe fn log(ptr: *const u8, len: u32) {
179        eprintln!("[guest] {}", String::from_utf8_lossy(unsafe { bytes(ptr, len) }));
180    }
181    pub unsafe fn has(ptr: *const u8, len: u32) -> i32 {
182        let name = unsafe { text(ptr, len) };
183        crate::sys::IMPORTS.contains(&name.as_str()) as i32   // this stub implements all of them
184    }
185    pub unsafe fn time_ms() -> f64 {
186        with(|s| s.now_ms)
187    }
188    pub unsafe fn video_set_aspect(num: u32, den: u32) {
189        let ok = (num, den) == (0, 0) || ((1..=65535).contains(&num) && (1..=65535).contains(&den) && num as u64 * 8 >= den as u64 && den as u64 * 8 >= num as u64);
190        assert!(ok, "video_set_aspect: invalid ratio {num}:{den}");
191    }
192    /// A frame of a `gasm_run` game ends: natively the game just runs on, so
193    /// this is the frame boundary (time advances as after a `gasm_frame`).
194    pub unsafe fn yield_frame() {
195        super::end_frame();
196    }
197    pub unsafe fn set_title(ptr: *const u8, len: u32) {
198        eprintln!("[gasm] title: {}", unsafe { text(ptr, len) });
199    }
200    pub unsafe fn set_frame_rate(hz: f64) {
201        if hz.is_finite() && (1.0..=1000.0).contains(&hz) {
202            with(|s| s.frame_rate = hz);
203        }
204    }
205    pub unsafe fn video_present(ptr: *const u8, w: u32, h: u32, stride: u32) {
206        assert!(w > 0 && h > 0 && w <= 4096 && h <= 4096 && stride >= w * 4, "video_present: bad geometry {w}x{h} stride {stride}");
207        with(|s| {
208            if s.hashing {
209                for y in 0..h {
210                    let row = unsafe { bytes(ptr.add((y * stride) as usize), w * 4) };
211                    s.video_hash = fnv(s.video_hash, row);
212                }
213            }
214            s.size = (w, h);
215            s.presented += 1;
216        })
217    }
218    pub unsafe fn audio_config(rate: u32, channels: u32) {
219        if (8000..=192_000).contains(&rate) && (channels == 1 || channels == 2) {
220            with(|s| s.channels = channels);
221        }
222    }
223    pub unsafe fn audio_push(ptr: *const f32, frames: u32) {
224        with(|s| {
225            if s.hashing {
226                let b = unsafe { bytes(ptr as *const u8, frames * s.channels * 4) };
227                s.audio_hash = fnv(s.audio_hash, b);
228            }
229            s.audio_frames += frames as u64;
230        })
231    }
232    pub unsafe fn input_pad(player: u32) -> u32 {
233        with(|s| s.pads.get(player as usize).copied().unwrap_or(0))
234    }
235    // raw input: like a headless run with an empty script (keyboard and pointer
236    // present, nothing pressed, pointer at 0,0 in a 1280x720 drawable, no gamepads)
237    pub unsafe fn input_mode(flags: u32) {
238        with(|s| s.input_mode = flags & 7);
239    }
240    pub unsafe fn key_state(dst: *mut u8, len: u32) -> i32 {
241        let n = (len as usize).min(32);
242        unsafe { std::ptr::write_bytes(dst, 0, n) };
243        32
244    }
245    pub unsafe fn key_events(_: *mut u8, _: u32) -> i32 {
246        0
247    }
248    pub unsafe fn pointer(dst: *mut u8, cap: u32) -> i32 {
249        if cap >= 48 {
250            let (fw, fh, mode) = with(|s| (s.size.0 as f64, s.size.1 as f64, s.input_mode));
251            let (fx, fy) = if fw > 0.0 && fh > 0.0 {
252                let scale = (1280.0 / fw).min(720.0 / fh);
253                ((-(1280.0 - fw * scale) / 2.0 / scale) as f32, (-(720.0 - fh * scale) / 2.0 / scale) as f32)
254            } else {
255                (0.0, 0.0)
256            };
257            let mut b = [0u8; 48];
258            b[8..12].copy_from_slice(&fx.to_le_bytes());
259            b[12..16].copy_from_slice(&fy.to_le_bytes());
260            b[44..48].copy_from_slice(&(1 | (mode & 6)).to_le_bytes());
261            unsafe { std::ptr::copy_nonoverlapping(b.as_ptr(), dst, 48) };
262        }
263        48
264    }
265    pub unsafe fn gamepad(slot: u32, dst: *mut u8, cap: u32) -> i32 {
266        if slot > 3 {
267            return -1;
268        }
269        if cap >= 204 {
270            unsafe { std::ptr::write_bytes(dst, 0, 204) };
271        }
272        204
273    }
274    pub unsafe fn gamepad_name(_: u32, _: *mut u8, _: u32) -> i32 {
275        -1
276    }
277    pub unsafe fn text_input(dst: *mut u8, cap: u32) -> i32 {
278        with(|s| unsafe { copy_out(s.text.as_bytes(), dst, cap) })
279    }
280    pub unsafe fn asset_size(name: *const u8, len: u32) -> i32 {
281        let n = unsafe { text(name, len) };
282        with(|s| match s.assets.get(&n) {
283            None => -1,
284            Some(a) if a.len() > i32::MAX as usize => -2,
285            Some(a) => a.len() as i32,
286        })
287    }
288    pub unsafe fn asset_size64(name: *const u8, len: u32) -> i64 {
289        let n = unsafe { text(name, len) };
290        with(|s| s.assets.get(&n).map_or(-1, |a| a.len() as i64))
291    }
292    pub unsafe fn asset_count() -> u32 {
293        with(|s| s.assets.len() as u32)
294    }
295    pub unsafe fn asset_name(index: u32, dst: *mut u8, cap: u32) -> i32 {
296        with(|s| match s.assets.keys().nth(index as usize) {
297            Some(n) => unsafe { copy_out(n.as_bytes(), dst, cap) },
298            None => -1,
299        })
300    }
301    pub unsafe fn asset_read(name: *const u8, len: u32, dst: *mut u8, cap: u32) -> i32 {
302        unsafe { asset_read_at64(name, len, 0, dst, cap) }
303    }
304    pub unsafe fn asset_read_at(name: *const u8, len: u32, offset: u32, dst: *mut u8, cap: u32) -> i32 {
305        unsafe { asset_read_at64(name, len, offset as u64, dst, cap) }
306    }
307    pub unsafe fn asset_read_at64(name: *const u8, len: u32, offset: u64, dst: *mut u8, cap: u32) -> i32 {
308        let n = unsafe { text(name, len) };
309        with(|s| match s.assets.get(&n) {
310            Some(a) => {
311                let start = offset.min(a.len() as u64) as usize;
312                let k = (a.len() - start).min(cap as usize);
313                unsafe { std::ptr::copy_nonoverlapping(a[start..].as_ptr(), dst, k) };
314                k as i32
315            }
316            None => -1,
317        })
318    }
319
320    // storage: in memory (like headless runners), same rules and error codes
321    pub unsafe fn storage_get(key: *const u8, len: u32, dst: *mut u8, cap: u32) -> i32 {
322        let k = unsafe { text(key, len) };
323        with(|s| match s.storage.get(&k) {
324            Some(v) => unsafe { copy_out(v, dst, cap) },
325            None => -1,
326        })
327    }
328    pub unsafe fn storage_set(key: *const u8, len: u32, data: *const u8, data_len: u32) -> i32 {
329        let k = unsafe { text(key, len) };
330        let v = unsafe { bytes(data, data_len) }.to_vec();
331        if !valid_key(&k) {
332            return crate::sys::GASM_STORAGE_ERR_KEY;
333        }
334        if v.len() > STORAGE_MAX_VALUE {
335            return crate::sys::GASM_STORAGE_ERR_SIZE;
336        }
337        with(|s| {
338            let old = s.storage.get(&k).map_or(0, |o| k.len() + o.len());
339            let used = s.storage_used - old + k.len() + v.len();
340            if used > STORAGE_QUOTA {
341                return crate::sys::GASM_STORAGE_ERR_QUOTA;
342            }
343            s.storage_used = used;
344            s.storage.insert(k, v);
345            0
346        })
347    }
348    pub unsafe fn storage_count() -> u32 {
349        with(|s| s.storage.len() as u32)
350    }
351    pub unsafe fn storage_key(index: u32, dst: *mut u8, cap: u32) -> i32 {
352        with(|s| match s.storage.keys().nth(index as usize) {
353            Some(k) => unsafe { copy_out(k.as_bytes(), dst, cap) },
354            None => -1,
355        })
356    }
357    pub unsafe fn storage_delete(key: *const u8, len: u32) -> i32 {
358        let k = unsafe { text(key, len) };
359        with(|s| match s.storage.remove(&k) {
360            Some(v) => {
361                s.storage_used -= k.len() + v.len();
362                0
363            }
364            None => -1,
365        })
366    }
367
368    pub unsafe fn param(name: *const u8, len: u32, dst: *mut u8, cap: u32) -> i32 {
369        let n = unsafe { text(name, len) };
370        with(|s| match s.params.get(&n) {
371            Some(v) => unsafe { copy_out(v.as_bytes(), dst, cap) },
372            None => -1,
373        })
374    }
375
376    // gfx: null backend. Hashes uploads like the headless runners; checks handles
377    // and buffer ranges (the runners' full validation lives in gasm-host).
378    fn add(obj: GfxObj) -> u32 {
379        with(|s| {
380            s.gfx.push(obj);
381            s.gfx.len() as u32
382        })
383    }
384    fn live(s: &State, h: u32) -> GfxObj {
385        match s.gfx.get((h as usize).wrapping_sub(1)) {
386            Some(GfxObj::Destroyed) => panic!("gfx: handle {h} was destroyed"),
387            Some(o) => *o,
388            None => panic!("gfx: invalid handle {h}"),
389        }
390    }
391    fn buffer_size(s: &State, h: u32) -> u64 {
392        match live(s, h) {
393            GfxObj::Buffer(n) => n,
394            _ => panic!("gfx: handle {h} is not a buffer"),
395        }
396    }
397    pub unsafe fn gfx_width() -> u32 { 1280 }
398    pub unsafe fn gfx_height() -> u32 { 720 }
399    pub unsafe fn gfx_create_shader(_: *const u8, _: u32) -> u32 { add(GfxObj::Other) }
400    pub unsafe fn gfx_create_buffer(size: u32, _: u32) -> u32 {
401        assert!(size > 0 && size % 4 == 0, "gfx.create_buffer: size {size} must be a non-zero multiple of 4");
402        add(GfxObj::Buffer(size as u64))
403    }
404    pub unsafe fn gfx_create_pipeline(_: *const u8, _: u32) -> u32 { add(GfxObj::Other) }
405    pub unsafe fn gfx_create_bind_group(_: *const u8, _: u32) -> u32 { add(GfxObj::Other) }
406    pub unsafe fn gfx_create_bind_group_layout(_: *const u8, _: u32) -> u32 { add(GfxObj::Other) }
407    pub unsafe fn gfx_create_texture(_: *const u8, _: u32) -> u32 { add(GfxObj::Other) }
408    pub unsafe fn gfx_create_sampler(_: *const u8, _: u32) -> u32 { add(GfxObj::Other) }
409    #[allow(clippy::too_many_arguments)]
410    pub unsafe fn gfx_write_texture(t: u32, mip: u32, x: u32, y: u32, w: u32, h: u32, ptr: *const u8, len: u32) {
411        with(|s| {
412            live(s, t);
413            if s.hashing {
414                for v in [t, mip, x, y, w, h] {
415                    s.video_hash = fnv(s.video_hash, &v.to_le_bytes());
416                }
417                s.video_hash = fnv(s.video_hash, unsafe { bytes(ptr, len) });
418            }
419        })
420    }
421    pub unsafe fn gfx_set_bind_group_offsets(_: u32, bg: u32, _: *const u32, _: u32) {
422        with(|s| live(s, bg));
423    }
424    pub unsafe fn gfx_set_viewport(_: f32, _: f32, _: f32, _: f32, _: f32, _: f32) {}
425    pub unsafe fn gfx_set_scissor_rect(_: u32, _: u32, _: u32, _: u32) {}
426    pub unsafe fn gfx_write_buffer(buf: u32, offset: u32, ptr: *const u8, len: u32) {
427        assert!(offset % 4 == 0 && len % 4 == 0, "gfx.write_buffer: offset {offset} and length {len} must be multiples of 4");
428        with(|s| {
429            let size = buffer_size(s, buf);
430            assert!(offset as u64 + len as u64 <= size, "gfx.write_buffer: {offset}+{len} exceeds buffer size {size}");
431            if s.hashing {
432                s.video_hash = fnv(s.video_hash, unsafe { bytes(ptr, len) });
433            }
434        })
435    }
436    pub unsafe fn gfx_begin_frame(_: f32, _: f32, _: f32, _: f32) -> u32 { 0 }
437    pub unsafe fn gfx_set_pipeline(p: u32) {
438        with(|s| live(s, p));
439    }
440    pub unsafe fn gfx_set_bind_group(_: u32, bg: u32) {
441        with(|s| live(s, bg));
442    }
443    pub unsafe fn gfx_set_vertex_buffer(_: u32, buf: u32, offset: u32) {
444        with(|s| assert!(offset as u64 <= buffer_size(s, buf), "gfx.set_vertex_buffer: offset {offset} outside the buffer"));
445    }
446    pub unsafe fn gfx_set_index_buffer(buf: u32, format: u32, offset: u32) {
447        assert!(format <= 1, "gfx.set_index_buffer: format {format}");
448        with(|s| assert!(offset as u64 <= buffer_size(s, buf), "gfx.set_index_buffer: offset {offset} outside the buffer"));
449    }
450    pub unsafe fn gfx_draw(_: u32, _: u32, _: u32, _: u32) {}
451    pub unsafe fn gfx_draw_indexed(_: u32, _: u32, _: u32, _: i32, _: u32) {}
452    pub unsafe fn gfx_end_frame() {
453        with(|s| s.presented += 1);
454    }
455    pub unsafe fn gfx_destroy(h: u32) {
456        with(|s| {
457            live(s, h);
458            s.gfx[h as usize - 1] = GfxObj::Destroyed;
459        })
460    }
461
462    // net: always denied natively (no handle is ever valid)
463    pub unsafe fn net_open(_: *const u8, _: u32) -> i32 { -1 }
464    pub unsafe fn net_state(h: i32) -> u32 { panic!("gasm:net: invalid connection handle {h}") }
465    pub unsafe fn net_send(h: i32, _: *const u8, _: u32) -> i32 { panic!("gasm:net: invalid connection handle {h}") }
466    pub unsafe fn net_recv(h: i32, _: *mut u8, _: u32) -> i32 { panic!("gasm:net: invalid connection handle {h}") }
467    pub unsafe fn net_close(h: i32) { panic!("gasm:net: invalid connection handle {h}") }
468
469    #[allow(clippy::too_many_arguments)]
470    mod gl {
471        include!("native_gl.rs");
472    }
473    pub use gl::*;
474
475    pub unsafe fn proc_exit(code: i32) -> ! {
476        std::process::exit(code)
477    }
478}