plum

#treesitter#compiler#wasm

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

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


9d0ff39 — Peter John 2026-11-03T21:55:51+05:30
Move the pure and functional scene styles into their own folders
README.md CHANGED
@@ -1377,13 +1377,13 @@ The page calls `init` once after the assets are loaded, then `update` and `draw`
1377
1377
 
1378
1378
  ### Pure scene graph
1379
1379
 
1380
- The pure scene graph is the same kind of tree as `std/game/Scene`, but a function never changes a value it was only given: updates take an owned actor (`own self`, `own` parameters, see [Own records](#own-records)), change it in place and hand it back, so no `clone()` is needed. `Actor` is an `own trait` and `Group`, `Sprite`, `Label`, `Button` and `Stage` are `own record`s. The graph is split into four modules: `std/game/Actions` (timed actions such as `forever(rotateBy(...))`), `std/game/PureScene` (the `Actor` trait and its leaves, `group`, `sprite`, `button`, and the draw walk), `std/game/SceneTree` (update, hit testing, paths and event routing) and `std/game/SceneInput` (`Stage`, pointer capture and `UiEvent`). A game imports them by name:
1380
+ The pure scene graph is the same kind of tree as `std/game/Scene`, but a function never changes a value it was only given: updates take an owned actor (`own self`, `own` parameters, see [Own records](#own-records)), change it in place and hand it back, so no `clone()` is needed. `Actor` is an `own trait` and `Group`, `Sprite`, `Label`, `Button` and `Stage` are `own record`s. The graph is split into four modules: `std/game/pure/Actions` (timed actions such as `forever(rotateBy(...))`), `std/game/pure/PureScene` (the `Actor` trait and its leaves, `group`, `sprite`, `button`, and the draw walk), `std/game/pure/SceneTree` (update, hit testing, paths and event routing) and `std/game/pure/SceneInput` (`Stage`, pointer capture and `UiEvent`). A game imports them by name:
1381
1381
 
1382
1382
  ```
1383
- import std/game/Actions
1383
+ import std/game/pure/Actions
1384
- import std/game/PureScene
1384
+ import std/game/pure/PureScene
1385
- import std/game/SceneTree
1385
+ import std/game/pure/SceneTree
1386
- import std/game/SceneInput
1386
+ import std/game/pure/SceneInput
1387
1387
  ```
1388
1388
 
1389
1389
  A minimal game holds a `Stage` and replaces it each frame:
@@ -1411,8 +1411,8 @@ export fun draw(w: World) =
1411
1411
 
1412
1412
  - **Immediate mode** ([`game.plum`](plum-examples/game.plum)): one state record and a `draw` that issues the calls every frame. Use it for flat arcade games with a few kinds of thing.
1413
1413
  - **Scene** ([`game_scene.plum`](plum-examples/game_scene.plum)): a retained tree of mutable actors. Use it when you want objects that change in place and a familiar `act`/`draw` object model.
1414
- - **Pure scene** ([`game_pure_scene.plum`](plum-examples/game_pure_scene.plum)): every update takes an owned value and hands it back, pointer clicks arrive as `UiEvent`s, and the rules can be tested without a host. Use it when you want pure updates and tests that run without a game host.
1415
- - **State/view** ([`game_state_view.plum`](plum-examples/game_state_view.plum)): the game state is plain data and a `view` turns it into `std/game/Picture`, a recursive picture type drawn by recursive functions. Use it when you want to draw from state instead of keeping a tree of actors.
1414
+ - **Pure scene** ([`game_pure_scene.plum`](plum-examples/games/pure/game_pure_scene.plum)): every update takes an owned value and hands it back, pointer clicks arrive as `UiEvent`s, and the rules can be tested without a host. Use it when you want pure updates and tests that run without a game host.
1415
+ - **State/view** ([`game_state_view.plum`](plum-examples/games/functional/game_state_view.plum)): the game state is plain data and a `view` turns it into `std/game/functional/Picture`, a recursive picture type drawn by recursive functions. Use it when you want to draw from state instead of keeping a tree of actors.
1416
1416
 
1417
1417
  **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.
1418
1418
 
docs/plans/2026-11-03-state-view-parity.review.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Functional state/view at feature parity with PureScene: review plan
2
2
 
3
- **Builds on:** master after `2026-11-03-pure-scene-enums` (its T1–T6; T7 dropped).
3
+ **Builds on:** master 53de7e3 (pure-scene-enums merged; T7 dropped).
4
4
  **Goal (user, 2026-11-03):** the most functional approach, where a frame is a recursive value built from state and walked recursively, with the same features as PureScene. Performance is measured and reported, not chased: matching the mutable scene isn't a goal ("otherwise I would just stick with mutate").
5
5
 
6
6
  ## Summary
@@ -28,7 +28,9 @@ The three scene styles currently share `plum-std/game/`, which is confusing. Eac
28
28
  | `plum-examples/games/pure/` | `game_pure_scene.plum` | |
29
29
  | `plum-examples/games/functional/` | `game_state_view.plum` | |
30
30
 
31
- The mutable examples (`game.plum`, `game_scene.plum`, `scene.plum`, `trait_scene.plum`) stay at the top of `plum-examples`. Imports in std, examples, web-test fixtures, host tests, web checks and the README are updated in the same part, and an import of an old path is a compile error. This is done first, as one mechanical part, so nothing else moves twice.
31
+ The mutable examples (`game.plum`, `game_scene.plum`, `scene.plum`, `trait_scene.plum`) stay at the top of `plum-examples`.
32
+
33
+ **Done before this plan** (user, 2026-11-03: "do that first"), as a separate change on master. This plan builds on the new paths: the new `Anim` and `Ui` go in `plum-std/game/functional/`.
32
34
 
33
35
  ## The design
34
36
  - **Animations are functions of time, not stepped state.**
@@ -47,11 +49,6 @@ The mutable examples (`game.plum`, `game_scene.plum`, `scene.plum`, `trait_scene
47
49
  - capture is dropped when the captured id is no longer in the picture.
48
50
  - Button visuals come from the UI state: `Ui.pressed(ui, id)`, `Ui.hovered(ui, id)`. `Ui.button(id, label, rect, ui)` builds the button's `SceneNode`.
49
51
  - Events are `UiEvent(id, kind)`, as in PureScene, and the game handles them in `step`.
50
- - **Static subtree reuse (user, 2026-11-03).** Parts of a picture that never change are built once and returned as the same value every frame. Examples: a starfield's layout, terrain tiles, HUD frames, panel backgrounds.
51
- - `view` places them as-is (`Group(.., children: Cons(s.staticBg, ...))`), so they cost no allocation per frame. Only the moving parts are rebuilt.
52
- - Anything that varies over time is applied around the shared subtree, not inside it: a twinkle via `Filter(Alpha(..), child: staticStar)`, a scroll via `Group(Translate(..), child: staticMap)`.
53
- - Stored in `State` (built in `newState`), or as a module-level constant when it depends on nothing.
54
- - Test: a wasmparser/allocation count shows the static part adds no `struct.new` per frame. `game_state_view`'s starfield becomes static (positions fixed, twinkle as a per-frame alpha wrapper).
55
52
  - **The game shell stays thin.** `update` reads input into a `Msg`, then calls `step`, then `Ui.step` against `view(state)`, then `step` again for each `UiEvent`. Sound remains the only side effect.
56
53
 
57
54
  ## What you will get
@@ -68,12 +65,10 @@ The mutable examples (`game.plum`, `game_scene.plum`, `scene.plum`, `trait_scene
68
65
  ## What will be built
69
66
  | Part | What it does | Size |
70
67
  |---|---|---|
71
- | Folders | move pure and functional modules and their example games into their folders; update every import and test path | S |
72
68
  | Anim | `enum Anim`, `at`, `length`, poses; tests for zero-length, sequences, parallel and forever without drift | M |
73
69
  | Picture tags | `Tagged(id, child)`, `hitId`, draw/bounds pass-through; tests | S |
74
70
  | Ui | `UiState`, `Ui.step`, events, capture, `pressed`/`hovered`, `button`; PureScene-equivalent tests | M |
75
71
  | Orb Run state/view | restart via Ui, Anim for the title and hit flash; tests unchanged plus Ui/Anim cases | M |
76
- | Static subtrees | starfield built once in `State`, twinkle as an alpha wrapper; allocation test | S |
77
72
  | Stress fixture | tags, buttons, animations in `stress_state_view.plum` | S |
78
73
  | Benchmark + README | headed table, "Functional scenes" section | S |
79
74
 
@@ -102,10 +97,10 @@ flowchart LR
102
97
  ## Risks
103
98
  - **R1: allocation.** `view` rebuilds the tree every frame, and the UI adds a hit test per input. Measured and reported; the supercompiler's view-into-draw fusion is the later lever.
104
99
  - **R2: hit test against the view.** `Ui.step` needs `view(state)` for `hitId`. Building it twice per frame (once for input, once for draw) is allowed, measured, and may be shared later.
105
- - **R4: nested std paths.** No std module lives three levels deep yet (`std/game/pure/X`). If the loader, LSP or `plum game` asset/bundle code assumes two levels, the Folders part fixes that in the same part, with a test.
106
100
  - **R3: compiler gaps.** Recursive types and generic `List` code have hit gaps before. Each gap is reported as an incident and fixed in the compiler, not worked around.
107
101
 
108
102
  ## Out of scope
103
+ - Static subtree reuse: building unchanging parts of a picture (starfield layout, terrain, HUD frames) once and sharing them every frame. Deferred by the user, 2026-11-03, as a later optimisation.
109
104
  - Removing PureScene, SceneTree, SceneInput or Actions (a later decision).
110
105
  - Fixed timestep and replays (separate queued plan).
111
106
  - The supercompiler.
docs/plans/2026-11-03-supercompiler.review.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Supercompiler: fusing List chains and view-into-draw: review plan
2
2
 
3
- **Builds on:** master after `2026-11-03-pure-scene-enums` (pure scene on `Node` + `Kind`, `own` kept): closure specialisation, tail calls and TRMC, unboxed variants, std cons `List`, recursive `Picture` and the state/view Orb Run.
3
+ **Builds on:** master after `2026-11-03-pure-scene-enums` (53de7e3) and the folder split (`std/game/pure/`, `std/game/functional/Picture`): closure specialisation, tail calls and TRMC, unboxed variants, std cons `List`, recursive `Picture` and the state/view Orb Run.
4
4
 
5
5
  **Added 2026-11-03 (user):** a uniqueness stage. `own` stays for now. If the compiler can prove for itself that nobody else still holds a value, `own` becomes redundant and can be removed in a later plan. See "Uniqueness without `own`" below.
6
6
 
plum-cli/src/game.rs CHANGED
@@ -20,16 +20,43 @@ fn repoRoot() -> PathBuf {
20
20
  .to_path_buf()
21
21
  }
22
22
 
23
- /// The asset root: `--assets`, else `assets` beside the source file.
23
+ /// The asset root: `--assets`, else the nearest `assets` directory beside the source file or in
24
+ /// a parent folder (so games in subfolders share one), else `assets` beside the source file.
24
25
  fn assetRoot(file: &Path, cli: Option<PathBuf>) -> PathBuf {
25
26
  cli.unwrap_or_else(|| {
27
+ let dir = file
26
- file.parent()
28
+ .parent()
27
29
  .filter(|p| !p.as_os_str().is_empty())
28
- .unwrap_or(Path::new("."))
30
+ .unwrap_or(Path::new("."));
31
+ let abs = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
32
+ for up in abs.ancestors() {
33
+ if up.join("assets").is_dir() {
34
+ return if up == abs { dir.join("assets") } else { up.join("assets") };
35
+ }
36
+ }
29
- .join("assets")
37
+ dir.join("assets")
30
38
  })
31
39
  }
32
40
 
41
+ #[cfg(test)]
42
+ mod assetRootTests {
43
+ use super::*;
44
+
45
+ #[test]
46
+ fn finds_assets_in_a_parent_folder() {
47
+ let root = std::env::temp_dir().join(format!("plum_assets_{}", std::process::id()));
48
+ let deep = root.join("games/pure");
49
+ fs::create_dir_all(&deep).unwrap();
50
+ fs::create_dir_all(root.join("assets")).unwrap();
51
+ let found = assetRoot(&deep.join("g.plum"), None);
52
+ assert_eq!(found.canonicalize().unwrap(), root.join("assets").canonicalize().unwrap());
53
+ fs::create_dir_all(deep.join("assets")).unwrap();
54
+ assert_eq!(assetRoot(&deep.join("g.plum"), None), deep.join("assets"));
55
+ assert_eq!(assetRoot(&deep.join("g.plum"), Some("x".into())), PathBuf::from("x"));
56
+ fs::remove_dir_all(&root).ok();
57
+ }
58
+ }
59
+
33
60
  /// Every file under `dir` (dotfiles skipped), as `/`-separated paths relative to it, sorted.
34
61
  fn listFiles(dir: &Path) -> Result<Vec<String>> {
35
62
  fn walk(dir: &Path, prefix: &str, out: &mut Vec<String>) -> Result<()> {
plum-examples/{game_state_view.plum → games/functional/game_state_view.plum} RENAMED
@@ -1,4 +1,4 @@
1
- # Orb Run as state and view (`plum game plum-examples/game_state_view.plum`): the same
1
+ # Orb Run as state and view (`plum game plum-examples/games/functional/game_state_view.plum`): the same
2
2
  # game as game_pure_scene.plum (same arena, rules, sprites, sounds, keys and scoring),
3
3
  # written the way a Rust state/view program is. Steer the ship with the arrow keys or
4
4
  # WASD, collect the drifting orbs and avoid the mines. Three lives; Enter, R or the
@@ -24,7 +24,7 @@ import std/Math
24
24
  import std/game/Vec2
25
25
  import std/game/Color
26
26
  import std/game/Game
27
- import std/game/Picture
27
+ import std/game/functional/Picture
28
28
 
29
29
  primitive Rules =
30
30
  ArenaW = 800.0
plum-examples/{game_pure_scene.plum → games/pure/game_pure_scene.plum} RENAMED
@@ -1,4 +1,4 @@
1
- # Orb Run on the pure scene graph (`plum game plum-examples/game_pure_scene.plum`): the same
1
+ # Orb Run on the pure scene graph (`plum game plum-examples/games/pure/game_pure_scene.plum`): the same
2
2
  # game as game_scene.plum (same arena, rules, sprites, sounds, keys and scoring), but the
3
3
  # scene is made of values. Steer the ship with the arrow keys or WASD, collect the
4
4
  # drifting orbs and avoid the mines. Three lives; Enter, R or the "Again" button
@@ -25,10 +25,10 @@ import std/game/Vec2
25
25
  import std/game/Color
26
26
  import std/game/Affine
27
27
  import std/game/Game
28
- import std/game/Actions
28
+ import std/game/pure/Actions
29
- import std/game/PureScene
29
+ import std/game/pure/PureScene
30
- import std/game/SceneTree
30
+ import std/game/pure/SceneTree
31
- import std/game/SceneInput
31
+ import std/game/pure/SceneInput
32
32
 
33
33
  primitive Rules =
34
34
  ArenaW = 800.0
plum-game/tests/host_test.rs CHANGED
@@ -265,7 +265,7 @@ fn sceneExampleRunsFrames() {
265
265
 
266
266
  #[test]
267
267
  fn pureSceneExampleRunsFrames() {
268
- let wasm = compile(&root().join("plum-examples/game_pure_scene.plum"));
268
+ let wasm = compile(&root().join("plum-examples/games/pure/game_pure_scene.plum"));
269
269
  let mut g = PlumGame::load(&wasm, Recorder::with_assets(examples_assets())).expect("load game");
270
270
  g.init().expect("init");
271
271
  for _ in 0..10 {
@@ -284,7 +284,7 @@ fn pureSceneMatchesSceneDrawCounts() {
284
284
  g
285
285
  };
286
286
  let mut a = load("plum-examples/game_scene.plum");
287
- let mut b = load("plum-examples/game_pure_scene.plum");
287
+ let mut b = load("plum-examples/games/pure/game_pure_scene.plum");
288
288
  let tex_ids = |calls: &[String]| {
289
289
  let mut v: Vec<String> = calls
290
290
  .iter()
plum-game/web-test/fixtures/stress_pure_scene.plum CHANGED
@@ -1,4 +1,4 @@
1
- # The stress scene of stress_scene.plum on the PURE scene graph (std/game/PureScene):
1
+ # The stress scene of stress_scene.plum on the PURE scene graph (std/game/pure/PureScene):
2
2
  # 5000 sprites in 50 slowly rotating groups, 500 rects and 200 circles (sprites of a
3
3
  # 1x1 white texture), 50 texts with a changing number, drawn through a camera. Every
4
4
  # node is an own value: each frame changes the tree in place with SceneTree.update. Group and
@@ -13,10 +13,10 @@ import std/game/Vec2
13
13
  import std/game/Color
14
14
  import std/game/Affine
15
15
  import std/game/Game
16
- import std/game/Actions
16
+ import std/game/pure/Actions
17
- import std/game/PureScene
17
+ import std/game/pure/PureScene
18
- import std/game/SceneTree
18
+ import std/game/pure/SceneTree
19
- import std/game/SceneInput
19
+ import std/game/pure/SceneInput
20
20
 
21
21
  primitive Counts =
22
22
  Sprites = 5000
plum-game/web-test/fixtures/stress_state_view.plum CHANGED
@@ -15,7 +15,7 @@ import std/List
15
15
  import std/game/Vec2
16
16
  import std/game/Color
17
17
  import std/game/Game
18
- import std/game/Picture
18
+ import std/game/functional/Picture
19
19
 
20
20
  primitive Counts =
21
21
  Sprites = 5000
plum-game/web-test/fixtures/stress_state_view_deep.plum CHANGED
@@ -17,7 +17,7 @@ import std/List
17
17
  import std/game/Vec2
18
18
  import std/game/Color
19
19
  import std/game/Game
20
- import std/game/Picture
20
+ import std/game/functional/Picture
21
21
 
22
22
  primitive Counts =
23
23
  Sprites = 5000
plum-game/web-test/tests/web_check.rs CHANGED
@@ -12,7 +12,7 @@ fn orb_run_scene_renders_and_animates() {
12
12
 
13
13
  #[test]
14
14
  fn pure_scene_renders_and_animates() {
15
- orb_run("plum-examples/game_pure_scene.plum");
15
+ orb_run("plum-examples/games/pure/game_pure_scene.plum");
16
16
  }
17
17
 
18
18
  #[test]
plum-std/game/{Picture.plum → functional/Picture.plum} RENAMED
File without changes
plum-std/game/{Actions.plum → pure/Actions.plum} RENAMED
File without changes
plum-std/game/{PureScene.plum → pure/PureScene.plum} RENAMED
@@ -8,14 +8,14 @@ import std/game/Vec2
8
8
  import std/game/Color
9
9
  import std/game/Affine
10
10
  import std/game/Game
11
- import std/game/Actions
11
+ import std/game/pure/Actions
12
12
 
13
13
  # A pure scene graph: a node is an own record (`Node`) holding a `Transform` and
14
14
  # an `enum Kind`. Group, Sprite, Label and Button are built in; games add their
15
15
  # own behaviour with `Custom(actor: Actor)`, a behaviour-only trait their records
16
16
  # embed (like Rust's `Box<dyn Actor>`). `onPointer` takes the node, changes it in
17
17
  # place and hands it back; readers borrow. Only `drawTree` talks to the host,
18
- # through `std/game/Game`. Actions and tree-wide updates live in std/game/SceneTree.
18
+ # through `std/game/Game`. Actions and tree-wide updates live in std/game/pure/SceneTree.
19
19
 
20
20
  enum Touchable =
21
21
  | Enabled
plum-std/game/{SceneInput.plum → pure/SceneInput.plum} RENAMED
@@ -7,9 +7,9 @@ import std/game/Vec2
7
7
  import std/game/Color
8
8
  import std/game/Affine
9
9
  import std/game/Game
10
- import std/game/Actions
10
+ import std/game/pure/Actions
11
- import std/game/PureScene
11
+ import std/game/pure/PureScene
12
- import std/game/SceneTree
12
+ import std/game/pure/SceneTree
13
13
 
14
14
  # The root of a pure scene tree, its camera and pointer capture. A Stage is an
15
15
  # own value: `input` and `frame` take it, change it in place and hand it back
plum-std/game/{SceneTree.plum → pure/SceneTree.plum} RENAMED
@@ -5,8 +5,8 @@ import std/Slice
5
5
  import std/Str
6
6
  import std/game/Vec2
7
7
  import std/game/Affine
8
- import std/game/Actions
8
+ import std/game/pure/Actions
9
- import std/game/PureScene
9
+ import std/game/pure/PureScene
10
10
 
11
11
  fun treeActorOf(n: Node) -> Actor? =
12
12
  match n.kind