plum

#treesitter#compiler#wasm

git clone https://git.pyrossh.dev/plum

A statically typed, imperative programming language inspired by rust, python


43b65ab — Peter John 2026-11-02T13:56:10+05:30
T4: native host: Backend, wasmtime binding, macroquad, recorder
plum-game/src/api.rs ADDED
@@ -0,0 +1,149 @@
1
+ //! The host API a Plum game programs against: one `Backend` method per
2
+ //! extern in `plum-std/Game.plum`, in Rust types (`Float` is `f32`, `Int` is
3
+ //! `i32`, `Str` is `&str`, a `Color` is `[f32; 4]`, a `Vec2` is `(f32, f32)`).
4
+ //! `Int` handles are indexes into host vectors, `-1` on failure.
5
+
6
+ use crate::assets::{Atlas, AssetRoot, MapData};
7
+ use std::path::PathBuf;
8
+
9
+ pub type Color = [f32; 4];
10
+
11
+ pub trait Backend: 'static {
12
+ fn screen_width(&mut self) -> f32;
13
+ fn screen_height(&mut self) -> f32;
14
+ fn frame_time(&mut self) -> f32;
15
+
16
+ fn clear(&mut self, c: Color);
17
+ fn draw_rect(&mut self, x: f32, y: f32, w: f32, h: f32, c: Color);
18
+ fn draw_line(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, thickness: f32, c: Color);
19
+ fn draw_text(&mut self, s: &str, x: f32, y: f32, size: f32, c: Color);
20
+
21
+ fn set_camera(&mut self, x: f32, y: f32, scale: f32);
22
+ fn reset_camera(&mut self);
23
+
24
+ fn atlas_load(&mut self, name: &str) -> i32;
25
+ fn sprite_count(&mut self, atlas: i32) -> i32;
26
+ fn sprite_w(&mut self, atlas: i32, idx: i32) -> i32;
27
+ fn sprite_h(&mut self, atlas: i32, idx: i32) -> i32;
28
+ fn sprite_pivot_x(&mut self, atlas: i32, idx: i32) -> i32;
29
+ fn sprite_pivot_y(&mut self, atlas: i32, idx: i32) -> i32;
30
+ fn sprite_color(&mut self, atlas: i32, idx: i32) -> Color;
31
+
32
+ fn draw_sprite(&mut self, atlas: i32, idx: i32, x: f32, y: f32, flip_x: bool, tint: Color);
33
+ #[allow(clippy::too_many_arguments)]
34
+ fn draw_tile(&mut self, atlas: i32, idx: i32, x: f32, y: f32, size: f32, quarter_turns: i32, tint: Color);
35
+
36
+ fn is_key_down(&mut self, k: i32) -> bool;
37
+ fn is_key_pressed(&mut self, k: i32) -> bool;
38
+ fn mouse_pos(&mut self) -> (f32, f32);
39
+ fn is_mouse_down(&mut self, b: i32) -> bool;
40
+ fn is_mouse_pressed(&mut self, b: i32) -> bool;
41
+ fn mouse_wheel(&mut self) -> f32;
42
+
43
+ fn map_load(&mut self, path: &str) -> i32;
44
+ fn map_width(&mut self, m: i32) -> i32;
45
+ fn map_height(&mut self, m: i32) -> i32;
46
+ fn map_terrain(&mut self, m: i32, x: i32, y: i32) -> i32;
47
+ fn map_rotation(&mut self, m: i32, x: i32, y: i32) -> i32;
48
+ fn map_elevation(&mut self, m: i32, x: i32, y: i32) -> i32;
49
+ fn map_object(&mut self, m: i32, x: i32, y: i32) -> i32;
50
+ }
51
+
52
+ /// Radians clockwise for `quarter_turns` quarter turns; `drawTile` rotates
53
+ /// about the tile's centre by this angle.
54
+ pub fn quarter_turns_radians(quarter_turns: i32) -> f32 {
55
+ quarter_turns.rem_euclid(4) as f32 * std::f32::consts::FRAC_PI_2
56
+ }
57
+
58
+ /// Atlases and maps loaded from an asset root, addressed by handle. Shared by
59
+ /// every backend so handle numbering and lookups behave the same everywhere.
60
+ pub struct AssetStore {
61
+ root: PathBuf,
62
+ mapelem: Vec<u8>,
63
+ pub atlases: Vec<Atlas>,
64
+ pub maps: Vec<MapData>,
65
+ }
66
+
67
+ impl AssetStore {
68
+ pub fn new(root: Option<PathBuf>) -> Self {
69
+ let root = AssetRoot::resolve(root.as_deref());
70
+ let mapelem = std::fs::read(root.join("data/defines/mapelem.dat")).unwrap_or_default();
71
+ AssetStore { root, mapelem, atlases: Vec::new(), maps: Vec::new() }
72
+ }
73
+
74
+ pub fn atlas_load(&mut self, name: &str) -> i32 {
75
+ let path = self.root.join("sprites").join(format!("{name}.rxx"));
76
+ let Ok(bytes) = std::fs::read(&path) else { return -1 };
77
+ match Atlas::parse(&bytes) {
78
+ Ok(a) => {
79
+ self.atlases.push(a);
80
+ self.atlases.len() as i32 - 1
81
+ }
82
+ Err(_) => -1,
83
+ }
84
+ }
85
+
86
+ pub fn map_load(&mut self, path: &str) -> i32 {
87
+ let Ok(bytes) = std::fs::read(self.root.join(path)) else { return -1 };
88
+ match MapData::parse_with(&bytes, &self.mapelem) {
89
+ Ok(m) => {
90
+ self.maps.push(m);
91
+ self.maps.len() as i32 - 1
92
+ }
93
+ Err(_) => -1,
94
+ }
95
+ }
96
+
97
+ fn sprite(&self, atlas: i32, idx: i32) -> Option<&crate::assets::Sprite> {
98
+ let a = self.atlases.get(usize::try_from(atlas).ok()?)?;
99
+ a.sprites.get(usize::try_from(idx).ok()?)?.as_ref()
100
+ }
101
+
102
+ pub fn sprite_count(&self, atlas: i32) -> i32 {
103
+ usize::try_from(atlas).ok().and_then(|a| self.atlases.get(a)).map_or(0, |a| a.sprites.len() as i32 - 1)
104
+ }
105
+ pub fn sprite_w(&self, atlas: i32, idx: i32) -> i32 {
106
+ self.sprite(atlas, idx).map_or(0, |s| s.w as i32)
107
+ }
108
+ pub fn sprite_h(&self, atlas: i32, idx: i32) -> i32 {
109
+ self.sprite(atlas, idx).map_or(0, |s| s.h as i32)
110
+ }
111
+ pub fn sprite_pivot_x(&self, atlas: i32, idx: i32) -> i32 {
112
+ self.sprite(atlas, idx).map_or(0, |s| s.px)
113
+ }
114
+ pub fn sprite_pivot_y(&self, atlas: i32, idx: i32) -> i32 {
115
+ self.sprite(atlas, idx).map_or(0, |s| s.py)
116
+ }
117
+ pub fn sprite_color(&self, atlas: i32, idx: i32) -> Color {
118
+ match (usize::try_from(atlas).ok().and_then(|a| self.atlases.get(a)), usize::try_from(idx).ok()) {
119
+ (Some(a), Some(i)) => a.avg_color(i),
120
+ _ => [0.0; 4],
121
+ }
122
+ }
123
+
124
+ fn cell(&self, m: i32, x: i32, y: i32) -> Option<(&MapData, usize)> {
125
+ let map = self.maps.get(usize::try_from(m).ok()?)?;
126
+ if x < 0 || y < 0 || x >= map.w || y >= map.h {
127
+ return None;
128
+ }
129
+ Some((map, (y * map.w + x) as usize))
130
+ }
131
+ pub fn map_width(&self, m: i32) -> i32 {
132
+ usize::try_from(m).ok().and_then(|m| self.maps.get(m)).map_or(0, |m| m.w)
133
+ }
134
+ pub fn map_height(&self, m: i32) -> i32 {
135
+ usize::try_from(m).ok().and_then(|m| self.maps.get(m)).map_or(0, |m| m.h)
136
+ }
137
+ pub fn map_terrain(&self, m: i32, x: i32, y: i32) -> i32 {
138
+ self.cell(m, x, y).map_or(0, |(d, i)| d.terrain[i] as i32)
139
+ }
140
+ pub fn map_rotation(&self, m: i32, x: i32, y: i32) -> i32 {
141
+ self.cell(m, x, y).map_or(0, |(d, i)| d.rotation[i] as i32)
142
+ }
143
+ pub fn map_elevation(&self, m: i32, x: i32, y: i32) -> i32 {
144
+ self.cell(m, x, y).map_or(0, |(d, i)| d.elevation[i] as i32)
145
+ }
146
+ pub fn map_object(&self, m: i32, x: i32, y: i32) -> i32 {
147
+ self.cell(m, x, y).map_or(0, |(d, i)| d.object[i] as i32)
148
+ }
149
+ }
plum-game/src/lib.rs CHANGED
@@ -1 +1,10 @@
1
+ //! Native and web host for Plum game programs.
2
+
3
+ pub mod api;
1
4
  pub mod assets;
