plum

#treesitter#compiler#wasm

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

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


298c125 — Peter John 2026-11-02T16:11:22+05:30
plum-game: command-buffer decoder, native cmd mode, PLUM_GAME_MEASURE timing
plum-game/src/cmd.rs ADDED
@@ -0,0 +1,202 @@
1
+ //! Decoder for the command buffer written by `std/GameCmd` (format in
2
+ //! docs/plans/2026-11-02-render-experiments.md): little-endian 4-byte fields,
3
+ //! each command a u32 opcode followed by its fields.
4
+
5
+ use crate::api::{Backend, Color};
6
+
7
+ struct Reader<'a> {
8
+ buf: &'a [u8],
9
+ pos: usize,
10
+ }
11
+
12
+ impl Reader<'_> {
13
+ fn word(&mut self) -> Result<[u8; 4], String> {
14
+ let end = self.pos + 4;
15
+ let w = self.buf.get(self.pos..end).ok_or_else(|| format!("command buffer truncated at byte {}", self.pos))?;
16
+ self.pos = end;
17
+ Ok([w[0], w[1], w[2], w[3]])
18
+ }
19
+ fn u32(&mut self) -> Result<u32, String> {
20
+ self.word().map(u32::from_le_bytes)
21
+ }
22
+ fn i32(&mut self) -> Result<i32, String> {
23
+ self.word().map(i32::from_le_bytes)
24
+ }
25
+ fn f32(&mut self) -> Result<f32, String> {
26
+ self.word().map(f32::from_le_bytes)
27
+ }
28
+ fn color(&mut self) -> Result<Color, String> {
29
+ Ok([self.f32()?, self.f32()?, self.f32()?, self.f32()?])
30
+ }
31
+ fn f4(&mut self) -> Result<[f32; 4], String> {
32
+ self.color()
33
+ }
34
+ }
35
+
36
+ /// Replays `buf` into `b`, in command order. Errors (never panics) on an
37
+ /// unknown opcode or a truncated command; commands before it were already played.
38
+ pub fn decode(buf: &[u8], b: &mut dyn Backend) -> Result<(), String> {
39
+ let mut r = Reader { buf, pos: 0 };
40
+ while r.pos < buf.len() {
41
+ let at = r.pos;
42
+ match r.u32()? {
43
+ 1 => {
44
+ let c = r.color()?;
45
+ b.clear(c);
46
+ }
47
+ 2 => {
48
+ let (x, y, w, h) = (r.f32()?, r.f32()?, r.f32()?, r.f32()?);
49
+ let c = r.color()?;
50
+ b.draw_rect(x, y, w, h, c);
51
+ }
52
+ 3 => {
53
+ let (x1, y1, x2, y2, t) = (r.f32()?, r.f32()?, r.f32()?, r.f32()?, r.f32()?);
54
+ let c = r.color()?;
55
+ b.draw_line(x1, y1, x2, y2, t, c);
56
+ }
57
+ 4 => {
58
+ let (x, y, rad) = (r.f32()?, r.f32()?, r.f32()?);
59
+ let c = r.color()?;
60
+ b.draw_circle(x, y, rad, c);
61
+ }
62
+ 5 => {
63
+ let (x, y, size) = (r.f32()?, r.f32()?, r.f32()?);
64
+ let c = r.color()?;
65
+ let len = usize::try_from(r.i32()?).map_err(|_| format!("negative text length in command at byte {at}"))?;
66
+ let padded = len.checked_add(3).ok_or("text length overflow")? / 4 * 4;
67
+ let end = r.pos.checked_add(padded).filter(|e| *e <= buf.len()).ok_or_else(|| format!("command buffer truncated in text at byte {at}"))?;
68
+ let s = String::from_utf8_lossy(&buf[r.pos..r.pos + len]).into_owned();
69
+ r.pos = end;
70
+ b.draw_text(&s, x, y, size, c);
71
+ }
72
+ 6 => {
73
+ let t = r.i32()?;
74
+ let (x, y) = (r.f32()?, r.f32()?);
75
+ let c = r.color()?;
76
+ b.draw_texture(t, x, y, c);
77
+ }
78
+ 7 => {
79
+ let t = r.i32()?;
80
+ let src = r.f4()?;
81
+ let dest = r.f4()?;
82
+ let rot = r.f32()?;
83
+ let flip = r.i32()? != 0;
84
+ let c = r.color()?;
85
+ b.draw_texture_ex(t, src, dest, rot, flip, c);
86
+ }
87
+ 8 => {
88
+ let (x, y, s) = (r.f32()?, r.f32()?, r.f32()?);
89
+ b.set_camera(x, y, s);
90
+ }
91
+ 9 => b.reset_camera(),
92
+ op => return Err(format!("unknown command opcode {op} at byte {at}")),
93
+ }
94
+ }
95
+ Ok(())
96
+ }
97
+
98
+ #[cfg(test)]
99
+ mod tests {
100
+ use super::decode;
101
+ use crate::recorder::Recorder;
102
+
103
+ struct Enc(Vec<u8>);
104
+ impl Enc {
105
+ fn op(&mut self, o: u32) -> &mut Self {
106
+ self.0.extend(o.to_le_bytes());
107
+ self
108
+ }
109
+ fn f(&mut self, v: &[f32]) -> &mut Self {
110
+ for x in v {
111
+ self.0.extend(x.to_le_bytes());
112
+ }
113
+ self
114
+ }
115
+ fn i(&mut self, v: i32) -> &mut Self {
116
+ self.0.extend(v.to_le_bytes());
117
+ self
118
+ }
119
+ fn text(&mut self, s: &str) -> &mut Self {
120
+ self.i(s.len() as i32);
121
+ self.0.extend(s.as_bytes());
122
+ while self.0.len() % 4 != 0 {
123
+ self.0.push(0);
124
+ }
125
+ self
126
+ }
127
+ }
128
+
129
+ fn run(buf: &[u8]) -> (Result<(), String>, Vec<String>) {
130
+ let mut rec = Recorder::new();
131
+ let r = decode(buf, &mut rec);
132
+ (r, rec.calls)
133
+ }
134
+
135
+ #[test]
136
+ fn every_opcode() {
137
+ let mut e = Enc(vec![]);
138
+ e.op(1).f(&[0., 0., 0., 1.]);
139
+ e.op(2).f(&[1., 2., 3., 4., 1., 0., 0., 1.]);
140
+ e.op(3).f(&[1., 2., 3., 4., 5., 0., 1., 0., 1.]);
141
+ e.op(4).f(&[5., 6., 7., 0., 0., 1., 1.]);
142
+ e.op(5).f(&[1., 2., 16., 1., 1., 1., 1.]).text("hello");
143
+ e.op(6).i(2).f(&[3., 4., 1., 1., 1., 1.]);
144
+ e.op(7).i(2).f(&[0., 0., 8., 8., 10., 20., 16., 16., 0.5]).i(1).f(&[1., 1., 1., 1.]);
145
+ e.op(8).f(&[1., 2., 3.]);
146
+ e.op(9);
147
+ let (r, calls) = run(&e.0);
148
+ r.unwrap();
149
+ let mut want = Recorder::new();
150
+ use crate::api::Backend;
151
+ want.clear([0., 0., 0., 1.]);
152
+ want.draw_rect(1., 2., 3., 4., [1., 0., 0., 1.]);
153
+ want.draw_line(1., 2., 3., 4., 5., [0., 1., 0., 1.]);
154
+ want.draw_circle(5., 6., 7., [0., 0., 1., 1.]);
155
+ want.draw_text("hello", 1., 2., 16., [1., 1., 1., 1.]);
156
+ want.draw_texture(2, 3., 4., [1., 1., 1., 1.]);
157
+ want.draw_texture_ex(2, [0., 0., 8., 8.], [10., 20., 16., 16.], 0.5, true, [1., 1., 1., 1.]);
158
+ want.set_camera(1., 2., 3.);
159
+ want.reset_camera();
160
+ assert_eq!(calls, want.calls);
161
+ assert_eq!(calls.len(), 9);
162
+ }
163
+
164
+ #[test]
165
+ fn text_padding() {
166
+ for s in ["", "a", "abcd", "abcde", "héllo"] {
167
+ let mut e = Enc(vec![]);
168
+ e.op(5).f(&[0., 0., 8., 1., 1., 1., 1.]).text(s);
169
+ e.op(9);
170
+ let (r, calls) = run(&e.0);
171
+ r.unwrap();
172
+ assert_eq!(calls, [format!("drawText({s:?},0,0,8,1,1,1,1)"), "resetCamera()".to_string()], "{s}");
173
+ }
174
+ }
175
+
176
+ #[test]
177
+ fn truncated_errors_without_panic() {
178
+ let mut e = Enc(vec![]);
179
+ e.op(2).f(&[1., 2., 3., 4., 1., 0., 0., 1.]);
180
+ e.op(5).f(&[1., 2., 16., 1., 1., 1., 1.]).text("hello");
181
+ for n in 1..e.0.len() {
182
+ if n == 36 {
183
+ continue; // exactly the end of the first command
184
+ }
185
+ let (r, _) = run(&e.0[..n]);
186
+ assert!(r.is_err(), "len {n} should error");
187
+ }
188
+ assert!(run(&e.0).0.is_ok());
189
+ assert!(run(&[]).0.is_ok());
190
+ }
191
+
192
+ #[test]
193
+ fn bad_input_errors() {
194
+ assert!(run(&Enc(vec![]).op(99).0).0.is_err());
195
+ let mut e = Enc(vec![]);
196
+ e.op(5).f(&[0.; 7]).i(-1);
197
+ assert!(run(&e.0).0.is_err());
198
+ let mut e = Enc(vec![]);
199
+ e.op(5).f(&[0.; 7]).i(i32::MAX);
200
+ assert!(run(&e.0).0.is_err());
201
+ }
202
+ }
plum-game/src/lib.rs CHANGED
@@ -1,6 +1,7 @@
1
1
  //! Native and web host for Plum game programs.
