plum

#treesitter#compiler#wasm

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 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 build matching the bundled Playwright driver (about 100 MB, to
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`; it skips with a note if Chromium is not installed.
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 build matching the bundled Playwright driver.
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 `playwright install chromium` has been run (browser cache dir exists).
203
+ /// Whether `browser` has been installed for Playwright (browser cache dir exists).
167
- pub fn chromium_installed() -> bool {
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("chromium")))
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
- let mut args: Vec<String> = ["--ignore-gpu-blocklist", "--autoplay-policy=no-user-gesture-required"].map(String::from).to_vec();
407
+ let mut args: Vec<String> = ["--ignore-gpu-blocklist", "--autoplay-policy=no-user-gesture-required"].map(String::from).to_vec();
353
- // The renderer is WebGPU only: headless Chromium needs a software adapter.
408
+ // The renderer is WebGPU only: headless Chromium needs a software adapter.
354
- args.extend(["--enable-unsafe-webgpu", "--enable-features=Vulkan"].map(String::from));
409
+ args.extend(["--enable-unsafe-webgpu", "--enable-features=Vulkan"].map(String::from));
355
- if !opts.headed {
410
+ if !opts.headed {
356
- args.extend(["--use-angle=swiftshader", "--enable-unsafe-swiftshader", "--use-webgpu-adapter=swiftshader"].map(String::from));
411
+ args.extend(["--use-angle=swiftshader", "--enable-unsafe-swiftshader", "--use-webgpu-adapter=swiftshader"].map(String::from));
357
- }
412
+ }
358
- if opts.uncapped {
413
+ if opts.uncapped {
359
- args.extend(["--disable-gpu-vsync", "--disable-frame-rate-limit"].map(String::from));
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
- let browser = pw.chromium().launch_with_options(launch).await.map_err(pe("launch chromium"))?;
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::{chromium_installed, run, ExpectPixel, Options};
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 !chromium_installed() {
43
+ if !browser_installed(opts.browser) {
40
- fail("Playwright Chromium is not installed; see plum-game/web-test/README.md (one-time setup)");
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", "keydown", "touchstart"]) window.addEventListener(ev, resume);
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 WebAssembly.instantiate(module, { plum: plumImports });
1215
+ const instance = await instantiatePlum(host);
1178
1216
  ctx.plum = instance.exports;
1179
1217
  const ex = ctx.plum;
1180
1218
  const state = ex.init();