plum
git clone https://git.pyrossh.dev/plum
A statically typed, imperative programming language inspired by rust, python
6d64a1a
— Peter John
2026-11-02T17:56:05+05:30
Game bridge: instantiate plum.wasm without WebAssembly.Module.imports (Safari); web-test --browser webkit, --expect-unsupported-webgpu, --no-module-introspection
- README.md +2 -2
- plum-game/web-test/README.md +3 -3
- plum-game/web-test/examples/install-browsers.rs +2 -2
- plum-game/web-test/src/lib.rs +131 -14
- plum-game/web-test/src/main.rs +7 -3
- plum-game/web-test/tests/web_check.rs +50 -1
- plum-game/web/plum_bridge.js +50 -12
README.md
CHANGED
|
@@ -1037,7 +1037,7 @@ Every module that imports `std/Str` also exports four helpers for hosts that can
|
|
|
1037
1037
|
|
|
1038
1038
|
## Games
|
|
1039
1039
|
|
|
1040
|
-
Games run in the browser on Plum's own WebGPU renderer (`plum-game/web/plum_bridge.js`): the Plum module calls the bridge directly, one extern call per draw, and the bridge batches everything into one buffer upload per frame. A draw call binds up to 16 textures (each vertex carries its texture slot), so mixing atlases in y-sorted scenes costs a handful of draw calls; the canvas is `CSS size x devicePixelRatio` while every game coordinate (`screenWidth`, `mousePos`, the camera) stays in CSS pixels; text is drawn from a signed-distance-field glyph atlas, so it stays crisp at any size or zoom; and if the GPU device is lost the renderer requests a new one, re-uploads the textures and logs `GPU device lost (<reason>); restored` (render-target contents start transparent again). WebGPU is required (Chrome or Edge, Safari 26+, Firefox); there is no fallback.
|
|
1040
|
+
Games run in the browser on Plum's own WebGPU renderer (`plum-game/web/plum_bridge.js`): the Plum module calls the bridge directly, one extern call per draw, and the bridge batches everything into one buffer upload per frame. A draw call binds up to 16 textures (each vertex carries its texture slot), so mixing atlases in y-sorted scenes costs a handful of draw calls; the canvas is `CSS size x devicePixelRatio` while every game coordinate (`screenWidth`, `mousePos`, the camera) stays in CSS pixels; text is drawn from a signed-distance-field glyph atlas, so it stays crisp at any size or zoom; and if the GPU device is lost the renderer requests a new one, re-uploads the textures and logs `GPU device lost (<reason>); restored` (render-target contents start transparent again). WebGPU is required (Chrome or Edge, Safari 26+, Firefox); there is no fallback. The bridge reads nothing from the wasm module's import or export lists (`WebAssembly.Module.imports` throws in Safari for typed GC references), so it loads the same way in Safari; `plum-web-test --browser webkit` checks that against Playwright's WebKit.
|
|
1041
1041
|
|
|
1042
1042
|
`plum game file.plum [--assets DIR] [--port N] [--no-open]` compiles the game, serves it on 127.0.0.1 (port 8080, else any free port; `--port` picks one), prints the URL and opens the default browser unless `--no-open` is given. Ctrl-C stops it. `plum game file.plum -o dist [--assets DIR]` writes a static build instead and exits: `index.html`, `plum_bridge.js`, `plum.wasm`, `preload.txt` and an `assets/` copy of the whole asset directory (every file in it is listed in `preload.txt` and fetched before the first frame). Host that directory on any static file server; the browser needs wasm GC support. The asset root defaults to `assets` beside the source file.
|
|
1043
1043
|
|
|
@@ -1057,7 +1057,7 @@ The page calls `init` once after the assets are loaded, then `update` and `draw`
|
|
|
1057
1057
|
|
|
1058
1058
|
**Headless tests:** the `plum-game` crate (a standalone crate, wasmtime only) runs a compiled game with no browser against a `Recorder` backend that logs every host call as a string and answers input from scripted values, so `cargo test --manifest-path plum-game/Cargo.toml` checks games' draw-call sequences (`plum-game/tests/host_test.rs`). `plum-game <wasm> [--assets DIR] [--frames N]` does the same from the command line and prints the host calls of the last frame.
|
|
1059
1059
|
|
|
1060
|
-
**Browser check:** `cargo run --manifest-path plum-game/web-test/Cargo.toml -- plum-examples/game.plum --screenshot /tmp/orb-run.png` builds the static web version, serves it and drives headless Chromium (software WebGPU) through [playwright-rs](https://crates.io/crates/playwright-rs): it fails if the canvas stays blank or stops changing, or the page logs an error, and reports whether audio started. It also takes `--assets DIR`, `--require-audio`, `--measure SECS` (with `--json`, `draws` is the GPU draw calls per frame), `--headed`, `--dpr N` (a device pixel ratio of N), `--lose-device` (destroys the GPU device after a few frames and requires rendering to resume) and `--expect-pixel x,y,r,g,b[,tol]` (repeatable; the screenshot pixel at CSS (x, y)). `plum-game/web-test/fixtures/` has `interleave.plum`, `text_sizes.plum` and `rendertarget.plum` for these. One-time setup: `cargo run --manifest-path plum-game/web-test/Cargo.toml --example install-browsers`. See [`plum-game/web-test/README.md`](plum-game/web-test/README.md); `docs/game.png` above was made with `--screenshot`. [`plum-game/web-test/BENCHMARK.md`](plum-game/web-test/BENCHMARK.md) records the renderer benchmark that led to this design.
|
|
1060
|
+
**Browser check:** `cargo run --manifest-path plum-game/web-test/Cargo.toml -- plum-examples/game.plum --screenshot /tmp/orb-run.png` builds the static web version, serves it and drives headless Chromium (software WebGPU; `--browser webkit` drives Playwright's WebKit, the Safari engine, instead) through [playwright-rs](https://crates.io/crates/playwright-rs): it fails if the canvas stays blank or stops changing, or the page logs an error, and reports whether audio started. It also takes `--assets DIR`, `--require-audio`, `--measure SECS` (with `--json`, `draws` is the GPU draw calls per frame), `--headed`, `--dpr N` (a device pixel ratio of N), `--lose-device` (destroys the GPU device after a few frames and requires rendering to resume) and `--expect-pixel x,y,r,g,b[,tol]` (repeatable; the screenshot pixel at CSS (x, y)), `--expect-unsupported-webgpu` (hides `navigator.gpu` and requires the page's "WebGPU is not supported" message while plum.wasm still loads) and `--no-module-introspection` (makes `WebAssembly.Module.imports/exports/customSections` throw, as Safari does). `plum-game/web-test/fixtures/` has `interleave.plum`, `text_sizes.plum` and `rendertarget.plum` for these. One-time setup: `cargo run --manifest-path plum-game/web-test/Cargo.toml --example install-browsers`. See [`plum-game/web-test/README.md`](plum-game/web-test/README.md); `docs/game.png` above was made with `--screenshot`. [`plum-game/web-test/BENCHMARK.md`](plum-game/web-test/BENCHMARK.md) records the renderer benchmark that led to this design.
|
|
1061
1061
|
|
|
1062
1062
|
**Asset root:** textures and files are read relative to the asset root. A path that escapes the root (`..` or absolute) fails like a missing file: `-1`, `""` or `false`. The headless runner's root is `--assets` or `assets` beside the wasm; it reads image headers for `textureWidth`/`textureHeight` and records sounds without decoding them.
|
|
1063
1063
|
|
plum-game/web-test/README.md
CHANGED
|
@@ -12,7 +12,7 @@ AudioContext state is reported; a missing running context is only a warning unle
|
|
|
12
12
|
|
|
13
13
|
## One-time setup
|
|
14
14
|
|
|
15
|
-
Downloads the Chromium
|
|
15
|
+
Downloads the Chromium and WebKit builds matching the bundled Playwright driver (about 180 MB, to
|
|
16
16
|
`~/Library/Caches/ms-playwright` on macOS; the driver itself is fetched by the crate's build script):
|
|
17
17
|
|
|
18
18
|
cargo run --manifest-path plum-game/web-test/Cargo.toml --example install-browsers
|
|
@@ -21,8 +21,8 @@ Downloads the Chromium build matching the bundled Playwright driver (about 100 M
|
|
|
21
21
|
|
|
22
22
|
cargo run --manifest-path plum-game/web-test/Cargo.toml -- plum-examples/game.plum --screenshot /tmp/orb-run.png
|
|
23
23
|
|
|
24
|
-
Options: `--frames-wait SECS` (default 8), `--screenshot OUT.png`, `--assets DIR` (default: `assets` beside the source), `--require-audio`, `--json` (adds a one-line JSON object with the backend and frame stats), `--headed` (a real GPU window instead of headless software WebGPU), `--measure SECS` (records requestAnimationFrame intervals in the page and prints avg / p50 / p95 / p99 ms, fps and the mean per-frame update/draw/render split; `--json` adds `update_ms`, `draw_ms`, `render_ms`) and `--uncapped` (lifts vsync so the frame time is the work per frame), `--dpr N` (Playwright `deviceScaleFactor`; also checks the backing store is CSS size x ratio), `--lose-device` (destroys the GPU device from the page after a few frames and requires the `GPU device lost (...); restored` log and resumed rendering; the page exposes the device only when the harness asks) and `--expect-pixel x,y,r,g,b[,tol]` (repeatable; the screenshot pixel at CSS (x, y), default tolerance 2). `--json` also reports `draws`, the GPU draw calls per frame. `fixtures/interleave.plum`, `text_sizes.plum` and `rendertarget.plum` exercise multi-texture batching, SDF text and render targets/blend modes. `fixtures/stress.plum` and `fixtures/stress_scene.plum` are the scenes behind [BENCHMARK.md](BENCHMARK.md).
|
|
24
|
+
Options: `--browser chromium|webkit` (default chromium; WebKit is Playwright's Safari-engine build and has WebGPU, so the same render assertions apply; a WebKit build without `navigator.gpu` is checked for the "WebGPU is not supported in this browser" message and a loadable plum.wasm instead), `--expect-unsupported-webgpu` (hides `navigator.gpu` in any browser and checks that path), `--no-module-introspection` (makes `WebAssembly.Module.imports/exports/customSections` throw the TypeError Safari gives for typed GC references), `--frames-wait SECS` (default 8), `--screenshot OUT.png`, `--assets DIR` (default: `assets` beside the source), `--require-audio`, `--json` (adds a one-line JSON object with the backend and frame stats), `--headed` (a real GPU window instead of headless software WebGPU), `--measure SECS` (records requestAnimationFrame intervals in the page and prints avg / p50 / p95 / p99 ms, fps and the mean per-frame update/draw/render split; `--json` adds `update_ms`, `draw_ms`, `render_ms`) and `--uncapped` (lifts vsync so the frame time is the work per frame), `--dpr N` (Playwright `deviceScaleFactor`; also checks the backing store is CSS size x ratio), `--lose-device` (destroys the GPU device from the page after a few frames and requires the `GPU device lost (...); restored` log and resumed rendering; the page exposes the device only when the harness asks) and `--expect-pixel x,y,r,g,b[,tol]` (repeatable; the screenshot pixel at CSS (x, y), default tolerance 2). `--json` also reports `draws`, the GPU draw calls per frame. `fixtures/interleave.plum`, `text_sizes.plum` and `rendertarget.plum` exercise multi-texture batching, SDF text and render targets/blend modes. `fixtures/stress.plum` and `fixtures/stress_scene.plum` are the scenes behind [BENCHMARK.md](BENCHMARK.md).
|
|
25
25
|
Exit code is non-zero with a message on failure.
|
|
26
26
|
|
|
27
27
|
`cargo test --manifest-path plum-game/web-test/Cargo.toml` runs the same check on
|
|
28
|
-
`plum-examples/game.plum` and `plum-examples/scene.plum`;
|
|
28
|
+
`plum-examples/game.plum` and `plum-examples/scene.plum`, plus WebKit runs; each skips with a note if its browser is not installed.
|
plum-game/web-test/examples/install-browsers.rs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// One-time setup: installs the Chromium
|
|
1
|
+
// One-time setup: installs the Chromium and WebKit builds matching the bundled Playwright driver.
|
|
2
2
|
#[tokio::main]
|
|
3
3
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
4
|
-
playwright_rs::install_browsers(Some(&["chromium"])).await?;
|
|
4
|
+
playwright_rs::install_browsers(Some(&["chromium", "webkit"])).await?;
|
|
5
5
|
Ok(())
|
|
6
6
|
}
|
plum-game/web-test/src/lib.rs
CHANGED
|
@@ -8,7 +8,41 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
|
|
8
8
|
use std::sync::{Arc, Mutex};
|
|
9
9
|
use std::time::Duration;
|
|
10
10
|
|
|
11
|
+
/// The browser engine the check drives.
|
|
12
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
13
|
+
pub enum Browser {
|
|
14
|
+
Chromium,
|
|
15
|
+
Webkit,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
impl Browser {
|
|
19
|
+
pub fn parse(s: &str) -> Result<Browser, String> {
|
|
20
|
+
match s {
|
|
21
|
+
"chromium" => Ok(Browser::Chromium),
|
|
22
|
+
"webkit" => Ok(Browser::Webkit),
|
|
23
|
+
_ => Err(format!("--browser {s}: expected chromium or webkit")),
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
pub fn name(self) -> &'static str {
|
|
28
|
+
match self {
|
|
29
|
+
Browser::Chromium => "chromium",
|
|
30
|
+
Browser::Webkit => "webkit",
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
11
35
|
pub struct Options {
|
|
36
|
+
/// The engine to drive (default chromium).
|
|
37
|
+
pub browser: Browser,
|
|
38
|
+
/// Hide `navigator.gpu` and require the page to report that WebGPU is unsupported
|
|
39
|
+
/// (the bridge's own message) while plum.wasm still loads. A WebKit run is checked
|
|
40
|
+
/// this way automatically when its build has no `navigator.gpu`.
|
|
41
|
+
pub expect_unsupported_webgpu: bool,
|
|
42
|
+
/// Make `WebAssembly.Module.imports/exports/customSections` throw the TypeError
|
|
43
|
+
/// JavaScriptCore gives for modules with typed GC references, in any engine: the
|
|
44
|
+
/// bridge must not need module introspection.
|
|
45
|
+
pub no_module_introspection: bool,
|
|
12
46
|
pub frames_wait: f64,
|
|
13
47
|
pub screenshot: Option<PathBuf>,
|
|
14
48
|
pub require_audio: bool,
|
|
@@ -33,6 +67,9 @@ pub struct Options {
|
|
|
33
67
|
impl Default for Options {
|
|
34
68
|
fn default() -> Self {
|
|
35
69
|
Options {
|
|
70
|
+
browser: Browser::Chromium,
|
|
71
|
+
expect_unsupported_webgpu: false,
|
|
72
|
+
no_module_introspection: false,
|
|
36
73
|
frames_wait: 8.0,
|
|
37
74
|
screenshot: None,
|
|
38
75
|
require_audio: false,
|
|
@@ -163,8 +200,8 @@ pub fn stats_json(opts: &Options, backend: Option<&str>, st: Option<&FrameStats>
|
|
|
163
200
|
s
|
|
164
201
|
}
|
|
165
202
|
|
|
166
|
-
/// Whether `
|
|
203
|
+
/// Whether `browser` has been installed for Playwright (browser cache dir exists).
|
|
167
|
-
pub fn
|
|
204
|
+
pub fn browser_installed(browser: Browser) -> bool {
|
|
168
205
|
let dir = if let Ok(p) = std::env::var("PLAYWRIGHT_BROWSERS_PATH") {
|
|
169
206
|
PathBuf::from(p)
|
|
170
207
|
} else {
|
|
@@ -178,10 +215,20 @@ pub fn chromium_installed() -> bool {
|
|
|
178
215
|
}
|
|
179
216
|
};
|
|
180
217
|
std::fs::read_dir(dir)
|
|
181
|
-
.map(|rd| rd.flatten().any(|e| e.file_name().to_string_lossy().starts_with(
|
|
218
|
+
.map(|rd| rd.flatten().any(|e| e.file_name().to_string_lossy().starts_with(browser.name())))
|
|
182
219
|
.unwrap_or(false)
|
|
183
220
|
}
|
|
184
221
|
|
|
222
|
+
/// Whether `playwright install chromium` has been run.
|
|
223
|
+
pub fn chromium_installed() -> bool {
|
|
224
|
+
browser_installed(Browser::Chromium)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/// Whether `playwright install webkit` has been run.
|
|
228
|
+
pub fn webkit_installed() -> bool {
|
|
229
|
+
browser_installed(Browser::Webkit)
|
|
230
|
+
}
|
|
231
|
+
|
|
185
232
|
pub fn repo_root() -> PathBuf {
|
|
186
233
|
Path::new(env!("CARGO_MANIFEST_DIR")).join("../..").canonicalize().expect("repo root")
|
|
187
234
|
}
|
|
@@ -312,6 +359,12 @@ const AUDIO_HOOK: &str = r#"(() => {
|
|
|
312
359
|
}
|
|
313
360
|
})()"#;
|
|
314
361
|
|
|
362
|
+
const NO_MODULE_INTROSPECTION: &str = r#"(() => {
|
|
363
|
+
for (const [k, what] of [["imports", "import"], ["exports", "export"], ["customSections", "custom section"]]) {
|
|
364
|
+
WebAssembly.Module[k] = () => { throw new TypeError("WebAssembly.Module." + k + " unable to produce " + what + " descriptors for the given module"); };
|
|
365
|
+
}
|
|
366
|
+
})()"#;
|
|
367
|
+
|
|
315
368
|
const RAF_PROBE: &str = r#"(() => {
|
|
316
369
|
window.__rafStamps = [];
|
|
317
370
|
window.__rafOn = false;
|
|
@@ -349,22 +402,73 @@ fn pe<E: std::fmt::Display>(what: &'static str) -> impl FnOnce(E) -> String {
|
|
|
349
402
|
|
|
350
403
|
async fn check(port: u16, opts: &Options) -> Result<String, String> {
|
|
351
404
|
let pw = Playwright::launch().await.map_err(pe("launch playwright"))?;
|
|
405
|
+
let browser = match opts.browser {
|
|
406
|
+
Browser::Chromium => {
|
|
352
|
-
|
|
407
|
+
let mut args: Vec<String> = ["--ignore-gpu-blocklist", "--autoplay-policy=no-user-gesture-required"].map(String::from).to_vec();
|
|
353
|
-
|
|
408
|
+
// The renderer is WebGPU only: headless Chromium needs a software adapter.
|
|
354
|
-
|
|
409
|
+
args.extend(["--enable-unsafe-webgpu", "--enable-features=Vulkan"].map(String::from));
|
|
355
|
-
|
|
410
|
+
if !opts.headed {
|
|
356
|
-
|
|
411
|
+
args.extend(["--use-angle=swiftshader", "--enable-unsafe-swiftshader", "--use-webgpu-adapter=swiftshader"].map(String::from));
|
|
357
|
-
|
|
412
|
+
}
|
|
358
|
-
|
|
413
|
+
if opts.uncapped {
|
|
359
|
-
|
|
414
|
+
args.extend(["--disable-gpu-vsync", "--disable-frame-rate-limit"].map(String::from));
|
|
360
|
-
|
|
415
|
+
}
|
|
361
|
-
let launch = LaunchOptions::new().headless(!opts.headed).args(args);
|
|
362
|
-
|
|
416
|
+
pw.chromium().launch_with_options(LaunchOptions::new().headless(!opts.headed).args(args)).await.map_err(pe("launch chromium"))?
|
|
417
|
+
}
|
|
418
|
+
Browser::Webkit => pw.webkit().launch_with_options(LaunchOptions::new().headless(!opts.headed)).await.map_err(pe("launch webkit"))?,
|
|
419
|
+
};
|
|
363
420
|
let out = check_page(&browser, port, opts).await;
|
|
364
421
|
let _ = browser.close().await;
|
|
365
422
|
out
|
|
366
423
|
}
|
|
367
424
|
|
|
425
|
+
const UNSUPPORTED_MESSAGE: &str = "WebGPU is not supported in this browser";
|
|
426
|
+
|
|
427
|
+
/// The page has no `navigator.gpu`: it must say so, plum.wasm must still load and
|
|
428
|
+
/// initialise (`plumWasmProbe` is "ok"), and nothing else may have gone wrong.
|
|
429
|
+
async fn check_unsupported(
|
|
430
|
+
page: &playwright_rs::protocol::Page,
|
|
431
|
+
errors: &Arc<Mutex<Vec<String>>>,
|
|
432
|
+
logs: &Arc<Mutex<Vec<String>>>,
|
|
433
|
+
opts: &Options,
|
|
434
|
+
) -> Result<String, String> {
|
|
435
|
+
let mut probe: Option<String> = None;
|
|
436
|
+
for _ in 0..40 {
|
|
437
|
+
probe = page.evaluate("() => window.plumWasmProbe || null", None::<&()>).await.unwrap_or(None);
|
|
438
|
+
if probe.is_some() {
|
|
439
|
+
break;
|
|
440
|
+
}
|
|
441
|
+
tokio::time::sleep(Duration::from_millis(250)).await;
|
|
442
|
+
}
|
|
443
|
+
let text: String = page.evaluate("() => document.body.innerText", None::<&()>).await.unwrap_or_default();
|
|
444
|
+
if let Some(p) = &opts.screenshot {
|
|
445
|
+
let png = page.screenshot(None).await.map_err(pe("screenshot"))?;
|
|
446
|
+
std::fs::write(p, &png).map_err(|e| format!("write {}: {e}", p.display()))?;
|
|
447
|
+
}
|
|
448
|
+
let expected_log = format!("console.error: {UNSUPPORTED_MESSAGE}");
|
|
449
|
+
let mut failures: Vec<String> = vec![];
|
|
450
|
+
if !text.contains(UNSUPPORTED_MESSAGE) {
|
|
451
|
+
failures.push(format!("the page does not show '{UNSUPPORTED_MESSAGE}'"));
|
|
452
|
+
}
|
|
453
|
+
match probe.as_deref() {
|
|
454
|
+
Some("ok") => {}
|
|
455
|
+
Some(e) => failures.push(format!("plum.wasm did not load: {e}")),
|
|
456
|
+
None => failures.push("plum.wasm did not finish loading (no plumWasmProbe result)".to_string()),
|
|
457
|
+
}
|
|
458
|
+
failures.extend(errors.lock().unwrap().iter().filter(|e| **e != expected_log).cloned());
|
|
459
|
+
let report = format!(
|
|
460
|
+
"WebGPU: navigator.gpu is not available in {}; the page shows '{UNSUPPORTED_MESSAGE}'\nplum.wasm load probe: {}",
|
|
461
|
+
opts.browser.name(),
|
|
462
|
+
probe.as_deref().unwrap_or("none")
|
|
463
|
+
);
|
|
464
|
+
if failures.is_empty() {
|
|
465
|
+
Ok(report)
|
|
466
|
+
} else {
|
|
467
|
+
let logs = logs.lock().unwrap().join("\n");
|
|
468
|
+
Err(format!("{}\n{report}\n--- console ---\n{logs}", failures.join("\n")))
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
368
472
|
async fn check_page(browser: &playwright_rs::protocol::Browser, port: u16, opts: &Options) -> Result<String, String> {
|
|
369
473
|
let page = match opts.dpr {
|
|
370
474
|
Some(d) => {
|
|
@@ -408,11 +512,24 @@ async fn check_page(browser: &playwright_rs::protocol::Browser, port: u16, opts:
|
|
|
408
512
|
if opts.lose_device {
|
|
409
513
|
page.add_init_script("window.__plumExposeDevice = true;").await.map_err(pe("init script"))?;
|
|
410
514
|
}
|
|
515
|
+
if opts.expect_unsupported_webgpu {
|
|
516
|
+
page.add_init_script("delete Navigator.prototype.gpu;").await.map_err(pe("init script"))?;
|
|
517
|
+
}
|
|
518
|
+
if opts.no_module_introspection {
|
|
519
|
+
page.add_init_script(NO_MODULE_INTROSPECTION).await.map_err(pe("init script"))?;
|
|
520
|
+
}
|
|
411
521
|
page.add_init_script(RAF_PROBE).await.map_err(pe("init script"))?;
|
|
522
|
+
// Without WebGPU the bridge stops after its message; this asks it to still compile,
|
|
523
|
+
// instantiate and initialise plum.wasm and report the outcome in `plumWasmProbe`.
|
|
524
|
+
page.add_init_script("window.__plumWasmProbe = true;").await.map_err(pe("init script"))?;
|
|
412
525
|
page.goto(&format!("http://127.0.0.1:{port}/"), None).await.map_err(pe("goto"))?;
|
|
413
526
|
|
|
414
527
|
// Give the wasm time to load, then provide a user gesture on the canvas.
|
|
415
528
|
tokio::time::sleep(Duration::from_secs_f64((opts.frames_wait / 2.0).min(3.0))).await;
|
|
529
|
+
let has_gpu: bool = page.evaluate("() => !!navigator.gpu", None::<&()>).await.unwrap_or(false);
|
|
530
|
+
if !has_gpu && (opts.expect_unsupported_webgpu || opts.browser == Browser::Webkit) {
|
|
531
|
+
return check_unsupported(&page, &errors, &logs, opts).await;
|
|
532
|
+
}
|
|
416
533
|
let size: Vec<f64> = page
|
|
417
534
|
.evaluate("() => { const r = document.getElementById('glcanvas').getBoundingClientRect(); return [r.width, r.height]; }", None::<&()>)
|
|
418
535
|
.await
|
plum-game/web-test/src/main.rs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
use plum_web_test::{
|
|
1
|
+
use plum_web_test::{browser_installed, run, Browser, ExpectPixel, Options};
|
|
2
2
|
use std::path::PathBuf;
|
|
3
3
|
|
|
4
4
|
const USAGE: &str = "usage: plum-web-test <file.plum> [--frames-wait SECS] [--screenshot OUT.png] [--require-audio]\n [--assets DIR] [--headed] [--measure SECS] [--uncapped] [--json]
|
|
5
|
+
[--browser chromium|webkit] [--expect-unsupported-webgpu] [--no-module-introspection]
|
|
5
6
|
[--dpr N] [--lose-device] [--expect-pixel x,y,r,g,b[,tol]]...";
|
|
6
7
|
|
|
7
8
|
#[tokio::main]
|
|
@@ -19,6 +20,9 @@ async fn main() {
|
|
|
19
20
|
"--headed" => opts.headed = true,
|
|
20
21
|
"--uncapped" => opts.uncapped = true,
|
|
21
22
|
"--json" => opts.json = true,
|
|
23
|
+
"--browser" => opts.browser = Browser::parse(&args.next().unwrap_or_else(|| fail("--browser needs chromium or webkit"))).unwrap_or_else(|e| fail(&e)),
|
|
24
|
+
"--no-module-introspection" => opts.no_module_introspection = true,
|
|
25
|
+
"--expect-unsupported-webgpu" => opts.expect_unsupported_webgpu = true,
|
|
22
26
|
"--expect-pixel" => {
|
|
23
27
|
let v = args.next().unwrap_or_else(|| fail("--expect-pixel needs x,y,r,g,b[,tol]"));
|
|
24
28
|
opts.expect_pixels.push(ExpectPixel::parse(&v).unwrap_or_else(|e| fail(&e)));
|
|
@@ -36,8 +40,8 @@ async fn main() {
|
|
|
36
40
|
}
|
|
37
41
|
}
|
|
38
42
|
let Some(file) = file else { fail(USAGE) };
|
|
39
|
-
if !
|
|
43
|
+
if !browser_installed(opts.browser) {
|
|
40
|
-
fail("Playwright
|
|
44
|
+
fail(&format!("Playwright {} is not installed; see plum-game/web-test/README.md (one-time setup)", opts.browser.name()));
|
|
41
45
|
}
|
|
42
46
|
match run(&file, &opts).await {
|
|
43
47
|
Ok(report) => println!("{report}\nPASS"),
|
plum-game/web-test/tests/web_check.rs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
use plum_web_test::{chromium_installed, repo_root, run, Options};
|
|
1
|
+
use plum_web_test::{chromium_installed, repo_root, run, webkit_installed, Browser, Options};
|
|
2
2
|
|
|
3
3
|
#[test]
|
|
4
4
|
fn orb_run_renders_and_animates() {
|
|
@@ -22,6 +22,55 @@ fn stats_json_is_one_object() {
|
|
|
22
22
|
assert_eq!(stats_json(&o, None, None, None), r#"{"backend":null,"headed":true,"uncapped":false}"#);
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/// Safari's JavaScriptCore throws from `WebAssembly.Module.imports` for modules whose imports
|
|
26
|
+
/// use typed GC references; the bridge must instantiate without any module introspection.
|
|
27
|
+
#[test]
|
|
28
|
+
fn bridge_does_not_introspect_the_module() {
|
|
29
|
+
let js = std::fs::read_to_string(repo_root().join("plum-game/web/plum_bridge.js")).unwrap();
|
|
30
|
+
for api in ["Module.imports(", "Module.exports(", "Module.customSections("] {
|
|
31
|
+
assert!(!js.contains(api), "plum_bridge.js calls WebAssembly.{api}");
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
#[test]
|
|
36
|
+
fn chromium_runs_without_module_introspection() {
|
|
37
|
+
if !chromium_installed() {
|
|
38
|
+
eprintln!("SKIP: Playwright Chromium not installed (see plum-game/web-test/README.md)");
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
let opts = Options { no_module_introspection: true, ..Options::default() };
|
|
42
|
+
let rt = tokio::runtime::Runtime::new().unwrap();
|
|
43
|
+
let report = rt.block_on(run(&repo_root().join("plum-examples/game.plum"), &opts)).unwrap_or_else(|e| panic!("{e}"));
|
|
44
|
+
println!("{report}");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
#[test]
|
|
48
|
+
fn webkit_runs_without_module_introspection() {
|
|
49
|
+
if !webkit_installed() {
|
|
50
|
+
eprintln!("SKIP: Playwright WebKit not installed (see plum-game/web-test/README.md)");
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
let opts = Options { browser: Browser::Webkit, no_module_introspection: true, ..Options::default() };
|
|
54
|
+
let rt = tokio::runtime::Runtime::new().unwrap();
|
|
55
|
+
let report = rt.block_on(run(&repo_root().join("plum-examples/game.plum"), &opts)).unwrap_or_else(|e| {
|
|
56
|
+
assert!(!e.contains("unable to produce import descriptors"), "{e}");
|
|
57
|
+
panic!("{e}")
|
|
58
|
+
});
|
|
59
|
+
println!("{report}");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
#[test]
|
|
63
|
+
fn webkit_without_webgpu_loads_the_wasm_and_says_so() {
|
|
64
|
+
if !webkit_installed() {
|
|
65
|
+
eprintln!("SKIP: Playwright WebKit not installed (see plum-game/web-test/README.md)");
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
let opts = Options { browser: Browser::Webkit, expect_unsupported_webgpu: true, no_module_introspection: true, ..Options::default() };
|
|
69
|
+
let rt = tokio::runtime::Runtime::new().unwrap();
|
|
70
|
+
let report = rt.block_on(run(&repo_root().join("plum-examples/game.plum"), &opts)).unwrap_or_else(|e| panic!("{e}"));
|
|
71
|
+
assert!(report.contains("plum.wasm load probe: ok"), "{report}");
|
|
72
|
+
}
|
|
73
|
+
|
|
25
74
|
fn orb_run(example: &str) {
|
|
26
75
|
if !chromium_installed() {
|
|
27
76
|
eprintln!("SKIP: Playwright Chromium not installed (see plum-game/web-test/README.md)");
|
plum-game/web/plum_bridge.js
CHANGED
|
@@ -153,7 +153,8 @@
|
|
|
153
153
|
const resume = () => {
|
|
154
154
|
if (ctx.state !== "running") ctx.resume();
|
|
155
155
|
};
|
|
156
|
+
// Safari only unlocks audio from the gesture events it counts as activation.
|
|
156
|
-
for (const ev of ["pointerdown", "
|
|
157
|
+
for (const ev of ["pointerdown", "mousedown", "touchstart", "touchend", "click", "keydown"]) window.addEventListener(ev, resume);
|
|
157
158
|
}
|
|
158
159
|
|
|
159
160
|
const sound = (h) => (h >= 0 && h < handles.length ? handles[h] : null);
|
|
@@ -1030,6 +1031,52 @@ fn shade(i: VOut) -> vec4f {
|
|
|
1030
1031
|
|
|
1031
1032
|
// --- start-up --------------------------------------------------------------------
|
|
1032
1033
|
|
|
1034
|
+
// Compiles and instantiates plum.wasm against `host` (the `plum` import namespace).
|
|
1035
|
+
// The imports are not read from the module (WebAssembly.Module.imports throws in
|
|
1036
|
+
// JavaScriptCore for signatures with typed GC references): the namespace is a Proxy
|
|
1037
|
+
// that answers every name the module asks for, with the host function or, for a
|
|
1038
|
+
// name the host lacks, a stub that throws when called.
|
|
1039
|
+
async function instantiatePlum(host) {
|
|
1040
|
+
const stubs = new Map();
|
|
1041
|
+
const plum = new Proxy(host, {
|
|
1042
|
+
get(target, name) {
|
|
1043
|
+
if (typeof name !== "string") return undefined;
|
|
1044
|
+
if (Object.prototype.hasOwnProperty.call(target, name)) return target[name];
|
|
1045
|
+
let stub = stubs.get(name);
|
|
1046
|
+
if (!stub) {
|
|
1047
|
+
stub = () => {
|
|
1048
|
+
throw new Error("host function '" + name + "' is not available here");
|
|
1049
|
+
};
|
|
1050
|
+
stubs.set(name, stub);
|
|
1051
|
+
}
|
|
1052
|
+
return stub;
|
|
1053
|
+
},
|
|
1054
|
+
});
|
|
1055
|
+
const imports = { plum };
|
|
1056
|
+
let module;
|
|
1057
|
+
try {
|
|
1058
|
+
module = await WebAssembly.compileStreaming(fetch("plum.wasm"));
|
|
1059
|
+
} catch (e) {
|
|
1060
|
+
// A server that does not send application/wasm (or an engine without streaming).
|
|
1061
|
+
const res = await fetch("plum.wasm");
|
|
1062
|
+
if (!res.ok) throw new Error("plum.wasm: HTTP " + res.status);
|
|
1063
|
+
module = await WebAssembly.compile(await res.arrayBuffer());
|
|
1064
|
+
}
|
|
1065
|
+
return WebAssembly.instantiate(module, imports);
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
// plum-web-test sets __plumWasmProbe: a browser without WebGPU still instantiates
|
|
1069
|
+
// plum.wasm so the check can see that the module loads there; the outcome is
|
|
1070
|
+
// plumWasmProbe ("ok" or the error text).
|
|
1071
|
+
async function probeWasm() {
|
|
1072
|
+
try {
|
|
1073
|
+
const { exports: ex } = await instantiatePlum({});
|
|
1074
|
+
globalThis.plumWasmProbe = ["init", "update", "draw"].every((n) => typeof ex[n] === "function") ? "ok" : "plum.wasm lacks init/update/draw";
|
|
1075
|
+
} catch (e) {
|
|
1076
|
+
globalThis.plumWasmProbe = String(e);
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1033
1080
|
async function main() {
|
|
1034
1081
|
const canvas = document.getElementById("glcanvas");
|
|
1035
1082
|
// Game coordinates are CSS pixels; the backing store is CSS size x devicePixelRatio.
|
|
@@ -1066,6 +1113,7 @@ fn shade(i: VOut) -> vec4f {
|
|
|
1066
1113
|
const msg = "WebGPU is not supported in this browser";
|
|
1067
1114
|
console.error(msg);
|
|
1068
1115
|
message(msg, "#f55");
|
|
1116
|
+
if (globalThis.__plumWasmProbe) await probeWasm();
|
|
1069
1117
|
return;
|
|
1070
1118
|
}
|
|
1071
1119
|
// The atlas is a backend texture (smooth, SDF) built once the backend exists.
|
|
@@ -1164,17 +1212,7 @@ fn shade(i: VOut) -> vec4f {
|
|
|
1164
1212
|
},
|
|
1165
1213
|
};
|
|
1166
1214
|
|
|
1167
|
-
const module = await WebAssembly.compileStreaming(fetch("plum.wasm"));
|
|
1168
|
-
const plumImports = {};
|
|
1169
|
-
for (const imp of WebAssembly.Module.imports(module)) {
|
|
1170
|
-
if (imp.module !== "plum" || imp.kind !== "function") continue;
|
|
1171
|
-
plumImports[imp.name] =
|
|
1172
|
-
host[imp.name] ||
|
|
1173
|
-
(() => {
|
|
1174
|
-
throw new Error("host function '" + imp.name + "' is not available here");
|
|
1175
|
-
});
|
|
1176
|
-
}
|
|
1177
|
-
const instance = await
|
|
1215
|
+
const instance = await instantiatePlum(host);
|
|
1178
1216
|
ctx.plum = instance.exports;
|
|
1179
1217
|
const ex = ctx.plum;
|
|
1180
1218
|
const state = ex.init();
|