2
2
 
3
3
  pub mod api;
4
+ pub mod cmd;
4
5
  pub mod mq;
5
6
  pub mod recorder;
6
7
 
plum-game/src/main.rs CHANGED
@@ -2,14 +2,24 @@
2
2
  //! macroquad window. The asset root defaults to `assets` beside the wasm, or
3
3
  //! `$PLUM_GAME_ASSETS`. With `PLUM_GAME_CAPTURE=<path.png>` (and optionally
4
4
  //! `PLUM_GAME_FRAMES=<n>`, default 30) it saves the screen after n frames and
5
+ //! exits 0. With `PLUM_GAME_MEASURE=<secs>` it runs uncapped (
5
- //! exits 0.
6
+ //! swap interval 0 is honored on
7
+ //! Linux/Windows GL, but miniquad on macOS paces frames with a CVDisplayLink, so
8
+ //! fps is capped at the display rate and present_ms then includes the wait) for that long, prints one JSON timing line
9
+ //! (frames, avg/p50/p95/p99 ms, fps, mean update/draw/present ms) and exits 0.
6
10
 
7
11
  #[cfg(not(target_arch = "wasm32"))]
8
- use plum_game::{mq::MqBackend, native::{Capture, PlumGame}};
12
+ use plum_game::{mq::MqBackend, native::{parse_measure, Capture, PlumGame, Timings}};
9
13
 
