plum
git clone https://git.pyrossh.dev/plum
A statically typed, imperative programming language inspired by rust, python
b883e37
— Peter John
2026-11-05T22:39:59+05:30
Docs: a short scene style guide, move internal notes out of the way
- docs/backlog.md +2 -2
- docs/decisions/2026-11-game-scene-styles-analysis.md +136 -0
- docs/{imperative-vs-functional.md → decisions/2026-11-imperative-vs-functional.md} +8 -6
- docs/decisions/2026-11-language-and-scene-decisions.md +1 -1
- docs/game-scene-styles.md +123 -115
- docs/game.md +1 -1
- docs/plans/2026-11-04-self-hosting-groundwork.review.md +1 -1
- docs/plans/2026-11-05-compiler-cleanup-phase5-6.md +149 -0
- docs/{self-hosting.md → plans/self-hosting.md} +1 -1
docs/backlog.md
CHANGED
|
@@ -35,7 +35,7 @@ Open work, in the order the user queued it, plus smaller items found during buil
|
|
|
35
35
|
- **One way to set up a struct.** Drop `fun init(self)` in favour of field defaults + ordinary constructor functions. `init` is what forces the Boxed layout and the EmbedInit machinery in `ctors.rs`. Main user: `std/game/imperative/Scene.plum`.
|
|
36
36
|
- **From Pony:** (1) a two-level ownership model (unique + mutable vs shared + immutable, like Pony's `iso`/`val` with `consume`) as the visible face of FBIP, replacing `mut` fields — take into the FBIP plan; (2) no ambient authority: no globals, a function without `Io` provably does no IO — what deterministic sim needs; (3) Pony partial functions (`?`, no error value) argue for `Option` + `?` as the common path and `Result` only when the error value matters. Not copied: union types, structural interfaces, six capabilities.
|
|
37
37
|
|
|
38
|
-
- **Self-hosting groundwork** (user 2026-11-04: plan now, build later) — `docs/plans/2026-11-04-self-hosting-groundwork.review.md`, design in `docs/self-hosting.md`. All host I/O onto `Io` (`Os.plum` deleted, `io.now()`, `io.httpGet`), then a Plum lexer, a winnow-style combinator library with chumsky-style errors, an AST and a parser in `compiler/` that parses every `.plum` file. AST comparison with the Rust parser and new host calls (args, exit, env, stderr, binary files) wait for cleanup phase 2a. The `Io` move should land before the `Io`-primitive plan above.
|
|
38
|
+
- **Self-hosting groundwork** (user 2026-11-04: plan now, build later) — `docs/plans/2026-11-04-self-hosting-groundwork.review.md`, design in `docs/plans/self-hosting.md`. All host I/O onto `Io` (`Os.plum` deleted, `io.now()`, `io.httpGet`), then a Plum lexer, a winnow-style combinator library with chumsky-style errors, an AST and a parser in `compiler/` that parses every `.plum` file. AST comparison with the Rust parser and new host calls (args, exit, env, stderr, binary files) wait for cleanup phase 2a. The `Io` move should land before the `Io`-primitive plan above.
|
|
39
39
|
|
|
40
40
|
## FBIP worklist: `mut` fields left after language-restrictions
|
|
41
41
|
|
|
@@ -74,4 +74,4 @@ Open work, in the order the user queued it, plus smaller items found during buil
|
|
|
74
74
|
- **Retire PureScene?** — after the component-style functional game, decide whether `std/game/pure` + `games/pure` stay. (Decision record §4b)
|
|
75
75
|
- **`adder(1)(2)`** (calling a returned closure directly) is not supported. (functional-gaps plan)
|
|
76
76
|
- **Fusion past a closure-spec copy on `Nil` miscompiles** — in `drive.rs` `quiet`, resolving `Nil.fold$c0(..)` to `List.fold$c0` (read-only) lets a value move past it, and codegen then fails with `unknown method 'List.fold$c0'` (5 `closure_spec_test` cases). `typeNameMethodKey` skips `$` copies until this is fixed.
|
|
77
|
-
- **Input model** — steps 1-3 built 2026-11-04 (typed `Msg` events for the functional style only); see [game-scene-styles.md](game-scene-styles.md) section 6.
|
|
77
|
+
- **Input model** — steps 1-3 built 2026-11-04 (typed `Msg` events for the functional style only); see [game-scene-styles.md](decisions/2026-11-game-scene-styles-analysis.md) section 6.
|
docs/decisions/2026-11-game-scene-styles-analysis.md
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# Game scene styles
|
|
2
|
+
|
|
3
|
+
> Internal design notes, kept for history. Some of this describes features that have since changed (`trait`, `own`). For the current guide see [Choosing a scene style](../game-scene-styles.md).
|
|
4
|
+
|
|
5
|
+
Plum ships three ways to structure a game's scene. This page compares them, maps them to engines people know, says how far each is from deterministic simulation, and proposes a better input model (section 6, not built yet).
|
|
6
|
+
|
|
7
|
+
Decisions behind the styles are in [decisions/2026-11-language-and-scene-decisions.md](2026-11-language-and-scene-decisions.md).
|
|
8
|
+
|
|
9
|
+
## 1. The three styles
|
|
10
|
+
|
|
11
|
+
| | Imperative | Pure | Functional |
|
|
12
|
+
|---|---|---|---|
|
|
13
|
+
| Std modules | `std/game/imperative/Scene` | `std/game/pure/PureScene`, `SceneInput`, `Actions` | `std/game/functional/FunctionalScene`, `Ui`, `Anim` |
|
|
14
|
+
| Example | `games/imperative/game_scene.plum` | `games/pure/game_pure_scene.plum` | `games/functional/game_state_view.plum` |
|
|
15
|
+
| Scene tree | Kept across frames, changed in place | Kept across frames, each update returns the next tree (in place when the compiler proves it unique) | Rebuilt from `State` every frame, then thrown away |
|
|
16
|
+
| Where game data lives | In actors (`record Walker(Actor, mut speed: Float)`) | In nodes (`Node` fields, `actions`, `Custom(actor)`) | In your own `State` and per-kind records (`Ship`, `Orb`); the tree holds none |
|
|
17
|
+
| Per-frame entry | `stage.act(dt)`, `stage.draw(io)` | `stage.frame(dt, inputs)`, `stage.draw(io)` | `state.update(msg)`, `state.render().draw(io)` |
|
|
18
|
+
| Behaviour | Override `act`/`draw`/`hit` on the `Actor` trait | `Actor` trait (`act`, `draw`) in a `Custom` node, plus timed `Action`s | Methods on your records; nodes have none |
|
|
19
|
+
| Finding a node | Object references (`same(a, b)`) | By id or path: `root.findPath(id)`, `root.modify(id, f)` | Not needed: change `State`, the next `render()` shows it |
|
|
20
|
+
| Animation | Code in `act` | `Action.forever(Action.rotateBy(..))`, stepped each frame | `Anim.at(anim, t)`, a function of time; nothing is stepped |
|
|
21
|
+
| Children | `Slice` of actors | `Slice[Node]` | `List[SceneNode]` |
|
|
22
|
+
|
|
23
|
+
### Main API (after the 2026-11-04 method refactor)
|
|
24
|
+
|
|
25
|
+
- **Pure:** `Node.group/sprite/label/button/custom(..)` build nodes. `node.update(dt)`, `node.drawTree(io, m, tint)`, `node.findHit(m, p)`, `node.findPath(id)`, `node.modify(id, f)`, `node.route(path, depth, e)` and `node.onPointer(e)` work on them. `Stage.create(root)`, `stage.frame(dt, inputs)`, `stage.draw(io)` and `Input.poll(io, wasDown)` run a frame. `Action.moveBy/rotateBy/delay/sequence/parallel/forever(..)` and `action.step(pose, dt)` drive animations.
|
|
26
|
+
- **Functional:** `SceneNode[M]` is an enum over the game's message type `M` (`Group`, `Filter`, `Sprite`, `Text`, `RectShape`, `CircleShape`, `LineShape`, `Clickable`). `node.draw(io)`, `node.bounds()`, `node.hitPath(x, y)` and `node.hitId(x, y)` use it. `Transform.matrix()`, `Effect.tint(alpha)` and `Rect` methods are the helpers. `Ui.step(ui, input, hitId)` and `Ui.button(..)` handle UI.
|
|
27
|
+
|
|
28
|
+
## 2. Engine analogies
|
|
29
|
+
|
|
30
|
+
| Plum style | Closest engine model | Notes |
|
|
31
|
+
|---|---|---|
|
|
32
|
+
| Imperative | **Flame** (Flutter's game engine), libGDX **Scene2D** | `Stage` ~ `FlameGame`, `Actor` ~ `PositionComponent`, `act(dt)` ~ `update(dt)`, `draw` ~ `render(canvas)`, `add`/`remove`/`zIndex` ~ `add`/`remove`/`priority`, `hit` ~ `containsLocalPoint`. The names come from Scene2D; the model is the same as Flame's. |
|
|
33
|
+
| Pure | None directly | An immutable Flame/Scene2D: the same component tree, updated by returning copies. Closest to Redux applied to a scene graph. Flutter never has you update its retained tree; the framework owns it. |
|
|
34
|
+
| Functional | **Elm**, and **Flutter's widget layer** | `render()` ~ `build()`, `SceneNode` ~ immutable widgets. Flutter also keeps an Element tree (diffed, holds local `State`) and a RenderObject tree (layout, paint); Plum has neither. Nothing is retained, so nothing is diffed: every frame is redrawn, and the compiler fuses `render` into `draw` so the tree is never built. There is no local widget state (everything is in `State`), no keys and no constraint layout (positions are absolute). |
|
|
35
|
+
|
|
36
|
+
## 3. Pure vs functional
|
|
37
|
+
|
|
38
|
+
Both avoid mutation in game logic. The difference is **where the state lives**:
|
|
39
|
+
|
|
40
|
+
- In **pure**, the tree *is* the game state. Changing the game means finding a node (`findPath`, `modify`) and returning a new tree. Nodes carry their own behaviour (`Actor`) and animations (`actions`), and pointer input changes nodes (a button's `pressed`/`over`).
|
|
41
|
+
- In **functional**, the tree is a *picture of* the game state. Changing the game means changing `State`; the next `render()` reflects it. Hover and press state lives in `UiState` inside `State`, and a click on a `Clickable(id, msg, ..)` becomes the game message `msg`.
|
|
42
|
+
|
|
43
|
+
Cost: pure updates run in place when the compiler proves uniqueness. Functional describes the whole frame each time, but fusion removes the tree (see `fusion_alloc_test.rs`, `state_view_draw_has_no_scenenode_alloc`).
|
|
44
|
+
|
|
45
|
+
## 4. Determinism
|
|
46
|
+
|
|
47
|
+
**Goal:** the same seed and the same inputs produce the same game, bit for bit, on every machine. This enables replays, lockstep multiplayer, rollback and desync checks (planned: [backlog](backlog.md), "deterministic sim").
|
|
48
|
+
|
|
49
|
+
### Already in place (all three styles)
|
|
50
|
+
|
|
51
|
+
- **Seeded randomness.** Every game uses a `Random` value threaded through its state (`Random.seeded(seed)`), never a host call per draw.
|
|
52
|
+
- **Math in Plum.** `Math.sin/cos/sqrt/hypot` are written in Plum (`std/Math.plum`), not imported from the browser, so they don't vary between JS engines.
|
|
53
|
+
- **wasm floats.** wasm `f64` arithmetic is IEEE 754 and gives the same results everywhere (only NaN bit patterns can differ).
|
|
54
|
+
- **Deterministic maps.** `Map` uses a fixed FNV-1a hash, so iteration order depends only on the insert sequence.
|
|
55
|
+
|
|
56
|
+
### Still missing (all three styles)
|
|
57
|
+
|
|
58
|
+
1. **Fixed timestep.** `plum_bridge.js` passes the real frame time (`dt = (now - last) / 1000`) to `update`. The simulation must instead advance in fixed steps (for example 1/60 s), with the shell accumulating real time and running 0..n steps per frame.
|
|
59
|
+
2. **Recorded seed.** `init` calls `io.randomSeed()`; a replay must record and supply that seed.
|
|
60
|
+
3. **Recorded input.** Done for input: `Input.poll` is the only input read, and the games feed every polled `Input` into their state (section 5). Recording is a matter of saving each step's `Polled.inputs`.
|
|
61
|
+
4. **Host values in state.** The functional game stores the screen size (`sw`, `sh`) and gets `Resize` messages; these must be in the input log too, or kept out of the simulation.
|
|
62
|
+
5. **Checksums.** A per-step hash of the simulation state, to detect desyncs.
|
|
63
|
+
|
|
64
|
+
### How hard each style is
|
|
65
|
+
|
|
66
|
+
1. **Functional: easiest.** All game state is one immutable `State`, and everything that happens arrives as a `Msg` (`Tick`, `Resize`, `Restart`, `UiEv`). A replay is the seed plus the `Msg` list, applied with `State.update` in order. Rollback is keeping an old `State` (nothing changes it). A checksum hashes `State`. Only the `World` shell, which turns keyboard and mouse into messages, touches `io`.
|
|
67
|
+
2. **Pure: moderate.** Updates are pure too, but state is split between the `Stage` tree and the `World` fields, and the tree is updated in place, so a snapshot needs an explicit deep copy. Inputs already arrive as a list per frame (`Input.poll`), which is easy to record.
|
|
68
|
+
3. **Imperative: hardest.** State is a mutable object graph: snapshots and checksums need a deep walk, and any code may read `io`. Replay still works if every step's inputs are recorded and nothing else reads the host.
|
|
69
|
+
|
|
70
|
+
**Recommendation:** build deterministic simulation on the functional style first. Its `Msg` stream is already the replay log.
|
|
71
|
+
|
|
72
|
+
## 5. Input
|
|
73
|
+
|
|
74
|
+
Steps 1 and 2 of the proposal below are built (2026-11-04).
|
|
75
|
+
|
|
76
|
+
- **One `Input` type** (`std/game/Input`): `PointerDown(x, y) | PointerUp(x, y) | PointerMove(x, y) | KeyDown(key) | KeyUp(key)`. `Input.poll(io, held) -> Polled(inputs, held)` is the only code that reads input from `io`. It compares the host's mouse button and every `Key` code with what was held after the last poll. `Held` (`isDown(key)`, `after(input)`) tracks held keys from the inputs, so games never call `io.isKeyDown`. `input.position()`, `input.through(m)` and `input.isPointer()` are helpers.
|
|
77
|
+
- **One `UiKind`/`UiEvent`**, also in `std/game/Input`, used by both `PureScene` and `Ui`.
|
|
78
|
+
|
|
79
|
+
| | Imperative | Pure | Functional |
|
|
80
|
+
|---|---|---|---|
|
|
81
|
+
| Pointer | `stage.input(e)`: the topmost actor under the pointer, then each parent, until one's `onInput` returns true | `stage.input(e)` / `stage.frame(dt, inputs)`: hit path, then `node.route` child first; `Button` reacts, `Custom` asks `actor.onInput` | `state.update(Event(input))` -> `render().hitId(x, y)` -> `Ui.step` -> `UiEvent`s |
|
|
82
|
+
| Keyboard | A press gives the actor the focus; keys go to it, then its parents | A press gives the node the focus; keys go to it (then its ancestors); keys no node consumes come back in `StageHandled.keys` | `Event(KeyDown/KeyUp)` updates `State.held`; the game reads `held.isDown(..)` |
|
|
83
|
+
| Capture (press, drag off, release) | none | yes | yes |
|
|
84
|
+
| Events | `onInput -> Bool` (consumed) | `UiEvent(id, kind)`, `Handled.consumed` | `UiEvent(id, kind)` |
|
|
85
|
+
|
|
86
|
+
The example games and fixtures poll once per frame and feed every input in. Nothing else reads input from the host.
|
|
87
|
+
|
|
88
|
+
In the functional style, clicks arrive as the game's own messages (step 3). The pure and imperative styles still identify UI elements by integer id.
|
|
89
|
+
|
|
90
|
+
## 6. Proposal: one input model with `onInput`
|
|
91
|
+
|
|
92
|
+
The sketch `scene_graph.pfl` (repo root, not tracked) designs input for a scene2d-style pure scene:
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
trait Actor<Msg> {
|
|
96
|
+
fn on_input(self: own Self, input: Input) -> (own Self, List<Msg>) = (self, [])
|
|
97
|
+
fn route(self: own Self, path: Path, input: Input) -> (own Self, List<Msg>)
|
|
98
|
+
}
|
|
99
|
+
class Button<Msg> { .., pressed: Bool, on_click: Msg }
|
|
100
|
+
enum Input { PointerDown(Vec2), PointerUp(Vec2), PointerMove(Vec2), Key(Int) }
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Its ideas:
|
|
104
|
+
- one `Input` type that includes keys;
|
|
105
|
+
- an overridable `on_input` on every actor;
|
|
106
|
+
- bubbling along the hit path, child first and then each parent, with messages flowing back up;
|
|
107
|
+
- **typed messages**: a button carries the game's own `Msg` value.
|
|
108
|
+
|
|
109
|
+
### Step 1: one shared `Input` type, keys included (built)
|
|
110
|
+
|
|
111
|
+
`std/game/Input.plum`: see section 5. Gain: one recorded input list per step (determinism item 3), one vocabulary across styles.
|
|
112
|
+
|
|
113
|
+
### Step 2: `onInput` for game actors (built)
|
|
114
|
+
|
|
115
|
+
- **Pure:** `trait Actor` has `fun onInput(self, n: Node, e: Input, over: Bool) -> Handled` (default: unchanged, nothing raised, not consumed). `Node.onInput` handles `Button` itself and asks a `Custom` node's actor. `Handled` has `consumed: Bool`; `route` skips the ancestors of a node that consumed the input. Only groups have children, so for pointers this matters once groups react; for keys it decides what the game receives.
|
|
116
|
+
- **Imperative:** `trait Actor` has `fun onInput(self, e: Input) -> Bool` (e in the actor's coordinates; true = consumed). `stage.input(e) -> Bool` bubbles from the hit actor through `parent`. A press sets `stage.focus`; keys bubble from the focused actor.
|
|
117
|
+
- **Functional:** no methods on nodes (they are data). Inputs reach `State.update` as `Event(input)` messages.
|
|
118
|
+
|
|
119
|
+
### Step 3: typed messages (built for the functional style)
|
|
120
|
+
|
|
121
|
+
- `SceneNode[M]` is generic over the game's message type. `Clickable(id, msg: M, child)` replaces `Tagged(id, child)`. `node.msgOf(id)` reads a clickable's message.
|
|
122
|
+
- `Ui.button(id, msg, label, ..)` builds one. `Ui.messages(view, events)` turns the Clicks that `Ui.step` raised into the game's own messages, read from the view the pointer was on.
|
|
123
|
+
- The game declares `RestartClicked` in its `Msg` and handles it in `update` like any other message. The `UiEv` wrapper and the `e.id == Id.Restart && e.kind == Click` check are gone. The id stays only to identify the button for hover and press state (`Ui` needs identity, and `==` on records is identity).
|
|
124
|
+
- Cost: every scene signature says `SceneNode[Msg]`, and a local built from a bare constructor needs an annotation (`x: SceneNode[Msg] := Group(..)`); passing it straight to its use (`Filter(child: Group(..))`) avoids that, and also keeps fusion. Helpers that do not mention `M` (`hitLeaf`, `hitPrepend`, `nearLine`) are top-level functions, because a static method of a generic type must mention its parameter. Fusion of `render().draw(io)` still holds (`fusion_alloc_test`); allocations in the exempt panel/animation functions went from 15 to 12.
|
|
125
|
+
- Found on the way: TRMC hoisted a list cell's head into an untyped temporary, so `Cons(head: Full(..), ..)` in a function returning `List[Box[Int]]` gave the temporary a `Box` with its own parameter left open, and produced invalid wasm. Fixed in `trmc.rs` (the temporary takes the field's concrete type); regression test `trmc_list_of_generic_enum` in `examples/recursion.plum`.
|
|
126
|
+
- Pure and imperative styles keep integer ids (generic traits, `Actor[Msg]`, are not supported).
|
|
127
|
+
|
|
128
|
+
### Next
|
|
129
|
+
|
|
130
|
+
1. Decide whether PureScene stays (backlog: "Retire PureScene?").
|
|
131
|
+
|
|
132
|
+
### Open questions
|
|
133
|
+
|
|
134
|
+
- Should pointer events reach a node's parents after a child consumes them (Flame: no, the DOM: yes unless stopped)?
|
|
135
|
+
- Should `onInput` see the event in local coordinates (as `onPointer` does today) or in world coordinates?
|
|
136
|
+
- Do held-key sets belong in `UiState` (shared) or in each game's state?
|
docs/{imperative-vs-functional.md → decisions/2026-11-imperative-vs-functional.md}
RENAMED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
# Imperative vs functional games in Plum
|
|
2
2
|
|
|
3
|
+
> Internal design notes, kept for history. Some of this describes features that have since changed (`trait`, `own`). For the current guide see [Choosing a scene style](../game-scene-styles.md).
|
|
4
|
+
|
|
3
5
|
Plum ships three ways to build a game's scene: **imperative** (a mutable scene graph, Flame/Scene2D style), **functional** (an immutable `State`, a `render()` that builds a picture of it each frame, Elm style) and **pure** (a retained scene tree that every update returns anew). This page compares them one aspect at a time and collects what has been measured or discussed. It is a comparison, not a decision: the open choices are in the last two sections.
|
|
4
6
|
|
|
5
|
-
Related pages: [game-scene-styles
|
|
7
|
+
Related pages: [game-scene-styles analysis](2026-11-game-scene-styles-analysis.md) (API tables, engine analogies, input model), [decisions/2026-11-language-and-scene-decisions.md](2026-11-language-and-scene-decisions.md) (why the styles exist), [backlog.md](../backlog.md) (deterministic games, FBIP, "One way to build a game").
|
|
6
8
|
|
|
7
9
|
Conventions: "measured" means a number from a file cited next to it; "proposal" and "undecided" mark things nobody has built or chosen. Timings are ms per frame on an Apple M3 Pro, headed Chromium (WebGPU), `--uncapped --measure 10`, unless a row says otherwise. Differences under about 5% are noise.
|
|
8
10
|
|
|
@@ -45,7 +47,7 @@ Functional cost, stated in the backlog: deep nested updates, and events returned
|
|
|
45
47
|
- **Functional.** `state.render().draw(io)`. `SceneNode.draw` is structural recursion that passes the parent matrix and alpha down; nothing is retained, so every frame is redrawn. The supercompiler fuses `render` into `draw`, so the tree is never built (test `state_view_draw_has_no_scenenode_alloc` in `compiler/wasm-codegen/tests/wasm_codegen/fusion_alloc_test.rs`). The one exception is the game-over overlay, whose `Filter` reads its child twice.
|
|
46
48
|
- **Pure.** `node.drawTree(io, m, tint)` walks the retained tree. Custom actors draw through `Actor.draw`.
|
|
47
49
|
|
|
48
|
-
Notes from the discussion ([decisions §5](
|
|
50
|
+
Notes from the discussion ([decisions §5](2026-11-language-and-scene-decisions.md)): the functional picture already follows the textbook design (recursive ADT, matrix passed down, top-down hit paths, assets by handle). Structural sharing pays off only for parts that do not change (UI, terrain); where everything moves, rebuilding costs about the same and is simpler. Folding the tree into a flat command list is redundant because the host already batches draws. Zippers are not needed, since code edits `State` and rebuilds. Static subtree reuse is deferred (backlog).
|
|
49
51
|
|
|
50
52
|
## Input handling
|
|
51
53
|
|
|
@@ -62,7 +64,7 @@ Typed messages exist only in the functional style: generic traits (`Actor[Msg]`)
|
|
|
62
64
|
|
|
63
65
|
## Sprite animation
|
|
64
66
|
|
|
65
|
-
Source: the 2026-11-05 notes in [backlog.md](backlog.md) ("One way to build a game"); `std/game/functional/Anim.plum`.
|
|
67
|
+
Source: the 2026-11-05 notes in [backlog.md](../backlog.md) ("One way to build a game"); `std/game/functional/Anim.plum`.
|
|
66
68
|
|
|
67
69
|
- **Imperative.** The sprite mutates `frame` and `timer` in `act`, so it must be advanced exactly once per tick per sprite. It drifts if a tick is skipped or repeated, and a rewind has to restore the mutated fields.
|
|
68
70
|
- **Functional.** Playback is a function of time. A sprite stores only `clip` and `startedAt` and computes the frame in `draw`: `(tick - startedAt) / ticksPerFrame % frames`. Nothing is mutated per tick, there is no drift, and replay or rewind is free. `Anim` is plain data (`MoveBy | RotateBy | ScaleTo | Delay | Seq | Par | Forever`) and `Anim.at(anim, t)` returns the pose `t` seconds in; `Forever` repeats using `t - floor(t/lap)*lap`.
|
|
@@ -92,7 +94,7 @@ The examples play sounds by diffing state around an update. Returning effects as
|
|
|
92
94
|
|
|
93
95
|
## Determinism, replay and rollback
|
|
94
96
|
|
|
95
|
-
Determinism comes from what may enter `update`, not from the style. Rules for all styles (design notes in [backlog.md](backlog.md), item "Deterministic games"; these are proposals, only some are in place):
|
|
97
|
+
Determinism comes from what may enter `update`, not from the style. Rules for all styles (design notes in [backlog.md](../backlog.md), item "Deterministic games"; these are proposals, only some are in place):
|
|
96
98
|
|
|
97
99
|
1. **Fixed timestep.** The host accumulates wall time and calls `update` with a constant `dt`; `draw` may interpolate. Today `plum_bridge.js` passes the real frame time, so this is not in place.
|
|
98
100
|
2. **Input as a per-tick `InputFrame` value.** Passed in, never polled inside `update`; a replay is the list of frames. Today every example calls `Input.poll(io, ..)` inside the shell's `update(io, w, dt)`. Recording would save each step's `Polled.inputs`.
|
|
@@ -102,7 +104,7 @@ Determinism comes from what may enter `update`, not from the style. Rules for al
|
|
|
102
104
|
6. **Floats.** wasm `f64` is IEEE 754 and gives the same results in every engine; do not hash NaN bits. `Math.sin/cos/sqrt/hypot` are written in Plum (`std/Math.plum`), not imported from the browser.
|
|
103
105
|
7. **A per-tick state checksum.** Hash the state each tick and compare runs. Not built.
|
|
104
106
|
|
|
105
|
-
Other gaps from [game-scene-styles
|
|
107
|
+
Other gaps from [game-scene-styles analysis](2026-11-game-scene-styles-analysis.md) section 4: host values in state (the functional game keeps `sw`, `sh` and gets `Resize` messages, so they must be in the input log or out of the simulation).
|
|
106
108
|
|
|
107
109
|
How each style stands under those rules:
|
|
108
110
|
|
|
@@ -235,5 +237,5 @@ Trade-off summary: embedding `Game` suits the imperative style (`update` returns
|
|
|
235
237
|
- **Events as data.** Return an event list from `update` (functional) instead of diffing state in the shell to play sounds. Not built.
|
|
236
238
|
- **Lens sugar** for deep functional updates, and spread from a field path (`Stage(..w.stage, x: 1)`). Proposals only.
|
|
237
239
|
- **Static subtree reuse** in functional scenes, deferred by the user as a later optimisation.
|
|
238
|
-
- **Input open questions** ([game-scene-styles
|
|
240
|
+
- **Input open questions** ([game-scene-styles analysis](2026-11-game-scene-styles-analysis.md)): whether a consumed pointer event still reaches parents, local vs world coordinates in `onInput`, and whether held keys live in `UiState` or each game's state.
|
|
239
241
|
- **Kids' games** (last in the queue) are functional-style; they would be the first games written after the choice.
|
docs/decisions/2026-11-language-and-scene-decisions.md
CHANGED
|
@@ -15,7 +15,7 @@ A record of the design decisions taken in the 2026-11-03/04 sessions, with the r
|
|
|
15
15
|
- `plum-std/game/imperative/`: the mutable `Scene` (moved 2026-11-04, user).
|
|
16
16
|
- `plum-std/game/pure/`: PureScene, SceneTree, SceneInput, Actions (pure updates, in place via `own` today).
|
|
17
17
|
- `plum-std/game/functional/`: Picture, Anim, Ui (state/view).
|
|
18
|
-
- **Update 2026-11-04 (user):** `Picture` was renamed `FunctionalScene` and `SceneTree` was merged into `PureScene`; their free functions became methods on `SceneNode`/`Node`/`Action` etc. See [game-scene-styles.md](
|
|
18
|
+
- **Update 2026-11-04 (user):** `Picture` was renamed `FunctionalScene` and `SceneTree` was merged into `PureScene`; their free functions became methods on `SceneNode`/`Node`/`Action` etc. See [game-scene-styles.md](2026-11-game-scene-styles-analysis.md).
|
|
19
19
|
- `plum-examples/games/imperative/`, `games/pure/` and `games/functional/` for their example games (the mutable games moved too, 2026-11-04). PureScene is imperative under an ownership discipline (`act(own n)` edits a retained tree in place); only the functional component style is functional. `plum game` finds assets in the nearest parent `assets/` folder.
|
|
20
20
|
|
|
21
21
|
- **Examples (user, 2026-11-04):** examples consolidated from 34 files to about 20 topical ones (every test kept), the mutable `game_loop` moved to `games/imperative/`, and the shared game assets moved to `plum-examples/games/assets/`.
|
docs/game-scene-styles.md
CHANGED
|
@@ -1,134 +1,142 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Choosing a scene style
|
|
2
2
|
|
|
3
|
-
Plum ships three
|
|
3
|
+
Plum ships three scene libraries. Each is a different answer to one question: where does your game's data live, and who changes it?
|
|
4
4
|
|
|
5
|
-
Decisions behind the styles are in [decisions/2026-11-language-and-scene-decisions.md](decisions/2026-11-language-and-scene-decisions.md).
|
|
6
|
-
|
|
7
|
-
## 1. The three styles
|
|
8
|
-
|
|
9
|
-
| |
|
|
5
|
+
| Style | Where data lives | How it changes | Good for |
|
|
10
6
|
|---|---|---|---|
|
|
11
|
-
|
|
|
12
|
-
|
|
|
13
|
-
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
7
|
+
| [Imperative](#imperative) | In objects in a tree | You change them in place | UI, and objects with their own behaviour |
|
|
8
|
+
| [Pure](#pure) | In a tree of nodes | Each update returns the next tree | Timed animations, and rules you can test without a browser |
|
|
9
|
+
| [State and view](#state-and-view) | In plain data, with no tree kept | `update` returns new data, and `view` draws it | Games that are simple to reason about |
|
|
10
|
+
|
|
11
|
+
Not sure? Start with **state and view** for a small game, or **imperative** if you're used to engines with objects and scene graphs. For a very small arcade game you may not need one at all: see [drawing directly](game.md#a-minimal-game).
|
|
12
|
+
|
|
13
|
+
## Imperative
|
|
14
|
+
|
|
15
|
+
A tree of actors, each with its own `act` and `draw`. Moving a parent moves everything inside it. Library: `std/game/imperative/Scene`.
|
|
16
|
+
|
|
17
|
+
```plum
|
|
18
|
+
import std/Int
|
|
19
|
+
import std/Float
|
|
20
|
+
import std/Str
|
|
21
|
+
import std/Slice
|
|
22
|
+
import std/Option
|
|
23
|
+
import std/Io
|
|
24
|
+
import std/game/Game
|
|
25
|
+
import std/game/Vec2
|
|
26
|
+
import std/game/Color
|
|
27
|
+
import std/game/Affine
|
|
28
|
+
import std/game/Input
|
|
29
|
+
import std/game/imperative/Scene
|
|
30
|
+
|
|
31
|
+
struct Ball(Actor(x, y), speed: Float) =
|
|
32
|
+
fun init(self) =
|
|
33
|
+
self.width = 20.0
|
|
34
|
+
self.height = 20.0
|
|
35
|
+
self.color = Color.Red
|
|
36
|
+
|
|
37
|
+
override fun act(self, dt: Float) =
|
|
38
|
+
self.x = self.x + self.speed * dt
|
|
39
|
+
|
|
40
|
+
override fun draw(self, io: Io, m: Affine, alpha: Float) =
|
|
41
|
+
drawRectBox(io, m, alpha, self.width, self.height, self.color)
|
|
42
|
+
|
|
43
|
+
struct World(stage: Stage)
|
|
44
|
+
|
|
45
|
+
export fun init(io: Io) -> World =
|
|
46
|
+
stage := Stage.create()
|
|
47
|
+
stage.root.add(Ball(x: 0.0, y: 40.0, speed: 30.0))
|
|
48
|
+
World(stage: stage)
|
|
49
|
+
|
|
50
|
+
export fun update(io: Io, w: World, dt: Float) =
|
|
51
|
+
w.stage.act(dt)
|
|
52
|
+
|
|
53
|
+
export fun draw(io: Io, w: World) =
|
|
54
|
+
io.clear(Color.Black)
|
|
55
|
+
w.stage.draw(io)
|
|
56
|
+
```
|
|
55
57
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
Useful extras: `toFront` and `toBack` for draw order, `sortChildren` to sort by depth each frame, and `setCullingArea` to skip what's off screen. Full example: [`game_scene.plum`](../examples/games/imperative/game_scene.plum).
|
|
59
|
+
|
|
60
|
+
## Pure
|
|
61
|
+
|
|
62
|
+
Also a tree, but an update never changes a node it was given: it returns the next one. The compiler reuses memory where it can, so this doesn't mean copying everything. Timed animations are plain values called actions. Library: `std/game/pure`.
|
|
63
|
+
|
|
64
|
+
```plum
|
|
65
|
+
import std/Int
|
|
66
|
+
import std/Float
|
|
67
|
+
import std/Str
|
|
68
|
+
import std/List
|
|
69
|
+
import std/Slice
|
|
70
|
+
import std/Option
|
|
71
|
+
import std/Io
|
|
72
|
+
import std/game/Game
|
|
73
|
+
import std/game/Vec2
|
|
74
|
+
import std/game/Color
|
|
75
|
+
import std/game/Input
|
|
76
|
+
import std/game/pure/Actions
|
|
77
|
+
import std/game/pure/PureScene
|
|
78
|
+
import std/game/pure/SceneInput
|
|
79
|
+
|
|
80
|
+
struct World(mut stage: Stage, mut held: Held)
|
|
81
|
+
|
|
82
|
+
export fun init(io: Io) -> World =
|
|
83
|
+
sprite := Node.sprite(2, 0, 100.0, 100.0, 48.0, 48.0)
|
|
84
|
+
spinning := Node(..sprite, actions: [Action.forever(Action.rotateBy(1.0, 1.0))].toSlice())
|
|
85
|
+
root := Node.group(1, 0.0, 0.0, 800.0, 600.0, [spinning].toSlice())
|
|
86
|
+
World(stage: Stage.create(root), held: Held.none())
|
|
87
|
+
|
|
88
|
+
export fun update(io: Io, w: World, dt: Float) =
|
|
89
|
+
polled := Input.poll(io, w.held)
|
|
90
|
+
w.held = polled.held
|
|
91
|
+
w.stage = w.stage.frame(dt, polled.inputs).intoStage()
|
|
92
|
+
|
|
93
|
+
export fun draw(io: Io, w: World) =
|
|
94
|
+
w.stage.draw(io)
|
|
95
|
+
```
|
|
61
96
|
|
|
62
|
-
|
|
97
|
+
Find a node by id with `findPath` and change it with `modify`. Full example: [`game_pure_scene.plum`](../examples/games/pure/game_pure_scene.plum).
|
|
63
98
|
|
|
64
|
-
|
|
65
|
-
2. **Pure: moderate.** Updates are pure too, but state is split between the `Stage` tree and the `World` fields, and the tree is updated in place, so a snapshot needs an explicit deep copy. Inputs already arrive as a list per frame (`Input.poll`), which is easy to record.
|
|
66
|
-
3. **Imperative: hardest.** State is a mutable object graph: snapshots and checksums need a deep walk, and any code may read `io`. Replay still works if every step's inputs are recorded and nothing else reads the host.
|
|
99
|
+
## State and view
|
|
67
100
|
|
|
68
|
-
|
|
101
|
+
Your game is plain data. `update` returns the next state, and `render` describes what to draw as a value. Nothing is kept between frames, so there's no tree to keep in sync. Library: `std/game/functional`.
|
|
69
102
|
|
|
70
|
-
|
|
103
|
+
```plum
|
|
104
|
+
import std/Int
|
|
105
|
+
import std/Float
|
|
106
|
+
import std/Str
|
|
107
|
+
import std/List
|
|
108
|
+
import std/Slice
|
|
109
|
+
import std/Option
|
|
110
|
+
import std/Io
|
|
111
|
+
import std/game/Game
|
|
112
|
+
import std/game/Vec2
|
|
113
|
+
import std/game/Color
|
|
114
|
+
import std/game/functional/FunctionalScene
|
|
71
115
|
|
|
72
|
-
|
|
116
|
+
enum Msg = NoMsg
|
|
73
117
|
|
|
74
|
-
|
|
75
|
-
|
|
118
|
+
struct State(x: Float) =
|
|
119
|
+
fun update(self, dt: Float) -> State =
|
|
120
|
+
State(..self, x: self.x + 30.0 * dt)
|
|
76
121
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
| Pointer | `stage.input(e)`: the topmost actor under the pointer, then each parent, until one's `onInput` returns true | `stage.input(e)` / `stage.frame(dt, inputs)`: hit path, then `node.route` child first; `Button` reacts, `Custom` asks `actor.onInput` | `state.update(Event(input))` -> `render().hitId(x, y)` -> `Ui.step` -> `UiEvent`s |
|
|
80
|
-
| Keyboard | A press gives the actor the focus; keys go to it, then its parents | A press gives the node the focus; keys go to it (then its ancestors); keys no node consumes come back in `StageHandled.keys` | `Event(KeyDown/KeyUp)` updates `State.held`; the game reads `held.isDown(..)` |
|
|
81
|
-
| Capture (press, drag off, release) | none | yes | yes |
|
|
82
|
-
| Events | `onInput -> Bool` (consumed) | `UiEvent(id, kind)`, `Handled.consumed` | `UiEvent(id, kind)` |
|
|
122
|
+
fun render(self) -> SceneNode[Msg] =
|
|
123
|
+
RectShape(x: self.x, y: 40.0, w: 20.0, h: 20.0, color: Color.Red)
|
|
83
124
|
|
|
84
|
-
|
|
125
|
+
struct World(mut state: State)
|
|
85
126
|
|
|
86
|
-
|
|
127
|
+
export fun init(io: Io) -> World =
|
|
128
|
+
World(state: State(x: 0.0))
|
|
87
129
|
|
|
88
|
-
|
|
130
|
+
export fun update(io: Io, w: World, dt: Float) =
|
|
131
|
+
w.state = w.state.update(dt)
|
|
89
132
|
|
|
90
|
-
|
|
133
|
+
export fun draw(io: Io, w: World) =
|
|
91
|
-
|
|
92
|
-
```
|
|
93
|
-
|
|
134
|
+
io.clear(Color.Black)
|
|
94
|
-
fn on_input(self: own Self, input: Input) -> (own Self, List<Msg>) = (self, [])
|
|
95
|
-
fn route(self: own Self, path: Path, input: Input) -> (own Self, List<Msg>)
|
|
96
|
-
}
|
|
97
|
-
|
|
135
|
+
w.state.render().draw(io)
|
|
98
|
-
enum Input { PointerDown(Vec2), PointerUp(Vec2), PointerMove(Vec2), Key(Int) }
|
|
99
136
|
```
|
|
100
137
|
|
|
101
|
-
|
|
102
|
-
- one `Input` type that includes keys;
|
|
103
|
-
- an overridable `on_input` on every actor;
|
|
104
|
-
- bubbling along the hit path, child first and then each parent, with messages flowing back up;
|
|
105
|
-
- **typed messages**: a button carries the game's own `Msg` value.
|
|
106
|
-
|
|
107
|
-
### Step 1: one shared `Input` type, keys included (built)
|
|
108
|
-
|
|
109
|
-
`std/game/Input.plum`: see section 5. Gain: one recorded input list per step (determinism item 3), one vocabulary across styles.
|
|
110
|
-
|
|
111
|
-
### Step 2: `onInput` for game actors (built)
|
|
112
|
-
|
|
113
|
-
- **Pure:** `trait Actor` has `fun onInput(self, n: Node, e: Input, over: Bool) -> Handled` (default: unchanged, nothing raised, not consumed). `Node.onInput` handles `Button` itself and asks a `Custom` node's actor. `Handled` has `consumed: Bool`; `route` skips the ancestors of a node that consumed the input. Only groups have children, so for pointers this matters once groups react; for keys it decides what the game receives.
|
|
114
|
-
- **Imperative:** `trait Actor` has `fun onInput(self, e: Input) -> Bool` (e in the actor's coordinates; true = consumed). `stage.input(e) -> Bool` bubbles from the hit actor through `parent`. A press sets `stage.focus`; keys bubble from the focused actor.
|
|
115
|
-
- **Functional:** no methods on nodes (they are data). Inputs reach `State.update` as `Event(input)` messages.
|
|
116
|
-
|
|
117
|
-
### Step 3: typed messages (built for the functional style)
|
|
118
|
-
|
|
119
|
-
- `SceneNode[M]` is generic over the game's message type. `Clickable(id, msg: M, child)` replaces `Tagged(id, child)`. `node.msgOf(id)` reads a clickable's message.
|
|
120
|
-
- `Ui.button(id, msg, label, ..)` builds one. `Ui.messages(view, events)` turns the Clicks that `Ui.step` raised into the game's own messages, read from the view the pointer was on.
|
|
121
|
-
- The game declares `RestartClicked` in its `Msg` and handles it in `update` like any other message. The `UiEv` wrapper and the `e.id == Id.Restart && e.kind == Click` check are gone. The id stays only to identify the button for hover and press state (`Ui` needs identity, and `==` on records is identity).
|
|
122
|
-
- Cost: every scene signature says `SceneNode[Msg]`, and a local built from a bare constructor needs an annotation (`x: SceneNode[Msg] := Group(..)`); passing it straight to its use (`Filter(child: Group(..))`) avoids that, and also keeps fusion. Helpers that do not mention `M` (`hitLeaf`, `hitPrepend`, `nearLine`) are top-level functions, because a static method of a generic type must mention its parameter. Fusion of `render().draw(io)` still holds (`fusion_alloc_test`); allocations in the exempt panel/animation functions went from 15 to 12.
|
|
123
|
-
- Found on the way: TRMC hoisted a list cell's head into an untyped temporary, so `Cons(head: Full(..), ..)` in a function returning `List[Box[Int]]` gave the temporary a `Box` with its own parameter left open, and produced invalid wasm. Fixed in `trmc.rs` (the temporary takes the field's concrete type); regression test `trmc_list_of_generic_enum` in `examples/recursion.plum`.
|
|
124
|
-
- Pure and imperative styles keep integer ids (generic traits, `Actor[Msg]`, are not supported).
|
|
125
|
-
|
|
126
|
-
### Next
|
|
127
|
-
|
|
128
|
-
1. Decide whether PureScene stays (backlog: "Retire PureScene?").
|
|
138
|
+
`Anim` describes motion as a function of time, and `Ui` handles buttons and pointer clicks, turning them into your `Msg` values. Because `update` never receives `io`, you can test all of your rules without a browser. Full example: [`game_state_view.plum`](../examples/games/functional/game_state_view.plum).
|
|
129
139
|
|
|
130
|
-
##
|
|
140
|
+
## Compare them
|
|
131
141
|
|
|
132
|
-
|
|
133
|
-
- Should `onInput` see the event in local coordinates (as `onPointer` does today) or in world coordinates?
|
|
134
|
-
- Do held-key sets belong in `UiState` (shared) or in each game's state?
|
|
142
|
+
The imperative, pure and state-and-view examples are the same game, Orb Run, written three ways. [`game.plum`](../examples/games/imperative/game.plum) is the same game drawn directly, with no scene library. Read them side by side to see how each one feels.
|
docs/game.md
CHANGED
|
@@ -76,7 +76,7 @@ Drawing directly is enough for small games. For bigger ones, Plum includes three
|
|
|
76
76
|
| **Pure scene** | A tree where each update returns the next version | rules you can test without a browser | [`game_pure_scene.plum`](../examples/games/pure/game_pure_scene.plum) |
|
|
77
77
|
| **State and view** | Plain data, and a `view` function draws it each frame | games that are simple to reason about | [`game_state_view.plum`](../examples/games/functional/game_state_view.plum) |
|
|
78
78
|
|
|
79
|
-
These four examples
|
|
79
|
+
These four examples are the same game, so you can compare them side by side. [Choosing a scene style](game-scene-styles.md) shows a small program in each.
|
|
80
80
|
|
|
81
81
|
## Testing a game
|
|
82
82
|
|
docs/plans/2026-11-04-self-hosting-groundwork.review.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Self-hosting groundwork: review plan
|
|
2
2
|
|
|
3
|
-
Design: [
|
|
3
|
+
Design: [self-hosting.md](self-hosting.md). Nothing is built until you approve.
|
|
4
4
|
|
|
5
5
|
## Summary
|
|
6
6
|
The first steps toward a Plum compiler written in Plum. Every host call moves onto the `Io` capability (`Os.plum`
|
docs/plans/2026-11-05-compiler-cleanup-phase5-6.md
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# Compiler cleanup, phases 5 and 6: file splits, type kinds, tests in Plum
|
|
2
|
+
|
|
3
|
+
**Review plan:** docs/plans/2026-11-04-compiler-cleanup.review.md (phase 5 rows "File splits", "Enum kinds"; phase 6 "Tests in Plum")
|
|
4
|
+
**Goal:** the checker and codegen are split out of their 4.6k and 7.9k line files into modules with clear jobs; the 949-line `compileSourceUnits` is broken into its stages; one kind field replaces the struct-only flags on `ast::Enum`; tests that only run a program and compare the answer become Plum `test` blocks. No language change; example wasm byte-identical. User 2026-11-05: "Let phase 5 and 6 proceed".
|
|
5
|
+
**Architecture:** compiler/checker, compiler/wasm-codegen, compiler/core, compiler/driver, compiler/lsp, new compiler/tests (Plum tests).
|
|
6
|
+
**Tech stack:** Rust 2021, Plum.
|
|
7
|
+
**Detail:** contracts
|
|
8
|
+
**Target-min:** 90
|
|
9
|
+
|
|
10
|
+
## Global constraints
|
|
11
|
+
- Work on `master` in the main checkout, default shared `target/`. Don't commit. Never run `plum format` on real files. No `sed -i -E`.
|
|
12
|
+
- No `cargo test` (hook): `cargo build --workspace --tests`, then run ALL 14 test binaries directly from `target/debug/deps`, executable files only, skipping `.rlib`/`.rmeta`: plum_checker, checker, plum, cli, plum_core, core, plum_driver, driver_tests, plum_host, plum_lsp, plum_test_support, plum_wasm_codegen, wasm_codegen, tree_sitter_plum. `plum` and `plum_lsp` each also have a non-test binary: run the one that prints a test result line. Report per-binary counts. Sweep `target/debug/plum test std examples` (662 before this plan).
|
|
13
|
+
- Byte-identical wasm: `bash .superlite/wasm-tree.sh check` must say identical (25 files). Never change the baseline.
|
|
14
|
+
- docs/ and README.md and the game engine (`std/game`, `std/Io.plum`) are out of scope: the user is still discussing them. Do not touch them, except T9's results doc.
|
|
15
|
+
- Each task ends with workspace build zero Rust warnings (tree-sitter C warnings are pre-existing), all binaries, sweep, wasm check, except that T1 and T2 run in parallel in separate crates and only build and test their own crate plus plum-cli.
|
|
16
|
+
|
|
17
|
+
## Task graph
|
|
18
|
+
```mermaid
|
|
19
|
+
flowchart LR
|
|
20
|
+
T1[checker split]
|
|
21
|
+
T2[codegen split]
|
|
22
|
+
T3[codegen driver stages]
|
|
23
|
+
T4[type kinds]
|
|
24
|
+
T5[verify phase 5]
|
|
25
|
+
T6[Plum test home + tail-call proof]
|
|
26
|
+
T7[move wasm_codegen run tests]
|
|
27
|
+
T8[move other run tests]
|
|
28
|
+
T9[final verify]
|
|
29
|
+
%% taskgraph
|
|
30
|
+
T1 --> T4
|
|
31
|
+
T2 --> T3
|
|
32
|
+
T3 --> T4
|
|
33
|
+
T4 --> T5
|
|
34
|
+
T5 --> T6
|
|
35
|
+
T6 --> T7
|
|
36
|
+
T6 --> T8
|
|
37
|
+
T7 --> T9
|
|
38
|
+
T8 --> T9
|
|
39
|
+
```
|
|
40
|
+
Ruling: T1 and T2 are in separate crates so they run in parallel; T7 and T8 touch separate test folders so they run in parallel; the rest is a chain because each edits what the last produced - cost: about 90 min.
|
|
41
|
+
|
|
42
|
+
## Package A: file splits
|
|
43
|
+
**Probe entry:** run=plum test std examples
|
|
44
|
+
|
|
45
|
+
### T1 checker split · Complexity 4 · Est. context 50k
|
|
46
|
+
**Owns:** compiler/checker/src/*.rs
|
|
47
|
+
**Components:** 1
|
|
48
|
+
**LOC owned:** 150
|
|
49
|
+
**Tests:** fast
|
|
50
|
+
**Model:** sonnet
|
|
51
|
+
**Produces:** `compiler/checker/src/lib.rs` under 500 lines: public API and module list; about 13 modules, each one job
|
|
52
|
+
e.g. `wc -l compiler/checker/src/*.rs` -> no file over 1000 lines except the core inference module, which is named in the report if over
|
|
53
|
+
**Notes:** Pure moves: no logic change, no renames of public items, tests stay green unchanged. Suggested modules follow what is there: tables and recording, unification and binds, type conversion, declarations and prepass, generics and instantiation, statements, expressions, calls, patterns, closures and hints, resolution errors. Choose by reading the code; report the final list. `pub(crate)` as needed, nothing newly `pub`.
|
|
54
|
+
**Test code:** checker binaries and sweep; wasm check; `git diff --stat` shows moves with no net logic.
|
|
55
|
+
|
|
56
|
+
### T2 codegen split · Complexity 5 · Est. context 60k
|
|
57
|
+
**Owns:** compiler/wasm-codegen/src/*.rs
|
|
58
|
+
**Components:** 1
|
|
59
|
+
**LOC owned:** 200
|
|
60
|
+
**Tests:** fast
|
|
61
|
+
**Model:** sonnet
|
|
62
|
+
**Produces:** `compiler/wasm-codegen/src/lib.rs` under 500 lines plus `compileSourceUnits`; about 11 modules, each one job
|
|
63
|
+
e.g. `wc -l compiler/wasm-codegen/src/*.rs` -> no file over 1500 lines
|
|
64
|
+
**Notes:** Pure moves. Suggested modules: GC type registry, wasm module builder, local layout, expressions, calls, closures, patterns and match, statements, generics units, host glue and exports, builtins. Keep `compileEntryTests` working. Line comments like `// ---- units` mark natural seams.
|
|
65
|
+
**Test code:** plum_wasm_codegen and wasm_codegen binaries; sweep; wasm check.
|
|
66
|
+
|
|
67
|
+
### T3 codegen driver stages · Complexity 5 · Est. context 55k
|
|
68
|
+
**Owns:** compiler/wasm-codegen/src/*.rs
|
|
69
|
+
**Components:** 1
|
|
70
|
+
**LOC owned:** 200
|
|
71
|
+
**Tests:** fast
|
|
72
|
+
**Consumes:** `compiler/wasm-codegen/src/lib.rs` under 500 lines plus `compileSourceUnits`; about 11 modules, each one job (T2)
|
|
73
|
+
**Produces:** `compileSourceUnits` is a short list of named stage calls, under 80 lines, with state passed in a struct
|
|
74
|
+
e.g. stages such as collect units, collect concrete generics, discover closures, declare types, compile bodies, emit
|
|
75
|
+
**Notes:** Stage order is unchanged; output is byte-identical, including the order of types and functions. A stage's state goes in fields of one struct rather than long parameter lists. Stop where a seam needs logic changes, and report it.
|
|
76
|
+
**Test code:** all binaries; sweep; wasm check.
|
|
77
|
+
|
|
78
|
+
### T4 type kinds · Complexity 5 · Est. context 55k
|
|
79
|
+
**Owns:** compiler/core/src/*.rs, compiler/checker/src/*.rs, compiler/wasm-codegen/src/*.rs, compiler/lsp/src/*.rs, compiler/driver/src/*.rs, compiler/core/tests/core/*.rs, compiler/checker/tests/**/*.rs, compiler/wasm-codegen/tests/wasm_codegen/**/*.rs
|
|
80
|
+
**Components:** 1
|
|
81
|
+
**LOC owned:** 180
|
|
82
|
+
**Tests:** fast
|
|
83
|
+
**Consumes:** `compiler/checker/src/lib.rs` under 500 lines: public API and module list; about 13 modules, each one job (T1)
|
|
84
|
+
**Consumes:** `compileSourceUnits` is a short list of named stage calls, under 80 lines, with state passed in a struct (T3)
|
|
85
|
+
**Produces:** `ast::Enum.kind: TypeKind` where `TypeKind` is `Enum` or `Struct(StructInfo { forceBoxed, embeds, embedFields, base })`, replacing the separate fields; `Fn.is_test` replaces `__test_` name-prefix checks; `core/src/unions.rs` renamed to say what it now does
|
|
86
|
+
e.g. `grep -rn 'isStruct\|__test_' compiler/*/src` -> only the name generation for test fns, if a name is still generated
|
|
87
|
+
**Notes:** The review plan's record, trait and value flags no longer exist because those features were removed. Only what's above remains, so this is smaller than planned. A small `e.isStruct()` helper is fine. Don't merge `embeds.rs` and `ctors.rs` unless it's plainly simpler; say which you chose. Other generated names (`__new`, `__init_`, `__for_`) stay.
|
|
88
|
+
**Test code:** all binaries; sweep; wasm check.
|
|
89
|
+
|
|
90
|
+
### T5 verify phase 5 · Complexity 2 · Est. context 25k
|
|
91
|
+
**Owns:** docs/plans/2026-11-05-compiler-cleanup-phase5-6.results.md
|
|
92
|
+
**Components:** 1
|
|
93
|
+
**LOC owned:** 20
|
|
94
|
+
**Tests:** slow
|
|
95
|
+
**Consumes:** `ast::Enum.kind: TypeKind` where `TypeKind` is `Enum` or `Struct(StructInfo { forceBoxed, embeds, embedFields, base })`, replacing the separate fields; `Fn.is_test` replaces `__test_` name-prefix checks; `core/src/unions.rs` renamed to say what it now does (T4)
|
|
96
|
+
**Produces:** phase 5 results section
|
|
97
|
+
**Notes:** Workspace build; all 14 binaries with counts; sweep; wasm; browser (stress_scene + one game per style via compiler/game/web-test); `wc -l` of both crates before and after. Report red, don't fix.
|
|
98
|
+
**Test code:** results doc section.
|
|
99
|
+
|
|
100
|
+
## Package B: tests in Plum
|
|
101
|
+
**Probe entry:** run=plum test std examples compiler/tests
|
|
102
|
+
|
|
103
|
+
### T6 Plum test home and tail-call proof · Complexity 3 · Est. context 35k
|
|
104
|
+
**Owns:** compiler/tests/**/*.plum, compiler/test-support/src/*.rs
|
|
105
|
+
**Components:** 1
|
|
106
|
+
**LOC owned:** 60
|
|
107
|
+
**Tests:** fast
|
|
108
|
+
**Consumes:** phase 5 results section (T5)
|
|
109
|
+
**Produces:** `compiler/tests/` holding `.plum` test files, swept by `target/debug/plum test std examples compiler/tests`; a test proving `plum test` runs tail calls like a compiled program
|
|
110
|
+
e.g. `test "deep tail recursion"` running a 200000-step self-tail call, a method tail call and a closure tail call under `plum test`
|
|
111
|
+
**Notes:** The review plan gates phase 6 on this. If tail calls behave differently under `plum test`, stop: reply BLOCKED, with no tests moved. `--no-fusion` should be swept too, if the moved tests are to keep exercising that path; say whether.
|
|
112
|
+
**Test code:** the new test passes; sweep with the new folder.
|
|
113
|
+
|
|
114
|
+
### T7 move wasm_codegen run tests · Complexity 4 · Est. context 50k
|
|
115
|
+
**Owns:** compiler/wasm-codegen/tests/wasm_codegen/**/*.rs, compiler/tests/codegen/*.plum
|
|
116
|
+
**Components:** 1
|
|
117
|
+
**LOC owned:** 200
|
|
118
|
+
**Tests:** fast
|
|
119
|
+
**Consumes:** `compiler/tests/` holding `.plum` test files, swept by `target/debug/plum test std examples compiler/tests`; a test proving `plum test` runs tail calls like a compiled program (T6)
|
|
120
|
+
**Produces:** wasm_codegen tests that only run a program and compare its answer are `.plum` tests; each moved test keeps its assertion
|
|
121
|
+
e.g. `assert_eq!(runInt(..., "fun main() -> Int = 40 + 2"), 42)` becomes `test "..."` with `assert` in a `.plum` file
|
|
122
|
+
**Notes:** Move ONLY tests that compile, run and compare. Keep in Rust: anything asserting a compile error, wasm contents, validation, traps, panics, fusion decisions, or host calls. Test that a moved test can fail: break one assert temporarily to see `plum test` report it. Group by feature, not one file per test. Report counts moved and kept.
|
|
123
|
+
**Test code:** `plum test` on the folder; wasm_codegen binary; sweep.
|
|
124
|
+
|
|
125
|
+
### T8 move other run tests · Complexity 3 · Est. context 40k
|
|
126
|
+
**Owns:** compiler/core/tests/core/*.rs, compiler/checker/tests/**/*.rs, compiler/cli/tests/cli/*.rs, compiler/tests/core/*.plum
|
|
127
|
+
**Components:** 1
|
|
128
|
+
**LOC owned:** 120
|
|
129
|
+
**Tests:** fast
|
|
130
|
+
**Consumes:** `compiler/tests/` holding `.plum` test files, swept by `target/debug/plum test std examples compiler/tests`; a test proving `plum test` runs tail calls like a compiled program (T6)
|
|
131
|
+
**Produces:** run-and-compare tests in core, checker and cli are `.plum` tests; each moved test keeps its assertion
|
|
132
|
+
e.g. core `closure_spec_test` cases that run a program and compare its answer
|
|
133
|
+
**Notes:** Same rule as T7. A core test that also asserts a pass's output stays in Rust. Report counts moved and kept.
|
|
134
|
+
**Test code:** `plum test` on the folder; core, checker, cli binaries; sweep.
|
|
135
|
+
|
|
136
|
+
### T9 final verify · Complexity 2 · Est. context 30k
|
|
137
|
+
**Owns:** docs/plans/2026-11-05-compiler-cleanup-phase5-6.results.md
|
|
138
|
+
**Components:** 1
|
|
139
|
+
**LOC owned:** 20
|
|
140
|
+
**Tests:** slow
|
|
141
|
+
**Consumes:** wasm_codegen tests that only run a program and compare its answer are `.plum` tests; each moved test keeps its assertion (T7)
|
|
142
|
+
**Consumes:** run-and-compare tests in core, checker and cli are `.plum` tests; each moved test keeps its assertion (T8)
|
|
143
|
+
**Produces:** phases 5 and 6 results
|
|
144
|
+
**Notes:** Everything in T5 again, plus the sweep with compiler/tests and the count of tests moved. Total assertions must not drop: compare Rust tests removed against Plum tests added. Report red, don't fix.
|
|
145
|
+
**Test code:** results doc.
|
|
146
|
+
|
|
147
|
+
## Review Focus
|
|
148
|
+
- A: state - pure moves keep behaviour; the driver's stage order and output order are unchanged.
|
|
149
|
+
- B: state - a moved test keeps its assertion's strength and can actually fail.
|
docs/{self-hosting.md → plans/self-hosting.md}
RENAMED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Self-hosting Plum
|
|
2
2
|
|
|
3
3
|
Status: design, 2026-11-04. The first slices are planned in
|
|
4
|
-
[plans/2026-11-04-self-hosting-groundwork.review.md](
|
|
4
|
+
[plans/2026-11-04-self-hosting-groundwork.review.md](2026-11-04-self-hosting-groundwork.review.md); nothing here is built yet.
|
|
5
5
|
|
|
6
6
|
Goal: the lexer, parser, checker, compiler passes, wasm emitter and CLI written in Plum, compiled by a Rust "stage 0"
|
|
7
7
|
compiler until the Plum compiler can compile itself.
|