plum
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 +202 -0
- plum-game/src/lib.rs +1 -0
- plum-game/src/main.rs +34 -3
- plum-game/src/native.rs +110 -3
- plum-game/tests/host_test.rs +30 -0
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
|
-
//!
|
|
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
|
+
}
|