5
+ pub mod recorder;
6
+
7
+ #[cfg(not(target_arch = "wasm32"))]
8
+ pub mod mq;
9
+ #[cfg(not(target_arch = "wasm32"))]
10
+ pub mod native;
plum-game/src/main.rs ADDED
@@ -0,0 +1,59 @@
1
+ //! `plum-game <module.wasm> [--assets DIR]`: runs a compiled Plum game in a
2
+ //! macroquad window.
3
+
4
+ #[cfg(not(target_arch = "wasm32"))]
5
+ use plum_game::{mq::MqBackend, native::PlumGame};
6
+
7
+ #[cfg(not(target_arch = "wasm32"))]
8
+ fn window_conf() -> macroquad::window::Conf {
9
+ macroquad::window::Conf { window_title: "Plum Game".to_string(), window_width: 1280, window_height: 720, ..Default::default() }
10
+ }
11
+
12
+ #[cfg(not(target_arch = "wasm32"))]
13
+ fn parse_args() -> Result<(std::path::PathBuf, Option<std::path::PathBuf>), String> {
14
+ let mut wasm = None;
15
+ let mut assets = None;
16
+ let mut args = std::env::args().skip(1);
17
+ while let Some(a) = args.next() {
18
+ if a == "--assets" {
19
+ assets = Some(args.next().ok_or("--assets needs a directory")?.into());
20
+ } else if wasm.is_none() {
21
+ wasm = Some(a.into());
22
+ } else {
23
+ return Err(format!("unexpected argument `{a}`"));
24
+ }
25
+ }
26
+ Ok((wasm.ok_or("usage: plum-game <module.wasm> [--assets DIR]")?, assets))
27
+ }
28
+
29
+ #[cfg(not(target_arch = "wasm32"))]
30
+ async fn run(wasm: std::path::PathBuf, assets: Option<std::path::PathBuf>) -> Result<(), String> {
31
+ let bytes = std::fs::read(&wasm).map_err(|e| format!("{}: {e}", wasm.display()))?;
32
+ let mut game = PlumGame::load(&bytes, MqBackend::new(assets)).map_err(|e| format!("{e:#}"))?;
33
+ game.init().map_err(|e| format!("init: {e:#}"))?;
34
+ loop {
35
+ let dt = macroquad::time::get_frame_time();
36
+ game.update(dt).map_err(|e| format!("update: {e:#}"))?;
37
+ game.draw().map_err(|e| format!("draw: {e:#}"))?;
38
+ macroquad::window::next_frame().await;
39
+ }
40
+ }
41
+
42
+ #[cfg(not(target_arch = "wasm32"))]
43
+ #[macroquad::main(window_conf)]
44
+ async fn main() {
45
+ let (wasm, assets) = match parse_args() {
46
+ Ok(a) => a,
47
+ Err(e) => {
48
+ eprintln!("{e}");
49
+ std::process::exit(2);
50
+ }
51
+ };
52
+ if let Err(e) = run(wasm, assets).await {
53
+ eprintln!("{e}");
54
+ std::process::exit(1);
55
+ }
56
+ }
57
+
58
+ #[cfg(target_arch = "wasm32")]
59
+ fn main() {}
plum-game/src/mq.rs ADDED
@@ -0,0 +1,240 @@
1
+ //! The macroquad `Backend`. The camera set by `setCamera` is applied by hand
2
+ //! (`screen = (world - camera) * scale`), so every draw works in world units
3
+ //! and `resetCamera` returns to screen pixels.
4
+
5
+ use crate::api::{quarter_turns_radians, AssetStore, Backend, Color};
6
+ use macroquad::prelude as mq;
7
+ use std::path::PathBuf;
8
+
9
+ /// Maps the Design's GLFW key numbers to macroquad key codes.
10
+ pub fn key_code(k: i32) -> Option<mq::KeyCode> {
11
+ use mq::KeyCode as K;
12
+ const LETTERS: [K; 26] = [
13
+ K::A, K::B, K::C, K::D, K::E, K::F, K::G, K::H, K::I, K::J, K::K, K::L, K::M,
14
+ K::N, K::O, K::P, K::Q, K::R, K::S, K::T, K::U, K::V, K::W, K::X, K::Y, K::Z,
15
+ ];
16
+ const DIGITS: [K; 10] = [K::Key0, K::Key1, K::Key2, K::Key3, K::Key4, K::Key5, K::Key6, K::Key7, K::Key8, K::Key9];
17
+ Some(match k {
18
+ 32 => K::Space,
19
+ 65..=90 => LETTERS[(k - 65) as usize],
20
+ 48..=57 => DIGITS[(k - 48) as usize],
21
+ 256 => K::Escape,
22
+ 257 => K::Enter,
23
+ 258 => K::Tab,
24
+ 259 => K::Backspace,
25
+ 262 => K::Right,
26
+ 263 => K::Left,
27
+ 264 => K::Down,
28
+ 265 => K::Up,
29
+ 340 => K::LeftShift,
30
+ 341 => K::LeftControl,
31
+ _ => return None,
32
+ })
33
+ }
34
+
35
+ pub fn mouse_button(b: i32) -> Option<mq::MouseButton> {
36
+ match b {
37
+ 0 => Some(mq::MouseButton::Left),
38
+ 1 => Some(mq::MouseButton::Right),
39
+ 2 => Some(mq::MouseButton::Middle),
40
+ _ => None,
41
+ }
42
+ }
43
+
44
+ fn col(c: Color) -> mq::Color {
45
+ mq::Color::new(c[0], c[1], c[2], c[3])
46
+ }
47
+
48
+ pub struct MqBackend {
49
+ assets: AssetStore,
50
+ /// Lazily uploaded textures, `[atlas][sprite]`.
51
+ textures: Vec<Vec<Option<mq::Texture2D>>>,
52
+ cam: (f32, f32, f32),
53
+ }
54
+
55
+ impl MqBackend {
56
+ pub fn new(assets: Option<PathBuf>) -> Self {
57
+ MqBackend { assets: AssetStore::new(assets), textures: Vec::new(), cam: (0.0, 0.0, 1.0) }
58
+ }
59
+
60
+ fn to_screen(&self, x: f32, y: f32) -> (f32, f32) {
61
+ ((x - self.cam.0) * self.cam.2, (y - self.cam.1) * self.cam.2)
62
+ }
63
+
64
+ fn texture(&mut self, atlas: i32, idx: i32) -> Option<mq::Texture2D> {
65
+ let (a, i) = (usize::try_from(atlas).ok()?, usize::try_from(idx).ok()?);
66
+ while self.textures.len() <= a {
67
+ let n = self.assets.atlases.get(self.textures.len()).map_or(0, |at| at.sprites.len());
68
+ self.textures.push(vec![None; n]);
69
+ }
70
+ if i >= self.textures[a].len() {
71
+ return None;
72
+ }
73
+ if self.textures[a][i].is_none() {
74
+ let s = self.assets.atlases.get(a)?.sprites.get(i)?.as_ref()?;
75
+ let t = mq::Texture2D::from_rgba8(s.w, s.h, &s.rgba);
76
+ t.set_filter(mq::FilterMode::Nearest);
77
+ self.textures[a][i] = Some(t);
78
+ }
79
+ self.textures[a].get(i)?.clone()
80
+ }
81
+ }
82
+
83
+ impl Backend for MqBackend {
84
+ fn screen_width(&mut self) -> f32 {
85
+ mq::screen_width()
86
+ }
87
+ fn screen_height(&mut self) -> f32 {
88
+ mq::screen_height()
89
+ }
90
+ fn frame_time(&mut self) -> f32 {
91
+ mq::get_frame_time()
92
+ }
93
+ fn clear(&mut self, c: Color) {
94
+ mq::clear_background(col(c));
95
+ }
96
+ fn draw_rect(&mut self, x: f32, y: f32, w: f32, h: f32, c: Color) {
97
+ let (sx, sy) = self.to_screen(x, y);
98
+ mq::draw_rectangle(sx, sy, w * self.cam.2, h * self.cam.2, col(c));
99
+ }
100
+ fn draw_line(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, t: f32, c: Color) {
101
+ let (a, b) = (self.to_screen(x1, y1), self.to_screen(x2, y2));
102
+ mq::draw_line(a.0, a.1, b.0, b.1, t * self.cam.2, col(c));
103
+ }
104
+ /// `(x, y)` is the text's top-left corner.
105
+ fn draw_text(&mut self, s: &str, x: f32, y: f32, size: f32, c: Color) {
106
+ let (sx, sy) = self.to_screen(x, y);
107
+ let px = size * self.cam.2;
108
+ let dims = mq::measure_text(s, None, px.max(1.0) as u16, 1.0);
109
+ mq::draw_text(s, sx, sy + dims.offset_y, px, col(c));
110
+ }
111
+ fn set_camera(&mut self, x: f32, y: f32, scale: f32) {
112
+ self.cam = (x, y, scale);
113
+ }
114
+ fn reset_camera(&mut self) {
115
+ self.cam = (0.0, 0.0, 1.0);
116
+ }
117
+ fn atlas_load(&mut self, name: &str) -> i32 {
118
+ self.assets.atlas_load(name)
119
+ }
120
+ fn sprite_count(&mut self, a: i32) -> i32 {
121
+ self.assets.sprite_count(a)
122
+ }
123
+ fn sprite_w(&mut self, a: i32, i: i32) -> i32 {
124
+ self.assets.sprite_w(a, i)
125
+ }
126
+ fn sprite_h(&mut self, a: i32, i: i32) -> i32 {
127
+ self.assets.sprite_h(a, i)
128
+ }
129
+ fn sprite_pivot_x(&mut self, a: i32, i: i32) -> i32 {
130
+ self.assets.sprite_pivot_x(a, i)
131
+ }
132
+ fn sprite_pivot_y(&mut self, a: i32, i: i32) -> i32 {
133
+ self.assets.sprite_pivot_y(a, i)
134
+ }
135
+ fn sprite_color(&mut self, a: i32, i: i32) -> Color {
136
+ self.assets.sprite_color(a, i)
137
+ }
138
+ fn draw_sprite(&mut self, a: i32, i: i32, x: f32, y: f32, flip_x: bool, tint: Color) {
139
+ let Some(tex) = self.texture(a, i) else { return };
140
+ let (px, py) = (self.assets.sprite_pivot_x(a, i) as f32, self.assets.sprite_pivot_y(a, i) as f32);
141
+ let (sx, sy) = self.to_screen(x + px, y + py);
142
+ let s = self.cam.2;
143
+ let params = mq::DrawTextureParams {
144
+ dest_size: Some(mq::vec2(tex.width() * s, tex.height() * s)),
145
+ flip_x,
146
+ ..Default::default()
147
+ };
148
+ mq::draw_texture_ex(&tex, sx, sy, col(tint), params);
149
+ }
150
+ fn draw_tile(&mut self, a: i32, i: i32, x: f32, y: f32, size: f32, quarter_turns: i32, tint: Color) {
151
+ let Some(tex) = self.texture(a, i) else { return };
152
+ let (sx, sy) = self.to_screen(x, y);
153
+ let side = size * self.cam.2;
154
+ let params = mq::DrawTextureParams {
155
+ dest_size: Some(mq::vec2(side, side)),
156
+ rotation: quarter_turns_radians(quarter_turns),
157
+ pivot: Some(mq::vec2(sx + side / 2.0, sy + side / 2.0)),
158
+ ..Default::default()
159
+ };
160
+ mq::draw_texture_ex(&tex, sx, sy, col(tint), params);
161
+ }
162
+ fn is_key_down(&mut self, k: i32) -> bool {
163
+ key_code(k).is_some_and(mq::is_key_down)
164
+ }
165
+ fn is_key_pressed(&mut self, k: i32) -> bool {
166
+ key_code(k).is_some_and(mq::is_key_pressed)
167
+ }
168
+ fn mouse_pos(&mut self) -> (f32, f32) {
169
+ mq::mouse_position()
170
+ }
171
+ fn is_mouse_down(&mut self, b: i32) -> bool {
172
+ mouse_button(b).is_some_and(mq::is_mouse_button_down)
173
+ }
174
+ fn is_mouse_pressed(&mut self, b: i32) -> bool {
175
+ mouse_button(b).is_some_and(mq::is_mouse_button_pressed)
176
+ }
177
+ /// One notch per frame with scrolling, signed: positive is up.
178
+ fn mouse_wheel(&mut self) -> f32 {
179
+ {
180
+ let dy = mq::mouse_wheel().1;
181
+ if dy == 0.0 { 0.0 } else { dy.signum() }
182
+ }
183
+ }
184
+ fn map_load(&mut self, path: &str) -> i32 {
185
+ self.assets.map_load(path)
186
+ }
187
+ fn map_width(&mut self, m: i32) -> i32 {
188
+ self.assets.map_width(m)
189
+ }
190
+ fn map_height(&mut self, m: i32) -> i32 {
191
+ self.assets.map_height(m)
192
+ }
193
+ fn map_terrain(&mut self, m: i32, x: i32, y: i32) -> i32 {
194
+ self.assets.map_terrain(m, x, y)
195
+ }
196
+ fn map_rotation(&mut self, m: i32, x: i32, y: i32) -> i32 {
197
+ self.assets.map_rotation(m, x, y)
198
+ }
199
+ fn map_elevation(&mut self, m: i32, x: i32, y: i32) -> i32 {
200
+ self.assets.map_elevation(m, x, y)
201
+ }
202
+ fn map_object(&mut self, m: i32, x: i32, y: i32) -> i32 {
203
+ self.assets.map_object(m, x, y)
204
+ }
205
+ }
206
+
207
+ #[cfg(test)]
208
+ mod tests {
209
+ use super::*;
210
+ use macroquad::prelude::KeyCode as K;
211
+
212
+ #[test]
213
+ fn keys_map_to_the_design_numbers() {
214
+ assert_eq!(key_code(32), Some(K::Space));
215
+ assert_eq!(key_code(65), Some(K::A));
216
+ assert_eq!(key_code(87), Some(K::W));
217
+ assert_eq!(key_code(90), Some(K::Z));
218
+ assert_eq!(key_code(48), Some(K::Key0));
219
+ assert_eq!(key_code(57), Some(K::Key9));
220
+ assert_eq!(key_code(256), Some(K::Escape));
221
+ assert_eq!(key_code(257), Some(K::Enter));
222
+ assert_eq!(key_code(258), Some(K::Tab));
223
+ assert_eq!(key_code(259), Some(K::Backspace));
224
+ assert_eq!(key_code(262), Some(K::Right));
225
+ assert_eq!(key_code(263), Some(K::Left));
226
+ assert_eq!(key_code(264), Some(K::Down));
227
+ assert_eq!(key_code(265), Some(K::Up));
228
+ assert_eq!(key_code(340), Some(K::LeftShift));
229
+ assert_eq!(key_code(341), Some(K::LeftControl));
230
+ assert_eq!(key_code(1), None);
231
+ }
232
+
233
+ #[test]
234
+ fn mouse_buttons() {
235
+ assert_eq!(mouse_button(0), Some(mq::MouseButton::Left));
236
+ assert_eq!(mouse_button(1), Some(mq::MouseButton::Right));
237
+ assert_eq!(mouse_button(2), Some(mq::MouseButton::Middle));
238
+ assert_eq!(mouse_button(3), None);
239
+ }
240
+ }
plum-game/src/native.rs ADDED
@@ -0,0 +1,195 @@
1
+ //! Runs a compiled Plum game module under wasmtime, binding every `plum`
2
+ //! import to a [`Backend`].
3
+
4
+ use crate::api::{Backend, Color};
5
+ use wasmtime::{AnyRef, Caller, Engine, Func, Instance, Linker, Module, OwnedRooted, Rooted, Store, Val};
6
+
7
+ pub use wasmtime::Result;
8
+
9
+ struct HostState<B> {
10
+ backend: B,
11
+ }
12
+
13
+ pub struct PlumGame<B: Backend> {
14
+ store: Store<HostState<B>>,
15
+ instance: Instance,
16
+ /// The `init` result, rooted across frames.
17
+ state: Option<OwnedRooted<AnyRef>>,
18
+ }
19
+
20
+ fn f(p: &[Val], i: usize) -> f32 {
21
+ match p[i] {
22
+ Val::F64(bits) => f64::from_bits(bits) as f32,
23
+ _ => 0.0,
24
+ }
25
+ }
26
+
27
+ fn int(p: &[Val], i: usize) -> i32 {
28
+ match p[i] {
29
+ Val::I32(v) => v,
30
+ _ => 0,
31
+ }
32
+ }
33
+
34
+ fn color(p: &[Val], i: usize) -> Color {
35
+ [f(p, i), f(p, i + 1), f(p, i + 2), f(p, i + 3)]
36
+ }
37
+
38
+ fn fv(v: f32) -> Val {
39
+ Val::F64((v as f64).to_bits())
40
+ }
41
+
42
+ /// Reads the bytes of a Plum `Str` (a struct of `data`, `start`, `len`).
43
+ fn str_bytes(store: &mut impl wasmtime::AsContextMut, s: &Rooted<AnyRef>) -> Result<Vec<u8>> {
44
+ let st = s.unwrap_struct(&*store)?;
45
+ let Val::AnyRef(Some(data)) = st.field(&mut *store, 0)? else {
46
+ return Err(wasmtime::Error::msg("Str.data is not a byte array"));
47
+ };
48
+ let start = st.field(&mut *store, 1)?.unwrap_i64() as usize;
49
+ let len = st.field(&mut *store, 2)?.unwrap_i64() as usize;
50
+ let arr = data.unwrap_array(&*store)?;
51
+ let mut buf = vec![0u8; arr.len(&*store)? as usize];
52
+ arr.copy_to_i8_slice(&mut *store, &mut buf)?;
53
+ buf.get(start..start + len).map(|b| b.to_vec()).ok_or_else(|| wasmtime::Error::msg("Str view out of range"))
54
+ }
55
+
56
+ fn str_arg<B: Backend>(caller: &mut Caller<'_, HostState<B>>, p: &[Val], i: usize) -> Result<String> {
57
+ let Val::AnyRef(Some(s)) = &p[i] else {
58
+ return Err(wasmtime::Error::msg("expected a Str argument"));
59
+ };
60
+ Ok(String::from_utf8_lossy(&str_bytes(caller, s)?).into_owned())
61
+ }
62
+
63
+ /// Calls the backend method behind the `plum` import `name`.
64
+ fn dispatch<B: Backend>(name: &str, caller: &mut Caller<'_, HostState<B>>, p: &[Val], r: &mut [Val]) -> Result<()> {
65
+ macro_rules! b {
66
+ () => {
67
+ caller.data_mut().backend
68
+ };
69
+ }
70
+ match name {
71
+ "printLn" => println!("{}", str_arg(caller, p, 0)?),
72
+ "crash" => return Err(wasmtime::Error::msg(format!("crash: {}", str_arg(caller, p, 0)?))),
73
+ "screenWidth" => r[0] = fv(b!().screen_width()),
74
+ "screenHeight" => r[0] = fv(b!().screen_height()),
75
+ "frameTime" => r[0] = fv(b!().frame_time()),
76
+ "clear" => b!().clear(color(p, 0)),
77
+ "drawRect" => b!().draw_rect(f(p, 0), f(p, 1), f(p, 2), f(p, 3), color(p, 4)),
78
+ "drawLine" => b!().draw_line(f(p, 0), f(p, 1), f(p, 2), f(p, 3), f(p, 4), color(p, 5)),
79
+ "drawText" => {
80
+ let s = str_arg(caller, p, 0)?;
81
+ b!().draw_text(&s, f(p, 1), f(p, 2), f(p, 3), color(p, 4));
82
+ }
83
+ "setCamera" => b!().set_camera(f(p, 0), f(p, 1), f(p, 2)),
84
+ "resetCamera" => b!().reset_camera(),
85
+ "atlasLoad" => {
86
+ let s = str_arg(caller, p, 0)?;
87
+ r[0] = Val::I32(b!().atlas_load(&s));
88
+ }
89
+ "spriteCount" => r[0] = Val::I32(b!().sprite_count(int(p, 0))),
90
+ "spriteW" => r[0] = Val::I32(b!().sprite_w(int(p, 0), int(p, 1))),
91
+ "spriteH" => r[0] = Val::I32(b!().sprite_h(int(p, 0), int(p, 1))),
92
+ "spritePivotX" => r[0] = Val::I32(b!().sprite_pivot_x(int(p, 0), int(p, 1))),
93
+ "spritePivotY" => r[0] = Val::I32(b!().sprite_pivot_y(int(p, 0), int(p, 1))),
94
+ "spriteColor" => {
95
+ let c = b!().sprite_color(int(p, 0), int(p, 1));
96
+ for (i, v) in c.into_iter().enumerate() {
97
+ r[i] = fv(v);
98
+ }
99
+ }
100
+ "drawSprite" => b!().draw_sprite(int(p, 0), int(p, 1), f(p, 2), f(p, 3), int(p, 4) != 0, color(p, 5)),
101
+ "drawTile" => b!().draw_tile(int(p, 0), int(p, 1), f(p, 2), f(p, 3), f(p, 4), int(p, 5), color(p, 6)),
102
+ "isKeyDown" => r[0] = Val::I32(b!().is_key_down(int(p, 0)) as i32),
103
+ "isKeyPressed" => r[0] = Val::I32(b!().is_key_pressed(int(p, 0)) as i32),
104
+ "mousePos" => {
105
+ let (x, y) = b!().mouse_pos();
106
+ r[0] = fv(x);
107
+ r[1] = fv(y);
108
+ }
109
+ "isMouseDown" => r[0] = Val::I32(b!().is_mouse_down(int(p, 0)) as i32),
110
+ "isMousePressed" => r[0] = Val::I32(b!().is_mouse_pressed(int(p, 0)) as i32),
111
+ "mouseWheel" => r[0] = fv(b!().mouse_wheel()),
112
+ "mapLoad" => {
113
+ let s = str_arg(caller, p, 0)?;
114
+ r[0] = Val::I32(b!().map_load(&s));
115
+ }
116
+ "mapWidth" => r[0] = Val::I32(b!().map_width(int(p, 0))),
117
+ "mapHeight" => r[0] = Val::I32(b!().map_height(int(p, 0))),
118
+ "mapTerrain" => r[0] = Val::I32(b!().map_terrain(int(p, 0), int(p, 1), int(p, 2))),
119
+ "mapRotation" => r[0] = Val::I32(b!().map_rotation(int(p, 0), int(p, 1), int(p, 2))),
120
+ "mapElevation" => r[0] = Val::I32(b!().map_elevation(int(p, 0), int(p, 1), int(p, 2))),
121
+ "mapObject" => r[0] = Val::I32(b!().map_object(int(p, 0), int(p, 1), int(p, 2))),
122
+ other => return Err(wasmtime::Error::msg(format!("host function '{other}' is not available here"))),
123
+ }
124
+ Ok(())
125
+ }
126
+
127
+ impl<B: Backend> PlumGame<B> {
128
+ /// Instantiates `wasm`, requiring `init`, `update` and `draw` exports.
129
+ pub fn load(wasm: &[u8], backend: B) -> Result<Self> {
130
+ let mut config = wasmtime::Config::new();
131
+ config.wasm_gc(true);
132
+ config.wasm_function_references(true);
133
+ let engine = Engine::new(&config)?;
134
+ let module = Module::new(&engine, wasm)?;
135
+ for name in ["init", "update", "draw"] {
136
+ if module.get_export(name).is_none() {
137
+ return Err(wasmtime::Error::msg(format!("module has no `{name}` export")));
138
+ }
139
+ }
140
+ let mut store = Store::new(&engine, HostState { backend });
141
+ let mut linker: Linker<HostState<B>> = Linker::new(&engine);
142
+ for imp in module.imports() {
143
+ let Some(ty) = imp.ty().func().cloned() else { continue };
144
+ let name = imp.name().to_string();
145
+ let func = Func::new(&mut store, ty, {
146
+ let name = name.clone();
147
+ move |mut caller, p, r| dispatch(&name, &mut caller, p, r)
148
+ });
149
+ linker.define(&store, imp.module(), imp.name(), func)?;
150
+ }
151
+ let instance = linker.instantiate(&mut store, &module)?;
152
+ Ok(PlumGame { store, instance, state: None })
153
+ }
154
+
155
+ pub fn backend(&self) -> &B {
156
+ &self.store.data().backend
157
+ }
158
+
159
+ pub fn backend_mut(&mut self) -> &mut B {
160
+ &mut self.store.data_mut().backend
161
+ }
162
+
163
+ fn func(&mut self, name: &str) -> Result<Func> {
164
+ self.instance.get_func(&mut self.store, name).ok_or_else(|| wasmtime::Error::msg(format!("module has no `{name}` export")))
165
+ }
166
+
167
+ /// Calls `init` and keeps its result as the game state.
168
+ pub fn init(&mut self) -> Result<()> {
169
+ let init = self.func("init")?;
170
+ let mut out = [Val::AnyRef(None)];
171
+ init.call(&mut self.store, &[], &mut out)?;
172
+ let Val::AnyRef(Some(g)) = &out[0] else {
173
+ return Err(wasmtime::Error::msg("`init` returned no state"));
174
+ };
175
+ self.state = Some(g.to_owned_rooted(&mut self.store)?);
176
+ Ok(())
177
+ }
178
+
179
+ fn state_val(&mut self) -> Result<Val> {
180
+ let s = self.state.as_ref().ok_or_else(|| wasmtime::Error::msg("`init` has not run"))?;
181
+ Ok(Val::AnyRef(Some(s.to_rooted(&mut self.store))))
182
+ }
183
+
184
+ pub fn update(&mut self, dt: f32) -> Result<()> {
185
+ let update = self.func("update")?;
186
+ let g = self.state_val()?;
187
+ update.call(&mut self.store, &[g, fv(dt)], &mut [])
188
+ }
189
+
190
+ pub fn draw(&mut self) -> Result<()> {
191
+ let draw = self.func("draw")?;
192
+ let g = self.state_val()?;
193
+ draw.call(&mut self.store, &[g], &mut [])
194
+ }
195
+ }
plum-game/src/recorder.rs ADDED
@@ -0,0 +1,173 @@
1
+ //! A `Backend` that records every call as a string and answers input queries
2
+ //! from scripted values. Asset calls go through a real `AssetStore`.
3
+
4
+ use crate::api::{AssetStore, Backend, Color};
5
+ use std::collections::HashSet;
6
+ use std::path::PathBuf;
7
+
8
+ pub struct Recorder {
9
+ pub calls: Vec<String>,
10
+ pub screen: (f32, f32),
11
+ pub frame_time: f32,
12
+ pub mouse: (f32, f32),
13
+ pub wheel: f32,
14
+ pub keys_down: HashSet<i32>,
15
+ pub keys_pressed: HashSet<i32>,
16
+ pub mouse_down: HashSet<i32>,
17
+ pub mouse_pressed: HashSet<i32>,
18
+ pub assets: AssetStore,
19
+ }
20
+
21
+ fn c4(c: Color) -> String {
22
+ format!("{},{},{},{}", c[0], c[1], c[2], c[3])
23
+ }
24
+
25
+ impl Recorder {
26
+ pub fn new() -> Self {
27
+ Self::with_assets(None)
28
+ }
29
+
30
+ pub fn with_assets(root: Option<PathBuf>) -> Self {
31
+ Recorder {
32
+ calls: Vec::new(),
33
+ screen: (800.0, 600.0),
34
+ frame_time: 1.0 / 60.0,
35
+ mouse: (0.0, 0.0),
36
+ wheel: 0.0,
37
+ keys_down: HashSet::new(),
38
+ keys_pressed: HashSet::new(),
39
+ mouse_down: HashSet::new(),
40
+ mouse_pressed: HashSet::new(),
41
+ assets: AssetStore::new(root),
42
+ }
43
+ }
44
+
45
+ fn rec(&mut self, s: String) {
46
+ self.calls.push(s);
47
+ }
48
+ }
49
+
50
+ impl Default for Recorder {
51
+ fn default() -> Self {
52
+ Self::new()
53
+ }
54
+ }
55
+
56
+ impl Backend for Recorder {
57
+ fn screen_width(&mut self) -> f32 {
58
+ self.rec("screenWidth()".into());
59
+ self.screen.0
60
+ }
61
+ fn screen_height(&mut self) -> f32 {
62
+ self.rec("screenHeight()".into());
63
+ self.screen.1
64
+ }
65
+ fn frame_time(&mut self) -> f32 {
66
+ self.rec("frameTime()".into());
67
+ self.frame_time
68
+ }
69
+ fn clear(&mut self, c: Color) {
70
+ self.rec(format!("clear({})", c4(c)));
71
+ }
72
+ fn draw_rect(&mut self, x: f32, y: f32, w: f32, h: f32, c: Color) {
73
+ self.rec(format!("drawRect({x},{y},{w},{h},{})", c4(c)));
74
+ }
75
+ fn draw_line(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, t: f32, c: Color) {
76
+ self.rec(format!("drawLine({x1},{y1},{x2},{y2},{t},{})", c4(c)));
77
+ }
78
+ fn draw_text(&mut self, s: &str, x: f32, y: f32, size: f32, c: Color) {
79
+ self.rec(format!("drawText({s:?},{x},{y},{size},{})", c4(c)));
80
+ }
81
+ fn set_camera(&mut self, x: f32, y: f32, scale: f32) {
82
+ self.rec(format!("setCamera({x},{y},{scale})"));
83
+ }
84
+ fn reset_camera(&mut self) {
85
+ self.rec("resetCamera()".into());
86
+ }
87
+ fn atlas_load(&mut self, name: &str) -> i32 {
88
+ self.rec(format!("atlasLoad({name:?})"));
89
+ self.assets.atlas_load(name)
90
+ }
91
+ fn sprite_count(&mut self, a: i32) -> i32 {
92
+ self.rec(format!("spriteCount({a})"));
93
+ self.assets.sprite_count(a)
94
+ }
95
+ fn sprite_w(&mut self, a: i32, i: i32) -> i32 {
96
+ self.rec(format!("spriteW({a},{i})"));
97
+ self.assets.sprite_w(a, i)
98
+ }
99
+ fn sprite_h(&mut self, a: i32, i: i32) -> i32 {
100
+ self.rec(format!("spriteH({a},{i})"));
101
+ self.assets.sprite_h(a, i)
102
+ }
103
+ fn sprite_pivot_x(&mut self, a: i32, i: i32) -> i32 {
104
+ self.rec(format!("spritePivotX({a},{i})"));
105
+ self.assets.sprite_pivot_x(a, i)
106
+ }
107
+ fn sprite_pivot_y(&mut self, a: i32, i: i32) -> i32 {
108
+ self.rec(format!("spritePivotY({a},{i})"));
109
+ self.assets.sprite_pivot_y(a, i)
110
+ }
111
+ fn sprite_color(&mut self, a: i32, i: i32) -> Color {
112
+ self.rec(format!("spriteColor({a},{i})"));
113
+ self.assets.sprite_color(a, i)
114
+ }
115
+ fn draw_sprite(&mut self, a: i32, i: i32, x: f32, y: f32, flip_x: bool, tint: Color) {
116
+ self.rec(format!("drawSprite({a},{i},{x},{y},{flip_x},{})", c4(tint)));
117
+ }
118
+ fn draw_tile(&mut self, a: i32, i: i32, x: f32, y: f32, size: f32, qt: i32, tint: Color) {
119
+ self.rec(format!("drawTile({a},{i},{x},{y},{size},{qt},{})", c4(tint)));
120
+ }
121
+ fn is_key_down(&mut self, k: i32) -> bool {
122
+ self.rec(format!("isKeyDown({k})"));
123
+ self.keys_down.contains(&k)
124
+ }
125
+ fn is_key_pressed(&mut self, k: i32) -> bool {
126
+ self.rec(format!("isKeyPressed({k})"));
127
+ self.keys_pressed.contains(&k)
128
+ }
129
+ fn mouse_pos(&mut self) -> (f32, f32) {
130
+ self.rec("mousePos()".into());
131
+ self.mouse
132
+ }
133
+ fn is_mouse_down(&mut self, b: i32) -> bool {
134
+ self.rec(format!("isMouseDown({b})"));
135
+ self.mouse_down.contains(&b)
136
+ }
137
+ fn is_mouse_pressed(&mut self, b: i32) -> bool {
138
+ self.rec(format!("isMousePressed({b})"));
139
+ self.mouse_pressed.contains(&b)
140
+ }
141
+ fn mouse_wheel(&mut self) -> f32 {
142
+ self.rec("mouseWheel()".into());
143
+ self.wheel
144
+ }
145
+ fn map_load(&mut self, path: &str) -> i32 {
146
+ self.rec(format!("mapLoad({path:?})"));
147
+ self.assets.map_load(path)
148
+ }
149
+ fn map_width(&mut self, m: i32) -> i32 {
150
+ self.rec(format!("mapWidth({m})"));
151
+ self.assets.map_width(m)
152
+ }
153
+ fn map_height(&mut self, m: i32) -> i32 {
154
+ self.rec(format!("mapHeight({m})"));
155
+ self.assets.map_height(m)
156
+ }
157
+ fn map_terrain(&mut self, m: i32, x: i32, y: i32) -> i32 {
158
+ self.rec(format!("mapTerrain({m},{x},{y})"));
159
+ self.assets.map_terrain(m, x, y)
160
+ }
161
+ fn map_rotation(&mut self, m: i32, x: i32, y: i32) -> i32 {
162
+ self.rec(format!("mapRotation({m},{x},{y})"));
163
+ self.assets.map_rotation(m, x, y)
164
+ }
165
+ fn map_elevation(&mut self, m: i32, x: i32, y: i32) -> i32 {
166
+ self.rec(format!("mapElevation({m},{x},{y})"));
167
+ self.assets.map_elevation(m, x, y)
168
+ }
169
+ fn map_object(&mut self, m: i32, x: i32, y: i32) -> i32 {
170
+ self.rec(format!("mapObject({m},{x},{y})"));
171
+ self.assets.map_object(m, x, y)
172
+ }
173
+ }
plum-game/tests/fixtures/host_fixture.plum ADDED
@@ -0,0 +1,25 @@
1
+ module host_fixture
2
+ import std/Int
3
+ import std/Float
4
+ import std/Str
5
+ import std/Os
6
+ import std/Vec2
7
+ import std/Color
8
+ import std/Game
9
+
10
+ record State(frames: Int)
11
+
12
+
13
+ export fun init() -> State =
14
+ State(frames: 0)
15
+
16
+ export fun update(g: State, dt: Float) =
17
+ g.frames = g.frames + 1
18
+
19
+ export fun draw(g: State) =
20
+ clear(Color.Black)
21
+ drawRect(1.0, 2.0, 3.0, 4.0, Color.Red)
22
+ p := mousePos()
23
+ drawText("hi", p.x, p.y, 16.0, Color.White)
24
+ if g.frames > 0
25
+ drawText("frames {g.frames}", 0.0, 0.0, 8.0, Color.White)
plum-game/tests/host_test.rs ADDED
@@ -0,0 +1,141 @@
1
+ #![allow(non_snake_case)]
2
+ use plum_game::api::quarter_turns_radians;
3
+ use plum_game::native::PlumGame;
4
+ use plum_game::recorder::Recorder;
5
+ use std::path::{Path, PathBuf};
6
+
7
+ fn root() -> PathBuf {
8
+ Path::new(env!("CARGO_MANIFEST_DIR")).join("..")
9
+ }
10
+
11
+ fn compile(src_path: &Path) -> Vec<u8> {
12
+ let source = plum_core::loadAndMerge(src_path, &root()).expect("load");
13
+ let typed = plum_wasm_codegen::typeCheckIn(&source, &src_path.display().to_string()).expect("typecheck");
14
+ plum_wasm_codegen::compileTyped(&source, typed).expect("compile")
15
+ }
16
+
17
+ fn fixture() -> Vec<u8> {
18
+ compile(&Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/host_fixture.plum"))
19
+ }
20
+
21
+ fn game(rec: Recorder) -> PlumGame<Recorder> {
22
+ let mut g = PlumGame::load(&fixture(), rec).expect("load game");
23
+ g.init().expect("init");
24
+ g
25
+ }
26
+
27
+ #[test]
28
+ fn callOrderAndArgs() {
29
+ let mut rec = Recorder::new();
30
+ rec.mouse = (5.0, 6.0);
31
+ let mut g = game(rec);
32
+ g.draw().unwrap();
33
+ assert_eq!(
34
+ g.backend().calls,
35
+ ["clear(0,0,0,1)", "drawRect(1,2,3,4,1,0,0,1)", "mousePos()", "drawText(\"hi\",5,6,16,1,1,1,1)"]
36
+ );
37
+ }
38
+
39
+ #[test]
40
+ fn colorFlattened() {
41
+ let mut g = game(Recorder::new());
42
+ g.draw().unwrap();
43
+ assert!(g.backend().calls.contains(&"drawRect(1,2,3,4,1,0,0,1)".to_string()));
44
+ }
45
+
46
+ #[test]
47
+ fn mousePosMultiValue() {
48
+ let mut rec = Recorder::new();
49
+ rec.mouse = (12.5, -3.0);
50
+ let mut g = game(rec);
51
+ g.draw().unwrap();
52
+ assert_eq!(g.backend().calls.last().unwrap(), "drawText(\"hi\",12.5,-3,16,1,1,1,1)");
53
+ }
54
+
55
+ #[test]
56
+ fn stateSurvivesFrames() {
57
+ let mut g = game(Recorder::new());
58
+ for _ in 0..3 {
59
+ g.update(0.016).unwrap();
60
+ }
61
+ g.draw().unwrap();
62
+ assert_eq!(g.backend().calls.last().unwrap(), "drawText(\"frames 3\",0,0,8,1,1,1,1)");
63
+ g.update(0.016).unwrap();
64
+ g.backend_mut().calls.clear();
65
+ g.draw().unwrap();
66
+ assert_eq!(g.backend().calls.last().unwrap(), "drawText(\"frames 4\",0,0,8,1,1,1,1)");
67
+ }
68
+
69
+ #[test]
70
+ fn missingExportErrors() {
71
+ let dir = std::env::temp_dir().join(format!("plum_game_missing_{}", std::process::id()));
72
+ std::fs::create_dir_all(&dir).unwrap();
73
+ let path = dir.join("m.plum");
74
+ std::fs::write(
75
+ &path,
76
+ "import std/Int\nimport std/Float\nrecord State(n: Int)\n\nexport fun init() -> State =\n State(n: 0)\nexport fun update(g: State, dt: Float) =\n g.n = 1\n",
77
+ )
78
+ .unwrap();
79
+ let wasm = compile(&path);
80
+ let _ = std::fs::remove_dir_all(&dir);
81
+ let err = PlumGame::load(&wasm, Recorder::new()).err().expect("should fail");
82
+ assert!(err.to_string().contains("`draw`"), "{err}");
83
+ }
84
+
85
+ #[test]
86
+ fn unknownImportTraps() {
87
+ let dir = std::env::temp_dir().join(format!("plum_game_unknown_{}", std::process::id()));
88
+ std::fs::create_dir_all(&dir).unwrap();
89
+ let path = dir.join("m.plum");
90
+ std::fs::write(
91
+ &path,
92
+ "import std/Int\nimport std/Float\nimport std/Str\nimport std/Os\nrecord State(n: Int)\n\nexport fun init() -> State =\n State(n: 0)\nexport fun update(g: State, dt: Float) =\n g.n = 1\nexport fun draw(g: State) =\n x := rawExists(\"a\")\n",
93
+ )
94
+ .unwrap();
95
+ let wasm = compile(&path);
96
+ let _ = std::fs::remove_dir_all(&dir);
97
+ let mut g = PlumGame::load(&wasm, Recorder::new()).unwrap();
98
+ g.init().unwrap();
99
+ let err = g.draw().unwrap_err();
100
+ assert!(format!("{err:?}").contains("is not available here"), "{err:?}");
101
+ }
102
+
103
+ #[test]
104
+ fn quarterTurnsRotateClockwise() {
105
+ use std::f32::consts::FRAC_PI_2;
106
+ assert_eq!(quarter_turns_radians(0), 0.0);
107
+ assert_eq!(quarter_turns_radians(1), FRAC_PI_2);
108
+ assert_eq!(quarter_turns_radians(3), 3.0 * FRAC_PI_2);
109
+ assert_eq!(quarter_turns_radians(4), 0.0);
110
+ assert_eq!(quarter_turns_radians(-1), 3.0 * FRAC_PI_2);
111
+ }
112
+
113
+ #[test]
114
+ fn intHandlesFailWithMinusOne() {
115
+ use plum_game::api::Backend;
116
+ let mut rec = Recorder::with_assets(Some(PathBuf::from("/nonexistent-assets")));
117
+ assert_eq!(rec.atlas_load("Tileset"), -1);
118
+ assert_eq!(rec.map_load("x.map"), -1);
119
+ assert_eq!(rec.sprite_w(5, 5), 0);
120
+ assert_eq!(rec.map_terrain(-1, 0, 0), 0);
121
+ }
122
+
123
+ #[test]
124
+ fn atlasAndMapThroughHost() {
125
+ use plum_game::api::Backend;
126
+ let assets = plum_game::assets::AssetRoot::resolve(None);
127
+ if !assets.join("sprites").is_dir() {
128
+ eprintln!("skipping: asset root {} not found", assets.display());
129
+ return;
130
+ }
131
+ let mut rec = Recorder::with_assets(None);
132
+ let a = rec.atlas_load("Tileset");
133
+ assert_eq!(a, 0);
134
+ assert_eq!(rec.sprite_w(a, 1), 32);
135
+ let m = rec.map_load("Maps/A new Beginning/A new Beginning.map");
136
+ assert_eq!(m, 0);
137
+ let (w, h) = (rec.map_width(m), rec.map_height(m));
138
+ assert!((32..=256).contains(&w) && (32..=256).contains(&h));
139
+ assert!(rec.map_terrain(m, 0, 0) < 512);
140
+ assert_eq!(rec.map_terrain(m, w, 0), 0);
141
+ }