10
14
  #[cfg(not(target_arch = "wasm32"))]
11
15
  fn window_conf() -> macroquad::window::Conf {
16
+ // PLUM_GAME_MEASURE runs uncapped: ask the driver for swap interval 0.
17
+ let measuring = std::env::var("PLUM_GAME_MEASURE").is_ok_and(|v| !v.is_empty());
12
- macroquad::window::Conf { window_title: "Plum Game".to_string(), window_width: 1280, window_height: 720, ..Default::default() }
18
+ let mut conf = macroquad::window::Conf { window_title: "Plum Game".to_string(), window_width: 1280, window_height: 720, ..Default::default() };
19
+ if measuring {
20
+ conf.platform.swap_interval = Some(0);
21
+ }
22
+ conf
13
23
  }
14
24
 
15
25
  #[cfg(not(target_arch = "wasm32"))]
@@ -47,21 +57,42 @@ async fn run(wasm: std::path::PathBuf, assets: std::path::PathBuf) -> Result<(),
47
57
  let mut game = PlumGame::load(&bytes, backend).map_err(|e| format!("{e:#}"))?;
48
58
  game.init().map_err(|e| format!("init: {e:#}"))?;
49
59
  let capture = Capture::from_env()?;
60
+ let measure = parse_measure(std::env::var("PLUM_GAME_MEASURE").ok())?;
61
+ let mut timings = Timings::default();
62
+ let started = std::time::Instant::now();
50
63
  let mut frame = 0u32;
