git.lucas.co / cce-designer
graphic design tool
git clone https://git.lucas.co/cce-designer.git

commitd2edbe573097a963c9fd5f995c28e8307341c00f
parent03399b73a5
authorLucas Galante <[email protected]>
date2026-09-24 09:55
feat: parameter expressions — ch() references by path, arithmetic, and the row menu

A parameter holds a value or an expression, and `ParamDef::expr` says
which — a flag, not a guess about the text, since a kernel contains
`chf(` and `0.5` parses as an expression too. `src/expr.rs` is the
language: `ch()`/`chf()`/`chi()`/`chb()`/`chs()` by Houdini path (a bare
name the node's own parameter, `..` its parent, `/` the root, `.x`/`.y`/
`.z` a float3 component), `$F`, arithmetic, comparisons, and a fixed
function set. `TreeScope` in geometry.rs binds it to the tree: chains
follow, circles are errors, the result is formatted for the target row.

A bare `ch("Name")` used to mean the parent; `Project::format` versions
the change and `migrate_param_refs` rewrites old saves once. Templates
infer the flag from a default that reads as a reference (embryo.json
now says `../`). A rename rewrites every path through the node and the
wires naming it.

The params pane gains a right-click row menu: Copy Parameter, Paste
Relative / Absolute Reference, Edit Expression, Delete Expression (which
bakes the current value). Expression rows draw tinted and as text.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

 CLAUDE.md         | 102 +++++---
 nodes/embryo.json |  48 ++--
 src/api.rs        |   2 +-
 src/app.rs        | 330 +++++++++++++++++++++++-
 src/dialog.rs     |   2 +-
 src/export_cli.rs |   1 +
 src/expr.rs       | 741 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/geometry.rs   | 495 ++++++++++++++++++++++++++----------
 src/main.rs       | 427 +++++++++++++++++++++++++++----
 src/project.rs    |   4 +
 src/render.rs     |  24 ++
 src/thumbnail.rs  |   1 +
 src/window.rs     |  20 +-
 13 files changed, 1963 insertions(+), 234 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index bd17e2a..649c52e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -551,30 +551,75 @@ wires it as a node parameter), so the free-form float ramp is ported as the
 three-way choice the falloff parameters already use. Linear is the default
 because linear is what the cast that worked used.
 
-### Parameter references, sibling-first inputs, and the Switch node
-
-Three pieces added on 2026-09-21 so a node can be BUILT FROM other nodes
+### Parameter expressions (`ch()` references, Houdini's way)
+
+`src/expr.rs` is the expression language and `geometry.rs`'s `TreeScope`
+is what binds it to the node tree. **A parameter holds a value or an
+expression, and `ParamDef::expr` says which** — a flag, not a guess about
+the text, because a kernel's Code contains `chf(`, a node name is an
+identifier and `0.5` parses as an expression too. Houdini makes the same
+choice (a parm has a channel or it does not). An expression parameter is
+evaluated every time its node is: `resolve_param_refs(root, node, frame,
+error)` hands back a clone whose `expr` params are VALUES, at the top of
+`generate_single_node_geometry_with_errors`, the scene walk's `visit`, and
+the kernel path's parent read.
+
+**Paths are Houdini's.** Relative to the node holding the expression: a bare
+name is the node's OWN parameter, `..` its parent, `../sphere1/Radius` a
+sibling's, a leading `/` the root. `.x` / `.y` / `.z` reads a float3
+component. `ch()` / `chf()` read a number (a toggle 1 or 0, a choice its
+option INDEX — `chi("../Method")` is what lets a subnet's dropdown drive a
+child switch's Index), `chi()` truncates, `chb()` is 1 or 0, `chs()` the
+string (a choice's option text). The rest is `+ - * / % ^`, comparisons,
+`&& || !`, `$F` (the evaluation's frame), strings with `+`, and a fixed
+function set (`if(c, a, b)`, `clamp`, `fit`, `lerp`, `min`/`max`, `rand(seed)`,
+the usual math). No ternary — `:` separates a float3's components, which
+are three expressions each (`chf("../a/Size.x"):0:0`). An expression that
+reads an expression follows the chain; a circle is an error on the node,
+never a stack overflow. The written-back value is formatted for the
+TARGET row (`format_for_param`): a number into a toggle is `true`/`false`,
+into a choice its option name, into a spinbox an integer.
+
+**Until 2026-09-24 a bare `ch("Name")` meant the PARENT's parameter** (the
+whole value had to be one reference, nothing else). `Project::format` is
+the version that tells the two apart: 0 (absent) loads through
+`migrate_param_refs`, which turns each old reference into an expression
+with `../` added to a bare name, and saves as 1 — beside
+`sanitize_node_names` on every load path, and never twice, since a bare
+name in a format-1 file is the node's own parameter. Templates go through
+`infer_template_exprs` instead: a default that READS as a reference is one
+(`embryo.json` says `chf("../Radius")` now). The same inference applies to a
+value typed into a plain row or scripted through `set_param`: a reference
+becomes an expression; bare arithmetic does not, and is asked for through
+the row menu.
+
+**The params pane's right-click menu** (`param_row_at` → `open_param_context_menu`,
+a fifth `context_menu` consumer with the `*_menu_actions` +
+`handle_*_menu_click` contract, and `run_param_action` as the one entry the
+menu and the tests share) is Houdini's: **Copy Parameter**, **Paste
+Relative Reference** (`relative_ref_path`: `../sphere1`), **Paste Absolute
+Reference** (`/sphere1`), and **Edit Expression** / **Delete Expression** —
+the latter bakes the CURRENT value back as a value, as Delete Channels
+does. `copied_param` holds a node ID, not a path, so a rename between copy
+and paste still pastes the right path. The paste writes `chs()` when the
+target row holds text or a choice and `ch()` otherwise, by the TARGET,
+because that is what the value has to fit. Expression rows draw with a
+green tint (`render.rs`, PARAM_IDX arm) and as text in the pane
+(`param_display`), since a slider cannot hold one.
+
+**A rename carries every reference to the node** (`rename_node_in_tree`):
+expression paths that pass through it are rewritten textually
+(`expr::rewrite_paths`, so spacing survives), resolved from where each
+stands BEFORE the name changes since a path is names; sibling wires whose
+value is the old name follow, as the load-time sanitizer rewrites them;
+and the active camera. A same-named node elsewhere is not this one.
+
+### Sibling-first inputs and the Switch node
+
+Two pieces added on 2026-09-21 so a node can be BUILT FROM other nodes
 the way a Houdini HDA is — the Embryo is the first to be recomposed that
-way — all in `src/geometry.rs`:
-
-- **A parameter value that is `ch("Name")` reads the enclosing subnet's
-  parameter `Name`** at evaluation time. `chf` / `chi` / `chb` are the
-  kernel vocabulary applied to references: `chi("Method")` on a choice with
-  options Basic, Scatter is 0 or 1, which is what lets a subnet's choice
-  drive a child switch's Index; `chb` reads a toggle as `true`/`false`. A
-  `../` per level climbs further (`ch("../../X")`); `ch("Name")` and
-  `ch("../Name")` both mean the parent. The whole value is the reference or
-  it is not one — there is no expression language, and a kernel's Code,
-  which merely contains `chf(`, is left alone. `resolve_param_refs` runs at
-  the top of `generate_single_node_geometry_with_errors` AND at the top of
-  the scene walk's `visit` (the walk hands nodes to their resolvers
-  directly), on a clone made only for nodes that actually reference. A
-  reference to nothing is reported through the node-error slot and the
-  value left as written, and a reference to a reference follows the chain
-  (bounded), so a composed node inside a composed node still reaches the
-  outermost control. The params pane shows a referencing value as text
-  (`param_display`), since a slider cannot hold it and a spinbox would write
-  zero back over it.
+way — both in `src/geometry.rs`:
+
 - **`find_input_node(root, target, name)` looks for a SIBLING first, then
   anywhere.** Every resolver used to search the whole tree from the top, so
   inside the second instance of a subnet a child wired to "input1" found the
@@ -616,10 +661,10 @@ family's first Pre-Simulation operator — "the seed geometry a simulation
 starts from" — as a SUBNET of ten ordinary nodes wired the way the HDA's
 network is, its controls reaching the children through parameter references
 (above). Dive in and the pipeline is there to read, break and reuse: `input1`
-and a `sphere1` (Radius `chf("Radius")`, Rows and Columns
-`chi("Base Resolution")`) behind `source1`, a `switch` whose Index is
-`chi("Source")`; `scatter1` in Surface mode reading the Scatter folder's
-controls, `hull1` behind it, and `method1`, a switch on `chi("Method")`
+and a `sphere1` (Radius `chf("../Radius")`, Rows and Columns
+`chi("../Base Resolution")`) behind `source1`, a `switch` whose Index is
+`chi("../Source")`; `scatter1` in Surface mode reading the Scatter folder's
+controls, `hull1` behind it, and `method1`, a switch on `chi("../Method")`
 between the source and the hull; then `relax1` in Repel mode, `subdivide1`,
 `normal1`, `output1`. The defaults are the HDA's, and
 `embryo_template_builds_a_sphere_a_hull_or_the_input` drives the template
@@ -629,7 +674,8 @@ It was a native node for one day (2026-09-21, `src/embryo.rs`, a pipeline in
 Rust), which is the wrong shape for this app: CLAUDE.md refuses `gem_graph`
 for the same reason, and a node you cannot dive into cannot be learned from.
 Recomposing it needed four reusable pieces, all of which outlive it:
-parameter references and the `switch` node (their own section above), the
+parameter references (now expressions, their own section above) and the
+`switch` node, the
 `hull` node (`src/hull.rs` — the incremental convex hull; points that span
 no volume pass through), and two modes on existing nodes (`src/scatter.rs`):
 **Scatter's Surface mode** (points ON the surface by area, seeded, optionally
diff --git a/nodes/embryo.json b/nodes/embryo.json
index dc6722e..c2fed30 100644
--- a/nodes/embryo.json
+++ b/nodes/embryo.json
@@ -145,15 +145,18 @@
    "params": [
     {
      "name": "Radius",
-     "default": "chf(\"Radius\")"
+     "default": "chf(\"../Radius\")",
+     "expr": true
     },
     {
      "name": "Rows",
-     "default": "chi(\"Base Resolution\")"
+     "default": "chi(\"../Base Resolution\")",
+     "expr": true
     },
     {
      "name": "Columns",
-     "default": "chi(\"Base Resolution\")"
+     "default": "chi(\"../Base Resolution\")",
+     "expr": true
     },
     {
      "name": "Center Y",
@@ -180,7 +183,8 @@
     },
     {
      "name": "Index",
-     "default": "chi(\"Source\")"
+     "default": "chi(\"../Source\")",
+     "expr": true
     }
    ],
    "geometry_visible": false
@@ -203,31 +207,38 @@
     },
     {
      "name": "Points",
-     "default": "chi(\"Scatter Count\")"
+     "default": "chi(\"../Scatter Count\")",
+     "expr": true
     },
     {
      "name": "Seed",
-     "default": "chf(\"Scatter Seed\")"
+     "default": "chf(\"../Scatter Seed\")",
+     "expr": true
     },
     {
      "name": "Relax Points",
-     "default": "chb(\"Relax Points\")"
+     "default": "chb(\"../Relax Points\")",
+     "expr": true
     },
     {
      "name": "Relax Iterations",
-     "default": "chi(\"Scatter Relax Iterations\")"
+     "default": "chi(\"../Scatter Relax Iterations\")",
+     "expr": true
     },
     {
      "name": "Scale Radii By",
-     "default": "chf(\"Scale Radii By\")"
+     "default": "chf(\"../Scale Radii By\")",
+     "expr": true
     },
     {
      "name": "Use Max Relax Radius",
-     "default": "chb(\"Use Max Relax Radius\")"
+     "default": "chb(\"../Use Max Relax Radius\")",
+     "expr": true
     },
     {
      "name": "Max Relax Radius",
-     "default": "chf(\"Scatter Relax Radius\")"
+     "default": "chf(\"../Scatter Relax Radius\")",
+     "expr": true
     },
     {
      "name": "Markers",
@@ -269,7 +280,8 @@
     },
     {
      "name": "Index",
-     "default": "chi(\"Method\")"
+     "default": "chi(\"../Method\")",
+     "expr": true
     }
    ],
    "geometry_visible": false
@@ -292,15 +304,18 @@
     },
     {
      "name": "Iterations",
-     "default": "chi(\"Relax Iterations\")"
+     "default": "chi(\"../Relax Iterations\")",
+     "expr": true
     },
     {
      "name": "Radius",
-     "default": "chf(\"Relax Radius\")"
+     "default": "chf(\"../Relax Radius\")",
+     "expr": true
     },
     {
      "name": "In 3D Space",
-     "default": "chb(\"Relax in 3D Space\")"
+     "default": "chb(\"../Relax in 3D Space\")",
+     "expr": true
     }
    ],
    "geometry_visible": false
@@ -319,7 +334,8 @@
     },
     {
      "name": "Depth",
-     "default": "chi(\"Subdivision Depth\")"
+     "default": "chi(\"../Subdivision Depth\")",
+     "expr": true
     }
    ],
    "geometry_visible": false
