plum

#treesitter#compiler#wasm

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

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


f77dbea — Peter John 2026-11-02T14:04:34+05:30
T4: native host: Backend, wasmtime binding, macroquad, recorder
plum-game/src/api.rs CHANGED
@@ -4,6 +4,7 @@
4
4
  //! `Int` handles are indexes into host vectors, `-1` on failure.
5
5
 
6
6
  use crate::assets::{Atlas, AssetRoot, MapData};
7
+ use std::collections::HashMap;
7
8
  use std::path::PathBuf;
8
9
 
9
10
  pub type Color = [f32; 4];
@@ -55,11 +56,24 @@ pub fn quarter_turns_radians(quarter_turns: i32) -> f32 {
55
56
  quarter_turns.rem_euclid(4) as f32 * std::f32::consts::FRAC_PI_2
56
57
  }
57
58
 
59
+ /// Where an [`AssetStore`] reads its files from.
60
+ enum Source {
61
+ /// Files under a directory (native).
62
+ Dir(PathBuf),
63
+ /// Preloaded bytes keyed by asset-root-relative path (the browser fetches
64
+ /// everything before the first frame).
65
+ Files(HashMap<String, Vec<u8>>),
66
+ }
67
+
68
+ const MAPELEM: &str = "data/defines/mapelem.dat";
69
+
58
70
  /// Atlases and maps loaded from an asset root, addressed by handle. Shared by
59
71
  /// every backend so handle numbering and lookups behave the same everywhere.
60
72
  pub struct AssetStore {
61
- root: PathBuf,
73
+ source: Source,
74
+ /// `mapelem.dat`, read on the first map load so files can be preloaded
75
+ /// after construction.
62
- mapelem: Vec<u8>,
76
+ mapelem: Option<Vec<u8>>,
63
77
  pub atlases: Vec<Atlas>,
64
78
  pub maps: Vec<MapData>,
65
79
  }
@@ -67,13 +81,31 @@ pub struct AssetStore {
67
81
  impl AssetStore {
68
82
  pub fn new(root: Option<PathBuf>) -> Self {
69
83
  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() }
84
+ AssetStore { source: Source::Dir(root), mapelem: None, atlases: Vec::new(), maps: Vec::new() }
85
+ }
86
+
87
+ /// A store that serves only files added with [`AssetStore::insert`].
88
+ pub fn preloaded() -> Self {
89
+ AssetStore { source: Source::Files(HashMap::new()), mapelem: None, atlases: Vec::new(), maps: Vec::new() }
90
+ }
91
+
92
+ /// Registers a file under its asset-root-relative path. No effect on a
93
+ /// directory-backed store.
94
+ pub fn insert(&mut self, path: &str, bytes: Vec<u8>) {
95
+ if let Source::Files(files) = &mut self.source {
96
+ files.insert(path.to_string(), bytes);
97
+ }
98
+ }
99
+
100
+ fn read(&self, path: &str) -> Option<Vec<u8>> {
101
+ match &self.source {
102
+ Source::Dir(root) => std::fs::read(root.join(path)).ok(),
103
+ Source::Files(files) => files.get(path).cloned(),
104
+ }
72
105
  }
73
106
 
74
107
  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 };