51
64
  loop {
65
+ let t0 = std::time::Instant::now();
52
66
  let dt = macroquad::time::get_frame_time();
53
67
  game.update(dt).map_err(|e| format!("update: {e:#}"))?;
68
+ let t1 = std::time::Instant::now();
54
69
  game.draw().map_err(|e| format!("draw: {e:#}"))?;
70
+ let t2 = std::time::Instant::now();
71
+ let ms = |a: std::time::Instant, b: std::time::Instant| (b - a).as_secs_f64() * 1000.0;
72
+ timings.update += ms(t0, t1);
73
+ timings.draw += ms(t1, t2);
55
74
  frame += 1;
56
75
  if let Some(c) = &capture {
57
76
  if frame >= c.frames {
58
77
  // `export_png` flips the bottom-up readback so the PNG is upright.
59
78
  let path = c.path.to_str().ok_or("PLUM_GAME_CAPTURE is not valid UTF-8")?;
60
79
  macroquad::texture::get_screen_data().export_png(path);
80
+ if measure.is_some() {
81
+ println!("{}", timings.json());
82
+ }
61
83
  return Ok(());
62
84
  }
63
85
  }
64
86
  macroquad::window::next_frame().await;
87
+ let t3 = std::time::Instant::now();
88
+ timings.present += (t3 - t2).as_secs_f64() * 1000.0;
89
+ timings.frame.push((t3 - t0).as_secs_f64() * 1000.0);
90
+ if let Some(secs) = measure {
91
+ if started.elapsed().as_secs_f64() >= secs {
92
+ println!("{}", timings.json());
93
+ return Ok(());
94
+ }
95
+ }
65
96
  }
66
97
  }
67
98
 
plum-game/src/native.rs CHANGED
@@ -15,6 +15,16 @@ pub struct PlumGame<B: Backend> {
15
15
  instance: Instance,
16
16
  /// The `init` result, rooted across frames.
17
17
  state: Option<OwnedRooted<AnyRef>>,
18
+ /// `Some` when the module exports `cmdReset`/`cmdPtr`/`cmdLen`/`memory`:
19
+ /// draw calls arrive as one command buffer read after `draw`.
20
+ cmd: Option<CmdExports>,
21
+ }
22
+
23
+ struct CmdExports {
24
+ reset: Func,
25
+ ptr: Func,
26
+ len: Func,
27
+ memory: wasmtime::Memory,
18
28
  }
19
29
 
20
30
  fn f(p: &[Val], i: usize) -> f32 {
@@ -178,7 +188,30 @@ impl<B: Backend> PlumGame<B> {
178
188
  linker.define(&store, imp.module(), imp.name(), func)?;
179
189
  }
180
190
  let instance = linker.instantiate(&mut store, &module)?;
191
+ let cmd = Self::cmd_exports(&mut store, &instance)?;
181
- Ok(PlumGame { store, instance, state: None })
192
+ Ok(PlumGame { store, instance, state: None, cmd })
193
+ }
194
+
195
+ fn cmd_exports(store: &mut Store<HostState<B>>, instance: &Instance) -> Result<Option<CmdExports>> {
196
+ let (Some(reset), Some(ptr), Some(len), Some(memory)) = (
197
+ instance.get_func(&mut *store, "cmdReset"),
198
+ instance.get_func(&mut *store, "cmdPtr"),
199
+ instance.get_func(&mut *store, "cmdLen"),
200
+ instance.get_memory(&mut *store, "memory"),
201
+ ) else {
202
+ return Ok(None);
203
+ };
204
+ Ok(Some(CmdExports {
205
+ reset,
206
+ ptr,
207
+ len,
208
+ memory,
209
+ }))
210
+ }
211
+
212
+ /// Whether draw calls are read from a command buffer.
213
+ pub fn uses_command_buffer(&self) -> bool {
214
+ self.cmd.is_some()
182
215
  }