diff --git a/src/api.rs b/src/api.rs
index a33c374..6e329f2 100644
--- a/src/api.rs
+++ b/src/api.rs
@@ -60,7 +60,7 @@ pub(crate) fn mcp_tools() -> Vec<McpTool> {
         ),
         tool(
             "set_param",
-            "Set a parameter on the node at the given slot. All values are strings (e.g. \"1.5\", \"0.2,0.4,1\").",
+            "Set a parameter on the node at the given slot. All values are strings (e.g. \"1.5\", \"0.2,0.4,1\"). A value that reads as an expression — ch(\"../sphere1/Radius\") * 2, $F / 24 — becomes one (Houdini paths: relative to the node, .. its parent, / the root, a bare name its own parameter).",
             json!({
                 "type": "object",
                 "properties": {
diff --git a/src/app.rs b/src/app.rs
index c28e3f1..877837a 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -107,6 +107,14 @@ pub struct ParamDef {
     /// stating that directly is easier to get right than stating its negation.
     #[serde(default)]
     pub show_when: String,
+    /// Whether `default` is an EXPRESSION to evaluate rather than a value —
+    /// `ch("../sphere1/Radius") * 2`, `$F / 24` (see `expr.rs`). A flag and
+    /// not a guess about the text, because a kernel's Code contains `chf(`,
+    /// a node name is an identifier and `0.5` parses as an expression too;
+    /// Houdini makes the same choice. The instance owns it with the value:
+    /// the template merge never touches it.
+    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
+    pub expr: bool,
 }
 
 fn default_param_type() -> String { "string".to_string() }
@@ -343,8 +351,18 @@ pub struct Project {
     pub root: FsNode,
     #[serde(default)]
     pub view_state: ProjectViewState,
+    /// The file format this project was saved in, so a load can tell an old
+    /// meaning from a new one. 0 (absent) is every save before 2026-09-24,
+    /// when a bare `ch("Name")` meant the PARENT's parameter; 1 is Houdini's
+    /// semantics, where it means the node's own — `migrate_param_refs` is
+    /// the step between them, and it must not run twice.
+    #[serde(default)]
+    pub format: u32,
 }
 
+/// The format `Project` saves in — see its `format` field.
+pub const PROJECT_FORMAT: u32 = 1;
+
 /// One entry in a node's right-click context menu, parallel to the visible
 /// labels shown via `context_menu::show`.
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -360,6 +378,22 @@ pub enum NodeMenuAction {
 }
 
 /// The 3D viewport's right-click context menu actions.
+/// One entry in a parameter row's right-click menu — Houdini's Copy
+/// Parameter / Paste Relative References, and the switch between a value
+/// and an expression.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ParamMenuAction {
+    CopyParameter,
+    PasteRelative,
+    PasteAbsolute,
+    /// Keep the value, but as an expression: the row turns to text so
+    /// arithmetic can be typed around it.
+    EditExpression,
+    /// Houdini's Delete Channels: the expression's CURRENT value, as a value.
+    DeleteExpression,
+    Separator,
+}
+
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 pub enum ViewportMenuAction {
     /// Move the active camera so the visible node geometry fills the view.
@@ -495,10 +529,10 @@ pub fn param_display(params: &[ParamDef]) -> Vec<(String, String, String)> {
         } else {
             p.default.clone()
         };
-        // A value that is a reference (`ch("Radius")`) is shown as the text
+        // An expression (`ch("../sphere1/Radius") * 2`) is shown as the text
         // it is: a slider cannot hold it, and a spinbox would show zero and
         // then write zero back over it.
-        let ptype = if crate::geometry::parse_param_ref(&value).is_some() {
+        let ptype = if p.expr {
             "text".to_string()
         } else if p.param_type == "slider" {
             let min = p.min.unwrap_or(0.0);
@@ -691,6 +725,53 @@ impl Project {
     }
 }
 
+impl Project {
+    /// Format 0 → 1: every parameter that was a pre-expression reference —
+    /// the whole value `ch("Name")`, `chf` / `chi` / `chb`, with `../` per
+    /// level — becomes an expression with Houdini's semantics. A bare name
+    /// meant the parent, so it gains `../`; an explicit `../` already meant
+    /// what it means now. Runs beside `sanitize_node_names` on every load
+    /// path, and on a format-1 file does nothing, which is what the version
+    /// is for: a bare name in a NEW file is the node's own parameter and
+    /// must not be rewritten.
+    pub fn migrate_param_refs(&mut self) {
+        if self.format >= PROJECT_FORMAT {
+            return;
+        }
+        fn walk(node: &mut FsNode) {
+            for p in &mut node.params {
+                if !p.expr {
+                    if let Some(new) = crate::expr::migrate_legacy_ref(&p.default) {
+                        p.default = new;
+                        p.expr = true;
+                    }
+                }
+            }
+            for c in &mut node.children {
+                walk(c);
+            }
+        }
+        walk(&mut self.root);
+        self.format = PROJECT_FORMAT;
+    }
+}
+
+/// A template's parameters whose defaults READ as expressions become ones:
+/// `chf("../Radius")` in `embryo.json` is a reference by any reading, and a
+/// template author should not have to say `"expr": true` beside it (though
+/// they may). `looks_like_expression` is the inference, and it is the same
+/// one a typed or scripted value gets — arithmetic alone is not enough.
+pub fn infer_template_exprs(node: &mut FsNode) {
+    for p in &mut node.params {
+        if !p.expr && crate::expr::looks_like_expression(&p.default) {
+            p.expr = true;
+        }
+    }
+    for c in &mut node.children {
+        infer_template_exprs(c);
+    }
+}
+
 pub fn merge_template_defs(root: &mut FsNode, templates: &[NodeTemplate]) {
     // The retired per-node `meta` children go first, before any matching:
     // the merge compares a subnet instance's children against its template's
@@ -850,7 +931,10 @@ pub fn load_fs_tree() -> FsNode {
         for path in paths {
             match fs::read_to_string(&path) {
                 Ok(content) => match serde_json::from_str::<FsNode>(&content) {
-                    Ok(node) => raw_nodes.push(node),
+                    Ok(mut node) => {
+                        infer_template_exprs(&mut node);
+                        raw_nodes.push(node);
+                    }
                     Err(e) => eprintln!(
                         "cce-designer: dropping node template {} — it does not parse: {e}",
                         path.display()
@@ -893,6 +977,10 @@ pub fn load_fs_tree() -> FsNode {
                     for override_p in &child.params {
                         if let Some(base_p) = resolved_child.params.iter_mut().find(|p| p.name == override_p.name) {
                             base_p.default = override_p.default.clone();
+                            // The flag travels with the value: an override
+                            // that is a reference (the Embryo's sphere1
+                            // reading `chf("../Radius")`) stays one.
+                            base_p.expr = override_p.expr;
                         }
                     }
                     if depth < 8 {
@@ -1575,6 +1663,15 @@ pub struct State {
     /// machinery as the node menu; this flag says the open menu is OURS).
     pub viewport_menu_active: bool,
     pub viewport_menu_actions: Vec<ViewportMenuAction>,
+    /// A parameter row's right-click menu — the same thread-local; the
+    /// target is (node id, parameter name) rather than a slot and a row, so
+    /// it holds across a re-layout of the pane.
+    pub param_menu_active: bool,
+    pub param_menu_actions: Vec<ParamMenuAction>,
+    pub param_menu_target: Option<(String, String)>,
+    /// Copy Parameter's clipboard: (node id, parameter name). An id, so a
+    /// rename between the copy and the paste still pastes the right path.
+    pub copied_param: Option<(String, String)>,
     /// The network editor's right-click menu — the same thread-local again,
     /// with the flag saying the open menu is this one.
     pub network_menu_active: bool,
@@ -2805,6 +2902,12 @@ impl State {
                             if p.default != *u_val {
                                 p.default = u_val.clone();
                                 param_changed = true;
+                                // A reference typed into a plain row becomes
+                                // an expression — the one way to make one
+                                // without the row menu.
+                                if !p.expr && crate::expr::looks_like_expression(&p.default) {
+                                    p.expr = true;
+                                }
                                 if p.param_type == "button" && p.default == "clicked" {
                                     triggered_buttons.push(p.name.clone());
                                     p.default = "".to_string();
@@ -3823,6 +3926,194 @@ impl State {
         self.sync_parameters_pane();
     }
 
+    /// The parameter row under a window point in the params pane: the slot
+    /// the pane shows and the parameter's NAME — the row's display key
+    /// resolved back, as the write-back resolves it. None off a row, off the
+    /// pane, or on a section header.
+    pub fn param_row_at(&self, x: f32, y: f32) -> Option<(usize, String)> {
+        if !self.show_parameters || self.is_detached_network {
+            return None;
+        }
+        let (px, py, pw, ph) = self.positions[crate::slots::PARAM_IDX];
+        if x < px || x > px + pw || y < py || y > py + ph {
+            return None;
+        }
+        let slot = self.param_editor_selected()?;
+        let rects = self.param_row_rects();
+        let child = self.param_editor_dir().children.get(slot)?;
+        let rows = param_display(&child.params);
+        let i = rects
+            .iter()
+            .position(|&(rx, ry, rw, rh)| rh > 0.0 && x >= rx && x <= rx + rw && y >= ry && y <= ry + rh)?;
+        let row = rows.get(i)?;
+        if row.2 == "section" {
+            return None;
+        }
+        let p = child.params.iter().find(|p| {
+            let key = if p.label.is_empty() { &p.name } else { &p.label };
+            *key == row.0
+        })?;
+        Some((slot, p.name.clone()))
+    }
+
+    /// The params pane's row rects, index-parallel to `param_display` of the
+    /// node it shows — the one geometry the row menu's hit test and the
+    /// expression tint both read.
+    pub fn param_row_rects(&self) -> Vec<(f32, f32, f32, f32)> {
+        self.slots.param.as_any().downcast_ref::<ParametersBg>().map(|pb| pb.get_param_rects()).unwrap_or_default()
+    }
+
+    /// Open a parameter row's right-click menu.
+    fn open_param_context_menu(&mut self, slot: usize, pname: String) {
+        let (node_id, is_expr) = {
+            let child = &self.param_editor_dir().children[slot];
+            (child.id.clone(), child.params.iter().find(|p| p.name == pname).is_some_and(|p| p.expr))
+        };
+        let mut options = vec!["Copy Parameter".to_string()];
+        let mut actions = vec![ParamMenuAction::CopyParameter];
+        if self.copied_param.is_some() {
+            options.push("Paste Relative Reference".to_string());
+            actions.push(ParamMenuAction::PasteRelative);
+            options.push("Paste Absolute Reference".to_string());
+            actions.push(ParamMenuAction::PasteAbsolute);
+        }
+        options.push("-".to_string());
+        actions.push(ParamMenuAction::Separator);
+        if is_expr {
+            options.push("Delete Expression".to_string());
+            actions.push(ParamMenuAction::DeleteExpression);
+        } else {
+            options.push("Edit Expression".to_string());
+            actions.push(ParamMenuAction::EditExpression);
+        }
+        let target = self.slots.get_dyn(crate::slots::PARAM_IDX).base().id();
+        cce_ui::widget::context_menu::show(self.cursor_x, self.cursor_y, options, 0, target);
+        self.param_menu_active = true;
+        self.param_menu_actions = actions;
+        self.param_menu_target = Some((node_id, pname));
+    }
+
+    pub fn param_menu_open(&self) -> bool {
+        cce_ui::widget::context_menu::is_visible() && self.param_menu_active
+    }
+
+    fn close_param_menu(&mut self) {
+        cce_ui::widget::context_menu::hide();
+        self.param_menu_active = false;
+        self.param_menu_actions.clear();
+        self.param_menu_target = None;
+    }
+
+    /// Route a left press while the row menu is open — same contract as
+    /// `handle_viewport_menu_click`.
+    fn handle_param_menu_click(&mut self) -> bool {
+        if !self.param_menu_open() {
+            return false;
+        }
+        if cce_ui::widget::context_menu::hit_test(self.cursor_x, self.cursor_y) {
+            let idx = cce_ui::widget::context_menu::row_at(self.cursor_x, self.cursor_y);
+            let picked = idx.and_then(|i| self.param_menu_actions.get(i).copied());
+            let target = self.param_menu_target.clone();
+            self.close_param_menu();
+            if let (Some(action), Some((node_id, pname))) = (picked, target) {
+                self.run_param_action(&node_id, &pname, action);
+            }
+            return true;
+        }
+        self.close_param_menu();
+        false
+    }
+
+    /// One row-menu action on one parameter, by node id and name — the
+    /// entry the menu, a test and any future command share.
+    pub fn run_param_action(&mut self, node_id: &str, pname: &str, action: ParamMenuAction) {
+        let node_label = crate::geometry::node_path_names(&self.fs_root, node_id)
+            .map(|n| format!("/{}", n.join("/")))
+            .unwrap_or_else(|| node_id.to_string());
+        match action {
+            ParamMenuAction::Separator => return,
+            ParamMenuAction::CopyParameter => {
+                self.copied_param = Some((node_id.to_string(), pname.to_string()));
+                self.update_status_text(&format!("Copied {node_label}/{pname} — paste it as a reference on another parameter."));
+                return;
+            }
+            ParamMenuAction::PasteRelative | ParamMenuAction::PasteAbsolute => {
+                let Some((src_id, src_p)) = self.copied_param.clone() else {
+                    self.update_status_text("Nothing copied — Copy Parameter first.");
+                    return;
+                };
+                let path = if action == ParamMenuAction::PasteRelative {
+                    crate::geometry::relative_ref_path(&self.fs_root, node_id, &src_id)
+                } else {
+                    crate::geometry::absolute_ref_path(&self.fs_root, &src_id)
+                };
+                let Some(path) = path else {
+                    self.update_status_text("The copied parameter's node is gone.");
+                    return;
+                };
+                // `chs` for a row that holds text, `ch` for one that holds a
+                // number — by the TARGET, since that is what the value has
+                // to fit: a choice pasted onto a switch's Index wants the
+                // option's index, pasted onto a text row its name.
+                let target_type = crate::viewer_state::find_node_by_id(&self.fs_root, node_id)
+                    .and_then(|n| n.params.iter().find(|p| p.name == pname))
+                    .map(|p| p.param_type.clone())
+                    .unwrap_or_default();
+                let func = if target_type == "text" || target_type == "string" || target_type.starts_with("choice") { "chs" } else { "ch" };
+                let full = if path.is_empty() { src_p.clone() } else { format!("{path}/{src_p}") };
+                let value = format!("{func}(\"{full}\")");
+                if let Some(p) = crate::viewer_state::find_node_by_id_mut(&mut self.fs_root, node_id)
+                    .and_then(|n| n.params.iter_mut().find(|p| p.name == pname))
+                {
+                    p.default = value.clone();
+                    p.expr = true;
+                }
+                self.update_status_text(&format!("{pname} = {value}"));
+            }
+            ParamMenuAction::EditExpression => {
+                if let Some(p) = crate::viewer_state::find_node_by_id_mut(&mut self.fs_root, node_id)
+                    .and_then(|n| n.params.iter_mut().find(|p| p.name == pname))
+                {
+                    p.expr = true;
+                }
+                self.update_status_text(&format!("{pname} is an expression — type ch(\"../node/Param\"), $F, arithmetic."));
+            }
+            ParamMenuAction::DeleteExpression => {
+                // The expression's value NOW becomes the value, as Houdini's
+                // Delete Channels keeps what the channel was showing. One that
+                // does not evaluate keeps its text, no longer as an expression.
+                let frame = self.sim_frame();
+                let evaluated = crate::viewer_state::find_node_by_id(&self.fs_root, node_id).and_then(|node| {
+                    let mut err = None;
+                    let resolved = crate::geometry::resolve_param_refs(&self.fs_root, node, frame, &mut err)?;
+                    resolved.params.into_iter().find(|p| p.name == pname).map(|p| (p.default, err))
+                });
+                let mut note = None;
+                if let Some(p) = crate::viewer_state::find_node_by_id_mut(&mut self.fs_root, node_id)
+                    .and_then(|n| n.params.iter_mut().find(|p| p.name == pname))
+                {
+                    p.expr = false;
+                    match evaluated {
+                        Some((value, None)) => {
+                            p.default = value.clone();
+                            note = Some(format!("{pname} = {value}, no longer an expression."));
+                        }
+                        Some((_, Some(e))) => note = Some(format!("{pname} kept as text — it did not evaluate: {e}")),
+                        None => {}
+                    }
+                }
+                if let Some(n) = note {
+                    self.update_status_text(&n);
+                }
+            }
+        }
+        // The SetParam resync sequence.
+        self.sync_grid_settings();
+        self.sync_nodes();
+        self.rebuild_scene_geometry();
+        self.sync_parameters_pane();
+    }
+
     /// Open the viewport right-click context menu at the cursor.
     fn open_viewport_context_menu(&mut self) {
         let mut options = vec!["Frame All".to_string(), "View 1:1".to_string()];
@@ -3853,7 +4144,7 @@ impl State {
         self.viewport_menu_actions = actions;
     }
 
-    fn viewport_menu_open(&self) -> bool {
+    pub fn viewport_menu_open(&self) -> bool {
         cce_ui::widget::context_menu::is_visible() && self.viewport_menu_active
     }
 
@@ -4501,6 +4792,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             if let Ok(content) = fs::read_to_string(&default_proj_path) {
                 if let Ok(mut proj) = serde_json::from_str::<Project>(&content) {
                     proj.sanitize_node_names();
+                    proj.migrate_param_refs();
                     merge_template_defs(&mut proj.root, &node_templates);
                     loaded_project = Some(proj);
                 }
@@ -4702,6 +4994,10 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             node_menu_actions: Vec::new(),
             viewport_menu_active: false,
             viewport_menu_actions: Vec::new(),
+            param_menu_active: false,
+            param_menu_actions: Vec::new(),
+            param_menu_target: None,
+            copied_param: None,
             network_menu_active: false,
             network_menu_actions: Vec::new(),
             sim_cache: crate::geometry::SimCache::default(),
@@ -7342,6 +7638,29 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                                 return true;
                             }
                         }
+                        // The parameter row menu, same contract again.
+                        if self.param_menu_open() {
+                            if *button == MouseButton::Left && self.handle_param_menu_click() {
+                                return true;
+                            }
+                            self.close_param_menu();
+                            if *button == MouseButton::Left {
+                                return true;
+                            }
+                        }
+                        // A right press on a parameter row opens its menu,
+                        // ahead of everything else a press in that pane could
+                        // mean — the pane's widgets have no right-click of
+                        // their own to lose.
+                        if *button == MouseButton::Right {
+                            if let Some((slot, pname)) = self.param_row_at(self.cursor_x, self.cursor_y) {
+                                self.close_node_menu();
+                                self.close_viewport_menu();
+                                self.close_network_menu();
+                                self.open_param_context_menu(slot, pname);
+                                return true;
+                            }
+                        }
 
                         if *button == MouseButton::Left && self.circular_network_pane {
                             // A border press focuses the pane and consumes. It used to also arm
@@ -7493,6 +7812,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                             self.close_node_menu();
                             self.close_viewport_menu();
                             self.close_network_menu();
+                            self.close_param_menu();
                             if self.cursor_in_viewport() && !in_circle_network_pane {
                                 self.open_viewport_context_menu();
                                 return true;
@@ -8128,6 +8448,8 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                             self.close_node_menu();
                         } else if self.viewport_menu_open() {
                             self.close_viewport_menu();
+                        } else if self.param_menu_open() {
+                            self.close_param_menu();
                         } else if self.network_menu_open() {
                             self.close_network_menu();
                         } else {
diff --git a/src/dialog.rs b/src/dialog.rs
index 20aec18..d34106c 100644
--- a/src/dialog.rs
+++ b/src/dialog.rs
@@ -1301,7 +1301,7 @@ fn shaped(
         min,
         max,
         step,
-        show_when: String::new(),
+        show_when: String::new(), expr: false,
     }
 }
 
diff --git a/src/export_cli.rs b/src/export_cli.rs
index e48f1bc..b27c5a9 100644
--- a/src/export_cli.rs
+++ b/src/export_cli.rs
@@ -32,6 +32,7 @@ pub fn run(
         .map_err(|e| format!("parse {}: {e}", state_file.display()))?;
     let templates = crate::app::flatten_node_templates(&crate::app::load_fs_tree());
     proj.sanitize_node_names();
+    proj.migrate_param_refs();
     crate::app::merge_template_defs(&mut proj.root, &templates);
 
     let mut ocl_error = None;
diff --git a/src/expr.rs b/src/expr.rs
new file mode 100644
index 0000000..41e1281
--- /dev/null
+++ b/src/expr.rs
@@ -0,0 +1,741 @@
+//! Parameter expressions — Houdini's channel references, with arithmetic.
+//!
+//! A parameter whose `expr` flag is set holds an EXPRESSION rather than a
+//! value, and is evaluated every time the node is: `ch("../sphere1/Radius")
+//! * 2 + 1`, `$F / 24`, `chs("../text1/Font")`. The flag is the model, not a
+//! guess about the text: a kernel's Code contains `chf(`, a node name is an
+//! identifier and `0.5` is an expression too, so anything that decided by
+//! looking at the string would be wrong somewhere. Houdini makes the same
+//! choice — a parm has an expression or it has a value.
+//!
+//! The language is deliberately small. Numbers and strings; `+ - * / % ^`,
+//! comparisons, `&& || !`; a fixed set of functions; the `$F` / `$FF` frame
+//! variables; and the channel functions, which are the whole point:
+//! `ch(path)` reads a parameter as a number (a toggle 1 or 0, a choice its
+//! option index, a float3 component through `.x` / `.y` / `.z`), `chs` as a
+//! string, `chf` / `chi` / `chb` are `ch` with the kernel vocabulary's
+//! conversions. Paths are Houdini's: relative to the node that holds the
+//! expression, `..` its parent, a leading `/` the root, and a bare name the
+//! node's OWN parameter. No ternary, because `:` separates a float3's
+//! components; `if(cond, a, b)` is the function instead.
+//!
+//! This module knows nothing about nodes: [`Scope`] is how an evaluation
+//! reaches a channel, and `geometry.rs` implements it over the tree.
+
+use std::fmt::Write as _;
+
+/// A parameter's evaluated value.
+#[derive(Debug, Clone, PartialEq)]
+pub enum Value {
+    Num(f64),
+    Str(String),
+}
+
+impl Value {
+    pub fn as_num(&self) -> f64 {
+        match self {
+            Value::Num(n) => *n,
+            Value::Str(s) => {
+                let t = s.trim();
+                if t.eq_ignore_ascii_case("true") {
+                    1.0
+                } else if t.eq_ignore_ascii_case("false") {
+                    0.0
+                } else {
+                    t.parse::<f64>().unwrap_or(0.0)
+                }
+            }
+        }
+    }
+
+    pub fn as_str(&self) -> String {
+        match self {
+            Value::Num(n) => fmt_num(*n),
+            Value::Str(s) => s.clone(),
+        }
+    }
+
+    pub fn truthy(&self) -> bool {
+        match self {
+            Value::Num(n) => *n != 0.0,
+            Value::Str(s) => !s.is_empty() && !s.eq_ignore_ascii_case("false") && s != "0",
+        }
+    }
+}
+
+/// A number as a parameter string: an integer when it is one, else the f32
+/// it will be read back as — so `0.1 + 0.2` shows as `0.3`, not the f64
+/// noise, and `2` is `2` rather than `2.0`.
+pub fn fmt_num(v: f64) -> String {
+    if !v.is_finite() {
+        return "0".to_string();
+    }
+    if v.fract() == 0.0 && v.abs() < 1e15 {
+        format!("{}", v as i64)
+    } else {
+        format!("{}", v as f32)
+    }
+}
+
+/// How a channel function wants the parameter it names.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ChKind {
+    /// `ch` / `chf`: a number.
+    Float,
+    /// `chi`: a number, truncated.
+    Int,
+    /// `chb`: 1 or 0.
+    Bool,
+    /// `chs`: the string as it is (a choice's option text, a toggle's
+    /// `true` / `false`).
+    Str,
+}
+
+/// What an evaluation asks of its surroundings.
+pub trait Scope {
+    /// The parameter at `path`, relative to the node holding the expression.
+    fn channel(&mut self, path: &str, kind: ChKind) -> Result<Value, String>;
+    /// A `$NAME` variable, or None when there is no such variable.
+    fn var(&self, name: &str) -> Option<Value>;
+}
+
+#[derive(Debug, Clone, PartialEq)]
+enum Node {
+    Num(f64),
+    Str(String),
+    Var(String),
+    Neg(Box<Node>),
+    Not(Box<Node>),
+    Bin(Op, Box<Node>, Box<Node>),
+    Call(String, Vec<Node>),
+    Ch(ChKind, String),
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum Op {
+    Add,
+    Sub,
+    Mul,
+    Div,
+    Rem,
+    Pow,
+    Lt,
+    Le,
+    Gt,
+    Ge,
+    Eq,
+    Ne,
+    And,
+    Or,
+}
+
+/// A parsed expression.
+#[derive(Debug, Clone, PartialEq)]
+pub struct Expr(Node);
+
+#[derive(Debug, Clone, PartialEq)]
+enum Tok {
+    Num(f64),
+    Str(String),
+    Ident(String),
+    Var(String),
+    Sym(&'static str),
+}
+
+fn tokenize(src: &str) -> Result<Vec<Tok>, String> {
+    let chars: Vec<char> = src.chars().collect();
+    let mut i = 0;
+    let mut out = Vec::new();
+    while i < chars.len() {
+        let c = chars[i];
+        if c.is_whitespace() {
+            i += 1;
+            continue;
+        }
+        if c.is_ascii_digit() || (c == '.' && chars.get(i + 1).is_some_and(|d| d.is_ascii_digit())) {
+            let start = i;
+            while i < chars.len() && (chars[i].is_ascii_digit() || chars[i] == '.') {
+                i += 1;
+            }
+            if i < chars.len() && (chars[i] == 'e' || chars[i] == 'E') {
+                let mut j = i + 1;
+                if j < chars.len() && (chars[j] == '+' || chars[j] == '-') {
+                    j += 1;
+                }
+                if j < chars.len() && chars[j].is_ascii_digit() {
+                    i = j;
+                    while i < chars.len() && chars[i].is_ascii_digit() {
+                        i += 1;
+                    }
+                }
+            }
+            let text: String = chars[start..i].iter().collect();
+            let n = text.parse::<f64>().map_err(|_| format!("bad number `{text}`"))?;
+            out.push(Tok::Num(n));
+            continue;
+        }
+        if c == '"' || c == '\'' {
+            let quote = c;
+            i += 1;
+            let mut s = String::new();
+            loop {
+                let Some(&d) = chars.get(i) else { return Err("unterminated string".to_string()) };
+                i += 1;
+                if d == quote {
+                    break;
+                }
+                if d == '\\' {
+                    if let Some(&e) = chars.get(i) {
+                        s.push(e);
+                        i += 1;
+                    }
+                    continue;
+                }
+                s.push(d);
+            }
+            out.push(Tok::Str(s));
+            continue;
+        }
+        if c == '$' {
+            let start = i + 1;
+            i += 1;
+            while i < chars.len() && (chars[i].is_ascii_alphanumeric() || chars[i] == '_') {
+                i += 1;
+            }
+            if i == start {
+                return Err("`$` with no variable name".to_string());
+            }
+            out.push(Tok::Var(chars[start..i].iter().collect()));
+            continue;
+        }
+        if c.is_ascii_alphabetic() || c == '_' {
+            let start = i;
+            while i < chars.len() && (chars[i].is_ascii_alphanumeric() || chars[i] == '_') {
+                i += 1;
+            }
+            out.push(Tok::Ident(chars[start..i].iter().collect()));
+            continue;
+        }
+        let two: String = chars[i..(i + 2).min(chars.len())].iter().collect();
+        let sym = match two.as_str() {
+            "<=" => Some("<="),
+            ">=" => Some(">="),
+            "==" => Some("=="),
+            "!=" => Some("!="),
+            "&&" => Some("&&"),
+            "||" => Some("||"),
+            _ => None,
+        };
+        if let Some(s) = sym {
+            out.push(Tok::Sym(s));
+            i += 2;
+            continue;
+        }
+        let one = match c {
+            '+' => "+",
+            '-' => "-",
+            '*' => "*",
+            '/' => "/",
+            '%' => "%",
+            '^' => "^",
+            '(' => "(",
+            ')' => ")",
+            ',' => ",",
+            '<' => "<",
+            '>' => ">",
+            '!' => "!",
+            _ => return Err(format!("unexpected `{c}`")),
+        };
+        out.push(Tok::Sym(one));
+        i += 1;
+    }
+    Ok(out)
+}
+
+struct Parser {
+    toks: Vec<Tok>,
+    pos: usize,
+}
+
+impl Parser {
+    fn peek(&self) -> Option<&Tok> {
+        self.toks.get(self.pos)
+    }
+    fn eat_sym(&mut self, s: &str) -> bool {
+        if matches!(self.peek(), Some(Tok::Sym(t)) if *t == s) {
+            self.pos += 1;
+            true
+        } else {
+            false
+        }
+    }
+    fn expect_sym(&mut self, s: &str) -> Result<(), String> {
+        if self.eat_sym(s) { Ok(()) } else { Err(format!("expected `{s}`")) }
+    }
+
+    fn or(&mut self) -> Result<Node, String> {
+        let mut l = self.and()?;
+        while self.eat_sym("||") {
+            let r = self.and()?;
+            l = Node::Bin(Op::Or, Box::new(l), Box::new(r));
+        }
+        Ok(l)
+    }
+    fn and(&mut self) -> Result<Node, String> {
+        let mut l = self.eq()?;
+        while self.eat_sym("&&") {
+            let r = self.eq()?;
+            l = Node::Bin(Op::And, Box::new(l), Box::new(r));
+        }
+        Ok(l)
+    }
+    fn eq(&mut self) -> Result<Node, String> {
+        let mut l = self.cmp()?;
+        loop {
+            let op = if self.eat_sym("==") { Op::Eq } else if self.eat_sym("!=") { Op::Ne } else { break };
+            let r = self.cmp()?;
+            l = Node::Bin(op, Box::new(l), Box::new(r));
+        }
+        Ok(l)
+    }
+    fn cmp(&mut self) -> Result<Node, String> {
+        let mut l = self.add()?;
+        loop {
+            let op = if self.eat_sym("<=") {
+                Op::Le
+            } else if self.eat_sym(">=") {
+                Op::Ge
+            } else if self.eat_sym("<") {
+                Op::Lt
+            } else if self.eat_sym(">") {
+                Op::Gt
+            } else {
+                break;
+            };
+            let r = self.add()?;
+            l = Node::Bin(op, Box::new(l), Box::new(r));
+        }
+        Ok(l)
+    }
+    fn add(&mut self) -> Result<Node, String> {
+        let mut l = self.mul()?;
+        loop {
+            let op = if self.eat_sym("+") { Op::Add } else if self.eat_sym("-") { Op::Sub } else { break };
+            let r = self.mul()?;
+            l = Node::Bin(op, Box::new(l), Box::new(r));
+        }
+        Ok(l)
+    }
+    fn mul(&mut self) -> Result<Node, String> {
+        let mut l = self.unary()?;
+        loop {
+            let op = if self.eat_sym("*") {
+                Op::Mul
+            } else if self.eat_sym("/") {
+                Op::Div
+            } else if self.eat_sym("%") {
+                Op::Rem
+            } else {
+                break;
+            };
+            let r = self.unary()?;
+            l = Node::Bin(op, Box::new(l), Box::new(r));
+        }
+        Ok(l)
+    }
+    fn unary(&mut self) -> Result<Node, String> {
+        if self.eat_sym("-") {
+            return Ok(Node::Neg(Box::new(self.unary()?)));
+        }
+        if self.eat_sym("!") {
+            return Ok(Node::Not(Box::new(self.unary()?)));
+        }
+        if self.eat_sym("+") {
+            return self.unary();
+        }
+        self.pow()
+    }
+    fn pow(&mut self) -> Result<Node, String> {
+        let base = self.atom()?;
+        if self.eat_sym("^") {
+            // Right-associative: 2^3^2 is 2^9.
+            let exp = self.unary()?;
+            return Ok(Node::Bin(Op::Pow, Box::new(base), Box::new(exp)));
+        }
+        Ok(base)
+    }
+    fn atom(&mut self) -> Result<Node, String> {
+        let tok = self.peek().cloned().ok_or_else(|| "unexpected end of expression".to_string())?;
+        self.pos += 1;
+        match tok {
+            Tok::Num(n) => Ok(Node::Num(n)),
+            Tok::Str(s) => Ok(Node::Str(s)),
+            Tok::Var(v) => Ok(Node::Var(v)),
+            Tok::Sym("(") => {
+                let inner = self.or()?;
+                self.expect_sym(")")?;
+                Ok(inner)
+            }
+            Tok::Ident(name) => {
+                if self.eat_sym("(") {
+                    let mut args = Vec::new();
+                    if !self.eat_sym(")") {
+                        loop {
+                            args.push(self.or()?);
+                            if self.eat_sym(",") {
+                                continue;
+                            }
+                            self.expect_sym(")")?;
+                            break;
+                        }
+                    }
+                    let kind = match name.as_str() {
+                        "ch" | "chf" => Some(ChKind::Float),
+                        "chi" => Some(ChKind::Int),
+                        "chb" => Some(ChKind::Bool),
+                        "chs" => Some(ChKind::Str),
+                        _ => None,
+                    };
+                    if let Some(kind) = kind {
+                        return match args.as_slice() {
+                            [Node::Str(path)] if !path.trim().is_empty() => Ok(Node::Ch(kind, path.trim().to_string())),
+                            _ => Err(format!("{name}() takes one quoted path")),
+                        };
+                    }
+                    Ok(Node::Call(name, args))
+                } else {
+                    match name.as_str() {
+                        "PI" => Ok(Node::Num(std::f64::consts::PI)),
+                        "E" => Ok(Node::Num(std::f64::consts::E)),
+                        _ => Err(format!("unknown name `{name}`")),
+                    }
+                }
+            }
+            Tok::Sym(s) => Err(format!("unexpected `{s}`")),
+        }
+    }
+}
+
+/// Parse an expression. Errors name what went wrong, since they land on a
+/// node's error slot for the user to read.
+pub fn parse(src: &str) -> Result<Expr, String> {
+    let toks = tokenize(src)?;
+    if toks.is_empty() {
+        return Err("empty expression".to_string());
+    }
+    let mut p = Parser { toks, pos: 0 };
+    let node = p.or()?;
+    if p.pos != p.toks.len() {
+        return Err("trailing input after the expression".to_string());
+    }
+    Ok(Expr(node))
+}
+
+/// Whether `s` reads as an expression that REFERS to something — a channel
+/// or a variable. This is the inference applied to a value typed or scripted
+/// into a parameter that has no expression yet: `ch("../a/Radius")` becomes
+/// one, while `1+2`, a node name and a kernel do not — arithmetic on a
+/// literal is asked for through the row's Edit Expression, not guessed.
+pub fn looks_like_expression(s: &str) -> bool {
+    let t = s.trim();
+    if t.len() > 512 || !(t.contains("ch") || t.contains('$')) {
+        return false;
+    }
+    if let Ok(e) = parse(t) {
+        return e.refers();
+    }
+    // A float3: three components, each a number or an expression, at least
+    // one of which refers — `chf("../a/Size.x"):0:0`.
+    let parts: Vec<&str> = t.split(':').collect();
+    parts.len() == 3 && {
+        let parsed: Vec<Option<Expr>> = parts.iter().map(|p| parse(p.trim()).ok()).collect();
+        parsed.iter().all(Option::is_some) && parsed.iter().flatten().any(Expr::refers)
+    }
+}
+
+impl Expr {
+    /// Whether the expression reads a channel or a variable.
+    pub fn refers(&self) -> bool {
+        fn walk(n: &Node) -> bool {
+            match n {
+                Node::Ch(..) | Node::Var(_) => true,
+                Node::Num(_) | Node::Str(_) => false,
+                Node::Neg(a) | Node::Not(a) => walk(a),
+                Node::Bin(_, a, b) => walk(a) || walk(b),
+                Node::Call(_, args) => args.iter().any(walk),
+            }
+        }
+        walk(&self.0)
+    }
+
+    /// Every channel path the expression names, in source order.
+    pub fn paths(&self) -> Vec<&str> {
+        fn walk<'a>(n: &'a Node, out: &mut Vec<&'a str>) {
+            match n {
+                Node::Ch(_, p) => out.push(p),
+                Node::Num(_) | Node::Str(_) | Node::Var(_) => {}
+                Node::Neg(a) | Node::Not(a) => walk(a, out),
+                Node::Bin(_, a, b) => {
+                    walk(a, out);
+                    walk(b, out);
+                }
+                Node::Call(_, args) => args.iter().for_each(|a| walk(a, out)),
+            }
+        }
+        let mut out = Vec::new();
+        walk(&self.0, &mut out);
+        out
+    }
+
+    pub fn eval(&self, scope: &mut dyn Scope) -> Result<Value, String> {
+        eval_node(&self.0, scope)
+    }
+}
+
+fn eval_node(n: &Node, scope: &mut dyn Scope) -> Result<Value, String> {
+    use Value::*;
+    Ok(match n {
+        Node::Num(v) => Num(*v),
+        Node::Str(s) => Str(s.clone()),
+        Node::Var(name) => scope.var(name).ok_or_else(|| format!("unknown variable ${name}"))?,
+        Node::Neg(a) => Num(-eval_node(a, scope)?.as_num()),
+        Node::Not(a) => Num(if eval_node(a, scope)?.truthy() { 0.0 } else { 1.0 }),
+        Node::Ch(kind, path) => scope.channel(path, *kind)?,
+        Node::Bin(op, a, b) => {
+            // Short-circuit before evaluating the right side.
+            match op {
+                Op::And => {
+                    let l = eval_node(a, scope)?;
+                    return Ok(Num(if !l.truthy() { 0.0 } else if eval_node(b, scope)?.truthy() { 1.0 } else { 0.0 }));
+                }
+                Op::Or => {
+                    let l = eval_node(a, scope)?;
+                    return Ok(Num(if l.truthy() { 1.0 } else if eval_node(b, scope)?.truthy() { 1.0 } else { 0.0 }));
+                }
+                _ => {}
+            }
+            let l = eval_node(a, scope)?;
+            let r = eval_node(b, scope)?;
+            let both_str = matches!((&l, &r), (Str(_), _) | (_, Str(_)));
+            match op {
+                Op::Add if both_str => Str(format!("{}{}", l.as_str(), r.as_str())),
+                Op::Eq if both_str => Num((l.as_str() == r.as_str()) as i32 as f64),
+                Op::Ne if both_str => Num((l.as_str() != r.as_str()) as i32 as f64),
+                _ => {
+                    let (x, y) = (l.as_num(), r.as_num());
+                    Num(match op {
+                        Op::Add => x + y,
+                        Op::Sub => x - y,
+                        Op::Mul => x * y,
+                        Op::Div => {
+                            if y == 0.0 {
+                                return Err("division by zero".to_string());
+                            }
+                            x / y
+                        }
+                        Op::Rem => {
+                            if y == 0.0 {
+                                return Err("modulo by zero".to_string());
+                            }
+                            x % y
+                        }
+                        Op::Pow => x.powf(y),
+                        Op::Lt => (x < y) as i32 as f64,
+                        Op::Le => (x <= y) as i32 as f64,
+                        Op::Gt => (x > y) as i32 as f64,
+                        Op::Ge => (x >= y) as i32 as f64,
+                        Op::Eq => (x == y) as i32 as f64,
+                        Op::Ne => (x != y) as i32 as f64,
+                        Op::And | Op::Or => unreachable!(),
+                    })
+                }
+            }
+        }
+        Node::Call(name, args) => call(name, args, scope)?,
+    })
+}
+
+fn call(name: &str, args: &[Node], scope: &mut dyn Scope) -> Result<Value, String> {
+    use Value::*;
+    // `if` evaluates one branch only, so a guarded division stays guarded.
+    if name == "if" {
+        if args.len() != 3 {
+            return Err("if() takes (condition, then, else)".to_string());
+        }
+        let c = eval_node(&args[0], scope)?;
+        return eval_node(if c.truthy() { &args[1] } else { &args[2] }, scope);
+    }
+    let vals: Vec<Value> = args.iter().map(|a| eval_node(a, scope)).collect::<Result<_, _>>()?;
+    let nums: Vec<f64> = vals.iter().map(Value::as_num).collect();
+    let arity = |n: usize| -> Result<(), String> {
+        if nums.len() == n { Ok(()) } else { Err(format!("{name}() takes {n} argument(s)")) }
+    };
+    let f1 = |f: fn(f64) -> f64| -> Result<Value, String> {
+        arity(1)?;
+        Ok(Num(f(nums[0])))
+    };
+    Ok(match name {
+        "abs" => f1(f64::abs)?,
+        "floor" => f1(f64::floor)?,
+        "ceil" => f1(f64::ceil)?,
+        "round" => f1(f64::round)?,
+        "int" | "trunc" => f1(f64::trunc)?,
+        "frac" => f1(f64::fract)?,
+        "sqrt" => f1(f64::sqrt)?,
+        "exp" => f1(f64::exp)?,
+        "log" => f1(f64::ln)?,
+        "sin" => f1(f64::sin)?,
+        "cos" => f1(f64::cos)?,
+        "tan" => f1(f64::tan)?,
+        "asin" => f1(f64::asin)?,
+        "acos" => f1(f64::acos)?,
+        "atan" => f1(f64::atan)?,
+        "sign" => f1(f64::signum)?,
+        "atan2" => {
+            arity(2)?;
+            Num(nums[0].atan2(nums[1]))
+        }
+        "pow" => {
+            arity(2)?;
+            Num(nums[0].powf(nums[1]))
+        }
+        "min" => {
+            if nums.is_empty() {
+                return Err("min() takes at least one argument".to_string());
+            }
+            Num(nums.iter().cloned().fold(f64::INFINITY, f64::min))
+        }
+        "max" => {
+            if nums.is_empty() {
+                return Err("max() takes at least one argument".to_string());
+            }
+            Num(nums.iter().cloned().fold(f64::NEG_INFINITY, f64::max))
+        }
+        "clamp" => {
+            arity(3)?;
+            Num(nums[0].max(nums[1]).min(nums[2]))
+        }
+        "lerp" => {
+            arity(3)?;
+            Num(nums[0] + (nums[1] - nums[0]) * nums[2])
+        }
+        // Houdini's fit: v in [omin, omax] mapped to [nmin, nmax], clamped.
+        "fit" => {
+            arity(5)?;
+            let (v, omin, omax, nmin, nmax) = (nums[0], nums[1], nums[2], nums[3], nums[4]);
+            let t = if omax == omin { 0.0 } else { ((v - omin) / (omax - omin)).clamp(0.0, 1.0) };
+            Num(nmin + (nmax - nmin) * t)
+        }
+        // Deterministic in its seed, as Houdini's is: the same seed is the
+        // same number on every evaluation and every machine.
+        "rand" => {
+            arity(1)?;
+            let bits = nums[0].to_bits();
+            let mut h = bits ^ 0x9E37_79B9_7F4A_7C15;
+            h = (h ^ (h >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
+            h = (h ^ (h >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
+            h ^= h >> 31;
+            Num((h >> 11) as f64 / (1u64 << 53) as f64)
+        }
+        "strlen" => {
+            arity(1)?;
+            Num(vals[0].as_str().chars().count() as f64)
+        }
+        _ => return Err(format!("unknown function {name}()")),
+    })
+}
+
+/// Rewrite every channel path in `src` through `f` — the textual counterpart
+/// of [`Expr::paths`], for a rename: `f` gets each quoted path inside a
+/// `ch*(...)` call and answers with its replacement, or None to leave it.
+/// Textual rather than a re-print of the AST so the user's spacing and
+/// spelling survive a rename of a node three levels away.
+pub fn rewrite_paths(src: &str, mut f: impl FnMut(&str) -> Option<String>) -> String {
+    let mut out = String::with_capacity(src.len());
+    let bytes = src.as_bytes();
+    let mut i = 0;
+    while i < bytes.len() {
+        // A channel call: an identifier ch / chf / chi / chb / chs not
+        // preceded by an identifier character, then `(`, then a string.
+        let at_ident_start = i == 0 || !(bytes[i - 1].is_ascii_alphanumeric() || bytes[i - 1] == b'_');
+        let mut matched = None;
+        if at_ident_start && bytes[i] == b'c' {
+            for name in ["chf", "chi", "chb", "chs", "ch"] {
+                if src[i..].starts_with(name) {
+                    let rest = &src[i + name.len()..];
+                    let trimmed = rest.trim_start();
+                    if let Some(after_paren) = trimmed.strip_prefix('(') {
+                        let inner = after_paren.trim_start();
+                        if let Some(q) = inner.chars().next().filter(|c| *c == '"' || *c == '\'') {
+                            let body = &inner[1..];
+                            if let Some(end) = body.find(q) {
+                                let path = &body[..end];
+                                let consumed = name.len() + (rest.len() - trimmed.len()) + 1 + (after_paren.len() - inner.len()) + 1 + end + 1;
+                                let prefix_len = consumed - end - 1;
+                                matched = Some((prefix_len, path.to_string(), consumed, q));
+                                break;
+                            }
+                        }
+                    }
+                }
+            }
+        }
+        if let Some((prefix_len, path, consumed, q)) = matched {
+            out.push_str(&src[i..i + prefix_len]);
+            match f(&path) {
+                Some(new) => out.push_str(&new),
+                None => out.push_str(&path),
+            }
+            out.push(q);
+            i += consumed;
+            continue;
+        }
+        let ch = src[i..].chars().next().unwrap();
+        out.push(ch);
+        i += ch.len_utf8();
+    }
+    out
+}
+
+/// The pre-2026-09-24 reference: the WHOLE value one of `ch("Name")`,
+/// `chf(...)`, `chi(...)`, `chb(...)`, with `../` per level — where a bare
+/// name meant the PARENT's parameter. Kept for the load-time migration,
+/// which turns it into an expression with Houdini's semantics (`../Name`).
+pub fn parse_legacy_ref(value: &str) -> Option<(&'static str, String)> {
+    let v = value.trim();
+    let (kind, rest) = if let Some(r) = v.strip_prefix("chf(") {
+        ("chf", r)
+    } else if let Some(r) = v.strip_prefix("chi(") {
+        ("chi", r)
+    } else if let Some(r) = v.strip_prefix("chb(") {
+        ("chb", r)
+    } else if let Some(r) = v.strip_prefix("ch(") {
+        ("ch", r)
+    } else {
+        return None;
+    };
+    let inner = rest.strip_suffix(')')?.trim();
+    let quote = inner.chars().next()?;
+    if quote != '"' && quote != '\'' {
+        return None;
+    }
+    let path = inner.strip_prefix(quote)?.strip_suffix(quote)?;
+    let mut name = path;
+    while let Some(r) = name.strip_prefix("../") {
+        name = r;
+    }
+    if name.trim().is_empty() || name.contains('/') {
+        return None;
+    }
+    Some((kind, path.to_string()))
+}
+
+/// The legacy reference rewritten to Houdini's semantics: a bare name gains
+/// `../` (it meant the parent), an explicit `../` path is already right.
+pub fn migrate_legacy_ref(value: &str) -> Option<String> {
+    let (kind, path) = parse_legacy_ref(value)?;
+    let path = if path.starts_with("../") { path } else { format!("../{path}") };
+    let mut s = String::new();
+    let _ = write!(s, "{kind}(\"{path}\")");
+    Some(s)
+}
diff --git a/src/geometry.rs b/src/geometry.rs
index 20e4ec8..b672fc9 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -595,68 +595,28 @@ pub fn find_input_node<'a>(root: &'a FsNode, target: &FsNode, name: &str) -> Opt
         .or_else(|| find_node_by_name(root, name))
 }
 
-/// How a parameter reference wants the value it points at.
-///
-/// The kernel vocabulary (`chf` / `chi` / `chb`, which kernels already read
-/// their parameters through) plus `ch` for the string as it is. The
-/// conversions are what make a subnet's CHOICE drive a child's INDEX:
-/// `chi("Method")` on a choice with options Basic, Scatter is 0 or 1.
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum RefKind {
-    Str,
-    Float,
-    Int,
-    Bool,
-}
-
-/// A parameter reference, parsed: `ch("Name")` and its kinds, with a `../`
-/// per level above the enclosing subnet — `ch("Name")` and `ch("../Name")`
-/// both mean the node's parent, `ch("../../Name")` its grandparent, as in
-/// Houdini, where a channel reference is a path.
-///
-/// The WHOLE value is the reference or it is not one: there is no expression
-/// language here, and a value that merely contains `ch(` (a kernel's Code)
-/// is left alone.
-pub fn parse_param_ref(value: &str) -> Option<(RefKind, usize, String)> {
-    let v = value.trim();
-    let (kind, rest) = if let Some(r) = v.strip_prefix("chf(") {
-        (RefKind::Float, r)
-    } else if let Some(r) = v.strip_prefix("chi(") {
-        (RefKind::Int, r)
-    } else if let Some(r) = v.strip_prefix("chb(") {
-        (RefKind::Bool, r)
-    } else if let Some(r) = v.strip_prefix("ch(") {
-        (RefKind::Str, r)
-    } else {
-        return None;
-    };
-    let inner = rest.strip_suffix(')')?.trim();
-    let quote = inner.chars().next()?;
-    if quote != '"' && quote != '\'' {
-        return None;
-    }
-    let path = inner.strip_prefix(quote)?.strip_suffix(quote)?;
-    let mut levels = 0;
-    let mut name = path;
-    while let Some(r) = name.strip_prefix("../") {
-        levels += 1;
-        name = r;
-    }
-    let name = name.trim();
-    if name.is_empty() || name.contains('/') {
-        return None;
-    }
-    Some((kind, levels.max(1), name.to_string()))
-}
+pub use crate::expr::{ChKind, Value};
 
-/// Whether any of `node`'s own parameters is a reference — the cheap test
+/// Whether any of `node`'s parameters holds an expression — the cheap test
 /// that lets evaluation skip the clone for the common node.
 pub fn has_param_refs(node: &FsNode) -> bool {
-    node.params.iter().any(|p| parse_param_ref(&p.default).is_some())
+    node.params.iter().any(|p| p.expr)
+}
+
+/// A choice's options: the `options` list, else the `choice:A,B` type.
+pub fn choice_options(p: &ParamDef) -> Vec<String> {
+    if !p.options.is_empty() {
+        p.options.clone()
+    } else {
+        p.param_type
+            .strip_prefix("choice:")
+            .map(|o| o.split(',').map(|x| x.trim().to_string()).collect())
+            .unwrap_or_default()
+    }
 }
 
 /// A parameter's value as a NUMBER — what a kernel's `chf` / `chi` / `chb`
-/// and a numeric reference both read. A toggle is 0 or 1; a choice is its
+/// and an expression's `ch()` both read. A toggle is 0 or 1; a choice is its
 /// option INDEX, the position in the template's list, the way an ordinal
 /// menu evaluates in Houdini. The index is what lets a subnet's dropdown
 /// drive a child switch's Index or a kernel's `chi("Method")`: the option
@@ -666,15 +626,7 @@ pub fn param_number(p: &ParamDef) -> f32 {
     let raw = p.default.trim();
     let is_choice = p.param_type == "choice" || p.param_type.starts_with("choice:");
     if is_choice {
-        let options: Vec<String> = if !p.options.is_empty() {
-            p.options.clone()
-        } else {
-            p.param_type
-                .strip_prefix("choice:")
-                .map(|o| o.split(',').map(|x| x.trim().to_string()).collect())
-                .unwrap_or_default()
-        };
-        options.iter().position(|o| o.eq_ignore_ascii_case(raw)).map_or(0.0, |i| i as f32)
+        choice_options(p).iter().position(|o| o.eq_ignore_ascii_case(raw)).map_or(0.0, |i| i as f32)
     } else {
         number_of_str(raw)
     }
@@ -691,66 +643,231 @@ fn number_of_str(raw: &str) -> f32 {
     }
 }
 
-/// A parameter's value converted for a reference of `kind`.
-fn convert_ref_value(kind: RefKind, p: &ParamDef) -> String {
-    let raw = p.default.trim();
-    let as_number = || param_number(p);
-    match kind {
-        RefKind::Str => raw.to_string(),
-        RefKind::Float => {
-            let v = as_number();
-            if v.fract() == 0.0 { format!("{}", v as i64) } else { format!("{v}") }
+/// The nodes from the root's first level down to `id`, the root itself
+/// excluded — empty for the root, None for an id not in the tree.
+pub fn node_chain<'a>(root: &'a FsNode, id: &str) -> Option<Vec<&'a FsNode>> {
+    fn visit<'a>(node: &'a FsNode, id: &str, stack: &mut Vec<&'a FsNode>) -> bool {
+        for c in &node.children {
+            stack.push(c);
+            if c.id == id || visit(c, id, stack) {
+                return true;
+            }
+            stack.pop();
         }
-        RefKind::Int => format!("{}", as_number().trunc() as i64),
-        RefKind::Bool => (as_number() != 0.0).to_string(),
+        false
+    }
+    let mut stack = Vec::new();
+    if root.id == id {
+        return Some(stack);
     }
+    if visit(root, id, &mut stack) { Some(stack) } else { None }
 }
 
-/// `target` with every parameter reference replaced by the value it names,
-/// or `None` when it has none — so the common node costs one scan and no
-/// clone. An unresolvable reference (no such ancestor, no such parameter)
-/// is reported through `error` and the value left as written, which is the
-/// difference between a node that silently reads zero and one that says why.
-///
-/// References chain: a subnet parameter that is itself a reference to ITS
-/// parent resolves on through, so a composed node nested in a composed node
-/// still reaches the outermost control. Bounded, because a chain can loop.
-pub fn resolve_param_refs(root: &FsNode, target: &FsNode, error: &mut Option<String>) -> Option<FsNode> {
-    if !has_param_refs(target) {
-        return None;
+/// A node's absolute path as names: `["sphere1", "opencl1"]`.
+pub fn node_path_names(root: &FsNode, id: &str) -> Option<Vec<String>> {
+    node_chain(root, id).map(|chain| chain.iter().map(|n| n.name.clone()).collect())
+}
+
+/// The node part of a channel path walked from `start` — `..` its parent,
+/// `.` itself, a name one of its children. Every step is reported by name,
+/// because the error lands on the node for the user to read.
+fn walk_ref_path<'a>(root: &'a FsNode, start: &'a FsNode, segments: &[&str]) -> Result<&'a FsNode, String> {
+    let mut cur = start;
+    for seg in segments {
+        cur = match *seg {
+            "." => cur,
+            ".." => find_parent_node(root, &cur.id)
+                .or_else(|| if cur.id == root.id { None } else { Some(root) })
+                .ok_or_else(|| format!("`..` climbs above the root from {}", cur.name))?,
+            name => cur
+                .children
+                .iter()
+                .find(|c| c.name == name)
+                .or_else(|| cur.children.iter().find(|c| c.name.eq_ignore_ascii_case(name)))
+                .ok_or_else(|| format!("no node `{name}` in {}", if cur.id == root.id { "/" } else { cur.name.as_str() }))?,
+        };
     }
-    fn lookup(root: &FsNode, from: &FsNode, kind: RefKind, levels: usize, name: &str, depth: usize) -> Result<String, String> {
-        let mut anc = from;
-        for _ in 0..levels {
-            anc = find_parent_node(root, &anc.id).ok_or_else(|| {
-                format!("{}: {name} is {levels} level(s) up, but {} has no parent that far", from.name, from.name)
-            })?;
+    Ok(cur)
+}
+
+/// A channel path split: whether it is absolute, its node segments, and its
+/// final parameter segment.
+fn split_ref_path(path: &str) -> (bool, Vec<&str>, &str) {
+    let t = path.trim();
+    let absolute = t.starts_with('/');
+    let mut segs: Vec<&str> = t.split('/').filter(|s| !s.is_empty()).collect();
+    let param = segs.pop().unwrap_or("");
+    (absolute, segs, param)
+}
+
+/// A parameter named by the last path segment, with an optional `.x` / `.y`
+/// / `.z` component for a float3 — tried as a whole name first, so a
+/// parameter that really is called `Size.x` still resolves.
+fn find_ref_param<'a>(node: &'a FsNode, name: &str) -> Option<(&'a ParamDef, Option<usize>)> {
+    if let Some(p) = node.params.iter().find(|p| p.name.eq_ignore_ascii_case(name)) {
+        return Some((p, None));
+    }
+    let (base, comp) = name.rsplit_once('.')?;
+    let comp = match comp {
+        "x" | "0" => 0,
+        "y" | "1" => 1,
+        "z" | "2" => 2,
+        _ => return None,
+    };
+    node.params.iter().find(|p| p.name.eq_ignore_ascii_case(base)).map(|p| (p, Some(comp)))
+}
+
+/// What an expression on `node` sees: the tree for its channels, the frame
+/// for `$F`, and the chain of parameters being evaluated, so a reference
+/// that comes back round to itself is an error rather than a stack overflow.
+struct TreeScope<'a> {
+    root: &'a FsNode,
+    node: &'a FsNode,
+    frame: i32,
+    stack: Vec<(String, String)>,
+}
+
+impl<'a> crate::expr::Scope for TreeScope<'a> {
+    fn channel(&mut self, path: &str, kind: ChKind) -> Result<Value, String> {
+        let (absolute, segs, pname) = split_ref_path(path);
+        if pname.is_empty() {
+            return Err(format!("{}: ch(\"{path}\") names no parameter", self.node.name));
         }
-        let p = anc
-            .params
-            .iter()
-            .find(|p| p.name.eq_ignore_ascii_case(name))
-            .ok_or_else(|| format!("{}: ch(\"{name}\") names no parameter on {}", from.name, anc.name))?;
-        if let Some((k2, l2, n2)) = parse_param_ref(&p.default) {
-            if depth >= 8 {
-                return Err(format!("{}: ch(\"{name}\") chains too deep", from.name));
+        // The REAL node, looked up by id: `self.node` may be a resolved clone,
+        // and a relative path starts from where it sits in the tree.
+        let start = if absolute {
+            self.root
+        } else {
+            crate::viewer_state::find_node_by_id(self.root, &self.node.id).unwrap_or(self.node)
+        };
+        let node = walk_ref_path(self.root, start, &segs).map_err(|e| format!("{}: ch(\"{path}\"): {e}", self.node.name))?;
+        let (p, comp) = find_ref_param(node, pname)
+            .ok_or_else(|| format!("{}: ch(\"{path}\") names no parameter {pname} on {}", self.node.name, if node.id == self.root.id { "/" } else { &node.name }))?;
+        let raw = if p.expr {
+            let key = (node.id.clone(), p.name.clone());
+            if self.stack.contains(&key) {
+                return Err(format!("{}: ch(\"{path}\") is a circular reference", self.node.name));
+            }
+            if self.stack.len() >= 32 {
+                return Err(format!("{}: ch(\"{path}\") chains too deep", self.node.name));
+            }
+            self.stack.push(key);
+            let mut inner = TreeScope { root: self.root, node, frame: self.frame, stack: std::mem::take(&mut self.stack) };
+            let r = eval_param_value(&mut inner, p);
+            self.stack = inner.stack;
+            self.stack.pop();
+            r?
+        } else {
+            p.default.clone()
+        };
+        let value = match comp {
+            Some(i) => Value::Num(raw.split(':').nth(i).and_then(|c| c.trim().parse::<f64>().ok()).unwrap_or(0.0)),
+            None => {
+                let mut lit = p.clone();
+                lit.default = raw;
+                lit.expr = false;
+                match kind {
+                    ChKind::Str => Value::Str(lit.default.trim().to_string()),
+                    _ => Value::Num(param_number(&lit) as f64),
+                }
+            }
+        };
+        Ok(match kind {
+            ChKind::Float | ChKind::Str => value,
+            ChKind::Int => Value::Num(value.as_num().trunc()),
+            ChKind::Bool => Value::Num(if value.truthy() { 1.0 } else { 0.0 }),
+        })
+    }
+
+    fn var(&self, name: &str) -> Option<Value> {
+        match name {
+            "F" | "FF" => Some(Value::Num(self.frame as f64)),
+            _ => None,
+        }
+    }
+}
+
+/// An evaluated value written back in the parameter's own vocabulary: a
+/// toggle's `true` / `false`, a choice's option NAME (an index picks one), a
+/// spinbox's integer, anything else the value's text.
+pub fn format_for_param(v: &Value, p: &ParamDef) -> String {
+    let ty = p.param_type.as_str();
+    if ty == "toggle" {
+        return v.truthy().to_string();
+    }
+    if ty == "choice" || ty.starts_with("choice:") {
+        return match v {
+            Value::Num(n) => {
+                let options = choice_options(p);
+                if options.is_empty() {
+                    crate::expr::fmt_num(*n)
+                } else {
+                    let i = (n.round().max(0.0) as usize).min(options.len() - 1);
+                    options[i].clone()
+                }
+            }
+            Value::Str(s) => s.clone(),
+        };
+    }
+    if ty == "spinbox" || ty.starts_with("spinbox:") {
+        if let Value::Num(n) = v {
+            return crate::expr::fmt_num(n.trunc());
+        }
+    }
+    v.as_str()
+}
+
+/// One parameter's expression evaluated to its value string. A float3 is
+/// three expressions separated by `:` — each component its own, as
+/// Houdini's channels are — so `chf("../a/Size.x"):0:0` reads naturally.
+fn eval_param_value(scope: &mut TreeScope, p: &ParamDef) -> Result<String, String> {
+    let is_float3 = p.param_type == "float3" || p.param_type.starts_with("float3:");
+    if is_float3 {
+        let parts: Vec<&str> = p.default.split(':').collect();
+        if parts.len() == 3 {
+            let mut out = Vec::with_capacity(3);
+            for part in parts {
+                let t = part.trim();
+                if t.parse::<f64>().is_ok() {
+                    out.push(t.to_string());
+                } else {
+                    let e = crate::expr::parse(t).map_err(|e| format!("{}: {} — {e}", scope.node.name, p.name))?;
+                    out.push(crate::expr::fmt_num(e.eval(scope)?.as_num()));
+                }
             }
-            let through = lookup(root, anc, k2, l2, &n2, depth + 1)?;
-            let mut resolved = p.clone();
-            resolved.default = through;
-            return Ok(convert_ref_value(kind, &resolved));
+            return Ok(out.join(":"));
         }
-        Ok(convert_ref_value(kind, p))
+    }
+    let e = crate::expr::parse(&p.default).map_err(|e| format!("{}: {} — {e}", scope.node.name, p.name))?;
+    let v = e.eval(scope)?;
+    Ok(format_for_param(&v, p))
+}
+
+/// `target` with every expression replaced by the value it evaluates to at
+/// `frame`, or `None` when it has none — so the common node costs one scan
+/// and no clone. A failing expression (bad syntax, a path to nothing, a
+/// circle) is reported through `error` and its text left as written, which
+/// the resolvers then read as they always have — a number that parses as
+/// nothing is 0. The clone's parameters come back as VALUES (`expr` off),
+/// so nothing downstream evaluates twice.
+pub fn resolve_param_refs(root: &FsNode, target: &FsNode, frame: i32, error: &mut Option<String>) -> Option<FsNode> {
+    if !has_param_refs(target) {
+        return None;
     }
     let mut out = target.clone();
     for p in &mut out.params {
-        if let Some((kind, levels, name)) = parse_param_ref(&p.default) {
-            match lookup(root, target, kind, levels, &name, 0) {
-                Ok(v) => p.default = v,
-                Err(e) => {
-                    if error.is_none() {
-                        *error = Some(e);
-                    }
+        if !p.expr {
+            continue;
+        }
+        let mut scope = TreeScope { root, node: target, frame, stack: vec![(target.id.clone(), p.name.clone())] };
+        match eval_param_value(&mut scope, p) {
+            Ok(v) => {
+                p.default = v;
+                p.expr = false;
+            }
+            Err(e) => {
+                if error.is_none() {
+                    *error = Some(e);
                 }
             }
         }
@@ -758,6 +875,110 @@ pub fn resolve_param_refs(root: &FsNode, target: &FsNode, error: &mut Option<Str
     Some(out)
 }
 
+/// The path an expression on `from` would use to reach `to`, relative and
+/// without the parameter: `../sphere1` for a sibling, `..` for the parent,
+/// `` for the node itself. What Paste Relative Reference writes.
+pub fn relative_ref_path(root: &FsNode, from: &str, to: &str) -> Option<String> {
+    let a = node_path_names(root, from)?;
+    let b = node_path_names(root, to)?;
+    let common = a.iter().zip(b.iter()).take_while(|(x, y)| x == y).count();
+    let mut segs: Vec<String> = vec!["..".to_string(); a.len() - common];
+    segs.extend(b[common..].iter().cloned());
+    Some(segs.join("/"))
+}
+
+/// The absolute path to `to`, `/a/b`, for Paste Absolute Reference.
+pub fn absolute_ref_path(root: &FsNode, to: &str) -> Option<String> {
+    node_path_names(root, to).map(|names| format!("/{}", names.join("/")))
+}
+
+/// Rename the node `id` to `new_name` and keep everything that names it
+/// pointing at it: the wires — sibling parameters whose value is the old
+/// name, as the load-time sanitizer rewrites them — and every expression
+/// anywhere in the tree whose channel path passes through the node.
+/// Expressions are rewritten by resolving each path from where it stands
+/// BEFORE the name changes, since a path is names and the old one is what
+/// still resolves.
+pub fn rename_node_in_tree(root: &mut FsNode, id: &str, new_name: &str) -> bool {
+    let Some(chain) = node_chain(root, id) else { return false };
+    let Some(node) = chain.last() else { return false };
+    let old_name = node.name.clone();
+    if old_name == new_name {
+        return false;
+    }
+    let parent_id = chain.iter().rev().nth(1).map(|p| p.id.clone()).unwrap_or_else(|| root.id.clone());
+
+    // Pass one, immutable: every expression that changes.
+    let mut edits: Vec<(String, String, String)> = Vec::new();
+    fn collect(root: &FsNode, node: &FsNode, id: &str, new_name: &str, edits: &mut Vec<(String, String, String)>) {
+        for p in &node.params {
+            if !p.expr {
+                continue;
+            }
+            let rewritten = crate::expr::rewrite_paths(&p.default, |path| {
+                let (absolute, segs, pname) = split_ref_path(path);
+                let mut cur = if absolute { root } else { node };
+                let mut out: Vec<String> = Vec::new();
+                let mut changed = false;
+                for seg in segs {
+                    match seg {
+                        "." => {
+                            out.push(".".into());
+                        }
+                        ".." => {
+                            cur = find_parent_node(root, &cur.id)?;
+                            out.push("..".into());
+                        }
+                        name => {
+                            cur = cur.children.iter().find(|c| c.name == name)?;
+                            if cur.id == id {
+                                out.push(new_name.to_string());
+                                changed = true;
+                            } else {
+                                out.push(name.to_string());
+                            }
+                        }
+                    }
+                }
+                if !changed {
+                    return None;
+                }
+                out.push(pname.to_string());
+                Some(format!("{}{}", if absolute { "/" } else { "" }, out.join("/")))
+            });
+            if rewritten != p.default {
+                edits.push((node.id.clone(), p.name.clone(), rewritten));
+            }
+        }
+        for c in &node.children {
+            collect(root, c, id, new_name, edits);
+        }
+    }
+    collect(root, root, id, new_name, &mut edits);
+
+    // Pass two, mutable: the expressions, the wires, the name.
+    for (nid, pname, value) in edits {
+        if let Some(n) = crate::viewer_state::find_node_by_id_mut(root, &nid) {
+            if let Some(p) = n.params.iter_mut().find(|p| p.name == pname) {
+                p.default = value;
+            }
+        }
+    }
+    if let Some(parent) = crate::viewer_state::find_node_by_id_mut(root, &parent_id) {
+        for sibling in &mut parent.children {
+            for p in &mut sibling.params {
+                if !p.expr && p.default == old_name {
+                    p.default = new_name.to_string();
+                }
+            }
+        }
+    }
+    if let Some(n) = crate::viewer_state::find_node_by_id_mut(root, id) {
+        n.name = new_name.to_string();
+    }
+    true
+}
+
 /// One simnet's solved state, kept between evaluations so playing forward costs
 /// one iteration per frame instead of re-solving the whole history every redraw.
 struct SimSolve {
@@ -868,7 +1089,7 @@ pub fn generate_single_node_geometry_with_errors(
     // Parameter references resolve here, once, for every resolver below:
     // a child of a composed subnet reads its parent's controls through
     // `ch("Name")` and the resolvers never know.
-    let resolved = resolve_param_refs(root, target, ocl_error);
+    let resolved = resolve_param_refs(root, target, sim.frame, ocl_error);
     let target = resolved.as_ref().unwrap_or(target);
 
     let res = if target.node_type.eq_ignore_ascii_case("sphere") {
@@ -994,7 +1215,7 @@ pub fn generate_single_node_geometry_with_errors(
             }
             // Resolved, like the kernel's parent read above: a subnet's
             // Input may itself be a reference.
-            let resolved_parent = resolve_param_refs(root, parent, ocl_error);
+            let resolved_parent = resolve_param_refs(root, parent, sim.frame, ocl_error);
             let parent = resolved_parent.as_ref().unwrap_or(parent);
             let input_name = node_param_str(parent, "Input", "");
             if !input_name.is_empty() {
@@ -4679,7 +4900,7 @@ pub fn parse_dynamic_params(code: &str) -> Vec<ParamDef> {
                                     min,
                                     max,
                                     step,
-                                    show_when: String::new(),
+                                    show_when: String::new(), expr: false,
                                 });
                             }
                         }
@@ -4728,7 +4949,7 @@ pub fn resolve_opencl_geometry_with_errors(
         // string, because a choice is worth its option index and only the
         // definition knows the options.
         let resolved_parent = find_parent_node(root, &target.id)
-            .map(|parent| resolve_param_refs(root, parent, ocl_error).unwrap_or_else(|| parent.clone()));
+            .map(|parent| resolve_param_refs(root, parent, sim.frame, ocl_error).unwrap_or_else(|| parent.clone()));
         let find_def = |name: &str| -> Option<&ParamDef> {
             target.params.iter().find(|d| d.name.eq_ignore_ascii_case(name)).or_else(|| {
                 resolved_parent.as_ref().and_then(|parent| parent.params.iter().find(|d| d.name.eq_ignore_ascii_case(name)))
@@ -5407,7 +5628,7 @@ pub fn network_sphere_vertices_with_errors(
         // The walk hands nodes to their resolvers directly, so it resolves
         // references itself — dived into a composed subnet, its children are
         // what is drawn, and their controls live on the subnet.
-        let resolved = resolve_param_refs(root, node, ocl_error);
+        let resolved = resolve_param_refs(root, node, sim.frame, ocl_error);
         let node = resolved.as_ref().unwrap_or(node);
         let is_visible = parent_visible && node.geometry_visible;
         if node.node_type.eq_ignore_ascii_case("sphere") {
@@ -6578,7 +6799,7 @@ mod tests {
                     min: None,
                     max: None,
                     step: None,
-                    show_when: String::new(),
+                    show_when: String::new(), expr: false,
                 })
                 .collect(),
             geometry_visible: true,
@@ -6709,7 +6930,7 @@ mod tests {
                     min: Some(1.0),
                     max: Some(10.0),
                     step: Some(1.0),
-                    show_when: String::new(),
+                    show_when: String::new(), expr: false,
                 },
                 ParamDef {
                     name: "Shape".to_string(),
@@ -6720,7 +6941,7 @@ mod tests {
                     min: None,
                     max: None,
                     step: None,
-                    show_when: String::new(),
+                    show_when: String::new(), expr: false,
                 },
             ],
             geometry_visible: true,
@@ -6784,7 +7005,7 @@ mod tests {
                     min: None,
                     max: None,
                     step: None,
-                    show_when: String::new(),
+                    show_when: String::new(), expr: false,
                 }
             ],
             geometry_visible: true,
@@ -6807,7 +7028,7 @@ mod tests {
                     min: None,
                     max: None,
                     step: None,
-                    show_when: String::new(),
+                    show_when: String::new(), expr: false,
                 },
                 ParamDef {
                     name: "Translation".to_string(),
@@ -6818,7 +7039,7 @@ mod tests {
                     min: None,
                     max: None,
                     step: None,
-                    show_when: String::new(),
+                    show_when: String::new(), expr: false,
                 }
             ],
             geometry_visible: true,
@@ -6865,7 +7086,7 @@ mod tests {
                     min: None,
                     max: None,
                     step: None,
-                    show_when: String::new(),
+                    show_when: String::new(), expr: false,
                 },
                 ParamDef {
                     name: "Translation".to_string(),
@@ -6876,7 +7097,7 @@ mod tests {
                     min: None,
                     max: None,
                     step: None,
-                    show_when: String::new(),
+                    show_when: String::new(), expr: false,
                 }
             ],
             geometry_visible: true,
@@ -6920,7 +7141,7 @@ mod tests {
                     min: None,
                     max: None,
                     step: None,
-                    show_when: String::new(),
+                    show_when: String::new(), expr: false,
                 },
                 ParamDef {
                     name: "Translation".to_string(),
@@ -6931,7 +7152,7 @@ mod tests {
                     min: None,
                     max: None,
                     step: None,
-                    show_when: String::new(),
+                    show_when: String::new(), expr: false,
                 }
             ],
             geometry_visible: true,
@@ -6977,7 +7198,7 @@ mod tests {
                     min: None,
                     max: None,
                     step: None,
-                    show_when: String::new(),
+                    show_when: String::new(), expr: false,
                 }
             ],
             geometry_visible: true,
@@ -7001,7 +7222,7 @@ mod tests {
                     min: None,
                     max: None,
                     step: None,
-                    show_when: String::new(),
+                    show_when: String::new(), expr: false,
                 },
                 ParamDef {
                     name: "Code".to_string(),
@@ -7019,7 +7240,7 @@ mod tests {
                     min: None,
                     max: None,
                     step: None,
-                    show_when: String::new(),
+                    show_when: String::new(), expr: false,
                 }
             ],
             geometry_visible: true,
@@ -7068,7 +7289,7 @@ mod tests {
                     min: None,
                     max: None,
                     step: None,
-                    show_when: String::new(),
+                    show_when: String::new(), expr: false,
                 }
             ],
             geometry_visible: true,
@@ -7092,7 +7313,7 @@ mod tests {
                     min: None,
                     max: None,
                     step: None,
-                    show_when: String::new(),
+                    show_when: String::new(), expr: false,
                 },
                 ParamDef {
                     name: "Points".to_string(),
@@ -7103,7 +7324,7 @@ mod tests {
                     min: None,
                     max: None,
                     step: None,
-                    show_when: String::new(),
+                    show_when: String::new(), expr: false,
                 },
                 ParamDef {
                     name: "Radius".to_string(),
@@ -7114,7 +7335,7 @@ mod tests {
                     min: None,
                     max: None,
                     step: None,
-                    show_when: String::new(),
+                    show_when: String::new(), expr: false,
                 }
             ],
             geometry_visible: true,
@@ -7388,7 +7609,7 @@ mod simnet_tests {
             min: None,
             max: None,
             step: None,
-            show_when: String::new(),
+            show_when: String::new(), expr: false,
         }
     }
 
diff --git a/src/main.rs b/src/main.rs
index 7b255b4..6189029 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -32,6 +32,7 @@ pub mod mold;
 pub mod hull;
 pub mod scatter;
 pub mod page;
+pub mod expr;
 pub mod thumbnail;
 
 #[cfg(test)]
@@ -552,7 +553,7 @@ mod tests {
         group.name = "My Region".into();
         group.node_type = "group".into();
         group.children.clear();
-        group.params = vec![ParamDef { name: "Input".into(), label: "Input".into(), param_type: "text".into(), default: "Sphere 1".into(), options: vec![], min: None, max: None, step: None, show_when: String::new() }];
+        group.params = vec![ParamDef { name: "Input".into(), label: "Input".into(), param_type: "text".into(), default: "Sphere 1".into(), options: vec![], min: None, max: None, step: None, show_when: String::new(), expr: false }];
         let mut clash = group.clone();
         clash.id = "c".into();
         clash.name = "sphere1".into();
@@ -562,6 +563,8 @@ mod tests {
 
         proj.sanitize_node_names();
 
+        proj.migrate_param_refs();
+
         let names: Vec<&str> = proj.root.children.iter().map(|c| c.name.as_str()).collect();
         assert!(names.contains(&"camera1"));
         assert!(names.contains(&"my_region"));
@@ -577,6 +580,7 @@ mod tests {
         // A clean file is left exactly alone.
         let before = serde_json::to_string(&proj).unwrap();
         proj.sanitize_node_names();
+        proj.migrate_param_refs();
         assert_eq!(serde_json::to_string(&proj).unwrap(), before);
     }
 
@@ -768,7 +772,7 @@ mod tests {
             min: None,
             max: None,
             step: None,
-            show_when: String::new(),
+            show_when: String::new(), expr: false,
         };
         state.fs_root.children.push(crate::app::FsNode {
             id: "legacy-main".to_string(),
@@ -888,7 +892,7 @@ mod tests {
             min: None,
             max: None,
             step: None,
-            show_when: String::new(),
+            show_when: String::new(), expr: false,
         };
         let subnet = |name: &str, params: Vec<crate::app::ParamDef>| crate::app::FsNode {
             id: format!("legacy-{name}"),
@@ -1040,7 +1044,7 @@ mod tests {
                 min: None,
                 max: None,
                 step: None,
-                show_when: String::new(),
+                show_when: String::new(), expr: false,
             }],
             geometry_visible: true,
             position: (0.0, 4.0),
@@ -3144,7 +3148,7 @@ mod tests {
         use crate::geometry::param_number;
         let p = |ty: &str, val: &str| crate::app::ParamDef {
             name: "X".into(), label: String::new(), param_type: ty.into(), default: val.into(),
-            options: vec![], min: None, max: None, step: None, show_when: String::new(),
+            options: vec![], min: None, max: None, step: None, show_when: String::new(), expr: false,
         };
         assert_eq!(param_number(&p("choice:UV,Icosphere,Cube", "Cube")), 2.0);
         assert_eq!(param_number(&p("choice:UV,Icosphere,Cube", "icosphere")), 1.0, "case-insensitive, like the reference path");
@@ -3390,6 +3394,7 @@ mod tests {
             name: "Test Project".to_string(),
             root,
             view_state,
+            format: crate::app::PROJECT_FORMAT,
         };
 
         let content = serde_json::to_string(&proj).expect("failed to serialize");
@@ -4115,6 +4120,7 @@ mod tests {
             max: None,
             step: None,
             show_when: show_when.into(),
+            expr: false,
         }
     }
 
@@ -4378,7 +4384,7 @@ mod tests {
                         min: None,
                         max: None,
                         step: None,
-                        show_when: String::new(),
+                        show_when: String::new(), expr: false,
                     })
                     .collect(),
                 geometry_visible: true,
@@ -5863,7 +5869,7 @@ mod tests {
                         Some(o) => ("choice".to_string(), o.split(',').map(str::to_string).collect()),
                         None => (t.to_string(), Vec::new()),
                     };
-                    ParamDef { name: n.into(), label: String::new(), param_type: ptype, default: d.into(), options, min: None, max: None, step: None, show_when: String::new() }
+                    ParamDef { name: n.into(), label: String::new(), param_type: ptype, default: d.into(), options, min: None, max: None, step: None, show_when: String::new(), expr: crate::expr::looks_like_expression(d) }
                 })
                 .collect(),
             geometry_visible: true,
@@ -5890,34 +5896,150 @@ mod tests {
         (hi - lo) * 0.5
     }
 
-    /// The reference syntax: the whole value, one of four kinds, a `../` per
-    /// level, and nothing that merely contains `ch(`.
-    #[test]
-    fn param_references_parse() {
-        use crate::geometry::{parse_param_ref, RefKind};
-        assert_eq!(parse_param_ref("ch(\"Radius\")"), Some((RefKind::Str, 1, "Radius".into())));
-        assert_eq!(parse_param_ref("  chf('Base Resolution')  "), Some((RefKind::Float, 1, "Base Resolution".into())));
-        assert_eq!(parse_param_ref("chi(\"../Source\")"), Some((RefKind::Int, 1, "Source".into())));
-        assert_eq!(parse_param_ref("chb(\"../../Relax Points\")"), Some((RefKind::Bool, 2, "Relax Points".into())));
-        assert_eq!(parse_param_ref("0.5"), None);
-        assert_eq!(parse_param_ref("float r = chf(\"Radius\", 0.5);"), None, "a kernel is not a reference");
-        assert_eq!(parse_param_ref("ch(Radius)"), None, "unquoted is not a reference");
-        assert_eq!(parse_param_ref("ch(\"\")"), None);
+    /// The expression language: arithmetic with Houdini's precedence, the
+    /// channel functions with their conversions, `$F`, strings, and the
+    /// functions. `Scope` is a table here, so none of this touches a tree.
+    #[test]
+    fn expressions_parse_and_evaluate() {
+        use crate::expr::{parse, ChKind, Scope, Value};
+        struct Table(i32);
+        impl Scope for Table {
+            fn channel(&mut self, path: &str, kind: ChKind) -> Result<Value, String> {
+                let v = match path {
+                    "../Radius" => Value::Num(0.5),
+                    "../sphere1/Rows" => Value::Num(16.0),
+                    "Mode" => Value::Num(1.0),
+                    "../text1/Font" => Value::Str("Inter".into()),
+                    _ => return Err(format!("no {path}")),
+                };
+                Ok(match kind {
+                    ChKind::Int => Value::Num(v.as_num().trunc()),
+                    ChKind::Bool => Value::Num(if v.truthy() { 1.0 } else { 0.0 }),
+                    _ => v,
+                })
+            }
+            fn var(&self, name: &str) -> Option<Value> {
+                (name == "F").then(|| Value::Num(self.0 as f64))
+            }
+        }
+        let ev = |src: &str| parse(src).unwrap_or_else(|e| panic!("{src}: {e}")).eval(&mut Table(12)).unwrap_or_else(|e| panic!("{src}: {e}"));
+        assert_eq!(ev("1 + 2 * 3"), Value::Num(7.0));
+        assert_eq!(ev("(1 + 2) * 3"), Value::Num(9.0));
+        assert_eq!(ev("2 ^ 3 ^ 2"), Value::Num(512.0), "power is right-associative");
+        assert_eq!(ev("-2 ^ 2"), Value::Num(-4.0), "unary minus binds looser than power, as in Houdini");
+        assert_eq!(ev("7 % 4"), Value::Num(3.0));
+        assert_eq!(ev("ch(\"../Radius\") * 2 + 1"), Value::Num(2.0));
+        assert_eq!(ev("chi(\"../sphere1/Rows\") / 3"), Value::Num(16.0 / 3.0));
+        assert_eq!(ev("chb(\"Mode\")"), Value::Num(1.0));
+        assert_eq!(ev("chs(\"../text1/Font\") + \" Bold\""), Value::Str("Inter Bold".into()));
+        assert_eq!(ev("$F / 24"), Value::Num(0.5));
+        assert_eq!(ev("if($F > 10, 1, 0)"), Value::Num(1.0));
+        assert_eq!(ev("$F > 10 && $F < 20"), Value::Num(1.0));
+        assert_eq!(ev("!($F == 12)"), Value::Num(0.0));
+        assert_eq!(ev("clamp(5, 0, 1) + min(3, 1, 2) + max(-1, -2)"), Value::Num(1.0));
+        assert_eq!(ev("fit(5, 0, 10, 0, 1)"), Value::Num(0.5));
+        assert_eq!(ev("floor(2.7) + ceil(2.2) + round(2.5) + int(-2.7)"), Value::Num(6.0));
+        assert_eq!(ev("sqrt(16) + abs(-1) + pow(2, 3)"), Value::Num(13.0));
+        assert!((ev("sin(PI / 2)").as_num() - 1.0).abs() < 1e-9);
+        assert_eq!(ev("rand(3)"), ev("rand(3)"), "rand is a function of its seed");
+        assert_ne!(ev("rand(3)"), ev("rand(4)"));
+        assert_eq!(ev("\"a\" == \"a\""), Value::Num(1.0));
+
+        // Errors name what went wrong.
+        for (src, needle) in [("1 +", "unexpected end"), ("ch(Radius)", "unknown name"), ("foo(1)", "unknown function"), ("1 / 0", "division by zero"), ("ch(\"nope\")", "no nope"), ("$X", "unknown variable"), ("1 2", "trailing")] {
+            let err = match parse(src) {
+                Ok(e) => e.eval(&mut Table(0)).unwrap_err(),
+                Err(e) => e,
+            };
+            assert!(err.contains(needle), "{src}: {err}");
+        }
+
+        // Formatting: integers bare, floats as the f32 they are read as.
+        assert_eq!(crate::expr::fmt_num(2.0), "2");
+        assert_eq!(crate::expr::fmt_num(0.1 + 0.2), "0.3");
+        assert_eq!(crate::expr::fmt_num(-1.5), "-1.5");
+    }
+
+    /// What is and is not inferred to be an expression when typed or
+    /// scripted into a plain parameter: a reference is, arithmetic on a
+    /// literal, a node name, a number and a kernel are not.
+    #[test]
+    fn a_typed_reference_is_an_expression_and_a_kernel_is_not() {
+        use crate::expr::looks_like_expression;
+        assert!(looks_like_expression("ch(\"../sphere1/Radius\")"));
+        assert!(looks_like_expression("chf(\"../Radius\") * 2"));
+        assert!(looks_like_expression("$F / 24"));
+        assert!(!looks_like_expression("1 + 2"), "arithmetic alone is asked for through Edit Expression");
+        assert!(!looks_like_expression("0.5"));
+        assert!(!looks_like_expression("sphere1"));
+        assert!(!looks_like_expression("true"));
+        assert!(!looks_like_expression("0.00:0.80:0.00"));
+        assert!(!looks_like_expression("float r = chf(\"Radius\", 0.5);"), "a kernel is not a reference");
+        let kernel = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/nodes/sphere.json")).unwrap();
+        assert!(!looks_like_expression(&kernel));
+    }
+
+    /// A rename rewrites the channel paths that pass through the node —
+    /// textually, so the user's spacing survives — and leaves every other
+    /// path alone.
+    #[test]
+    fn rename_rewrites_the_paths_through_a_node() {
+        use crate::expr::rewrite_paths;
+        let src = "ch( \"../sphere1/Radius\" ) * chs('../text1/Font') + chf(\"/sphere1/Rows\")";
+        let out = rewrite_paths(src, |path| path.contains("sphere1").then(|| path.replace("sphere1", "ball")));
+        assert_eq!(out, "ch( \"../ball/Radius\" ) * chs('../text1/Font') + chf(\"/ball/Rows\")");
+        assert_eq!(rewrite_paths("touch(\"x\")", |_| Some("no".into())), "touch(\"x\")", "only channel calls are paths");
+    }
+
+    /// The pre-expression reference migrates to Houdini's semantics: a bare
+    /// name meant the parent and gains `../`, an explicit `../` is kept, and
+    /// anything else is not a legacy reference.
+    #[test]
+    fn legacy_references_migrate_to_the_parent_path() {
+        use crate::expr::migrate_legacy_ref;
+        assert_eq!(migrate_legacy_ref("ch(\"Radius\")").as_deref(), Some("ch(\"../Radius\")"));
+        assert_eq!(migrate_legacy_ref("  chf('Base Resolution')  ").as_deref(), Some("chf(\"../Base Resolution\")"));
+        assert_eq!(migrate_legacy_ref("chi(\"../Source\")").as_deref(), Some("chi(\"../Source\")"));
+        assert_eq!(migrate_legacy_ref("chb(\"../../Relax Points\")").as_deref(), Some("chb(\"../../Relax Points\")"));
+        assert_eq!(migrate_legacy_ref("0.5"), None);
+        assert_eq!(migrate_legacy_ref("float r = chf(\"Radius\", 0.5);"), None, "a kernel is not a reference");
+        assert_eq!(migrate_legacy_ref("ch(Radius)"), None, "unquoted is not a reference");
+        assert_eq!(migrate_legacy_ref("ch(\"\")"), None);
+        assert_eq!(migrate_legacy_ref("ch(\"../a/Radius\")"), None, "a path was never the old form");
+
+        // And a whole project: format 0 migrates once, format 1 is left alone.
+        let mut proj = crate::app::Project {
+            name: "old".into(),
+            root: ref_node("root", "root", "node", vec![], vec![
+                ref_node("sub", "sub1", "node", vec![("Size", "slider", "0.8")], vec![
+                    ref_node("s", "sphere1", "sphere", vec![("Radius", "slider", "chf(\"Size\")")], vec![]),
+                ]),
+            ]),
+            view_state: Default::default(),
+            format: 0,
+        };
+        proj.root.children[0].children[0].params[0].expr = false;
+        proj.migrate_param_refs();
+        let r = &proj.root.children[0].children[0].params[0];
+        assert_eq!(r.default, "chf(\"../Size\")");
+        assert!(r.expr);
+        assert_eq!(proj.format, crate::app::PROJECT_FORMAT);
+        // A NEW file's bare name is the node's own parameter and stays.
+        proj.root.children[0].children[0].params[0].default = "chf(\"Radius\")".into();
+        proj.migrate_param_refs();
+        assert_eq!(proj.root.children[0].children[0].params[0].default, "chf(\"Radius\")");
     }
 
-    /// A child reads its subnet's controls: a number straight through, a
-    /// choice as its option index, a toggle as a bool, and a reference that
-    /// points at a reference follows the chain to the outermost control.
     #[test]
     fn param_references_resolve_against_the_enclosing_subnet() {
-        use crate::geometry::{resolve_param_refs, RefKind};
-        let sphere = ref_node("s", "sphere1", "sphere", vec![("Radius", "slider", "ch(\"Radius\")")], vec![]);
+        use crate::geometry::resolve_param_refs;
+        let sphere = ref_node("s", "sphere1", "sphere", vec![("Radius", "slider", "ch(\"../Radius\")")], vec![]);
         let output = ref_node("o", "output1", "output", vec![("Input", "text", "sphere1")], vec![]);
         let inner = ref_node("sub", "shape1", "node",
             vec![("Radius", "slider", "chf(\"../Size\")"), ("Mode", "choice:Basic,Scatter", "Scatter"), ("On", "toggle", "true")],
             vec![sphere, output]);
         let probe = ref_node("p", "probe", "switch",
-            vec![("Index", "spinbox", "chi(\"Mode\")"), ("Flag", "text", "chb(\"On\")"), ("Name", "text", "ch(\"Mode\")"), ("Plain", "text", "kept")],
+            vec![("Index", "spinbox", "chi(\"../Mode\")"), ("Flag", "text", "chb(\"../On\")"), ("Name", "text", "chs(\"../Mode\")"), ("Plain", "text", "kept")],
             vec![]);
         let mut inner = inner;
         inner.children.push(probe);
@@ -5927,14 +6049,13 @@ mod tests {
         // The probe's params, resolved against shape1.
         let probe = &root.children[0].children[0].children[2];
         let mut err = None;
-        let resolved = resolve_param_refs(&root, probe, &mut err).expect("it has references");
+        let resolved = resolve_param_refs(&root, probe, 0, &mut err).expect("it has references");
         assert!(err.is_none(), "{err:?}");
         let get = |n: &str| resolved.params.iter().find(|p| p.name == n).unwrap().default.clone();
         assert_eq!(get("Index"), "1", "chi on a choice is its option index");
-        assert_eq!(get("Flag"), "true");
+        assert_eq!(get("Flag"), "1", "chb into a text row is 1 or 0");
         assert_eq!(get("Name"), "Scatter");
         assert_eq!(get("Plain"), "kept");
-        let _ = RefKind::Str;
 
         // Evaluated, the sphere's Radius chains: sphere1 → shape1's Radius,
         // which is itself chf("../Size") → outer1's 0.8.
@@ -5945,25 +6066,239 @@ mod tests {
 
         // A node with no references is left alone: no clone, no error.
         let mut none = None;
-        assert!(resolve_param_refs(&root, &root.children[0].children[0].children[1], &mut none).is_none());
+        assert!(resolve_param_refs(&root, &root.children[0].children[0].children[1], 0, &mut none).is_none());
         assert!(none.is_none());
 
         // A reference to nothing is reported, and the value left as written.
-        let bad = ref_node("b", "bad1", "sphere", vec![("Radius", "slider", "ch(\"Nope\")")], vec![]);
+        let bad = ref_node("b", "bad1", "sphere", vec![("Radius", "slider", "ch(\"../Nope\")")], vec![]);
         let holder = ref_node("h", "holder1", "node", vec![], vec![bad]);
         let root2 = ref_node("root", "root", "node", vec![], vec![holder]);
         let mut err = None;
-        let r = resolve_param_refs(&root2, &root2.children[0].children[0], &mut err).unwrap();
-        assert_eq!(r.params[0].default, "ch(\"Nope\")");
-        assert!(err.as_deref().unwrap_or("").contains("names no parameter on holder1"), "{err:?}");
+        let r = resolve_param_refs(&root2, &root2.children[0].children[0], 0, &mut err).unwrap();
+        assert_eq!(r.params[0].default, "ch(\"../Nope\")");
+        assert!(err.as_deref().unwrap_or("").contains("names no parameter Nope on holder1"), "{err:?}");
         // Too many levels up, likewise.
         let far = ref_node("f", "far1", "sphere", vec![("Radius", "slider", "ch(\"../../../X\")")], vec![]);
         let root3 = ref_node("root", "root", "node", vec![], vec![far]);
         let mut err = None;
-        resolve_param_refs(&root3, &root3.children[0], &mut err);
+        resolve_param_refs(&root3, &root3.children[0], 0, &mut err);
         assert!(err.is_some());
     }
 
+    /// Channel paths over a tree, Houdini's way: a bare name is the node's
+    /// own parameter, `..` the parent, a sibling by name, `/` the root; a
+    /// float3 component through `.y`; an expression that reads an expression
+    /// follows the chain; `$F` is the evaluation's frame; and a circle is an
+    /// error, not a stack overflow.
+    #[test]
+    fn channel_paths_resolve_over_the_tree() {
+        use crate::geometry::resolve_param_refs;
+        let a = ref_node("a", "a1", "sphere", vec![("Radius", "slider", "0.25"), ("Center", "float3", "1:2:3")], vec![]);
+        let b = ref_node("b", "b1", "sphere", vec![
+            ("Radius", "slider", "ch(\"../a1/Radius\") * 2"),
+            ("Rows", "spinbox", "ch(\"Radius\") * 100"),
+            ("Y", "slider", "ch(\"../a1/Center.y\") + ch(\"/sub1/a1/Center.z\")"),
+            ("Frame", "slider", "$F / 2"),
+            ("Up", "slider", "ch(\"../Size\") + ch(\"/Top\")"),
+            ("Mode", "choice:Basic,Scatter", "1"),
+            ("On", "toggle", "ch(\"../a1/Radius\") > 0"),
+            ("Label", "text", "chs(\"../a1/Radius\") + \" units\""),
+            ("Center", "float3", "chf(\"../a1/Center.x\"):0:ch(\"../Size\")"),
+        ], vec![]);
+        let sub = ref_node("sub", "sub1", "node", vec![("Size", "slider", "0.5")], vec![a, b]);
+        let root = ref_node("root", "root", "node", vec![("Top", "slider", "10")], vec![sub]);
+        // The choice's value is an index written as an expression; flag it.
+        let mut root = root;
+        root.children[0].children[1].params.iter_mut().find(|p| p.name == "Mode").unwrap().expr = true;
+
+        let b = &root.children[0].children[1];
+        let mut err = None;
+        let r = resolve_param_refs(&root, b, 12, &mut err).expect("b1 has expressions");
+        assert!(err.is_none(), "{err:?}");
+        let get = |n: &str| r.params.iter().find(|p| p.name == n).unwrap().default.clone();
+        assert_eq!(get("Radius"), "0.5", "a sibling by path");
+        assert_eq!(get("Rows"), "50", "a bare name is the node's OWN parameter, read through its expression");
+        assert_eq!(get("Y"), "5", "components, relative and absolute");
+        assert_eq!(get("Frame"), "6");
+        assert_eq!(get("Up"), "10.5", "the parent and the root");
+        assert_eq!(get("Mode"), "Scatter", "a number into a choice picks the option");
+        assert_eq!(get("On"), "true", "a number into a toggle is true or false");
+        assert_eq!(get("Label"), "0.25 units");
+        assert_eq!(get("Center"), "1:0:0.5", "a float3 is three expressions");
+        assert!(r.params.iter().all(|p| !p.expr), "the resolved clone holds values");
+
+        // A circle: two parameters reading each other.
+        let x = ref_node("x", "x1", "sphere", vec![("Radius", "slider", "ch(\"../y1/Radius\")")], vec![]);
+        let y = ref_node("y", "y1", "sphere", vec![("Radius", "slider", "ch(\"../x1/Radius\") + 1")], vec![]);
+        let ring = ref_node("root", "root", "node", vec![], vec![x, y]);
+        let mut err = None;
+        let r = resolve_param_refs(&ring, &ring.children[0], 0, &mut err).unwrap();
+        assert!(err.as_deref().unwrap_or("").contains("circular"), "{err:?}");
+        assert_eq!(r.params[0].default, "ch(\"../y1/Radius\")", "left as written");
+        // A parameter reading itself is the shortest circle.
+        let me = ref_node("m", "me", "sphere", vec![("Radius", "slider", "ch(\"Radius\") + 1")], vec![]);
+        let solo = ref_node("root", "root", "node", vec![], vec![me]);
+        let mut err = None;
+        resolve_param_refs(&solo, &solo.children[0], 0, &mut err);
+        assert!(err.as_deref().unwrap_or("").contains("circular"), "{err:?}");
+
+        // A path to a node that is not there names the step that failed.
+        let lost = ref_node("l", "lost", "sphere", vec![("Radius", "slider", "ch(\"../nope/Radius\")")], vec![]);
+        let root4 = ref_node("root", "root", "node", vec![], vec![lost]);
+        let mut err = None;
+        resolve_param_refs(&root4, &root4.children[0], 0, &mut err);
+        assert!(err.as_deref().unwrap_or("").contains("no node `nope`"), "{err:?}");
+        // A syntax error names the parameter.
+        let broken = ref_node("k", "broken", "sphere", vec![("Radius", "slider", "1 +")], vec![]);
+        let mut broken = broken;
+        broken.params[0].expr = true;
+        let root5 = ref_node("root", "root", "node", vec![], vec![broken]);
+        let mut err = None;
+        resolve_param_refs(&root5, &root5.children[0], 0, &mut err);
+        assert!(err.as_deref().unwrap_or("").contains("broken: Radius"), "{err:?}");
+    }
+
+    /// The paths a paste writes, and what a rename does to the paths that
+    /// stand: `rename_node_in_tree` rewrites every expression whose path
+    /// passes through the node and every wire naming it, and leaves a
+    /// same-named node elsewhere alone.
+    #[test]
+    fn renaming_a_node_carries_its_references() {
+        use crate::geometry::{absolute_ref_path, relative_ref_path, rename_node_in_tree};
+        let a = ref_node("a", "a1", "sphere", vec![("Radius", "slider", "0.25")], vec![]);
+        let b = ref_node("b", "b1", "sphere", vec![
+            ("Radius", "slider", "ch( \"../a1/Radius\" ) * 2"),
+            ("Input", "text", "a1"),
+        ], vec![]);
+        let deep = ref_node("d", "deep1", "sphere", vec![("Radius", "slider", "chf(\"/sub1/a1/Radius\") + ch(\"../../a1/Radius\")")], vec![]);
+        let inner = ref_node("in", "inner1", "node", vec![], vec![deep]);
+        let other = ref_node("oa", "a1", "sphere", vec![("Radius", "slider", "ch(\"../a1/Radius\")")], vec![]);
+        let elsewhere = ref_node("el", "elsewhere", "node", vec![], vec![other]);
+        let sub = ref_node("sub", "sub1", "node", vec![("Size", "slider", "0.5")], vec![a, b, inner]);
+        let mut root = ref_node("root", "root", "node", vec![], vec![sub, elsewhere]);
+
+        assert_eq!(relative_ref_path(&root, "b", "a").as_deref(), Some("../a1"));
+        assert_eq!(relative_ref_path(&root, "d", "a").as_deref(), Some("../../a1"));
+        assert_eq!(relative_ref_path(&root, "b", "sub").as_deref(), Some(".."));
+        assert_eq!(relative_ref_path(&root, "b", "b").as_deref(), Some(""));
+        assert_eq!(relative_ref_path(&root, "a", "d").as_deref(), Some("../inner1/deep1"));
+        assert_eq!(absolute_ref_path(&root, "d").as_deref(), Some("/sub1/inner1/deep1"));
+
+        assert!(rename_node_in_tree(&mut root, "a", "ball"));
+        let get = |root: &FsNode, path: &[usize], n: &str| {
+            let mut node = root;
+            for &i in path {
+                node = &node.children[i];
+            }
+            node.params.iter().find(|p| p.name == n).unwrap().default.clone()
+        };
+        assert_eq!(root.children[0].children[0].name, "ball");
+        assert_eq!(get(&root, &[0, 1], "Radius"), "ch( \"../ball/Radius\" ) * 2", "spacing kept");
+        assert_eq!(get(&root, &[0, 1], "Input"), "ball", "the wire follows");
+        assert_eq!(get(&root, &[0, 2, 0], "Radius"), "chf(\"/sub1/ball/Radius\") + ch(\"../../ball/Radius\")");
+        assert_eq!(get(&root, &[1, 0], "Radius"), "ch(\"../a1/Radius\")", "the OTHER a1 is not this one");
+        assert!(!rename_node_in_tree(&mut root, "a", "ball"), "a rename to the same name is nothing");
+        assert!(!rename_node_in_tree(&mut root, "zzz", "x"), "and so is one of a node that is not there");
+    }
+
+    /// The parameter row menu, end to end: a right press on a row in the
+    /// params pane opens it (and nothing else claims the press), Copy
+    /// Parameter then Paste Relative Reference on another node's row writes
+    /// the Houdini path and flags the row, which the pane then shows as
+    /// text; Delete Expression writes the evaluated value back as a value;
+    /// Edit Expression flags a value without changing it.
+    #[test]
+    fn the_row_menu_copies_and_pastes_references() {
+        use crate::app::{McpAction, ParamMenuAction};
+        use crate::window::{LocalPosition, WindowEvent};
+        use cce_ui::widget::{ElementState, MouseButton};
+        let mut state = State::new(false);
+        state.resize(1600.0, 900.0, 1.0);
+        state.rebuild_positions();
+        state.apply_layout();
+        state.focused_pane = crate::slots::LEFT_MENUBAR_IDX;
+        state.param_editor = crate::slots::CONTENT_IDX;
+        let mut redraw = false;
+        state
+            .apply_action(McpAction::AddNode { template_name: "Sphere".into(), name: Some("ball".into()), x: 1.0, y: 8.0 }, &mut redraw)
+            .unwrap();
+        let slot_of = |state: &State, name: &str| state.current_dir().children.iter().position(|c| c.name == name).expect(name);
+        let (sphere, ball) = (slot_of(&state, "sphere1"), slot_of(&state, "ball"));
+
+        // Show sphere1 in the pane and find its Radius row.
+        let show = |state: &mut State, slot: usize| {
+            state.graph_mut().set_selected_node(Some(slot));
+            state.sync_parameters_pane();
+            state.rebuild_positions();
+            state.apply_layout();
+            assert_eq!(state.param_editor_selected(), Some(slot));
+        };
+        let row_center = |state: &State, pname: &str| -> (f32, f32) {
+            let child = &state.current_dir().children[state.param_editor_selected().unwrap()];
+            let rows = crate::app::param_display(&child.params);
+            let i = rows.iter().position(|r| r.0 == pname).expect(pname);
+            let rects = state.param_row_rects();
+            let (x, y, w, h) = rects[i];
+            (x + w * 0.5, y + h * 0.5)
+        };
+        show(&mut state, sphere);
+        let (x, y) = row_center(&state, "Radius");
+        assert_eq!(state.param_row_at(x, y), Some((sphere, "Radius".to_string())));
+        assert_eq!(state.param_row_at(x, state.positions[crate::slots::PARAM_IDX].1 - 5.0), None, "above the pane is no row");
+
+        state.handle_event(&WindowEvent::CursorMoved { position: LocalPosition { x: x as f64, y: y as f64 } });
+        state.handle_event(&WindowEvent::MouseInput { state: ElementState::Pressed, button: MouseButton::Right });
+        assert!(state.param_menu_open(), "a right press on a row opens its menu");
+        assert!(!state.viewport_menu_open());
+        assert_eq!(state.param_menu_actions, vec![ParamMenuAction::CopyParameter, ParamMenuAction::Separator, ParamMenuAction::EditExpression], "nothing copied yet, and the row holds a value");
+        state.handle_event(&WindowEvent::MouseInput { state: ElementState::Released, button: MouseButton::Right });
+
+        // Copy, then paste onto ball's Radius — a sibling, so `../sphere1`.
+        let sphere_id = state.current_dir().children[sphere].id.clone();
+        let ball_id = state.current_dir().children[ball].id.clone();
+        state.run_param_action(&sphere_id, "Radius", ParamMenuAction::CopyParameter);
+        assert_eq!(state.copied_param, Some((sphere_id.clone(), "Radius".to_string())));
+        show(&mut state, ball);
+        let (x, y) = row_center(&state, "Radius");
+        state.handle_event(&WindowEvent::CursorMoved { position: LocalPosition { x: x as f64, y: y as f64 } });
+        state.handle_event(&WindowEvent::MouseInput { state: ElementState::Pressed, button: MouseButton::Right });
+        assert!(state.param_menu_actions.contains(&ParamMenuAction::PasteRelative), "with a copy, paste is offered");
+        state.handle_event(&WindowEvent::MouseInput { state: ElementState::Released, button: MouseButton::Right });
+        state.run_param_action(&ball_id, "Radius", ParamMenuAction::PasteRelative);
+        let radius = |state: &State, slot: usize| state.current_dir().children[slot].params.iter().find(|p| p.name == "Radius").unwrap().clone();
+        assert_eq!(radius(&state, ball).default, "ch(\"../sphere1/Radius\")");
+        assert!(radius(&state, ball).expr);
+        let rows = crate::app::param_display(&state.current_dir().children[ball].params);
+        assert_eq!(rows.iter().find(|r| r.0 == "Radius").unwrap().2, "text", "the pane shows an expression as text");
+
+        // The reference is live: ball follows sphere1's Radius.
+        state.apply_action(McpAction::SetParam { slot: sphere, name: "Radius".into(), value: "0.9".into() }, &mut redraw).unwrap();
+        let mut err = None;
+        let r = crate::geometry::resolve_param_refs(&state.fs_root, &state.current_dir().children[ball], 0, &mut err).unwrap();
+        assert!(err.is_none(), "{err:?}");
+        assert_eq!(r.params.iter().find(|p| p.name == "Radius").unwrap().default, "0.9");
+
+        // Absolute paste, then Delete Expression bakes the current value.
+        state.run_param_action(&ball_id, "Radius", ParamMenuAction::PasteAbsolute);
+        assert_eq!(radius(&state, ball).default, "ch(\"/sphere1/Radius\")");
+        state.run_param_action(&ball_id, "Radius", ParamMenuAction::DeleteExpression);
+        assert_eq!(radius(&state, ball).default, "0.9");
+        assert!(!radius(&state, ball).expr);
+        // Edit Expression flags without changing.
+        state.run_param_action(&ball_id, "Radius", ParamMenuAction::EditExpression);
+        assert_eq!(radius(&state, ball).default, "0.9");
+        assert!(radius(&state, ball).expr);
+
+        // And a reference typed straight into a row (or scripted) becomes one.
+        state.apply_action(McpAction::SetParam { slot: ball, name: "Rows".into(), value: "chi(\"../sphere1/Rows\") * 2".into() }, &mut redraw).unwrap();
+        let rows_p = state.current_dir().children[ball].params.iter().find(|p| p.name == "Rows").unwrap();
+        assert!(rows_p.expr);
+
+        // A rename carries the paste along.
+        state.apply_action(McpAction::RenameNode { slot: sphere, new_name: "orb".into() }, &mut redraw).unwrap();
+        assert_eq!(state.current_dir().children[ball].params.iter().find(|p| p.name == "Rows").unwrap().default, "chi(\"../orb/Rows\") * 2");
+    }
+
     /// Inside the SECOND instance of a subnet, a child wired to a sibling by
     /// name finds its own sibling, not the first instance's.
     #[test]
@@ -6025,7 +6360,7 @@ mod tests {
         let a = ref_node("a", "small", "sphere", vec![("Radius", "slider", "0.2")], vec![]);
         let b = ref_node("b", "big", "sphere", vec![("Radius", "slider", "0.7")], vec![]);
         let sw = ref_node("sw", "switch1", "switch",
-            vec![("Input", "text", "small"), ("Input 2", "text", "big"), ("Index", "spinbox", "chi(\"Size\")")], vec![]);
+            vec![("Input", "text", "small"), ("Input 2", "text", "big"), ("Index", "spinbox", "chi(\"../Size\")")], vec![]);
         let out = ref_node("o", "output1", "output", vec![("Input", "text", "switch1")], vec![]);
         let mut a = a; a.geometry_visible = false;
         let mut b = b; b.geometry_visible = false;
@@ -6059,7 +6394,9 @@ mod tests {
         for c in &mut sphere.children {
             c.id = format!("sph_{}", c.name);
         }
-        sphere.params.iter_mut().find(|p| p.name == "Radius").unwrap().default = "ch(\"Radius\")".into();
+        let radius = sphere.params.iter_mut().find(|p| p.name == "Radius").unwrap();
+        radius.default = "ch(\"../Radius\")".into();
+        radius.expr = true;
         let out = ref_node("o", "output1", "output", vec![("Input", "text", "sphere1")], vec![]);
         let sub = ref_node("sub", "subnet1", "node", vec![("Input", "text", ""), ("Radius", "slider", "0.9")], vec![sphere, out]);
         let root = ref_node("root", "root", "node", vec![], vec![sub]);
@@ -6076,11 +6413,11 @@ mod tests {
     fn param_display_shows_references_as_text() {
         use crate::app::ParamDef;
         let params = vec![
-            ParamDef { name: "Radius".into(), label: String::new(), param_type: "slider".into(), default: "ch(\"Radius\")".into(), options: vec![], min: Some(0.0), max: Some(2.0), step: None, show_when: String::new() },
-            ParamDef { name: "Rows".into(), label: String::new(), param_type: "spinbox".into(), default: "16".into(), options: vec![], min: Some(2.0), max: Some(128.0), step: Some(1.0), show_when: String::new() },
+            ParamDef { name: "Radius".into(), label: String::new(), param_type: "slider".into(), default: "ch(\"../Radius\")".into(), options: vec![], min: Some(0.0), max: Some(2.0), step: None, show_when: String::new(), expr: true },
+            ParamDef { name: "Rows".into(), label: String::new(), param_type: "spinbox".into(), default: "16".into(), options: vec![], min: Some(2.0), max: Some(128.0), step: Some(1.0), show_when: String::new(), expr: false },
         ];
         let rows = crate::app::param_display(&params);
-        assert_eq!(rows[0], ("Radius".to_string(), "ch(\"Radius\")".to_string(), "text".to_string()));
+        assert_eq!(rows[0], ("Radius".to_string(), "ch(\"../Radius\")".to_string(), "text".to_string()));
         assert!(rows[1].2.starts_with("spinbox"));
     }
 
@@ -6300,7 +6637,7 @@ mod tests {
         use crate::app::ParamDef;
         let templates_root = crate::app::load_fs_tree();
         let templates = crate::app::flatten_node_templates(&templates_root);
-        let param = |n: &str, v: &str| ParamDef { name: n.into(), label: String::new(), param_type: "text".into(), default: v.into(), options: vec![], min: None, max: None, step: None, show_when: String::new() };
+        let param = |n: &str, v: &str| ParamDef { name: n.into(), label: String::new(), param_type: "text".into(), default: v.into(), options: vec![], min: None, max: None, step: None, show_when: String::new(), expr: false };
         let meta = ref_node("m", "meta", "meta", vec![("Point Markers", "toggle", "true")], vec![]);
         let mut native = ref_node("old-id", "embryo1", "embryo", vec![], vec![meta]);
         native.params = vec![param("Input", ""), param("Method", "Scatter"), param("Scatter Count", "150"), param("Radius", "0.7"), param("Base Resolution", "16")];
@@ -6349,7 +6686,7 @@ mod tests {
                         min: None,
                         max: None,
                         step: None,
-                        show_when: String::new(),
+                        show_when: String::new(), expr: false,
                     })
                     .collect(),
                 geometry_visible: true,
@@ -6967,7 +7304,7 @@ mod tests {
                 min: None,
                 max: None,
                 step: None,
-                show_when: String::new(),
+                show_when: String::new(), expr: false,
             });
 
         let (g, err) = eval_node(&root, "distance 1");
@@ -7801,7 +8138,7 @@ mod tests {
                     min: None,
                     max: None,
                     step: None,
-                    show_when: String::new(),
+                    show_when: String::new(), expr: false,
                 })
                 .collect(),
             geometry_visible: true,
@@ -7845,7 +8182,7 @@ mod tests {
                     min: None,
                     max: None,
                     step: None,
-                    show_when: String::new(),
+                    show_when: String::new(), expr: false,
                 })
                 .collect(),
             geometry_visible: true,
diff --git a/src/project.rs b/src/project.rs
index d4b5a42..c13058d 100644
--- a/src/project.rs
+++ b/src/project.rs
@@ -243,6 +243,7 @@ impl State {
             let proj = Project {
                 name: "Default Project".to_string(),
                 root: self.fs_root.clone(),
+                format: crate::app::PROJECT_FORMAT,
                 view_state: self.project_view_state(),
             };
             let content = serde_json::to_string_pretty(&proj)?;
@@ -264,6 +265,7 @@ impl State {
         let proj = Project {
             name: project_name,
             root: self.fs_root.clone(),
+            format: crate::app::PROJECT_FORMAT,
             view_state: self.project_view_state(),
         };
         let content = serde_json::to_string_pretty(&proj)?;
@@ -390,6 +392,7 @@ impl State {
             let content = fs::read_to_string(path)?;
             let mut proj: Project = serde_json::from_str(&content)?;
             proj.sanitize_node_names();
+            proj.migrate_param_refs();
             crate::app::merge_template_defs(&mut proj.root, &self.node_templates);
             self.fs_root = proj.root;
             // A load is not a colour change: an older save's wire colour is
@@ -448,6 +451,7 @@ impl State {
         let content = fs::read_to_string(&state_file_path)?;
         let mut proj: Project = serde_json::from_str(&content)?;
         proj.sanitize_node_names();
+        proj.migrate_param_refs();
         crate::app::merge_template_defs(&mut proj.root, &self.node_templates);
         self.fs_root = proj.root;
         // As in the default-project branch.
diff --git a/src/render.rs b/src/render.rs
index 4fc1421..6aee181 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -595,6 +595,30 @@ impl State {
                 w.paint_self(&self.ui_context, pc);
             });
 
+            // Expression rows carry Houdini's tint: a translucent green over
+            // the row, so a driven parameter reads as driven before its text
+            // is read. Rows are index-parallel to `param_display`, which is
+            // what the pane was handed.
+            if let Some(child) = self.param_editor_selected().and_then(|slot| self.param_editor_dir().children.get(slot)) {
+                let rows = crate::app::param_display(&child.params);
+                let is_expr = |key: &str| {
+                    child.params.iter().any(|p| {
+                        let k = if p.label.is_empty() { &p.name } else { &p.label };
+                        k == key && p.expr
+                    })
+                };
+                if rows.iter().any(|r| is_expr(&r.0)) {
+                    let rects = self.param_row_rects();
+                    pc.clip(view, |pc| {
+                        for (row, &(rx, ry, rw, rh)) in rows.iter().zip(rects.iter()) {
+                            if rh > 0.0 && is_expr(&row.0) {
+                                pc.rounded_rect(rect(rx, ry, rw, rh), 6.0, (true, true, true, true), [0.35, 0.8, 0.45, 0.16]);
+                            }
+                        }
+                    });
+                }
+            }
+
             if let Some((quads, true)) = &param_scrollbar {
                 for &(qx, qy, qw, qh, qc) in quads {
                     pc.rounded_rect(rect(qx, qy, qw, qh), qw.min(qh) * 0.5, (true, true, true, true), qc);
diff --git a/src/thumbnail.rs b/src/thumbnail.rs
index 95d853c..fbddaae 100644
--- a/src/thumbnail.rs
+++ b/src/thumbnail.rs
@@ -29,6 +29,7 @@ pub fn run(project: &Path, out: &Path, size: u32, samples: Option<u32>, frame: O
     // scene shows what opening it would show.
     let templates = crate::app::flatten_node_templates(&crate::app::load_fs_tree());
     proj.sanitize_node_names();
+    proj.migrate_param_refs();
     crate::app::merge_template_defs(&mut proj.root, &templates);
 
     let mut ocl_error = None;
diff --git a/src/window.rs b/src/window.rs
index 5ac11b8..28a4599 100644
--- a/src/window.rs
+++ b/src/window.rs
@@ -615,6 +615,7 @@ impl State {
             name: "Project".to_string(),
             root: self.fs_root.clone(),
             view_state: self.project_view_state(),
+            format: crate::app::PROJECT_FORMAT,
         }
     }
 
@@ -720,6 +721,11 @@ impl State {
                 if let Some(child) = dir.children.get_mut(slot) {
                     if let Some(p) = child.params.iter_mut().find(|p| p.name == name) {
                         p.default = value;
+                        // A value that reads as a reference becomes an
+                        // expression, as one typed into the pane does.
+                        if !p.expr && crate::expr::looks_like_expression(&p.default) {
+                            p.expr = true;
+                        }
                         // Same sequence as the interactive param-pane
                         // path, so settings params (viewport flags,
                         // grid) actually take effect via automation.
@@ -836,7 +842,17 @@ impl State {
             McpAction::RenameNode { slot, new_name } => {
                 let len = state.current_dir().children.len();
                 if slot < len {
-                    state.current_dir_mut().children[slot].name = crate::app::sanitize_node_name(&new_name);
+                    let new_name = crate::app::sanitize_node_name(&new_name);
+                    let (id, old_name) = {
+                        let n = &state.current_dir().children[slot];
+                        (n.id.clone(), n.name.clone())
+                    };
+                    // Everything that names the node follows it: the wires,
+                    // the expressions anywhere in the tree, the active camera.
+                    crate::geometry::rename_node_in_tree(&mut state.fs_root, &id, &new_name);
+                    if state.active_camera == old_name {
+                        state.active_camera = new_name.clone();
+                    }
                     state.sync_nodes();
                     // Connections reference nodes by name (Input params), so a
                     // rename changes downstream evaluation.
@@ -874,7 +890,7 @@ impl State {
                         min: None,
                         max: None,
                         step: None,
-                        show_when: String::new(),
+                        show_when: String::new(), expr: false,
                     };
                     state.current_dir_mut().children[slot].params.push(param);
                     state.sync_nodes();