108
+ let Some(bytes) = self.read(&format!("sprites/{name}.rxx")) else { return -1 };
77
109
  match Atlas::parse(&bytes) {
78
110
  Ok(a) => {
79
111
  self.atlases.push(a);
@@ -84,8 +116,11 @@ impl AssetStore {
84
116
  }
85
117
 
86
118
  pub fn map_load(&mut self, path: &str) -> i32 {
87
- let Ok(bytes) = std::fs::read(self.root.join(path)) else { return -1 };
119
+ let Some(bytes) = self.read(path) else { return -1 };
120
+ if self.mapelem.is_none() {
121
+ self.mapelem = Some(self.read(MAPELEM).unwrap_or_default());
122
+ }
88
- match MapData::parse_with(&bytes, &self.mapelem) {
123
+ match MapData::parse_with(&bytes, self.mapelem.as_deref().unwrap_or_default()) {
89
124
  Ok(m) => {
90
125
  self.maps.push(m);
91
126
  self.maps.len() as i32 - 1
@@ -147,3 +182,19 @@ impl AssetStore {
147
182
  self.cell(m, x, y).map_or(0, |(d, i)| d.object[i] as i32)
148
183
  }
149
184
  }
185
+
186
+ #[cfg(test)]
187
+ mod tests {
188
+ use super::*;
189
+
190
+ #[test]
191
+ fn preloaded_store_serves_only_inserted_files() {
192
+ let mut s = AssetStore::preloaded();
193
+ assert_eq!(s.atlas_load("missing"), -1);
194
+ assert_eq!(s.map_load("maps/missing.map"), -1);
195
+ s.insert("sprites/bad.rxx", vec![1, 2, 3]);
196
+ // Present but malformed: still a failure, not a panic.
197
+ assert_eq!(s.atlas_load("bad"), -1);
198
+ assert_eq!(s.sprite_count(0), 0);
199
+ }
200
+ }
plum-game/src/main.rs CHANGED
@@ -1,8 +1,10 @@
1
1
  //! `plum-game <module.wasm> [--assets DIR]`: runs a compiled Plum game in a
2
+ //! macroquad window. With `PLUM_GAME_CAPTURE=<path.png>` (and optionally
3
+ //! `PLUM_GAME_FRAMES=<n>`, default 30) it saves the screen after n frames and
2
- //! macroquad window.
4
+ //! exits 0.
3
5
 
4
6
  #[cfg(not(target_arch = "wasm32"))]
5
- use plum_game::{mq::MqBackend, native::PlumGame};
7
+ use plum_game::{mq::MqBackend, native::{Capture, PlumGame}};
6
8
 
7
9
  #[cfg(not(target_arch = "wasm32"))]
8
10
  fn window_conf() -> macroquad::window::Conf {
@@ -31,10 +33,21 @@ async fn run(wasm: std::path::PathBuf, assets: Option<std::path::PathBuf>) -> Re
31
33
  let bytes = std::fs::read(&wasm).map_err(|e| format!("{}: {e}", wasm.display()))?;
32
34
  let mut game = PlumGame::load(&bytes, MqBackend::new(assets)).map_err(|e| format!("{e:#}"))?;
33
35
  game.init().map_err(|e| format!("init: {e:#}"))?;
36
+ let capture = Capture::from_env()?;
37
+ let mut frame = 0u32;
34
38
  loop {
35
39
  let dt = macroquad::time::get_frame_time();
36
40
  game.update(dt).map_err(|e| format!("update: {e:#}"))?;
37
41
  game.draw().map_err(|e| format!("draw: {e:#}"))?;
42
+ frame += 1;
43
+ if let Some(c) = &capture {
44
+ if frame >= c.frames {
45
+ // `export_png` flips the bottom-up readback so the PNG is upright.
46
+ let path = c.path.to_str().ok_or("PLUM_GAME_CAPTURE is not valid UTF-8")?;
47
+ macroquad::texture::get_screen_data().export_png(path);
48
+ return Ok(());
49
+ }
50
+ }
38
51
  macroquad::window::next_frame().await;
39
52
  }
40
53
  }
@@ -49,9 +62,12 @@ async fn main() {
49
62
  std::process::exit(2);
50
63
  }
51
64
  };
52
- if let Err(e) = run(wasm, assets).await {
65
+ match run(wasm, assets).await {
66
+ Ok(()) => std::process::exit(0),
67
+ Err(e) => {
53
- eprintln!("{e}");
68
+ eprintln!("{e}");
54
- std::process::exit(1);
69
+ std::process::exit(1);
70
+ }
55
71
  }
56
72
  }
57
73
 
plum-game/src/mq.rs CHANGED
@@ -54,7 +54,15 @@ pub struct MqBackend {
54
54
 
55
55
  impl MqBackend {
56
56
  pub fn new(assets: Option<PathBuf>) -> Self {
57
+ Self::with_assets(AssetStore::new(assets))
58
+ }
59
+
60
+ pub fn with_assets(assets: AssetStore) -> Self {
57
- MqBackend { assets: AssetStore::new(assets), textures: Vec::new(), cam: (0.0, 0.0, 1.0) }
61
+ MqBackend { assets, textures: Vec::new(), cam: (0.0, 0.0, 1.0) }
62
+ }
63
+
64
+ pub fn assets_mut(&mut self) -> &mut AssetStore {
65
+ &mut self.assets
58
66
  }
59
67
 
60
68
  fn to_screen(&self, x: f32, y: f32) -> (f32, f32) {
@@ -176,10 +184,12 @@ impl Backend for MqBackend {
176
184
  }
177
185
  /// One notch per frame with scrolling, signed: positive is up.
178
186
  fn mouse_wheel(&mut self) -> f32 {
179
- {
180
187
  let dy = mq::mouse_wheel().1;
181
- if dy == 0.0 { 0.0 } else { dy.signum() }
188
+ if dy == 0.0 {
189
+ 0.0
190
+ } else {
191
+ dy.signum()
182
- }
192
+ }
183
193
  }
184
194
  fn map_load(&mut self, path: &str) -> i32 {
185
195
  self.assets.map_load(path)
plum-game/src/native.rs CHANGED
@@ -198,3 +198,47 @@ impl<B: Backend> PlumGame<B> {
198
198
  self.call_with_state("draw", &[])
199
199
  }
200
200
  }
201
+
202
+ /// Headless frame capture for visual checks: after `frames` frames the host
203
+ /// saves the framebuffer to `path` and exits 0.
204
+ #[derive(Debug, PartialEq)]
205
+ pub struct Capture {
206
+ pub path: std::path::PathBuf,
207
+ pub frames: u32,
208
+ }
209
+
210
+ impl Capture {
211
+ const DEFAULT_FRAMES: u32 = 30;
212
+
213
+ /// From `PLUM_GAME_CAPTURE` and `PLUM_GAME_FRAMES`. `None` when no path
214
+ /// is set; a frame count that is not a positive integer is an error.
215
+ pub fn parse(path: Option<String>, frames: Option<String>) -> std::result::Result<Option<Capture>, String> {
216
+ let Some(path) = path.filter(|p| !p.is_empty()) else { return Ok(None) };
217
+ let frames = match frames {
218
+ None => Self::DEFAULT_FRAMES,
219
+ Some(f) => f.trim().parse::<u32>().ok().filter(|n| *n > 0).ok_or_else(|| format!("PLUM_GAME_FRAMES must be a positive integer, got `{f}`"))?,
220
+ };
221
+ Ok(Some(Capture { path: path.into(), frames }))
222
+ }
223
+
224
+ pub fn from_env() -> std::result::Result<Option<Capture>, String> {
225
+ Self::parse(std::env::var("PLUM_GAME_CAPTURE").ok(), std::env::var("PLUM_GAME_FRAMES").ok())
226
+ }
227
+ }
228
+
229
+ #[cfg(test)]
230
+ mod tests {
231
+ use super::Capture;
232
+
233
+ #[test]
234
+ fn capture_env_parsing() {
235
+ assert_eq!(Capture::parse(None, Some("5".into())), Ok(None));
236
+ assert_eq!(Capture::parse(Some(String::new()), None), Ok(None));
237
+ let c = Capture::parse(Some("a.png".into()), None).unwrap().unwrap();
238
+ assert_eq!((c.path.to_str(), c.frames), (Some("a.png"), 30));
239
+ let c = Capture::parse(Some("a.png".into()), Some("7".into())).unwrap().unwrap();
240
+ assert_eq!(c.frames, 7);
241
+ assert!(Capture::parse(Some("a.png".into()), Some("0".into())).is_err());
242
+ assert!(Capture::parse(Some("a.png".into()), Some("x".into())).is_err());
243
+ }
244
+ }