183
216
 
184
217
  pub fn backend(&self) -> &B {
@@ -221,7 +254,30 @@ impl<B: Backend> PlumGame<B> {
221
254
  }
222
255
 
223
256
  pub fn draw(&mut self) -> Result<()> {
257
+ let Some(cmd) = &self.cmd else {
258
+ return self.call_with_state("draw", &[]);
259
+ };
260
+ let (reset, ptr, len, memory) = (cmd.reset.clone(), cmd.ptr.clone(), cmd.len.clone(), cmd.memory);
261
+ reset.call(&mut self.store, &[], &mut [])?;
224
- self.call_with_state("draw", &[])
262
+ self.call_with_state("draw", &[])?;
263
+ // `Int` is i64 in Plum; accept i32 too.
264
+ let mut int_result = |f: &Func| -> Result<i64> {
265
+ let mut out = [Val::I64(0)];
266
+ f.call(&mut self.store, &[], &mut out)?;
267
+ match out[0] {
268
+ Val::I64(v) => Ok(v),
269
+ Val::I32(v) => Ok(v as i64),
270
+ _ => Err(wasmtime::Error::msg("cmdPtr/cmdLen must return Int")),
271
+ }
272
+ };
273
+ let (ptr, len) = (int_result(&ptr)?, int_result(&len)?);
274
+ let (data, host) = memory.data_and_store_mut(&mut self.store);
275
+ let range = usize::try_from(ptr)
276
+ .ok()
277
+ .zip(usize::try_from(len).ok())
278
+ .and_then(|(p, l)| data.get(p..p.checked_add(l)?))
279
+ .ok_or_else(|| wasmtime::Error::msg(format!("command buffer [{ptr}, +{len}) is outside memory")))?;
280
+ crate::cmd::decode(range, &mut host.backend).map_err(wasmtime::Error::msg)
225
281
  }
226
282
  }
227
283
 
@@ -252,9 +308,60 @@ impl Capture {
252
308
  }
253
309
  }
254
310
 
311
+ /// Per-frame timings (milliseconds) for `PLUM_GAME_MEASURE`.
312
+ #[derive(Default)]
313
+ pub struct Timings {
314
+ pub frame: Vec<f64>,
315
+ pub update: f64,
316
+ pub draw: f64,
317
+ pub present: f64,
318
+ }
319
+
320
+ /// Seconds from `PLUM_GAME_MEASURE`; `None` when unset, an error when not a positive number.
321
+ pub fn parse_measure(v: Option<String>) -> std::result::Result<Option<f64>, String> {
322
+ let Some(v) = v.filter(|v| !v.is_empty()) else { return Ok(None) };
323
+ v.trim().parse::<f64>().ok().filter(|s| s.is_finite() && *s > 0.0).map(Some).ok_or_else(|| format!("PLUM_GAME_MEASURE must be a positive number of seconds, got `{v}`"))
324
+ }
325
+
326
+ impl Timings {
327
+ /// One JSON line: frame stats over all frames, split means per frame.
328
+ pub fn json(&mut self) -> String {
329
+ let n = self.frame.len().max(1) as f64;
330
+ let total: f64 = self.frame.iter().sum();
331
+ self.frame.sort_by(|a, b| a.total_cmp(b));
332
+ let pct = |p: f64| self.frame.get(((self.frame.len() as f64 * p) as usize).min(self.frame.len().saturating_sub(1))).copied().unwrap_or(0.0);
333
+ let avg = total / n;
334
+ format!(
335
+ "{{\"frames\":{},\"avg_ms\":{:.3},\"p50_ms\":{:.3},\"p95_ms\":{:.3},\"p99_ms\":{:.3},\"fps\":{:.1},\"update_ms\":{:.3},\"draw_ms\":{:.3},\"present_ms\":{:.3}}}",
336
+ self.frame.len(),
337
+ avg,
338
+ pct(0.5),
339
+ pct(0.95),
340
+ pct(0.99),
341
+ if avg > 0.0 { 1000.0 / avg } else { 0.0 },
342
+ self.update / n,
343
+ self.draw / n,
344
+ self.present / n
345
+ )
346
+ }
347
+ }
348
+
255
349
  #[cfg(test)]
