plum

#treesitter#compiler#wasm

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

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


2c424c4 — Peter John 2026-11-02T17:25:03+05:30
plum_bridge: HiDPI backing store and GPU device-loss recovery; web-test --dpr and --lose-device
plum-game/web-test/src/lib.rs CHANGED
@@ -22,6 +22,10 @@ pub struct Options {
22
22
  pub json: bool,
23
23
  /// Lift Chromium's vsync / frame-rate cap so frame times show the work per frame.
24
24
  pub uncapped: bool,
25
+ /// Browser `devicePixelRatio` (Playwright `deviceScaleFactor`); the page default when None.
26
+ pub dpr: Option<f64>,
27
+ /// Destroy the GPU device from the page after a few frames and require rendering to resume.
28
+ pub lose_device: bool,
25
29
  }
26
30
 
27
31
  impl Default for Options {
@@ -35,6 +39,8 @@ impl Default for Options {
35
39
  measure: None,
36
40
  json: false,
37
41
  uncapped: false,
42
+ dpr: None,
43
+ lose_device: false,
38
44
  }
39
45
  }
40
46
  }
@@ -319,7 +325,14 @@ async fn check(port: u16, opts: &Options) -> Result<String, String> {
319
325
  }
320
326
 
321
327
  async fn check_page(browser: &playwright_rs::protocol::Browser, port: u16, opts: &Options) -> Result<String, String> {
328
+ let page = match opts.dpr {
329
+ Some(d) => {
330
+ let ctx_opts = playwright_rs::protocol::BrowserContextOptions::builder().device_scale_factor(d).build();
331
+ let ctx = browser.new_context_with_options(ctx_opts).await.map_err(pe("new context"))?;
332
+ ctx.new_page().await.map_err(pe("new page"))?
333
+ }
322
- let page = browser.new_page().await.map_err(pe("new page"))?;
334
+ None => browser.new_page().await.map_err(pe("new page"))?,
335
+ };
323
336
  let errors: Arc<Mutex<Vec<String>>> = Arc::default();
324
337
  let logs: Arc<Mutex<Vec<String>>> = Arc::default();
325
338
  {
@@ -351,6 +364,9 @@ async fn check_page(browser: &playwright_rs::protocol::Browser, port: u16, opts:
351
364
  .map_err(pe("on_pageerror"))?;
352
365
  }
353
366
  page.add_init_script(AUDIO_HOOK).await.map_err(pe("init script"))?;
367
+ if opts.lose_device {
368
+ page.add_init_script("window.__plumExposeDevice = true;").await.map_err(pe("init script"))?;
369
+ }
354
370
  page.add_init_script(RAF_PROBE).await.map_err(pe("init script"))?;
355
371
  page.goto(&format!("http://127.0.0.1:{port}/"), None).await.map_err(pe("goto"))?;
356
372
 
@@ -363,6 +379,31 @@ async fn check_page(browser: &playwright_rs::protocol::Browser, port: u16, opts:
363
379
  page.mouse().click(size[0] / 2.0, size[1] / 2.0, None).await.map_err(pe("click"))?;
364
380
  tokio::time::sleep(Duration::from_secs_f64((opts.frames_wait - (opts.frames_wait / 2.0).min(3.0)).max(0.0))).await;
365
381
 
382
+ let mut loss_failure = None;
383
+ if opts.lose_device {
384
+ let before: f64 = page.evaluate("() => window.plumFrames || 0", None::<&()>).await.unwrap_or(0.0);
385
+ let destroyed: bool = page
386
+ .evaluate("() => { if (!window.__plumDevice) return false; window.__plumDevice.destroy(); return true; }", None::<&()>)
387
+ .await
388
+ .unwrap_or(false);
389
+ tokio::time::sleep(Duration::from_secs(3)).await;
390
+ let after: f64 = page.evaluate("() => window.plumFrames || 0", None::<&()>).await.unwrap_or(0.0);
391
+ let restored = logs.lock().unwrap().iter().any(|l| l.contains("GPU device lost (") && l.ends_with("; restored"));
392
+ loss_failure = if !destroyed {
393
+ Some("--lose-device: the page exposed no GPU device".to_string())
394
+ } else if !restored {
395
+ Some("--lose-device: no 'GPU device lost (...); restored' log after device.destroy()".to_string())
396
+ } else if after < before + 10.0 {
397
+ Some(format!("--lose-device: rendering did not resume (frames drawn {before} -> {after})"))
398
+ } else {
399
+ None
400
+ };
401
+ }
402
+ let backing: Vec<f64> = page
403
+ .evaluate("() => { const c = document.getElementById('glcanvas'); return [c.width, c.height, window.devicePixelRatio, c.clientWidth, c.clientHeight]; }", None::<&()>)
404
+ .await
405
+ .unwrap_or_default();
406
+
366
407
  let renderer: Option<String> = page.evaluate("() => window.plumRenderer || null", None::<&()>).await.unwrap_or(None);
367
408
  let mut measured = None;
368
409
  let mut phases = None;
@@ -396,6 +437,7 @@ async fn check_page(browser: &playwright_rs::protocol::Browser, port: u16, opts:
396
437
  let colours = distinct_colours(&b, 1000);
397
438
  let diff = differing_pixels(&a, &b);
398
439
  let errs = errors.lock().unwrap().clone();
440
+ let mut failures_pre: Vec<String> = vec![];
399
441
 
400
442
  let mut report = format!(
401
443
  "canvas {}x{}, distinct colours: {colours}{}, pixels changed over 1s: {}\naudio: {}",
@@ -433,7 +475,22 @@ async fn check_page(browser: &playwright_rs::protocol::Browser, port: u16, opts:
433
475
  if opts.json {
434
476
  report.push_str(&format!("\n{}", stats_json(opts, renderer.as_deref(), measured.as_ref(), phases.as_ref())));
435
477
  }
478
+ if let [bw, bh, ratio, cw, ch] = backing[..] {
479
+ report.push_str(&format!("\nbacking store {bw}x{bh} for CSS {cw}x{ch} at devicePixelRatio {ratio}"));
480
+ if (bw - (cw * ratio).round()).abs() > 1.0 || (bh - (ch * ratio).round()).abs() > 1.0 {
481
+ failures_pre.push(format!("backing store {bw}x{bh} is not CSS {cw}x{ch} x devicePixelRatio {ratio}"));
482
+ }
483
+ if let Some(d) = opts.dpr {
484
+ if (ratio - d).abs() > 1e-6 {
485
+ failures_pre.push(format!("--dpr {d}: the page reports devicePixelRatio {ratio}"));
486
+ }
487
+ }
488
+ }
489
+ if opts.lose_device {
490
+ report.push_str(if loss_failure.is_none() { "\ndevice loss: destroyed, restored, rendering resumed" } else { "\ndevice loss: FAILED" });
491
+ }
436
- let mut failures = vec![];
492
+ let mut failures = failures_pre;
493
+ failures.extend(loss_failure);
437
494
  if renderer.is_none() {
438
495
  failures.push("the page did not set globalThis.plumRenderer (WebGPU failed to start?)".to_string());
439
496
  }
plum-game/web-test/src/main.rs CHANGED
@@ -1,7 +1,8 @@
1
1
  use plum_web_test::{chromium_installed, run, Options};
2
2
  use std::path::PathBuf;
3
3
 
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]";
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
+ [--dpr N] [--lose-device]";
5
6
 
6
7
  #[tokio::main]
7
8
  async fn main() {
@@ -18,6 +19,8 @@ async fn main() {
18
19
  "--headed" => opts.headed = true,
19
20
  "--uncapped" => opts.uncapped = true,
20
21
  "--json" => opts.json = true,
22
+ "--lose-device" => opts.lose_device = true,
23
+ "--dpr" => opts.dpr = Some(args.next().and_then(|v| v.parse().ok()).unwrap_or_else(|| fail("--dpr needs a number"))),
21
24
  "--measure" => opts.measure = Some(args.next().and_then(|v| v.parse().ok()).unwrap_or_else(|| fail("--measure needs a number of seconds"))),
22
25
  "--assets" => opts.assets = Some(args.next().map(PathBuf::from).unwrap_or_else(|| fail("--assets needs a directory"))),
23
26
  "-h" | "--help" => {
plum-game/web/plum_bridge.js CHANGED
@@ -280,8 +280,9 @@
280
280
  let texSeq = 0;
281
281
  const white = { w: 1, h: 1, white: true, uid: texSeq++ };
282
282
  const textures = []; // handle -> texture object { w, h, uid, smooth, ... }
283
- let sw = 1;
283
+ let sw = 1; // screen size in CSS pixels (the unit of game coordinates)
284
284
  let sh = 1;
285
+ let dpr = 1; // device pixels per CSS pixel
285
286
  const cam = { x: 0, y: 0, s: 1 };
286
287
  // world -> clip: X = x * mx + bx, Y = y * my + by
287
288
  let mx = 2;
@@ -373,10 +374,11 @@
373
374
  white,
374
375
  cam,
375
376
  clearColor,
376
- // Per-frame state; (w, h) is the screen size in pixels.
377
+ // Per-frame state; (w, h) is the screen size in CSS pixels.
377
- beginFrame(w, h) {
378
+ beginFrame(w, h, ratio) {
378
379
  sw = w;
379
380
  sh = h;
381
+ dpr = ratio;
380
382
  vn = 0;
381
383
  inn = 0;
382
384
  nb = 0;
@@ -416,7 +418,7 @@
416
418
  },
417
419
  drawCircle(x, y, rad, r, g, b, a) {
418
420
  if (!(rad > 0)) return;
419
- const n = Math.min(MAX_SEGMENTS, Math.max(24, Math.ceil(rad * cam.s * 0.6)));
421
+ const n = Math.min(MAX_SEGMENTS, Math.max(24, Math.ceil(rad * cam.s * dpr * 0.6)));
420
422
  reserve(n + 1, n * 3);
421
423
  useTex(white);
422
424
  const c = packColor(r, g, b, a);
@@ -433,13 +435,13 @@
433
435
  cur.count += n * 3;
434
436
  },
435
437
  // `x, y` is the text's top-left. The run is rasterised at its on-screen
436
- // size (size * camera scale), so it is drawn 1:1 in screen pixels.
438
+ // size in device pixels (size * camera scale * dpr), so it is drawn 1:1.
437
439
  drawText(str, x, y, size, r, g, b, a) {
438
440
  if (!str) return;
439
- const px = Math.max(1, Math.round(size * cam.s));
441
+ const px = Math.max(1, Math.round(size * cam.s * dpr));
440
442
  const t = env.textTexture(str, px);
441
443
  if (!t) return;
442
- const s = cam.s;
444
+ const s = cam.s * dpr;
443
445
  const x0 = x - t.pad / s;
444
446
  const y0 = y - t.pad / s;
445
447
  const x1 = x0 + t.w / s;
@@ -538,56 +540,119 @@ ${cases} default: { return vec4f(1.0); }
538
540
  }
539
541
  const WGSL = makeWGSL();
540
542
 
543
+ // The WebGPU backend. Everything that belongs to the device lives in `setup`, so a
544
+ // lost device is replaced by requesting a new one, building that state again and
545
+ // re-uploading every live texture from its kept source (an ImageBitmap or pixel
546
+ // data; render-target contents are lost and start transparent).
547
+ // `onLost(reason, restored)` reports a loss and whether a new device was obtained.
541
- async function makeWebGPU(canvas, b) {
548
+ async function makeWebGPU(canvas, b, onLost) {
542
549
  const white = b.white;
543
550
  if (!navigator.gpu) return null;
544
- const adapter = await navigator.gpu.requestAdapter();
545
- if (!adapter) return null;
546
- const device = await adapter.requestDevice();
547
551
  const ctx = canvas.getContext("webgpu");
548
552
  if (!ctx) return null;
549
553
  const format = navigator.gpu.getPreferredCanvasFormat();
550
- ctx.configure({ device, format, alphaMode: "opaque" });
551
- const module = device.createShaderModule({ code: WGSL });
554
+ let device = null;
552
- const pipeline = device.createRenderPipeline({
555
+ let pipeline = null;
553
- layout: "auto",
556
+ let layout = null;
554
- vertex: {
555
- module,
556
- entryPoint: "vs",
557
- buffers: [{
558
- arrayStride: VERT_BYTES,
559
- attributes: [
560
- { shaderLocation: 0, format: "float32x2", offset: 0 },
561
- { shaderLocation: 1, format: "float32x2", offset: 8 },
562
- { shaderLocation: 2, format: "unorm8x4", offset: 16 },
563
- { shaderLocation: 3, format: "uint32", offset: 20 },
564
- ],
565
- }],
566
- },
567
- fragment: {
568
- module,
569
- entryPoint: "fs",
570
- targets: [{
557
+ let nearest = null;
571
- format,
572
- blend: {
573
- color: { srcFactor: "src-alpha", dstFactor: "one-minus-src-alpha", operation: "add" },
574
- alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
575
- },
576
- }],
577
- },
578
- primitive: { topology: "triangle-list" },
579
- });
580
- const layout = pipeline.getBindGroupLayout(0);
558
+ let linear = null;
581
- const nearest = device.createSampler({ magFilter: "nearest", minFilter: "nearest" });
582
- const linear = device.createSampler({ magFilter: "linear", minFilter: "linear" });
583
559
  let vbuf = null;
584
560
  let vsize = 0;
585
561
  let ibuf = null;
586
562
  let isize = 0;
563
+ const groups = new Map(); // bind groups by the textures of a draw's slots
564
+ const live = new Set(); // every texture that has GPU memory
565
+
566
+ async function acquire() {
567
+ const adapter = await navigator.gpu.requestAdapter();
568
+ return adapter ? adapter.requestDevice() : null;
569
+ }
570
+
571
+ function setup(dev) {
572
+ device = dev;
573
+ if (globalThis.__plumExposeDevice) globalThis.__plumDevice = dev; // set by plum-web-test --lose-device
574
+ ctx.configure({ device, format, alphaMode: "opaque" });
575
+ const module = device.createShaderModule({ code: WGSL });
576
+ pipeline = device.createRenderPipeline({
577
+ layout: "auto",
578
+ vertex: {
579
+ module,
580
+ entryPoint: "vs",
581
+ buffers: [{
582
+ arrayStride: VERT_BYTES,
583
+ attributes: [
584
+ { shaderLocation: 0, format: "float32x2", offset: 0 },
585
+ { shaderLocation: 1, format: "float32x2", offset: 8 },
586
+ { shaderLocation: 2, format: "unorm8x4", offset: 16 },
587
+ { shaderLocation: 3, format: "uint32", offset: 20 },
588
+ ],
589
+ }],
590
+ },
591
+ fragment: {
592
+ module,
593
+ entryPoint: "fs",
594
+ targets: [{
595
+ format,
596
+ blend: {
597
+ color: { srcFactor: "src-alpha", dstFactor: "one-minus-src-alpha", operation: "add" },
598
+ alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
599
+ },
600
+ }],
601
+ },
602
+ primitive: { topology: "triangle-list" },
603
+ });
604
+ layout = pipeline.getBindGroupLayout(0);
605
+ nearest = device.createSampler({ magFilter: "nearest", minFilter: "nearest" });
606
+ linear = device.createSampler({ magFilter: "linear", minFilter: "linear" });
607
+ vbuf = ibuf = null;
608
+ vsize = isize = 0;
609
+ groups.clear();
610
+ for (const t of live) allocate(t);
611
+ device.lost.then((info) => recover(info.reason || "unknown"));
612
+ }
613
+
614
+ // Gives `t` GPU memory on the current device and uploads its source.
615
+ function allocate(t) {
616
+ const gpu = device.createTexture({
617
+ size: [t.w, t.h],
618
+ format: "rgba8unorm",
619
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
620
+ });
621
+ const src = t.source;
622
+ if (t.white) device.queue.writeTexture({ texture: gpu }, new Uint8Array([255, 255, 255, 255]), { bytesPerRow: 4 }, [1, 1]);
623
+ else if (src && src.data) device.queue.writeTexture({ texture: gpu }, src.data, { bytesPerRow: t.w * 4 }, [t.w, t.h]);
624
+ else if (src) device.queue.copyExternalImageToTexture({ source: src }, { texture: gpu }, [t.w, t.h]);
625
+ t.gpu = gpu;
626
+ t.view = gpu.createView();
627
+ }
628
+
629
+ let recovering = false;
630
+ async function recover(reason) {
631
+ recovering = true;
632
+ let dev = null;
633
+ try {
634
+ dev = await acquire();
635
+ } catch (e) {
636
+ console.error("GPU device request failed:", e);
637
+ }
638
+ if (!dev) {
639
+ onLost(reason, false);
640
+ return; // stays lost: submit draws nothing
641
+ }
642
+ setup(dev);
643
+ recovering = false;
644
+ onLost(reason, true);
645
+ }
646
+
647
+ function createTexture(source, w, h, smooth, uid) {
648
+ const t = { w, h, uid, source, smooth: !!smooth, bid: 0, slot: 0 };
649
+ live.add(t);
650
+ if (!recovering) allocate(t);
651
+ return t;
652
+ }
587
653
 
588
654
  // Bind groups by the textures of a draw's slots (unused slots hold the white texture).
589
- const groups = new Map();
590
- function bindGroup(batch, white) {
655
+ function bindGroup(batch) {
591
656
  let key = "";
592
657
  for (let i = 0; i < SLOTS; i++) key += (i < batch.n ? batch.slots[i].uid : white.uid) + ",";
593
658
  let bg = groups.get(key);
@@ -600,18 +665,16 @@ ${cases} default: { return vec4f(1.0); }
600
665
  }
601
666
  return bg;
602
667
  }
603
- function createTexture(source, w, h, smooth, uid) {
668
+
604
- const gpu = device.createTexture({
669
+ const first = await acquire();
670
+ if (!first) return null;
671
+ white.white = true;
605
- size: [w, h],
672
+ white.w = 1;
673
+ white.h = 1;
606
- format: "rgba8unorm",
674
+ white.source = null;
607
- usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
608
- });
609
- if (source && source.data) device.queue.writeTexture({ texture: gpu }, source.data, { bytesPerRow: w * 4 }, [w, h]);
610
- else if (source) device.queue.copyExternalImageToTexture({ source }, { texture: gpu }, [w, h]);
611
- else device.queue.writeTexture({ texture: gpu }, new Uint8Array([255, 255, 255, 255]), { bytesPerRow: 4 }, [1, 1]);
612
- return { w, h, uid, gpu, view: gpu.createView(), smooth: !!smooth, bid: 0, slot: 0 };
613
- }
614
- Object.assign(white, createTexture(null, 1, 1, false, white.uid));
675
+ white.smooth = false;
676
+ live.add(white);
677
+ setup(first);
615
678
 
616
679
  return {
617
680
  name: "gpu-webgpu",
@@ -620,10 +683,13 @@ ${cases} default: { return vec4f(1.0); }
620
683
  t.smooth = !!smooth;
621
684
  },
622
685
  destroyTexture(t) {
686
+ live.delete(t);
623
687
  groups.clear();
624
688
  t.gpu.destroy();
625
689
  },
690
+ // Draws the batcher's frame; returns the number of draw calls issued.
626
691
  submit(b) {
692
+ if (recovering || !device) return 0;
627
693
  const vbytes = b.vertexCount * VERT_BYTES;
628
694
  const ibytes = b.indexCount * 4;
629
695
  if (vbytes > vsize) {
@@ -656,12 +722,13 @@ ${cases} default: { return vec4f(1.0); }
656
722
  pass.setIndexBuffer(ibuf, "uint32");
657
723
  for (let i = 0; i < b.batchCount; i++) {
658
724
  const bt = b.batches[i];
659
- pass.setBindGroup(0, bindGroup(bt, white));
725
+ pass.setBindGroup(0, bindGroup(bt));
660
726
  pass.drawIndexed(bt.count, 1, bt.start);
661
727
  }
662
728
  }
663
729
  pass.end();
664
730
  device.queue.submit([enc.finish()]);
731
+ globalThis.plumFrames = (globalThis.plumFrames || 0) + 1;
665
732
  return vbytes ? b.batchCount : 0;
666
733
  },
667
734
  };
@@ -723,26 +790,40 @@ ${cases} default: { return vec4f(1.0); }
723
790
 
724
791
  async function main() {
725
792
  const canvas = document.getElementById("glcanvas");
793
+ // Game coordinates are CSS pixels; the backing store is CSS size x devicePixelRatio.
794
+ const view = { w: 1, h: 1, dpr: 1 };
726
795
  const size = () => {
796
+ view.dpr = window.devicePixelRatio || 1;
727
- const w = Math.max(1, window.innerWidth | 0);
797
+ view.w = Math.max(1, canvas.clientWidth || window.innerWidth | 0);
798
+ view.h = Math.max(1, canvas.clientHeight || window.innerHeight | 0);
799
+ const w = Math.max(1, Math.round(view.w * view.dpr));
728
- const h = Math.max(1, window.innerHeight | 0);
800
+ const h = Math.max(1, Math.round(view.h * view.dpr));
729
801
  if (canvas.width !== w) canvas.width = w;
730
802
  if (canvas.height !== h) canvas.height = h;
731
803
  };
732
804
  size();
805
+ const message = (text, color) =>
806
+ document.body.append(Object.assign(document.createElement("pre"), { textContent: text, style: "position:fixed;top:0;left:0;color:" + color + ";margin:8px;font-size:18px" }));
733
807
 
734
808
  let backend = null;
735
809
  const text = makeTextCache(() => backend);
736
810
  const b = makeBatcher({ textTexture: text.textTexture });
737
811
  try {
738
- backend = await makeWebGPU(canvas, b);
812
+ backend = await makeWebGPU(canvas, b, (reason, restored) => {
813
+ if (restored) {
814
+ console.log("GPU device lost (" + reason + "); restored");
815
+ } else {
816
+ console.error("GPU device lost (" + reason + "); no new device");
817
+ message("The GPU device was lost (" + reason + ") and could not be restored. Reload the page.", "#f55");
818
+ }
819
+ });
739
820
  } catch (e) {
740
821
  console.error("WebGPU failed to start:", e);
741
822
  }
742
823
  if (!backend) {
743
824
  const msg = "WebGPU is not supported in this browser";
744
825
  console.error(msg);
745
- document.body.append(Object.assign(document.createElement("pre"), { textContent: msg, style: "position:fixed;top:0;left:0;color:#f55;margin:8px;font-size:18px" }));
826
+ message(msg, "#f55");
746
827
  return;
747
828
  }
748
829
  globalThis.plumRenderer = backend.name;
@@ -772,8 +853,8 @@ ${cases} default: { return vec4f(1.0); }
772
853
  let dt = 1 / 60;
773
854
 
774
855
  const host = {
775
- screenWidth: () => canvas.width,
856
+ screenWidth: () => view.w,
776
- screenHeight: () => canvas.height,
857
+ screenHeight: () => view.h,
777
858
  frameTime: () => dt,
778
859
  clear: b.clear,
779
860
  drawRect: b.drawRect,
@@ -851,14 +932,14 @@ ${cases} default: { return vec4f(1.0); }
851
932
  last = now;
852
933
  size();
853
934
  input.beginFrame();
854
- b.beginFrame(canvas.width, canvas.height);
935
+ b.beginFrame(view.w, view.h, view.dpr);
855
936
  const m = globalThis.__plumMeasure; // set by plum-web-test --measure; null otherwise
856
937
  const t0 = m ? performance.now() : 0;
857
938
  ex.update(state, dt);
858
939
  const t1 = m ? performance.now() : 0;
859
940
  ex.draw(state);
860
941
  const t2 = m ? performance.now() : 0;
861
- const draws = backend.submit(b, canvas.width, canvas.height);
942
+ const draws = backend.submit(b);
862
943
  if (m) { m.n++; m.u += t1 - t0; m.d += t2 - t1; m.r += performance.now() - t2; m.dr += draws; }
863
944
  text.endFrame();
864
945
  } catch (e) {