256
350
  mod tests {
257
- use super::Capture;
351
+ use super::{parse_measure, Capture, Timings};
352
+
353
+ #[test]
354
+ fn measure_parsing_and_json() {
355
+ assert_eq!(parse_measure(None), Ok(None));
356
+ assert_eq!(parse_measure(Some("2.5".into())), Ok(Some(2.5)));
357
+ assert!(parse_measure(Some("0".into())).is_err());
358
+ assert!(parse_measure(Some("x".into())).is_err());
359
+ let mut t = Timings { frame: vec![1.0, 2.0, 3.0, 4.0], update: 4.0, draw: 8.0, present: 0.0 };
360
+ assert_eq!(
361
+ t.json(),
362
+ r#"{"frames":4,"avg_ms":2.500,"p50_ms":3.000,"p95_ms":4.000,"p99_ms":4.000,"fps":400.0,"update_ms":1.000,"draw_ms":2.000,"present_ms":0.000}"#
363
+ );
364
+ }
258
365
 
259
366
  #[test]
260
367
  fn capture_env_parsing() {
plum-game/tests/host_test.rs CHANGED
@@ -250,3 +250,33 @@ fn soundPathEscapeRejected() {
250
250
  assert_eq!(escaped, [-1, -1, -1]);
251
251
  assert_eq!(ok, 0);
252
252
  }
253
+
254
+ /// Draw calls of one frame of `src`, with texture/size queries left in.
255
+ fn one_frame(src: &Path, assets: &Path) -> (Vec<String>, bool) {
256
+ let mut g = PlumGame::load(&compile(src), Recorder::with_assets(assets.to_path_buf())).expect("load");
257
+ g.init().expect("init");
258
+ g.update(1.0 / 60.0).unwrap();
259
+ g.backend_mut().calls.clear();
260
+ g.draw().unwrap();
261
+ (g.backend().calls.clone(), g.uses_command_buffer())
262
+ }
263
+
264
+ /// The command-buffer variant of the stress scene must replay the same call
265
+ /// sequence as the per-call variant. Skipped while std/GameCmd or the fixture
266
+ /// is not in the tree.
267
+ #[test]
268
+ fn commandBufferMatchesPerCall() {
269
+ let fixtures = Path::new(env!("CARGO_MANIFEST_DIR")).join("web-test/fixtures");
270
+ let (per_call, cmd) = (fixtures.join("stress.plum"), fixtures.join("stress_cmd.plum"));
271
+ if !root().join("plum-std/GameCmd.plum").exists() || !cmd.exists() || !per_call.exists() {
272
+ eprintln!("SKIP commandBufferMatchesPerCall: std/GameCmd.plum or stress_cmd.plum is missing");
273
+ return;
274
+ }
275
+ let assets = fixtures.join("assets");
276
+ let (a, a_cmd) = one_frame(&per_call, &assets);
277
+ let (b, b_cmd) = one_frame(&cmd, &assets);
278
+ assert!(!a_cmd && b_cmd, "stress_cmd must use the command buffer, stress must not");
279
+ assert!(!a.is_empty());
280
+ assert_eq!(a.len(), b.len());
281
+ assert_eq!(a, b);
282
+ }