diff --git a/.github/workflows/pre-merge.yml b/.github/workflows/pre-merge.yml index f3145104e..26c80dd5c 100644 --- a/.github/workflows/pre-merge.yml +++ b/.github/workflows/pre-merge.yml @@ -1148,6 +1148,11 @@ jobs: git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git fetch --depth=1 origin "$HEAD_REF" BEFORE_SHA="$(git rev-parse "origin/$HEAD_REF")" + # The dx build regenerates assets/tailwind.css in this working + # tree; it is build output here, and a dirty tracked file aborts + # the branch switch below. Discard it (the branch's own copy is + # what gets committed). + git checkout -- lp-app/lpa-studio-web/assets/tailwind.css || true # The pull_request checkout is a detached merge commit; the fresh # captures live in the gitignored story-images/.new/ and survive the # branch switch in place. diff --git a/docs/adr/2026-08-01-project-module-mitosis-container-format-gate.md b/docs/adr/2026-08-01-project-module-mitosis-container-format-gate.md new file mode 100644 index 000000000..e884b5407 --- /dev/null +++ b/docs/adr/2026-08-01-project-module-mitosis-container-format-gate.md @@ -0,0 +1,80 @@ +# Project/module mitosis: container manifest and the relocated format gate + +- Status: accepted +- Date: 2026-08-01 +- Context: `docs/design/modules.md` §1/§6 (ratified 2026-07-31); + implementation plan `planning/2026-08-01-1003-modules-impl-roadmap` P2. + +## Decision + +The authored project root splits into two files ("mitosis"): + +- **`project.json` — the container manifest, NOT a node.** Exactly the + workspace identity: `format`, optional `uid`, optional `name` (provenance + joins it in a later phase). No `kind` tag, no node envelope, unknown keys + rejected. Modeled as `lpc_model::ProjectManifest`: read by a streaming + `JsonSyntaxSource` probe, written by a hand-rolled deterministic writer. + It is deliberately **not** a `#[derive(Slotted)]` type — a second + shape+codec surface would link into every firmware image for three + fields, and serde surface is the flash lever. +- **`module.json` — the root module node** (`kind: "Module"`, `nodes` + map). `ModuleDef` loses `format`/`uid`/`name` to the container; carrying + them in a module artifact is now a parse error, so pre-mitosis roots + fail loudly instead of silently dropping identity fields. + +**Format gate (settled D-A).** `PROJECT_FORMAT_VERSION` bumps 2 → 3 +(bump-and-refuse; v2 schemas snapshotted to `schemas/history/v2/`). The +gate moves to the container: `ProjectRegistry::load_root` reads +`/project.json` through the streaming probe before anything parses — one +code path on host, browser, and device, proven on the emulated-firmware +path. A **missing or malformed container manifest is a hard refuse**, +never a skip: the manifest carries the gate, so the old +skip-on-malformed fallthrough would let unversioned projects load +ungated. Devices receive both files; the deploy path copies the whole +folder, so no firmware-side special case exists. + +**Vendored module folders carry no format** (Q10 settled): a module +folder inside a project is gated by the host project's container; the +loader never re-runs the gate for child artifacts. Standalone opening of +a bare module folder wraps it in a workbench project and assumes the +current format (alpha posture). + +**Schemas.** `project.schema.json` becomes the closed container schema +(`additionalProperties: false`, `format` const-pinned); a new +`module.schema.json` is the single-variant kind-tagged envelope over +`ModuleDef`; the conformance walk routes by filename. + +**Studio.** The library's `package_manifest` rewrites on +`ProjectManifest` (read→modify→write is lossless because the vocabulary +is closed). The gallery rename now also patches the manifest `name` — +post-mitosis the manifest is library-owned workspace metadata, never an +authored def slot, so rename lives where the identity lives. The project +popup's settings rows render read-only from the manifest; the root def +contributes only its `nodes` count. Blank Created packages write a +minimal `module.json` so they stay loadable — gated to blank creates so +device pulls stay byte-faithful and adoption parity hashes cannot +diverge. + +## Rejected alternatives + +- **Deploy-time-only gating** (validate format when Studio pushes, skip + on device): loses the on-device refusal for projects that arrive by + other means (copied SD contents, partial syncs), and splits the gate + into two implementations. The streaming container probe costs the + device one tiny file read. +- **Slotted container type**: uniform codec machinery, but drags a second + shape and its serializer into every firmware image for three fields. +- **Keeping identity fields on the root module def**: the design's whole + point is that workspace concerns (who/when/what version) are not part + of the module's technical spec; it also kept the "Studio lets you + retype your project's uid" class of bugs structurally possible. + +## Consequences + +- Pre-mitosis projects (format 2 and earlier) refuse with a clear + format/manifest diagnostic; there is no migration (alpha posture). +- `read_module_format_json`/`ModuleFormatProbe` are gone; the manifest + probe is the single format authority. +- The persisted-state and panel phases (P8+) get a stable, non-authored + home for container-adjacent state (`.lp/`), and P3 adds provenance to + the manifest vocabulary. diff --git a/docs/adr/2026-08-01-scoped-bus-engine-architecture.md b/docs/adr/2026-08-01-scoped-bus-engine-architecture.md new file mode 100644 index 000000000..800f0870c --- /dev/null +++ b/docs/adr/2026-08-01-scoped-bus-engine-architecture.md @@ -0,0 +1,83 @@ +# Scoped-bus engine architecture: structural scopes, scoped resolver keys, module runtime + +- Status: accepted +- Date: 2026-08-01 +- Context: implements docs/design/modules.md R1–R7 (ratified 2026-07-31); + supersedes the two ADRs on the closed #218 spike branch — + `2026-07-28-scoped-buses.md` and `2026-07-28-effects-are-projects.md` + (branch `claude/composite-effects-planning-f4f51b`, kept as a harvest + source). The spike's *model* survived review; this records how the + restructured implementation differs. +- Plan: `planning/2026-08-01-1003-modules-impl-roadmap` P4–P6. + +## Decision + +**Scope is structural engine state, not a load-time side table.** +`RuntimeNodeEntry` carries the scope a node inhabits +(`ScopeRef::Module { owner }` / `ScopeRef::Sink { owner, entry }`) plus an +introduces-scope bit, assigned inside `ensure_runtime_spine` — the single +code path both fresh load and `apply_project_changes` run — so an edited +project can never wear different scopes than a reloaded one (pinned by a +load-vs-apply differential test). `Pending`/`Failed` entries carry scope +too (R1: the engine always answers), and payload reattach never touches +it. The spike's `BusScopes` table, built and dropped inside one loader +function, is the rejected alternative. + +**The resolver stays scope-dumb; scope arrives as a richer key.** +`QueryKey::Bus` is `{ scope, channel }`: the reading node's scope is part +of the cache and cycle-detection identity, so same-named channels in +different scopes can neither collide in the cache nor fake a cycle. The +host answers "which providers win for a read from this scope" +(`NodeTree::providers_for_bus_read`): a pure outward writer-shadowing walk +(R5) that never descends into a scope — which is what makes sink-scope +no-demand (R2) hold *by construction*. The spike's probe-side filter for +inactive playlist entries is the rejected alternative; the pinned test +asserts a probe read with `include_values` never ticks a sink-scope +producer. The playlist ownership-suppression rule is deleted outright: +entry children publish into their entry's sink scope like any producer. + +**Reading-scope rule.** A node reads from the scope it inhabits — except +scope *introducers*, whose bus reads face inward (the root's unscoped +reads are root-scope reads; a module export republishing an inner channel +reads it from the scope the module introduces). Write-side classification +is always the owner's inhabited scope (R4: produces write locally; a +module node resides in its parent's scope). + +**A real `ModuleNode` runtime, root included.** Every module-kinded node +wears the mirror runtime (harvested from the spike's `ProjectNode`): +`produce` resolves the introduced scope's `visual.out` and forwards +render/sample dispatch to the producer; no writer renders cleared. The +loader registers the R7 surface: authored bindings (the contention pick), +authored exports, and the automatic `output` → `visual.out` fallback +publish for non-root modules (drop-in embedding). Root is no longer a +placeholder special case — the same runtime attaches on load and on the +apply path's reattach. The runtime is deliberately never feature-gated +(every project has a root module; C6 headroom after: ~244 KB). + +**Primary visual is an engine-reported role.** `WireBusChannel` gains +`primary_visual`, decided once in the probe (the root scope's listing of +the vocabulary channel the root mirror reads). Studio and friends consume +the flag; `channel.name == "visual.out"` string tests are dead. The name +comparison survives in exactly one place, engine-side, next to the +vocabulary constant. + +**Persisted scope identity.** A scope's stable string is its owner's tree +path; a sink scope keys by the authored playlist entry +(`…/entries[k]`) — stable under sibling reorder (names and keys, never +indices), stable across reattach/reload, and following the entry SLOT +rather than its content, so swapping what an entry plays keeps the +entry's panel state. This string becomes the panel-state key prefix in +the panel phases; it was chosen before any device persists one. + +## Consequences + +- Depth-2 composition works and is pinned (E5): module publishes and + exports count as writers in resolution like any producer — the spike's + latent `collect_writers` omission cannot recur silently. +- Two fallback writers in one scope (host visual + embedded module's + publish) resolve ambiguous-until-authored — the accepted consequence; + the pick is authorable on the module node (`ModuleDef.bindings`). +- Feedback via one channel in one scope reports as a cycle; chains that + need explicit topology use `node:` refs (E5 note). +- The wire still lists channels flat; the structured `WireScopeRef` + surface and per-scope listings land in the next phase (P7). diff --git a/docs/design/modules.md b/docs/design/modules.md index 07e83b4bf..1ad619629 100644 --- a/docs/design/modules.md +++ b/docs/design/modules.md @@ -65,6 +65,10 @@ no format bump semantics to preserve (alpha). Inventory: - Studio copy: users "open a project", "add a module", browse "Effects". - "Plugin" is rejected (connotes third-party binaries / dynamic loading). +> Status: the kind/type rename (`ModuleDef`, `NodeKind::Module`, kind +> strings `Module`/`module`, tree paths `.module`, schemas, corpora, +> wire bump) landed 2026-08-01. The file split (§6) is the next phase. + ## 3. Bus rules (normative) ### R1 — Modules introduce scopes, structurally @@ -175,6 +179,12 @@ public input is an *invitation*, not an error. Inputs need no counterpart: consumed inheritance (R5) already lets a host feed a module's inner consumers with zero authoring (see E6). +> Status: implemented 2026-08-01 (engine C1–C3) — structural scopes, +> scoped resolver keys with writer-shadowing, the module mirror runtime +> (root included), automatic publish + authored exports, and the +> engine-reported primary-visual role. See +> `docs/adr/2026-08-01-scoped-bus-engine-architecture.md`. + ### R8 — The panel: one concept, every node, derived from publicity The **panel** is a first-class per-node concept, not a feature of @@ -478,6 +488,11 @@ my-project/ by construction — a module folder's internal wiring is location-independent. +> Status: the project.json/module.json split, the container-manifest +> format gate (missing manifest = hard refuse, format bumped to 3), and +> the split schemas landed 2026-08-01. `.lp/state.json` arrives with the +> panel phases. + ## 7. Bus vocabulary — under discovery The set of standard channel names (`time`, `visual.out`, `audio.*`, …) @@ -492,20 +507,30 @@ Known vocabulary pressure beyond scalars: **touch/gesture sets** (E7 — multi-point, per-touch identity; shape question tracked as panel.md P-Q5) and the `phase` convention (panel.md P3). -## 8. Provenance (proposed field set — Q7) +## 8. Provenance (field set settled — Q7) `author`, `version`, `license`, `created` (ISO date). Optional on any node and on `project.json`; skip-if-default; no semver semantics yet. Copy-on-extract per R14. +> Status: landed 2026-08-01 as `ProvenanceDef` (module defs carry it; +> the container manifest carries the same four keys at its top level). +> Copy-on-extract mechanics arrive with the vendoring flows. + ## 9. Open questions (G1 redline register) - **Q3:** → specced as `panel.md` P8/P11 (wire ops, state file shape); ratify there. -- **Q7:** provenance field set (§8) — enough? (`description`/`homepage` - deferred?) -- **Q10:** bare-module-folder open assumes current format (§6) — fine for - alpha? +- **Q7:** SETTLED (2026-08-01, implementation P3): the §8 four-field set + as proposed (`author`/`version`/`license`/`created`); `description`/ + `homepage` deferred until a real need shows up in the registry/import + flow. +- **Q10:** SETTLED (2026-08-01, implementation P2): format is a + container-level concept. A module folder inside a project is gated by + the project's container manifest; the loader never re-runs the gate for + child artifacts (pinned by test). Bare-module-folder standalone opening + keeps the §6 assume-current posture; import-time gating arrives with + the registry/import flow. - **Q11:** → moved to `panel.md` P6 (merge/tiebreak rules); ratify there. - **Q12:** → resolved by the latch model: grabbing authored-driven channels is core behavior (`panel.md` P2/P5), not an increment. diff --git a/docs/design/source-artifacts.md b/docs/design/source-artifacts.md index ed16d5c8f..709a589b4 100644 --- a/docs/design/source-artifacts.md +++ b/docs/design/source-artifacts.md @@ -26,7 +26,7 @@ satisfies this): ```json { - "kind": "Project", + "kind": "Module", "format": 1, "name": "example", "nodes": { diff --git a/examples/basic/module.json b/examples/basic/module.json new file mode 100644 index 000000000..2443ff6d0 --- /dev/null +++ b/examples/basic/module.json @@ -0,0 +1,17 @@ +{ + "kind": "Module", + "nodes": { + "clock": { + "ref": "./clock.json" + }, + "fixture": { + "ref": "./fixture.json" + }, + "output": { + "ref": "./output.json" + }, + "shader": { + "ref": "./shader.json" + } + } +} diff --git a/examples/basic/project.json b/examples/basic/project.json index f4ae551f3..850eada09 100644 --- a/examples/basic/project.json +++ b/examples/basic/project.json @@ -1,19 +1,4 @@ { - "kind": "Project", - "format": 2, - "name": "Basic", - "nodes": { - "clock": { - "ref": "./clock.json" - }, - "fixture": { - "ref": "./fixture.json" - }, - "output": { - "ref": "./output.json" - }, - "shader": { - "ref": "./shader.json" - } - } + "format": 3, + "name": "Basic" } diff --git a/examples/basic2/module.json b/examples/basic2/module.json new file mode 100644 index 000000000..9254fa00c --- /dev/null +++ b/examples/basic2/module.json @@ -0,0 +1,20 @@ +{ + "kind": "Module", + "nodes": { + "clock": { + "ref": "./clock.json" + }, + "fixture": { + "ref": "./fixture.json" + }, + "output": { + "ref": "./output.json" + }, + "shader": { + "ref": "./shader.json" + }, + "texture": { + "ref": "./texture.json" + } + } +} diff --git a/examples/basic2/project.json b/examples/basic2/project.json index 6beef28a3..1b686e872 100644 --- a/examples/basic2/project.json +++ b/examples/basic2/project.json @@ -1,22 +1,4 @@ { - "kind": "Project", - "format": 2, - "name": "basic2", - "nodes": { - "clock": { - "ref": "./clock.json" - }, - "fixture": { - "ref": "./fixture.json" - }, - "output": { - "ref": "./output.json" - }, - "shader": { - "ref": "./shader.json" - }, - "texture": { - "ref": "./texture.json" - } - } + "format": 3, + "name": "basic2" } diff --git a/examples/button-playlist/module.json b/examples/button-playlist/module.json new file mode 100644 index 000000000..24fda7611 --- /dev/null +++ b/examples/button-playlist/module.json @@ -0,0 +1,20 @@ +{ + "kind": "Module", + "nodes": { + "button": { + "ref": "./button.json" + }, + "clock": { + "ref": "./clock.json" + }, + "fixture": { + "ref": "./fixture.json" + }, + "output": { + "ref": "./output.json" + }, + "playlist": { + "ref": "./playlist.json" + } + } +} diff --git a/examples/button-playlist/project.json b/examples/button-playlist/project.json index 6f08698c0..e6b586cdf 100644 --- a/examples/button-playlist/project.json +++ b/examples/button-playlist/project.json @@ -1,22 +1,4 @@ { - "kind": "Project", - "format": 2, - "name": "button-playlist", - "nodes": { - "button": { - "ref": "./button.json" - }, - "clock": { - "ref": "./clock.json" - }, - "fixture": { - "ref": "./fixture.json" - }, - "output": { - "ref": "./output.json" - }, - "playlist": { - "ref": "./playlist.json" - } - } + "format": 3, + "name": "button-playlist" } diff --git a/examples/button-sign/module.json b/examples/button-sign/module.json new file mode 100644 index 000000000..4c7bc567a --- /dev/null +++ b/examples/button-sign/module.json @@ -0,0 +1,23 @@ +{ + "kind": "Module", + "nodes": { + "button": { + "ref": "./button.json" + }, + "clock": { + "ref": "./clock.json" + }, + "fixture": { + "ref": "./fixture.json" + }, + "output": { + "ref": "./output.json" + }, + "playlist": { + "ref": "./playlist.json" + }, + "radio": { + "ref": "./radio.json" + } + } +} diff --git a/examples/button-sign/project.json b/examples/button-sign/project.json index 8d1994caf..acc9b93e4 100644 --- a/examples/button-sign/project.json +++ b/examples/button-sign/project.json @@ -1,25 +1,4 @@ { - "kind": "Project", - "format": 2, - "name": "button-sign", - "nodes": { - "button": { - "ref": "./button.json" - }, - "clock": { - "ref": "./clock.json" - }, - "fixture": { - "ref": "./fixture.json" - }, - "output": { - "ref": "./output.json" - }, - "playlist": { - "ref": "./playlist.json" - }, - "radio": { - "ref": "./radio.json" - } - } + "format": 3, + "name": "button-sign" } diff --git a/examples/button/module.json b/examples/button/module.json new file mode 100644 index 000000000..e245adf86 --- /dev/null +++ b/examples/button/module.json @@ -0,0 +1,17 @@ +{ + "kind": "Module", + "nodes": { + "button": { + "ref": "./button.json" + }, + "fixture": { + "ref": "./fixture.json" + }, + "output": { + "ref": "./output.json" + }, + "shader": { + "ref": "./shader.json" + } + } +} diff --git a/examples/button/project.json b/examples/button/project.json index 3e8170f9c..961111e2d 100644 --- a/examples/button/project.json +++ b/examples/button/project.json @@ -1,19 +1,4 @@ { - "kind": "Project", - "format": 2, - "name": "button", - "nodes": { - "button": { - "ref": "./button.json" - }, - "fixture": { - "ref": "./fixture.json" - }, - "output": { - "ref": "./output.json" - }, - "shader": { - "ref": "./shader.json" - } - } + "format": 3, + "name": "button" } diff --git a/examples/events/module.json b/examples/events/module.json new file mode 100644 index 000000000..1be4f0051 --- /dev/null +++ b/examples/events/module.json @@ -0,0 +1,23 @@ +{ + "kind": "Module", + "nodes": { + "clock": { + "ref": "./clock.json" + }, + "event_a": { + "ref": "./event_a.json" + }, + "event_b": { + "ref": "./event_b.json" + }, + "fixture": { + "ref": "./fixture.json" + }, + "output": { + "ref": "./output.json" + }, + "shader": { + "ref": "./shader.json" + } + } +} diff --git a/examples/events/project.json b/examples/events/project.json index 02ab70dd1..42b83a118 100644 --- a/examples/events/project.json +++ b/examples/events/project.json @@ -1,25 +1,4 @@ { - "kind": "Project", - "format": 2, - "name": "events", - "nodes": { - "clock": { - "ref": "./clock.json" - }, - "event_a": { - "ref": "./event_a.json" - }, - "event_b": { - "ref": "./event_b.json" - }, - "fixture": { - "ref": "./fixture.json" - }, - "output": { - "ref": "./output.json" - }, - "shader": { - "ref": "./shader.json" - } - } + "format": 3, + "name": "events" } diff --git a/examples/fast/module.json b/examples/fast/module.json new file mode 100644 index 000000000..9254fa00c --- /dev/null +++ b/examples/fast/module.json @@ -0,0 +1,20 @@ +{ + "kind": "Module", + "nodes": { + "clock": { + "ref": "./clock.json" + }, + "fixture": { + "ref": "./fixture.json" + }, + "output": { + "ref": "./output.json" + }, + "shader": { + "ref": "./shader.json" + }, + "texture": { + "ref": "./texture.json" + } + } +} diff --git a/examples/fast/project.json b/examples/fast/project.json index c68404cc9..a1356211d 100644 --- a/examples/fast/project.json +++ b/examples/fast/project.json @@ -1,22 +1,4 @@ { - "kind": "Project", - "format": 2, - "name": "fast", - "nodes": { - "clock": { - "ref": "./clock.json" - }, - "fixture": { - "ref": "./fixture.json" - }, - "output": { - "ref": "./output.json" - }, - "shader": { - "ref": "./shader.json" - }, - "texture": { - "ref": "./texture.json" - } - } + "format": 3, + "name": "fast" } diff --git a/examples/fiber-headband/module.json b/examples/fiber-headband/module.json new file mode 100644 index 000000000..2443ff6d0 --- /dev/null +++ b/examples/fiber-headband/module.json @@ -0,0 +1,17 @@ +{ + "kind": "Module", + "nodes": { + "clock": { + "ref": "./clock.json" + }, + "fixture": { + "ref": "./fixture.json" + }, + "output": { + "ref": "./output.json" + }, + "shader": { + "ref": "./shader.json" + } + } +} diff --git a/examples/fiber-headband/project.json b/examples/fiber-headband/project.json index 8b3271434..196cb25f2 100644 --- a/examples/fiber-headband/project.json +++ b/examples/fiber-headband/project.json @@ -1,19 +1,4 @@ { - "kind": "Project", - "format": 2, - "name": "fiber-headband", - "nodes": { - "clock": { - "ref": "./clock.json" - }, - "fixture": { - "ref": "./fixture.json" - }, - "output": { - "ref": "./output.json" - }, - "shader": { - "ref": "./shader.json" - } - } + "format": 3, + "name": "fiber-headband" } diff --git a/examples/fluid/module.json b/examples/fluid/module.json new file mode 100644 index 000000000..c31bf29d6 --- /dev/null +++ b/examples/fluid/module.json @@ -0,0 +1,20 @@ +{ + "kind": "Module", + "nodes": { + "clock": { + "ref": "./clock.json" + }, + "compute": { + "ref": "./compute.json" + }, + "fixture": { + "ref": "./fixture.json" + }, + "fluid": { + "ref": "./fluid.json" + }, + "output": { + "ref": "./output.json" + } + } +} diff --git a/examples/fluid/project.json b/examples/fluid/project.json index 22abbdb2e..6d5bf0fb8 100644 --- a/examples/fluid/project.json +++ b/examples/fluid/project.json @@ -1,22 +1,4 @@ { - "kind": "Project", - "format": 2, - "name": "fluid", - "nodes": { - "clock": { - "ref": "./clock.json" - }, - "compute": { - "ref": "./compute.json" - }, - "fixture": { - "ref": "./fixture.json" - }, - "fluid": { - "ref": "./fluid.json" - }, - "output": { - "ref": "./output.json" - } - } + "format": 3, + "name": "fluid" } diff --git a/examples/fyeah-button/module.json b/examples/fyeah-button/module.json new file mode 100644 index 000000000..4c7bc567a --- /dev/null +++ b/examples/fyeah-button/module.json @@ -0,0 +1,23 @@ +{ + "kind": "Module", + "nodes": { + "button": { + "ref": "./button.json" + }, + "clock": { + "ref": "./clock.json" + }, + "fixture": { + "ref": "./fixture.json" + }, + "output": { + "ref": "./output.json" + }, + "playlist": { + "ref": "./playlist.json" + }, + "radio": { + "ref": "./radio.json" + } + } +} diff --git a/examples/fyeah-button/project.json b/examples/fyeah-button/project.json index d35eda0af..7ac17a0c4 100644 --- a/examples/fyeah-button/project.json +++ b/examples/fyeah-button/project.json @@ -1,25 +1,4 @@ { - "kind": "Project", - "format": 2, - "name": "fyeah-button", - "nodes": { - "button": { - "ref": "./button.json" - }, - "clock": { - "ref": "./clock.json" - }, - "fixture": { - "ref": "./fixture.json" - }, - "output": { - "ref": "./output.json" - }, - "playlist": { - "ref": "./playlist.json" - }, - "radio": { - "ref": "./radio.json" - } - } + "format": 3, + "name": "fyeah-button" } diff --git a/examples/fyeah-sign/module.json b/examples/fyeah-sign/module.json new file mode 100644 index 000000000..4c7bc567a --- /dev/null +++ b/examples/fyeah-sign/module.json @@ -0,0 +1,23 @@ +{ + "kind": "Module", + "nodes": { + "button": { + "ref": "./button.json" + }, + "clock": { + "ref": "./clock.json" + }, + "fixture": { + "ref": "./fixture.json" + }, + "output": { + "ref": "./output.json" + }, + "playlist": { + "ref": "./playlist.json" + }, + "radio": { + "ref": "./radio.json" + } + } +} diff --git a/examples/fyeah-sign/project.json b/examples/fyeah-sign/project.json index b6c816169..ec0ed1fd4 100644 --- a/examples/fyeah-sign/project.json +++ b/examples/fyeah-sign/project.json @@ -1,25 +1,4 @@ { - "kind": "Project", - "format": 2, - "name": "fyeah-sign", - "nodes": { - "button": { - "ref": "./button.json" - }, - "clock": { - "ref": "./clock.json" - }, - "fixture": { - "ref": "./fixture.json" - }, - "output": { - "ref": "./output.json" - }, - "playlist": { - "ref": "./playlist.json" - }, - "radio": { - "ref": "./radio.json" - } - } + "format": 3, + "name": "fyeah-sign" } diff --git a/examples/perf/baseline/module.json b/examples/perf/baseline/module.json new file mode 100644 index 000000000..9254fa00c --- /dev/null +++ b/examples/perf/baseline/module.json @@ -0,0 +1,20 @@ +{ + "kind": "Module", + "nodes": { + "clock": { + "ref": "./clock.json" + }, + "fixture": { + "ref": "./fixture.json" + }, + "output": { + "ref": "./output.json" + }, + "shader": { + "ref": "./shader.json" + }, + "texture": { + "ref": "./texture.json" + } + } +} diff --git a/examples/perf/baseline/project.json b/examples/perf/baseline/project.json index 72b8179ac..d65e6952f 100644 --- a/examples/perf/baseline/project.json +++ b/examples/perf/baseline/project.json @@ -1,22 +1,4 @@ { - "kind": "Project", - "format": 2, - "name": "perf-baseline", - "nodes": { - "clock": { - "ref": "./clock.json" - }, - "fixture": { - "ref": "./fixture.json" - }, - "output": { - "ref": "./output.json" - }, - "shader": { - "ref": "./shader.json" - }, - "texture": { - "ref": "./texture.json" - } - } + "format": 3, + "name": "perf-baseline" } diff --git a/examples/perf/fastmath/module.json b/examples/perf/fastmath/module.json new file mode 100644 index 000000000..9254fa00c --- /dev/null +++ b/examples/perf/fastmath/module.json @@ -0,0 +1,20 @@ +{ + "kind": "Module", + "nodes": { + "clock": { + "ref": "./clock.json" + }, + "fixture": { + "ref": "./fixture.json" + }, + "output": { + "ref": "./output.json" + }, + "shader": { + "ref": "./shader.json" + }, + "texture": { + "ref": "./texture.json" + } + } +} diff --git a/examples/perf/fastmath/project.json b/examples/perf/fastmath/project.json index 36f6b583d..91bae8778 100644 --- a/examples/perf/fastmath/project.json +++ b/examples/perf/fastmath/project.json @@ -1,22 +1,4 @@ { - "kind": "Project", - "format": 2, - "name": "perf-fastmath", - "nodes": { - "clock": { - "ref": "./clock.json" - }, - "fixture": { - "ref": "./fixture.json" - }, - "output": { - "ref": "./output.json" - }, - "shader": { - "ref": "./shader.json" - }, - "texture": { - "ref": "./texture.json" - } - } + "format": 3, + "name": "perf-fastmath" } diff --git a/examples/rocaille/module.json b/examples/rocaille/module.json new file mode 100644 index 000000000..9254fa00c --- /dev/null +++ b/examples/rocaille/module.json @@ -0,0 +1,20 @@ +{ + "kind": "Module", + "nodes": { + "clock": { + "ref": "./clock.json" + }, + "fixture": { + "ref": "./fixture.json" + }, + "output": { + "ref": "./output.json" + }, + "shader": { + "ref": "./shader.json" + }, + "texture": { + "ref": "./texture.json" + } + } +} diff --git a/examples/rocaille/project.json b/examples/rocaille/project.json index 4411444ce..98231882a 100644 --- a/examples/rocaille/project.json +++ b/examples/rocaille/project.json @@ -1,22 +1,4 @@ { - "kind": "Project", - "format": 2, - "name": "rocaille", - "nodes": { - "clock": { - "ref": "./clock.json" - }, - "fixture": { - "ref": "./fixture.json" - }, - "output": { - "ref": "./output.json" - }, - "shader": { - "ref": "./shader.json" - }, - "texture": { - "ref": "./texture.json" - } - } + "format": 3, + "name": "rocaille" } diff --git a/examples/shader-oracle/module.json b/examples/shader-oracle/module.json new file mode 100644 index 000000000..8d6e8e2fd --- /dev/null +++ b/examples/shader-oracle/module.json @@ -0,0 +1,14 @@ +{ + "kind": "Module", + "nodes": { + "fixture": { + "ref": "./fixture.json" + }, + "output": { + "ref": "./output.json" + }, + "shader": { + "ref": "./shader.json" + } + } +} diff --git a/examples/shader-oracle/project.json b/examples/shader-oracle/project.json index 8f5fd557b..1b39cef01 100644 --- a/examples/shader-oracle/project.json +++ b/examples/shader-oracle/project.json @@ -1,16 +1,4 @@ { - "kind": "Project", - "format": 2, - "name": "Shader oracle", - "nodes": { - "shader": { - "ref": "./shader.json" - }, - "fixture": { - "ref": "./fixture.json" - }, - "output": { - "ref": "./output.json" - } - } + "format": 3, + "name": "Shader oracle" } diff --git a/justfile b/justfile index ec0f4258a..1180bd774 100644 --- a/justfile +++ b/justfile @@ -529,7 +529,7 @@ schema-check: format-bump: #!/usr/bin/env bash set -euo pipefail - const_file="lp-core/lpc-model/src/nodes/project/project_def.rs" + const_file="lp-core/lpc-model/src/nodes/module/module_def.rs" version=$(sed -n 's/^pub const PROJECT_FORMAT_VERSION: u32 = \([0-9][0-9]*\);.*$/\1/p' "$const_file") if [[ -z "$version" ]]; then echo "error: could not parse PROJECT_FORMAT_VERSION from $const_file" >&2 diff --git a/lp-app/lpa-fs-opfs/tests/store_round_trip.rs b/lp-app/lpa-fs-opfs/tests/store_round_trip.rs index 8782b763e..8f2d71b27 100644 --- a/lp-app/lpa-fs-opfs/tests/store_round_trip.rs +++ b/lp-app/lpa-fs-opfs/tests/store_round_trip.rs @@ -170,7 +170,7 @@ async fn lpc_history_runs_over_the_store() { store .write_file( LpPath::new("/packages/x/project.json"), - b"{\"kind\":\"Project\"}", + b"{\"kind\":\"Module\"}", ) .unwrap(); store diff --git a/lp-app/lpa-link/src/providers/fake_device/tests.rs b/lp-app/lpa-link/src/providers/fake_device/tests.rs index 5f3dd090b..2625b2816 100644 --- a/lp-app/lpa-link/src/providers/fake_device/tests.rs +++ b/lp-app/lpa-link/src/providers/fake_device/tests.rs @@ -140,8 +140,7 @@ async fn light_player_state_speaks_real_frames_through_the_real_transport() { FakeLightPlayerState::new() .with_project_files(vec![( "project.json".to_string(), - br#"{"kind":"Project","uid":"prj_fakefakefakefak0","name":"Fake","nodes":{}}"# - .to_vec(), + br#"{"format":3,"uid":"prj_fakefakefakefak0","name":"Fake"}"#.to_vec(), )]) .with_identity(identity), ))); diff --git a/lp-app/lpa-server/tests/binding_apply_live.rs b/lp-app/lpa-server/tests/binding_apply_live.rs index e09a3b961..acf483e66 100644 --- a/lp-app/lpa-server/tests/binding_apply_live.rs +++ b/lp-app/lpa-server/tests/binding_apply_live.rs @@ -218,10 +218,16 @@ fn server_with_clock_project(name: &str) -> (LpServer, LpPathBuf) { .base_fs_mut() .write_file( project_path.join("project.json").as_path(), + b"{\n \"format\": 3\n}\n", + ) + .expect("write container manifest"); + server + .base_fs_mut() + .write_file( + project_path.join("module.json").as_path(), br#" { - "kind": "Project", - "format": 2, + "kind": "Module", "nodes": { "clock": { "ref": "./clock.json" diff --git a/lp-app/lpa-server/tests/file_sync_contract.rs b/lp-app/lpa-server/tests/file_sync_contract.rs index 436421ae3..768926624 100644 --- a/lp-app/lpa-server/tests/file_sync_contract.rs +++ b/lp-app/lpa-server/tests/file_sync_contract.rs @@ -105,7 +105,7 @@ fn since_current_transfers_nothing() { #[test] fn full_pull_then_incremental_then_empty() { let fs = LpFsMemory::new(); - write(&fs, "/project.json", b"{\"kind\":\"Project\"}"); + write(&fs, "/project.json", b"{\"kind\":\"Module\"}"); write(&fs, "/shader.glsl", b"void main() {}"); // full pull (since = 0) enumerates everything @@ -236,7 +236,7 @@ fn write_chunk_reassembles_and_validates_offsets() { #[test] fn hash_package_matches_direct_computation() { let fs = LpFsMemory::new(); - write(&fs, "/project.json", b"{\"kind\":\"Project\"}"); + write(&fs, "/project.json", b"{\"kind\":\"Module\"}"); write(&fs, "/shader.glsl", b"void main() {}"); write(&fs, "/.lp/meta.json", b"{\"origin\":\"x\"}"); // excluded from hash diff --git a/lp-app/lpa-server/tests/fs_version_tracking.rs b/lp-app/lpa-server/tests/fs_version_tracking.rs index 548dfd8b6..6b7307621 100644 --- a/lp-app/lpa-server/tests/fs_version_tracking.rs +++ b/lp-app/lpa-server/tests/fs_version_tracking.rs @@ -29,12 +29,19 @@ fn test_fs_changes_not_repeated() { let project_name = "test-project"; let project_path = "/projects".as_path_buf().join(project_name); - // Create project.json + // Create the container manifest and root module artifact server .base_fs_mut() .write_file( project_path.join("project.json").as_path(), - b"{ \"kind\": \"Project\", \"format\": 2, \"name\": \"test\" }\n", + b"{\n \"format\": 3,\n \"name\": \"test\"\n}\n", + ) + .unwrap(); + server + .base_fs_mut() + .write_file( + project_path.join("module.json").as_path(), + b"{ \"kind\": \"Module\", \"nodes\": {} }\n", ) .unwrap(); diff --git a/lp-app/lpa-server/tests/overlay_revision.rs b/lp-app/lpa-server/tests/overlay_revision.rs index f70419edc..ce2417405 100644 --- a/lp-app/lpa-server/tests/overlay_revision.rs +++ b/lp-app/lpa-server/tests/overlay_revision.rs @@ -253,10 +253,16 @@ fn server_with_clock_project(name: &str) -> (LpServer, LpPathBuf) { .base_fs_mut() .write_file( project_path.join("project.json").as_path(), + b"{\n \"format\": 3\n}\n", + ) + .expect("write container manifest"); + server + .base_fs_mut() + .write_file( + project_path.join("module.json").as_path(), br#" { - "kind": "Project", - "format": 2, + "kind": "Module", "nodes": { "clock": { "ref": "./clock.json" diff --git a/lp-app/lpa-server/tests/project_fs_refresh.rs b/lp-app/lpa-server/tests/project_fs_refresh.rs index 403008243..8fe353f44 100644 --- a/lp-app/lpa-server/tests/project_fs_refresh.rs +++ b/lp-app/lpa-server/tests/project_fs_refresh.rs @@ -103,10 +103,16 @@ fn server_with_clock_project(name: &str) -> (LpServer, LpPathBuf) { .base_fs_mut() .write_file( project_file(name, "project.json").as_path(), + b"{\n \"format\": 3\n}\n", + ) + .expect("write container manifest"); + server + .base_fs_mut() + .write_file( + project_file(name, "module.json").as_path(), br#" { - "kind": "Project", - "format": 2, + "kind": "Module", "nodes": { "clock": { "ref": "./clock.json" diff --git a/lp-app/lpa-server/tests/quad_output_channels.rs b/lp-app/lpa-server/tests/quad_output_channels.rs index 08f228912..9f8b26e18 100644 --- a/lp-app/lpa-server/tests/quad_output_channels.rs +++ b/lp-app/lpa-server/tests/quad_output_channels.rs @@ -130,9 +130,10 @@ fn run_chains(chains: &[usize]) -> (LpServer, Rc>) .expect("file name") .to_string_lossy() .into_owned(); - // The authored project.json wires all four chains; the subset under - // test gets a generated one in its place. - if name == "project.json" { + // The authored module.json wires all four chains; the subset under + // test gets a generated one in its place. The container manifest + // (project.json) carries no node wiring, so it is copied as-is. + if name == "module.json" { continue; } server @@ -146,10 +147,10 @@ fn run_chains(chains: &[usize]) -> (LpServer, Rc>) server .base_fs_mut() .write_file( - project.join("project.json").as_path(), - project_json(chains).as_bytes(), + project.join("module.json").as_path(), + module_json(chains).as_bytes(), ) - .expect("write project.json"); + .expect("write module.json"); server .load_project(project.as_path()) @@ -160,7 +161,7 @@ fn run_chains(chains: &[usize]) -> (LpServer, Rc>) (server, provider) } -fn project_json(chains: &[usize]) -> String { +fn module_json(chains: &[usize]) -> String { let mut nodes = String::from( "\"clock\": {\"ref\": \"./clock.json\"}, \"shader\": {\"ref\": \"./shader.json\"}", ); @@ -170,9 +171,7 @@ fn project_json(chains: &[usize]) -> String { , \"output{chain}\": {{\"ref\": \"./output{chain}.json\"}}" )); } - format!( - "{{\"kind\": \"Project\", \"format\": 2, \"name\": \"Quad strips\", \"nodes\": {{{nodes}}}}}" - ) + format!("{{\"kind\": \"Module\", \"nodes\": {{{nodes}}}}}") } fn endpoint(spec: &str) -> HwEndpointSpec { diff --git a/lp-app/lpa-server/tests/request_error_response.rs b/lp-app/lpa-server/tests/request_error_response.rs index ffbe4ccc9..8533bc9ee 100644 --- a/lp-app/lpa-server/tests/request_error_response.rs +++ b/lp-app/lpa-server/tests/request_error_response.rs @@ -28,13 +28,16 @@ use lpfs::LpFsMemory; #[test] fn failed_load_project_gets_error_response_and_later_requests_still_answer() { let mut server = memory_server(); - // Manifest missing `format: 1` fails to load server-side (the original - // repro from the device-link e2e tests). + // A pre-mitosis, single-file `project.json` (kind-tagged root artifact) + // is not a valid container manifest — `kind`/`nodes` are unknown fields + // there now — so the load gate hard-refuses it server-side (the original + // repro from the device-link e2e tests, updated for the project/module + // split). server .base_fs_mut() .write_file( "/projects/bad/project.json".as_path(), - br#"{ "kind": "Project", "nodes": {} }"#, + br#"{ "kind": "Module", "nodes": {} }"#, ) .expect("write bad manifest"); diff --git a/lp-app/lpa-server/tests/stop_all_projects.rs b/lp-app/lpa-server/tests/stop_all_projects.rs index 2c732a2e8..554fb7124 100644 --- a/lp-app/lpa-server/tests/stop_all_projects.rs +++ b/lp-app/lpa-server/tests/stop_all_projects.rs @@ -33,12 +33,23 @@ fn test_stop_all_projects() { // Prepare base filesystem with project files let base_fs = Box::new(LpFsMemory::new()); - let project_toml = temp_fs + let project_manifest = temp_fs .borrow() .read_file("/project.json".as_path()) .unwrap(); base_fs - .write_file(project_prefix.join("project.json").as_path(), &project_toml) + .write_file( + project_prefix.join("project.json").as_path(), + &project_manifest, + ) + .unwrap(); + + let module_json = temp_fs + .borrow() + .read_file("/module.json".as_path()) + .unwrap(); + base_fs + .write_file(project_prefix.join("module.json").as_path(), &module_json) .unwrap(); let node_paths = vec![ diff --git a/lp-app/lpa-studio-core/src/app/bus/ui_bus_view.rs b/lp-app/lpa-studio-core/src/app/bus/ui_bus_view.rs index b69b87a47..cdfadcf0c 100644 --- a/lp-app/lpa-studio-core/src/app/bus/ui_bus_view.rs +++ b/lp-app/lpa-studio-core/src/app/bus/ui_bus_view.rs @@ -189,7 +189,7 @@ mod tests { fn sites_with_focus_carry_clickable_actions() { let mut ch = channel(); ch.writers[0].focus = Some(crate::UiAction::from_op( - crate::ControllerId::new("test.project"), + crate::ControllerId::new("test.module"), crate::ProjectEditorOp::Focus, )); let aspects = ch.visible_aspects(); diff --git a/lp-app/lpa-studio-core/src/app/home/embedded_example.rs b/lp-app/lpa-studio-core/src/app/home/embedded_example.rs index 7d68a1c86..81c976ec1 100644 --- a/lp-app/lpa-studio-core/src/app/home/embedded_example.rs +++ b/lp-app/lpa-studio-core/src/app/home/embedded_example.rs @@ -32,7 +32,7 @@ pub fn embedded_examples() -> &'static [EmbeddedExample] { &[EmbeddedExample { id: DEMO_PROJECT_ID, name: "Fyeah Sign", - kind: "Project", + kind: "Module", }] } @@ -52,7 +52,7 @@ mod tests { fn demo_example_is_embedded_with_files() { let example = embedded_example(DEMO_PROJECT_ID).expect("demo example is embedded"); assert_eq!(example.name, "Fyeah Sign"); - assert_eq!(example.kind, "Project"); + assert_eq!(example.kind, "Module"); let files = example.files(); assert!( files diff --git a/lp-app/lpa-studio-core/src/app/home/home_view_builder.rs b/lp-app/lpa-studio-core/src/app/home/home_view_builder.rs index 7485c8ae8..e6a5adfec 100644 --- a/lp-app/lpa-studio-core/src/app/home/home_view_builder.rs +++ b/lp-app/lpa-studio-core/src/app/home/home_view_builder.rs @@ -642,7 +642,7 @@ mod tests { "Basic", &[( "project.json".to_string(), - br#"{"kind":"Project","name":"Basic"}"#.to_vec(), + br#"{"format":3,"name":"Basic"}"#.to_vec(), )], PackageProvenance::SeededFrom { source: "examples/fyeah-sign".to_string(), @@ -669,7 +669,7 @@ mod tests { .find(|card| card.slug == "2026-07-09-1421-scratch") .unwrap(); assert_eq!(scratch.provenance, None); - assert_eq!(scratch.kind, "Project"); + assert_eq!(scratch.kind, "Module"); } #[test] diff --git a/lp-app/lpa-studio-core/src/app/home/ui_package_card.rs b/lp-app/lpa-studio-core/src/app/home/ui_package_card.rs index ce4c0e084..413664ddf 100644 --- a/lp-app/lpa-studio-core/src/app/home/ui_package_card.rs +++ b/lp-app/lpa-studio-core/src/app/home/ui_package_card.rs @@ -7,7 +7,7 @@ pub struct UiPackageCard { /// `prj_…` uid string — the identity every card action carries. pub uid: String, - /// Manifest kind ("Project" today, "Module" later). + /// Manifest kind (`"Module"`; pre-rename packages said `"Project"`). pub kind: String, /// THE user-facing identifier (dated: `2026-07-09-1421-basic`): card /// title, URL, export name. Rename edits it. diff --git a/lp-app/lpa-studio-core/src/app/library/library_store.rs b/lp-app/lpa-studio-core/src/app/library/library_store.rs index 4637086ae..3205373e1 100644 --- a/lp-app/lpa-studio-core/src/app/library/library_store.rs +++ b/lp-app/lpa-studio-core/src/app/library/library_store.rs @@ -242,21 +242,23 @@ impl LibraryStore { view.write_file(path.as_str().as_path(), bytes)?; } if !view.file_exists(package_manifest::MANIFEST_PATH.as_path())? { - // `format` is required by the loader's root format gate - // (`ProjectRegistry::check_root_format`); without it a + // `format` is required by the loader's container format gate + // (a missing manifest is a hard refuse); without it a // Created package would be unloadable. - let minimal = serde_json::json!({ - "kind": "Project", - "format": lpc_model::PROJECT_FORMAT_VERSION, - "name": label, - }); + let manifest = lpc_model::ProjectManifest::new_current(label); view.write_file( package_manifest::MANIFEST_PATH.as_path(), - serde_json::to_vec_pretty(&minimal) - .map_err(|e| LibraryError::Manifest(e.to_string()))? - .as_slice(), + manifest.write_json().as_bytes(), )?; } + if files.is_empty() && !view.file_exists("/module.json".as_path())? { + // The root module node is the other half of the mitosis + // split; a blank Created package needs one to be loadable. + // Only for blank creates: installs (device pulls, imports) + // must stay byte-faithful to their source or parity hashes + // would diverge on adoption. + view.write_file("/module.json".as_path(), b"{\n \"kind\": \"Module\"\n}\n")?; + } let fields = package_manifest::read_manifest(&*view)?; if fields.name.is_none() { package_manifest::set_name(&*view, label)?; @@ -307,16 +309,20 @@ impl LibraryStore { Ok(package) } - /// Rename = change the slug and MOVE the package directory. The uid (and - /// therefore history and device associations) is untouched; the manifest - /// `name` field is the editor's concern, not the gallery's. Returns the - /// final slug (slugified, collision-suffixed). No-op when unchanged. + /// Rename = change the slug, MOVE the package directory, and patch the + /// manifest `name` to the raw typed name. The uid (and therefore history + /// and device associations) is untouched. Post-mitosis the manifest is + /// library-owned workspace metadata (never an authored def slot), so the + /// gallery rename is THE manifest patch path. Returns the final slug + /// (slugified, collision-suffixed). Slug no-op still patches the name. pub fn rename(&self, uid: PrefixedUid, new_slug: &str) -> Result { let old_slug = self .slug_for_uid(uid)? .ok_or_else(|| LibraryError::NotFound(uid.to_string()))?; let requested = slugify(new_slug); if requested == old_slug { + let package_fs = self.chroot_package(&old_slug)?; + package_manifest::set_name(&*package_fs.borrow(), new_slug)?; return Ok(old_slug); } let taken: Vec = self @@ -344,6 +350,7 @@ impl LibraryStore { self.fs .borrow() .delete_dir(format!("{PACKAGES_DIR}/{old_slug}").as_str().as_path())?; + package_manifest::set_name(&*new_fs.borrow(), new_slug)?; Ok(final_slug) } @@ -461,7 +468,7 @@ impl LibraryStore { fn read_summary(&self, slug: &str) -> Result { let package_fs = self.chroot_package(slug)?; let view = package_fs.borrow(); - let ManifestFields { uid, name, kind } = package_manifest::read_manifest(&*view)?; + let ManifestFields { uid, name, .. } = package_manifest::read_manifest(&*view)?; let uid = uid .ok_or_else(|| LibraryError::Manifest(format!("package {slug} has no uid")))? .parse() @@ -469,7 +476,7 @@ impl LibraryStore { Ok(PackageSummary { uid, name: name.unwrap_or_else(|| slug.to_string()), - kind, + kind: String::from("Module"), slug: slug.to_string(), }) } @@ -550,8 +557,11 @@ mod tests { vec![ ( "project.json".to_string(), - br#"{"kind":"Project","name":"demo","nodes":{"clock":{"ref":"./clock.json"}}}"# - .to_vec(), + br#"{"format":3,"name":"demo"}"#.to_vec(), + ), + ( + "module.json".to_string(), + br#"{"kind":"Module","nodes":{"clock":{"ref":"./clock.json"}}}"#.to_vec(), ), ("clock.json".to_string(), br#"{"kind":"Clock"}"#.to_vec()), ("shader.glsl".to_string(), b"void main() {}".to_vec()), @@ -615,7 +625,7 @@ mod tests { assert_eq!(copy_handle.history.version_number(copy_head), Some(2)); // source untouched let source_files = store.open(original.uid).unwrap().read_all_files().unwrap(); - assert_eq!(source_files.len(), 4); // 3 demo files + sidecar + assert_eq!(source_files.len(), 5); // 4 demo files + sidecar } #[test] diff --git a/lp-app/lpa-studio-core/src/app/library/package_manifest.rs b/lp-app/lpa-studio-core/src/app/library/package_manifest.rs index f0c4aed8e..7896f7e9d 100644 --- a/lp-app/lpa-studio-core/src/app/library/package_manifest.rs +++ b/lp-app/lpa-studio-core/src/app/library/package_manifest.rs @@ -1,127 +1,69 @@ -//! `project.json` manifest access: read fields, patch in place. +//! `project.json` container-manifest access: read fields, patch in place. //! -//! Patching never re-serializes the node graph: the manifest is read as a -//! `serde_json::Value`, the field is set, and the whole value is written -//! back — unknown keys and the `nodes` table pass through untouched. +//! Post-mitosis the manifest is the closed three-field container +//! ([`lpc_model::ProjectManifest`]: `format`/`uid`/`name`) — no node graph +//! to skirt, so patching is a plain read→modify→write of the canonical +//! form. The strict reader (unknown fields are an error) is what makes the +//! rewrite lossless by construction. use lpc_history::{PrefixedUid, UidPrefix}; -use lpc_model::AsLpPath; +use lpc_model::{AsLpPath, ProjectManifest}; use lpfs::LpFs; use super::library_store::LibraryError; pub const MANIFEST_PATH: &str = "/project.json"; -/// The manifest fields the library cares about (the rest passes through). +/// The manifest fields the library cares about. #[derive(Debug, Clone)] pub struct ManifestFields { + pub format: Option, pub uid: Option, pub name: Option, - pub kind: String, } pub fn read_manifest(fs: &dyn LpFs) -> Result { - let value = read_value(fs)?; + let manifest = read(fs)?; Ok(ManifestFields { - uid: value - .get("uid") - .and_then(|v| v.as_str()) - .map(str::to_string), - name: value - .get("name") - .and_then(|v| v.as_str()) - .map(str::to_string), - kind: value - .get("kind") - .and_then(|v| v.as_str()) - .unwrap_or("Project") - .to_string(), + format: manifest.format, + uid: manifest.uid, + name: manifest.name, }) } /// Ensure the manifest carries a uid, minting one from `random` if absent. /// Returns the (existing or minted) uid. pub fn ensure_uid(fs: &dyn LpFs, random: &[u8; 16]) -> Result { - let mut value = read_value(fs)?; - if let Some(existing) = value.get("uid").and_then(|v| v.as_str()) { + let mut manifest = read(fs)?; + if let Some(existing) = &manifest.uid { return existing .parse() .map_err(|e| LibraryError::Manifest(format!("invalid uid {existing:?}: {e}"))); } let uid = PrefixedUid::mint(UidPrefix::Project, random); - set_field( - &mut value, - "uid", - serde_json::Value::String(uid.to_string()), - ); - write_value(fs, &value)?; + manifest.uid = Some(uid.to_string()); + write(fs, &manifest)?; Ok(uid) } pub fn set_name(fs: &dyn LpFs, name: &str) -> Result<(), LibraryError> { - let mut value = read_value(fs)?; - set_field( - &mut value, - "name", - serde_json::Value::String(name.to_string()), - ); - write_value(fs, &value) + let mut manifest = read(fs)?; + manifest.name = Some(name.to_string()); + write(fs, &manifest) } -fn set_field(value: &mut serde_json::Value, key: &str, field: serde_json::Value) { - if let serde_json::Value::Object(map) = value { - map.insert(key.to_string(), field); - } -} - -fn read_value(fs: &dyn LpFs) -> Result { +fn read(fs: &dyn LpFs) -> Result { let bytes = fs .read_file(MANIFEST_PATH.as_path()) .map_err(|e| LibraryError::Manifest(format!("read project.json: {e}")))?; - serde_json::from_slice(&bytes) + let text = core::str::from_utf8(&bytes) + .map_err(|_| LibraryError::Manifest("project.json is not UTF-8".to_string()))?; + ProjectManifest::read_json(text) .map_err(|e| LibraryError::Manifest(format!("parse project.json: {e}"))) } -fn write_value(fs: &dyn LpFs, value: &serde_json::Value) -> Result<(), LibraryError> { - // The slot codec streams and requires the top-level `kind` to precede - // the variant's fields, and serde_json's map is ordered alphabetically — - // so emit a canonical key order (matching ProjectDef's declaration - // order) instead of serializing the map directly. - const CANONICAL_ORDER: [&str; 4] = ["kind", "format", "uid", "name"]; - let serde_json::Value::Object(map) = value else { - return Err(LibraryError::Manifest( - "project.json root must be an object".to_string(), - )); - }; - let mut ordered = Vec::new(); - for key in CANONICAL_ORDER { - if let Some(v) = map.get(key) { - ordered.push((key.to_string(), v.clone())); - } - } - for (key, v) in map { - if !CANONICAL_ORDER.contains(&key.as_str()) { - ordered.push((key.clone(), v.clone())); - } - } - let mut out = String::from("{\n"); - for (index, (key, v)) in ordered.iter().enumerate() { - let rendered = serde_json::to_string_pretty(v) - .map_err(|e| LibraryError::Manifest(format!("serialize project.json: {e}")))?; - // indent nested lines to match to_string_pretty at depth 1 - let indented = rendered.replace('\n', "\n "); - out.push_str(&format!( - " {}: {indented}", - serde_json::Value::String(key.clone()) - )); - if index + 1 < ordered.len() { - out.push(','); - } - out.push('\n'); - } - out.push('}'); - out.push('\n'); - fs.write_file(MANIFEST_PATH.as_path(), out.as_bytes()) +fn write(fs: &dyn LpFs, manifest: &ProjectManifest) -> Result<(), LibraryError> { + fs.write_file(MANIFEST_PATH.as_path(), manifest.write_json().as_bytes()) .map_err(|e| LibraryError::Manifest(format!("write project.json: {e}"))) } @@ -131,65 +73,57 @@ mod tests { use lpfs::LpFsMemory; const MANIFEST: &[u8] = br#"{ - "kind": "Project", - "name": "fluid", - "nodes": { "clock": { "ref": "./clock.json" }, "custom-key": 7 } -}"#; + "format": 3, + "name": "demo" +} +"#; #[test] - fn ensure_uid_mints_once_and_preserves_unknown_fields() { + fn read_manifest_reads_uid_and_name() { let fs = LpFsMemory::new(); fs.write_file(MANIFEST_PATH.as_path(), MANIFEST).unwrap(); + let fields = read_manifest(&fs).unwrap(); + assert_eq!(fields.uid, None); + assert_eq!(fields.name.as_deref(), Some("demo")); + } + #[test] + fn ensure_uid_mints_once_and_is_stable() { + let fs = LpFsMemory::new(); + fs.write_file(MANIFEST_PATH.as_path(), MANIFEST).unwrap(); let minted = ensure_uid(&fs, &[7u8; 16]).unwrap(); let again = ensure_uid(&fs, &[9u8; 16]).unwrap(); assert_eq!(minted, again, "second call must keep the existing uid"); - - let value: serde_json::Value = - serde_json::from_slice(&fs.read_file(MANIFEST_PATH.as_path()).unwrap()).unwrap(); - assert_eq!(value["uid"].as_str().unwrap(), minted.to_string()); - assert_eq!(value["nodes"]["custom-key"], 7); - assert_eq!(value["nodes"]["clock"]["ref"], "./clock.json"); + let fields = read_manifest(&fs).unwrap(); + assert_eq!(fields.uid.as_deref(), Some(minted.to_string().as_str())); } #[test] fn set_name_patches_only_the_name() { let fs = LpFsMemory::new(); fs.write_file(MANIFEST_PATH.as_path(), MANIFEST).unwrap(); + let minted = ensure_uid(&fs, &[7u8; 16]).unwrap(); set_name(&fs, "renamed").unwrap(); let fields = read_manifest(&fs).unwrap(); assert_eq!(fields.name.as_deref(), Some("renamed")); - assert_eq!(fields.kind, "Project"); - let value: serde_json::Value = - serde_json::from_slice(&fs.read_file(MANIFEST_PATH.as_path()).unwrap()).unwrap(); - assert_eq!(value["nodes"]["custom-key"], 7); + assert_eq!(fields.uid.as_deref(), Some(minted.to_string().as_str())); } #[test] - fn patched_manifest_parses_through_the_slot_codec() { - // The whole point of canonical ordering + the ProjectDef uid slot: - // a library-patched manifest must load on the runtime. + fn patched_manifest_stays_canonical() { + // The whole point of the closed manifest: a library-patched + // project.json must round-trip byte-identically through the model's + // canonical writer, so library patches never churn project diffs. let fs = LpFsMemory::new(); - fs.write_file( - MANIFEST_PATH.as_path(), - br#"{ - "kind": "Project", - "format": 2, - "name": "basic", - "nodes": { "clock": { "ref": "./clock.json" } } -}"#, - ) - .unwrap(); + fs.write_file(MANIFEST_PATH.as_path(), MANIFEST).unwrap(); ensure_uid(&fs, &[5u8; 16]).unwrap(); set_name(&fs, "renamed").unwrap(); let bytes = fs.read_file(MANIFEST_PATH.as_path()).unwrap(); let text = core::str::from_utf8(&bytes).unwrap(); - assert!( - text.trim_start().starts_with("{\n \"kind\""), - "kind must lead: {text}" - ); - lpc_model::NodeDef::from_json_str(text) - .unwrap_or_else(|e| panic!("codec rejected patched manifest: {e}\n{text}")); + let manifest = lpc_model::ProjectManifest::read_json(text) + .unwrap_or_else(|e| panic!("manifest rejected after patching: {e}\n{text}")); + assert_eq!(manifest.write_json(), text); + assert_eq!(manifest.format, Some(lpc_model::PROJECT_FORMAT_VERSION)); } #[test] @@ -197,7 +131,11 @@ mod tests { let fs = LpFsMemory::new(); fs.write_file( MANIFEST_PATH.as_path(), - br#"{"kind":"Project","uid":"garbage"}"#, + br#"{ + "format": 3, + "uid": "garbage" +} +"#, ) .unwrap(); assert!(ensure_uid(&fs, &[1u8; 16]).is_err()); diff --git a/lp-app/lpa-studio-core/src/app/library/package_zip.rs b/lp-app/lpa-studio-core/src/app/library/package_zip.rs index cf758c92a..86c14301e 100644 --- a/lp-app/lpa-studio-core/src/app/library/package_zip.rs +++ b/lp-app/lpa-studio-core/src/app/library/package_zip.rs @@ -164,8 +164,9 @@ mod tests { &[ ( "project.json".to_string(), - br#"{"kind":"Project","name":"demo","nodes":{}}"#.to_vec(), + br#"{"format":3,"name":"demo"}"#.to_vec(), ), + ("module.json".to_string(), br#"{"kind":"Module"}"#.to_vec()), ("shader.glsl".to_string(), b"void main() {}".to_vec()), ("assets/map.bin".to_string(), vec![0u8, 159, 146, 150]), ], diff --git a/lp-app/lpa-studio-core/src/app/places/device_session.rs b/lp-app/lpa-studio-core/src/app/places/device_session.rs index 019f03928..8eae97345 100644 --- a/lp-app/lpa-studio-core/src/app/places/device_session.rs +++ b/lp-app/lpa-studio-core/src/app/places/device_session.rs @@ -573,7 +573,7 @@ mod tests { vec![ ( "project.json".to_string(), - format!(r#"{{"kind":"Project","name":"Demo {marker}","nodes":{{}}}}"#).into_bytes(), + format!(r#"{{"format":3,"name":"Demo {marker}"}}"#).into_bytes(), ), ("shader.glsl".to_string(), marker.as_bytes().to_vec()), ] @@ -684,8 +684,7 @@ mod tests { let mut files = vec![ ( "project.json".to_string(), - br#"{"kind":"Project","uid":"prj_zzzzzzzzzzzzzzzz","name":"Wild One","nodes":{}}"# - .to_vec(), + br#"{"format":3,"uid":"prj_zzzzzzzzzzzzzzzz","name":"Wild One"}"#.to_vec(), ), ("shader.glsl".to_string(), b"wild".to_vec()), (".lp/device.json".to_string(), b"{}".to_vec()), diff --git a/lp-app/lpa-studio-core/src/app/preview_host/preview_content.rs b/lp-app/lpa-studio-core/src/app/preview_host/preview_content.rs index 4c9d1b597..d11252f6c 100644 --- a/lp-app/lpa-studio-core/src/app/preview_host/preview_content.rs +++ b/lp-app/lpa-studio-core/src/app/preview_host/preview_content.rs @@ -84,7 +84,7 @@ mod tests { &[ ( "project.json".to_string(), - br#"{"kind":"Project","format":2,"name":"demo"}"#.to_vec(), + br#"{"format":3,"name":"demo"}"#.to_vec(), ), ("shader.glsl".to_string(), b"void main() {}".to_vec()), ], diff --git a/lp-app/lpa-studio-core/src/app/project/demo_project.rs b/lp-app/lpa-studio-core/src/app/project/demo_project.rs index 0c8956fe5..30a515222 100644 --- a/lp-app/lpa-studio-core/src/app/project/demo_project.rs +++ b/lp-app/lpa-studio-core/src/app/project/demo_project.rs @@ -23,6 +23,10 @@ pub fn demo_project_files() -> &'static [DemoProjectFile] { relative_path: "project.json", bytes: include_bytes!("../../../../../examples/fyeah-sign/project.json"), }, + DemoProjectFile { + relative_path: "module.json", + bytes: include_bytes!("../../../../../examples/fyeah-sign/module.json"), + }, DemoProjectFile { relative_path: "button.json", bytes: include_bytes!("../../../../../examples/fyeah-sign/button.json"), @@ -105,6 +109,14 @@ mod tests { .bytes, include_bytes!("../../../../../examples/fyeah-sign/project.json") ); + assert_eq!( + files + .iter() + .find(|file| file.relative_path == "module.json") + .unwrap() + .bytes, + include_bytes!("../../../../../examples/fyeah-sign/module.json") + ); // The fixture's mapping document must deploy with the project — its // absence fails the fixture at load (found the hard way when the M2 // migration updated fixture.json but not this compiled-in list). diff --git a/lp-app/lpa-studio-core/src/app/project/mod.rs b/lp-app/lpa-studio-core/src/app/project/mod.rs index e14cf6907..f954c6b54 100644 --- a/lp-app/lpa-studio-core/src/app/project/mod.rs +++ b/lp-app/lpa-studio-core/src/app/project/mod.rs @@ -69,7 +69,7 @@ pub use project_controller::{ }; pub use project_editor_op::ProjectEditorOp; pub use project_editor_target::ProjectEditorTarget; -pub use project_editor_view::ProjectEditorView; +pub use project_editor_view::{ProjectEditorView, UiProjectManifest}; pub use project_inventory_summary::ProjectInventorySummary; pub use project_node_tree_view::{ ProjectNodeStatusTone, ProjectNodeStatusView, ProjectNodeTreeItem, ProjectNodeTreeView, diff --git a/lp-app/lpa-studio-core/src/app/project/node/node_controller.rs b/lp-app/lpa-studio-core/src/app/project/node/node_controller.rs index 50f6a07f7..44ef6d72d 100644 --- a/lp-app/lpa-studio-core/src/app/project/node/node_controller.rs +++ b/lp-app/lpa-studio-core/src/app/project/node/node_controller.rs @@ -960,7 +960,7 @@ fn node_kind_label(path: &TreePath) -> String { return "Node".to_string(); }; match segment.ty.as_str() { - "project" | "show" => "Project".to_string(), + "module" | "project" | "show" => "Module".to_string(), "vis" | "visual" => "Visual".to_string(), "shader" | "shader_node" => "Shader".to_string(), "compute" | "compute_shader" => "Compute".to_string(), diff --git a/lp-app/lpa-studio-core/src/app/project/node/node_create_op.rs b/lp-app/lpa-studio-core/src/app/project/node/node_create_op.rs index 2275d5482..3b08859fa 100644 --- a/lp-app/lpa-studio-core/src/app/project/node/node_create_op.rs +++ b/lp-app/lpa-studio-core/src/app/project/node/node_create_op.rs @@ -100,7 +100,7 @@ mod tests { #[test] fn attach_targets_compare_by_site() { let playlist = UiAttachTarget::Playlist { - node: ProjectNodeAddress::parse("/demo.project/loop.playlist").unwrap(), + node: ProjectNodeAddress::parse("/demo.module/loop.playlist").unwrap(), }; assert_ne!(UiAttachTarget::ProjectRoot, playlist); assert_eq!(playlist.clone(), playlist); diff --git a/lp-app/lpa-studio-core/src/app/project/node/node_face_builder.rs b/lp-app/lpa-studio-core/src/app/project/node/node_face_builder.rs index 522cc5caf..517a7c342 100644 --- a/lp-app/lpa-studio-core/src/app/project/node/node_face_builder.rs +++ b/lp-app/lpa-studio-core/src/app/project/node/node_face_builder.rs @@ -643,7 +643,7 @@ mod tests { /// The stable authored address faces are built for; entry activation /// actions carry it (P7's runtime command channel). fn test_address() -> ProjectNodeAddress { - ProjectNodeAddress::parse("/demo.project/node.playlist").expect("valid address") + ProjectNodeAddress::parse("/demo.module/node.playlist").expect("valid address") } #[test] @@ -1060,7 +1060,7 @@ mod tests { fn address(path: &str) -> ProjectSlotAddress { ProjectSlotAddress::new( - ProjectNodeAddress::parse("/demo.project/node.shader").expect("valid address"), + ProjectNodeAddress::parse("/demo.module/node.shader").expect("valid address"), ProjectSlotRoot::Def, SlotPath::parse(path).expect("valid path"), ) @@ -1177,10 +1177,10 @@ mod tests { let mut child = UiNodeChild::new( label, "Shader", - format!("/main.project/playlist.playlist/{name}.shader"), + format!("/main.module/playlist.playlist/{name}.shader"), ); child.action = Some( - UiAction::from_op(ControllerId::new("test.project"), ProjectEditorOp::Focus) + UiAction::from_op(ControllerId::new("test.module"), ProjectEditorOp::Focus) .with_label(format!("Focus {label}")), ); child diff --git a/lp-app/lpa-studio-core/src/app/project/node/node_naming.rs b/lp-app/lpa-studio-core/src/app/project/node/node_naming.rs index dbfb6f366..90d993f25 100644 --- a/lp-app/lpa-studio-core/src/app/project/node/node_naming.rs +++ b/lp-app/lpa-studio-core/src/app/project/node/node_naming.rs @@ -15,7 +15,7 @@ use lpc_model::NodeKind; /// and file stem (`shader` → `shader.json` + `shader.glsl`). pub fn node_kind_slug(kind: NodeKind) -> &'static str { match kind { - NodeKind::Project => "project", + NodeKind::Module => "module", NodeKind::Button => "button", NodeKind::Clock => "clock", NodeKind::Texture => "texture", @@ -32,7 +32,7 @@ pub fn node_kind_slug(kind: NodeKind) -> &'static str { /// Human-readable picker label for a node kind. pub fn node_kind_label(kind: NodeKind) -> &'static str { match kind { - NodeKind::Project => "Project", + NodeKind::Module => "Module", NodeKind::Button => "Button", NodeKind::Clock => "Clock", NodeKind::Texture => "Texture", @@ -121,7 +121,7 @@ mod tests { #[test] fn every_kind_slug_is_a_valid_node_name() { for kind in [ - NodeKind::Project, + NodeKind::Module, NodeKind::Button, NodeKind::Clock, NodeKind::Texture, diff --git a/lp-app/lpa-studio-core/src/app/project/node/node_remove_op.rs b/lp-app/lpa-studio-core/src/app/project/node/node_remove_op.rs index e55b9c60f..43d6712b5 100644 --- a/lp-app/lpa-studio-core/src/app/project/node/node_remove_op.rs +++ b/lp-app/lpa-studio-core/src/app/project/node/node_remove_op.rs @@ -67,7 +67,7 @@ mod tests { #[test] fn node_remove_is_editor_foreground_class_with_destructive_meta() { let op = NodeRemoveOp { - node: ProjectNodeAddress::parse("/demo.project/orbit.shader").unwrap(), + node: ProjectNodeAddress::parse("/demo.module/orbit.shader").unwrap(), }; assert_eq!( diff --git a/lp-app/lpa-studio-core/src/app/project/node/node_revert_op.rs b/lp-app/lpa-studio-core/src/app/project/node/node_revert_op.rs index e765cfd72..96992c682 100644 --- a/lp-app/lpa-studio-core/src/app/project/node/node_revert_op.rs +++ b/lp-app/lpa-studio-core/src/app/project/node/node_revert_op.rs @@ -63,7 +63,7 @@ mod tests { #[test] fn node_revert_is_editor_foreground_class_with_revert_meta() { let op = NodeRevertOp { - node: ProjectNodeAddress::parse("/demo.project/pixels.fixture").unwrap(), + node: ProjectNodeAddress::parse("/demo.module/pixels.fixture").unwrap(), }; assert_eq!( diff --git a/lp-app/lpa-studio-core/src/app/project/node/node_share_op.rs b/lp-app/lpa-studio-core/src/app/project/node/node_share_op.rs index bd673efad..d41e86ed9 100644 --- a/lp-app/lpa-studio-core/src/app/project/node/node_share_op.rs +++ b/lp-app/lpa-studio-core/src/app/project/node/node_share_op.rs @@ -120,7 +120,7 @@ mod tests { #[test] fn share_ops_carry_the_editor_foreground_class() { let copy = NodeCopyOp { - node: ProjectNodeAddress::parse("/main.project/orbit.shader").unwrap(), + node: ProjectNodeAddress::parse("/main.module/orbit.shader").unwrap(), }; let paste = NodePasteOp { envelope: String::new(), @@ -138,7 +138,7 @@ mod tests { #[test] fn ops_compare_by_value_not_identity() { - let node = ProjectNodeAddress::parse("/main.project/orbit.shader").unwrap(); + let node = ProjectNodeAddress::parse("/main.module/orbit.shader").unwrap(); let a = NodeCopyOp { node: node.clone() }; let b = NodeCopyOp { node }; assert!(a.eq_op(&b)); diff --git a/lp-app/lpa-studio-core/src/app/project/node/playlist_activate_op.rs b/lp-app/lpa-studio-core/src/app/project/node/playlist_activate_op.rs index 6a06a1e99..6ca7e7294 100644 --- a/lp-app/lpa-studio-core/src/app/project/node/playlist_activate_op.rs +++ b/lp-app/lpa-studio-core/src/app/project/node/playlist_activate_op.rs @@ -69,7 +69,7 @@ mod tests { #[test] fn activate_is_editor_foreground_class_with_activate_meta() { let op = PlaylistActivateOp { - node: ProjectNodeAddress::parse("/demo.project/show.playlist").unwrap(), + node: ProjectNodeAddress::parse("/demo.module/show.playlist").unwrap(), entry: 2, }; diff --git a/lp-app/lpa-studio-core/src/app/project/node/project_node_address.rs b/lp-app/lpa-studio-core/src/app/project/node/project_node_address.rs index 55e95ac29..fcc2db2fc 100644 --- a/lp-app/lpa-studio-core/src/app/project/node/project_node_address.rs +++ b/lp-app/lpa-studio-core/src/app/project/node/project_node_address.rs @@ -53,17 +53,17 @@ mod tests { #[test] fn address_displays_canonical_tree_path() { - let address = ProjectNodeAddress::parse("/demo.project/orbit.shader").unwrap(); + let address = ProjectNodeAddress::parse("/demo.module/orbit.shader").unwrap(); - assert_eq!(address.to_string(), "/demo.project/orbit.shader"); + assert_eq!(address.to_string(), "/demo.module/orbit.shader"); } #[test] fn is_self_or_under_is_a_segment_wise_subtree_test() { - let root = ProjectNodeAddress::parse("/demo.project").unwrap(); - let node = ProjectNodeAddress::parse("/demo.project/orbit.shader").unwrap(); - let nested = ProjectNodeAddress::parse("/demo.project/orbit.shader/tail.vis").unwrap(); - let sibling = ProjectNodeAddress::parse("/demo.project/clock.clock").unwrap(); + let root = ProjectNodeAddress::parse("/demo.module").unwrap(); + let node = ProjectNodeAddress::parse("/demo.module/orbit.shader").unwrap(); + let nested = ProjectNodeAddress::parse("/demo.module/orbit.shader/tail.vis").unwrap(); + let sibling = ProjectNodeAddress::parse("/demo.module/clock.clock").unwrap(); assert!(node.is_self_or_under(&node), "a node is in its own subtree"); assert!(node.is_self_or_under(&root)); diff --git a/lp-app/lpa-studio-core/src/app/project/node/project_node_target.rs b/lp-app/lpa-studio-core/src/app/project/node/project_node_target.rs index f3c9923ed..b06726fc8 100644 --- a/lp-app/lpa-studio-core/src/app/project/node/project_node_target.rs +++ b/lp-app/lpa-studio-core/src/app/project/node/project_node_target.rs @@ -25,7 +25,7 @@ mod tests { #[test] fn target_keeps_address_and_runtime_id_separate() { - let address = ProjectNodeAddress::parse("/demo.project/orbit.shader").unwrap(); + let address = ProjectNodeAddress::parse("/demo.module/orbit.shader").unwrap(); let target = ProjectNodeTarget::new(address.clone(), NodeId::new(7)); assert_eq!(target.address, address); diff --git a/lp-app/lpa-studio-core/src/app/project/node/ui_add_node_menu.rs b/lp-app/lpa-studio-core/src/app/project/node/ui_add_node_menu.rs index 88a9a9281..1218de38c 100644 --- a/lp-app/lpa-studio-core/src/app/project/node/ui_add_node_menu.rs +++ b/lp-app/lpa-studio-core/src/app/project/node/ui_add_node_menu.rs @@ -8,7 +8,7 @@ use super::node_create_op::{NodeCreateOp, UiAttachTarget}; use super::node_naming::{node_kind_label, node_kind_slug}; /// Picker order: the common authoring targets first, hardware-/niche kinds -/// last. Stable — the picker never reorders. `Project` is excluded (it is +/// last. Stable — the picker never reorders. `Module` is excluded (it is /// the artifact root; nested sub-projects are future work). const PICKER_KINDS: &[NodeKind] = &[ NodeKind::Shader, @@ -50,7 +50,7 @@ pub struct UiAddNodeMenuEntry { pub action: UiAction, } -/// Build the picker for one attach site: every kind except `Project`, in +/// Build the picker for one attach site: every kind except `Module`, in /// [`PICKER_KINDS`] order. pub fn add_node_menu(attach: &UiAttachTarget) -> UiAddNodeMenu { UiAddNodeMenu { @@ -83,11 +83,11 @@ mod tests { use super::*; #[test] - fn menu_offers_every_kind_except_project_in_stable_order() { + fn menu_offers_every_kind_except_module_in_stable_order() { let menu = add_node_menu(&UiAttachTarget::ProjectRoot); - assert_eq!(menu.entries.len(), 10, "all kinds except Project"); - assert!(menu.entries.iter().all(|e| e.kind != NodeKind::Project)); + assert_eq!(menu.entries.len(), 10, "all kinds except Module"); + assert!(menu.entries.iter().all(|e| e.kind != NodeKind::Module)); assert_eq!(menu.entries[0].kind, NodeKind::Shader); assert_eq!(menu.entries[0].label, "Shader"); assert_eq!(menu.entries[0].icon, "shader"); @@ -98,7 +98,7 @@ mod tests { #[test] fn entry_actions_dispatch_create_at_the_menu_site() { let playlist = UiAttachTarget::Playlist { - node: crate::ProjectNodeAddress::parse("/demo.project/loop.playlist").unwrap(), + node: crate::ProjectNodeAddress::parse("/demo.module/loop.playlist").unwrap(), }; let menu = add_node_menu(&playlist); let entry = &menu.entries[0]; diff --git a/lp-app/lpa-studio-core/src/app/project/node_card_ui_state.rs b/lp-app/lpa-studio-core/src/app/project/node_card_ui_state.rs index a0b58ca53..f99746ffb 100644 --- a/lp-app/lpa-studio-core/src/app/project/node_card_ui_state.rs +++ b/lp-app/lpa-studio-core/src/app/project/node_card_ui_state.rs @@ -12,7 +12,7 @@ //! it did for device cards. //! //! The state is keyed by the node's ADDRESS PATH (`UiNodeHeader::path`, -//! e.g. `/demo.project/orbit.shader`) so it follows the node, not the +//! e.g. `/demo.module/orbit.shader`) so it follows the node, not the //! widget, and it is pruned when the loaded project closes. //! //! **The composer draft is deliberately a MIRROR, not the live value.** @@ -127,7 +127,7 @@ mod tests { #[test] fn ops_round_trip_through_the_state() { - let node = "/demo.project/orbit.shader".to_string(); + let node = "/demo.module/orbit.shader".to_string(); let mut state = NodeCardUiState::default(); assert!(!state.code_open && !state.advanced_open && !state.agent_collapsed); assert!(state.composer_draft.is_empty()); @@ -180,21 +180,21 @@ mod tests { fn every_op_names_its_node() { let ops = [ NodeUiOp::SetDrawer { - node: "/a.project/b.shader".into(), + node: "/a.module/b.shader".into(), drawer: NodeCardDrawer::Advanced, open: true, }, NodeUiOp::SetAgentCollapsed { - node: "/a.project/b.shader".into(), + node: "/a.module/b.shader".into(), collapsed: true, }, NodeUiOp::SetDraft { - node: "/a.project/b.shader".into(), + node: "/a.module/b.shader".into(), draft: String::new(), }, ]; for op in &ops { - assert_eq!(op.node(), "/a.project/b.shader"); + assert_eq!(op.node(), "/a.module/b.shader"); } } @@ -203,16 +203,16 @@ mod tests { // Order is the contract: the mirror must land before the collapse // flips, so the state a collapsed remount seeds from already // carries the half-typed text. - let ops = NodeUiOp::toggle_agent_section("/a.project/b.shader", false, "half a thought"); + let ops = NodeUiOp::toggle_agent_section("/a.module/b.shader", false, "half a thought"); assert_eq!( ops, vec![ NodeUiOp::SetDraft { - node: "/a.project/b.shader".into(), + node: "/a.module/b.shader".into(), draft: "half a thought".into(), }, NodeUiOp::SetAgentCollapsed { - node: "/a.project/b.shader".into(), + node: "/a.module/b.shader".into(), collapsed: true, }, ] @@ -224,11 +224,11 @@ mod tests { // Expanding must never write the draft: the composer is unmounted // while collapsed, so its live value is whatever the caller has on // hand — the mirror is the truth and stays untouched. - let ops = NodeUiOp::toggle_agent_section("/a.project/b.shader", true, ""); + let ops = NodeUiOp::toggle_agent_section("/a.module/b.shader", true, ""); assert_eq!( ops, vec![NodeUiOp::SetAgentCollapsed { - node: "/a.project/b.shader".into(), + node: "/a.module/b.shader".into(), collapsed: false, }] ); diff --git a/lp-app/lpa-studio-core/src/app/project/project_controller.rs b/lp-app/lpa-studio-core/src/app/project/project_controller.rs index 219a789fd..8ab6d7ca1 100644 --- a/lp-app/lpa-studio-core/src/app/project/project_controller.rs +++ b/lp-app/lpa-studio-core/src/app/project/project_controller.rs @@ -391,7 +391,7 @@ impl ProjectController { .and_then(|value| value.value.as_ref()) .map(format_lp_value), value_error: channel.value.as_ref().and_then(|value| value.error.clone()), - primary_visual: channel.name == lpc_model::PRIMARY_VISUAL_CHANNEL, + primary_visual: channel.primary_visual, writers: channel.providers.iter().filter_map(site).collect(), readers: channel.consumers.iter().filter_map(site).collect(), }) @@ -412,7 +412,7 @@ impl ProjectController { let channel = graph .channels .iter() - .find(|channel| channel.name == lpc_model::PRIMARY_VISUAL_CHANNEL)?; + .find(|channel| channel.primary_visual)?; let value = channel.value.as_ref()?.value.as_ref()?; let lpc_model::LpValue::Product(product) = value else { return None; @@ -1572,6 +1572,7 @@ impl ProjectController { .with_project_name(self.project_name(project_id)) .with_channel_choices(self.ui_channel_choices()) .with_root_slots(root_slots) + .with_manifest(self.active_manifest()) .with_library_identity(self.active_library_uid().zip(self.active_library_slug())) .with_dirty(dirty) .with_pending_edits(self.pending_edits()) @@ -2407,6 +2408,22 @@ impl ProjectController { } } + /// The container-manifest identity of the open library package, for the + /// project popup's read-only settings rows. `None` when no library + /// package backs the running project (demo path, unknown device + /// project) or the manifest fails to parse — the popup then skips the + /// identity rows rather than rendering a broken section. + pub fn active_manifest(&self) -> Option { + let active = self.library.as_ref()?.active.as_ref()?; + let view = active.handle.package_fs.borrow(); + let fields = crate::app::library::package_manifest::read_manifest(&*view).ok()?; + Some(crate::UiProjectManifest { + format: fields.format, + uid: fields.uid, + name: fields.name, + }) + } + /// The `prj_…` uid of the open library package, when the running /// project is backed by one. pub fn active_library_uid(&self) -> Option { @@ -5073,7 +5090,7 @@ mod tests { fn project_view_keeps_existing_focus_when_syncing() { let mut project = ProjectController::new(); project.apply_project_view(&tree_view()).unwrap(); - let orbit = node_address("/demo.project/orbit.shader"); + let orbit = node_address("/demo.module/orbit.shader"); clear_node_focus(&mut project.root_nodes); project.node_mut(&orbit).unwrap().state_mut().focused = true; @@ -5082,7 +5099,7 @@ mod tests { assert!(project.node(&orbit).unwrap().state().focused); assert!( !project - .node(&node_address("/demo.project/clock.clock")) + .node(&node_address("/demo.module/clock.clock")) .unwrap() .state() .focused @@ -5091,7 +5108,7 @@ mod tests { #[test] fn node_update_preserves_local_state_and_refreshes_runtime_id() { - let address = node_address("/demo.project/orbit.shader"); + let address = node_address("/demo.module/orbit.shader"); let mut project = ProjectController::new(); project .apply_project_view(&single_node_view(1, NodeRuntimeStatus::Ok)) @@ -5124,15 +5141,15 @@ mod tests { let mut project = ProjectController::new(); project .apply_project_view(&root_view(&[ - (1, "/demo.project/a.shader"), - (2, "/demo.project/b.shader"), + (1, "/demo.module/a.shader"), + (2, "/demo.module/b.shader"), ])) .unwrap(); project .apply_project_view(&root_view(&[ - (3, "/demo.project/c.shader"), - (1, "/demo.project/a.shader"), + (3, "/demo.module/c.shader"), + (1, "/demo.module/a.shader"), ])) .unwrap(); @@ -5146,7 +5163,7 @@ mod tests { ); assert!( project - .node(&node_address("/demo.project/b.shader")) + .node(&node_address("/demo.module/b.shader")) .is_none() ); } @@ -5192,7 +5209,7 @@ mod tests { event: ProjectReadQueryEvent::Nodes(ProjectReadNodeEvent::TreeDeltas { deltas: vec![lpc_wire::WireTreeDelta::Created { id: NodeId::new(1), - path: TreePath::parse("/demo.project").unwrap(), + path: TreePath::parse("/demo.module").unwrap(), parent: None, child_kind: None, children: Vec::new(), @@ -5228,7 +5245,7 @@ mod tests { project.apply_project_view(&view).unwrap(); let node = project - .node(&node_address("/demo.project/orbit.shader")) + .node(&node_address("/demo.module/orbit.shader")) .unwrap(); assert_eq!( node.slots() @@ -5242,7 +5259,7 @@ mod tests { #[test] fn slot_update_preserves_local_state() { - let node = node_address("/demo.project/orbit.shader"); + let node = node_address("/demo.module/orbit.shader"); let brightness = ProjectSlotAddress::new( node.clone(), ProjectSlotRoot::def(), @@ -5274,7 +5291,7 @@ mod tests { #[test] fn record_to_scalar_shape_change_removes_stale_slot_children() { - let node = node_address("/demo.project/orbit.shader"); + let node = node_address("/demo.module/orbit.shader"); let root = ProjectSlotAddress::root(node.clone(), ProjectSlotRoot::def()); let mut view = single_node_view(1, NodeRuntimeStatus::Ok); install_test_slots(&mut view, 1, Revision::new(2), false); @@ -5293,7 +5310,7 @@ mod tests { #[test] fn map_entry_changes_reconcile_keyed_slot_children() { - let node = node_address("/demo.project/orbit.shader"); + let node = node_address("/demo.module/orbit.shader"); let mut view = single_node_view(1, NodeRuntimeStatus::Ok); install_map_slot(&mut view, 1, Revision::new(2), &["a", "b"]); let mut project = ProjectController::new(); @@ -5348,7 +5365,7 @@ mod tests { let mut view = tree_view(); install_ui_projection_slots(&mut view, 2, Revision::new(4)); project.apply_project_view(&view).unwrap(); - let node = node_address("/demo.project"); + let node = node_address("/demo.module"); project.node_mut(&node).unwrap().state_mut().focused = true; project.node_mut(&node).unwrap().state_mut().collapsed = true; @@ -5356,8 +5373,8 @@ mod tests { assert_eq!(nodes.len(), 1); assert_eq!(nodes[0].header.title, "Demo"); - assert_eq!(nodes[0].header.kind, "Project"); - assert_eq!(nodes[0].header.path, "/demo.project"); + assert_eq!(nodes[0].header.kind, "Module"); + assert_eq!(nodes[0].header.path, "/demo.module"); assert_eq!(nodes[0].header.status.label, "Running"); assert!(nodes[0].focused); assert!(nodes[0].collapsed); @@ -5378,7 +5395,7 @@ mod tests { .collect::>(), vec!["Clock", "Orbit"] ); - assert_eq!(nodes[0].children[0].detail, "/demo.project/clock.clock"); + assert_eq!(nodes[0].children[0].detail, "/demo.module/clock.clock"); assert!(!nodes[0].children[0].sections.is_empty()); } @@ -5388,7 +5405,7 @@ mod tests { let mut view = tree_view(); install_ui_projection_slots(&mut view, 3, Revision::new(4)); project.apply_project_view(&view).unwrap(); - let child_address = node_address("/demo.project/orbit.shader"); + let child_address = node_address("/demo.module/orbit.shader"); project .node_mut(&child_address) .unwrap() @@ -5442,7 +5459,7 @@ mod tests { assert_eq!( target, ProjectEditorTarget::addressed_node(ProjectNodeTarget::new( - node_address("/demo.project/orbit.shader"), + node_address("/demo.module/orbit.shader"), NodeId::new(3), )) ); @@ -5477,7 +5494,7 @@ mod tests { let inventory = ProjectInventorySummary::default(); project.mark_ready("studio-demo", 7, inventory.clone()); project.apply_project_view(&tree_view()).unwrap(); - let orbit = "/demo.project/orbit.shader".to_string(); + let orbit = "/demo.module/orbit.shader".to_string(); dispatch_node_ui( &mut project, @@ -5522,7 +5539,7 @@ mod tests { let inventory = ProjectInventorySummary::default(); project.mark_ready("studio-demo", 7, inventory.clone()); project.apply_project_view(&tree_view()).unwrap(); - let orbit = "/demo.project/orbit.shader".to_string(); + let orbit = "/demo.module/orbit.shader".to_string(); dispatch_node_ui( &mut project, @@ -5569,7 +5586,7 @@ mod tests { dispatch_node_ui( &mut project, NodeUiOp::SetDraft { - node: "/demo.project/orbit.shader".to_string(), + node: "/demo.module/orbit.shader".to_string(), draft: "stale draft".to_string(), }, ); @@ -5590,7 +5607,7 @@ mod tests { fn nested_child_cards_wear_their_own_card_ui_overlay() { let mut project = ProjectController::new(); project.apply_node_ui_op(NodeUiOp::SetDrawer { - node: "/demo.project/list.playlist/glow.shader".to_string(), + node: "/demo.module/list.playlist/glow.shader".to_string(), drawer: crate::NodeCardDrawer::Advanced, open: true, }); @@ -5600,12 +5617,12 @@ mod tests { let mut children = vec![crate::UiNodeChild::new( "List", "Playlist", - "/demo.project/list.playlist", + "/demo.module/list.playlist", )]; children[0].children = vec![crate::UiNodeChild::new( "Glow", "Shader", - "/demo.project/list.playlist/glow.shader", + "/demo.module/list.playlist/glow.shader", )]; project.overlay_child_card_ui(&mut children); @@ -5783,6 +5800,7 @@ mod tests { value: Some(LpValue::F32(0.5)), error: None, }), + primary_visual: true, }], }; project @@ -5999,6 +6017,7 @@ mod tests { value, error: None, }), + primary_visual: true, }], } } @@ -6147,7 +6166,7 @@ mod tests { ); // An explicit opt-out still wins over the sim policy. - let address = node_address("/demo.project/orbit.shader"); + let address = node_address("/demo.module/orbit.shader"); project .node_mut(&address) .unwrap() @@ -6220,7 +6239,7 @@ mod tests { fn orbit_def_address(path: &str) -> crate::ProjectSlotAddress { crate::ProjectSlotAddress::new( - node_address("/demo.project/orbit.shader"), + node_address("/demo.module/orbit.shader"), ProjectSlotRoot::def(), SlotPath::parse(path).unwrap(), ) @@ -6359,6 +6378,7 @@ mod tests { providers: vec![0], consumers: vec![1], value: None, + primary_visual: false, }, lpc_wire::WireBusChannel { name: "wobble".to_string(), @@ -6366,6 +6386,7 @@ mod tests { providers: vec![0], consumers: Vec::new(), value: None, + primary_visual: false, }, ], }; @@ -6480,6 +6501,7 @@ mod tests { value: Some(LpValue::F32(value)), error: None, }), + primary_visual: false, } }; project @@ -6546,17 +6568,12 @@ mod tests { #[test] fn playlist_children_visual_products_stay_tracked_for_entry_thumbs() { let mut view = ProjectView::new(); - let mut playlist = node_entry( - 1, - "/demo.project/list.playlist", - None, - NodeRuntimeStatus::Ok, - ); + let mut playlist = node_entry(1, "/demo.module/list.playlist", None, NodeRuntimeStatus::Ok); playlist.children = vec![NodeId::new(2)]; view.tree.insert(playlist); view.tree.insert(node_entry( 2, - "/demo.project/list.playlist/glow.shader", + "/demo.module/list.playlist/glow.shader", Some(1), NodeRuntimeStatus::Ok, )); @@ -6567,7 +6584,7 @@ mod tests { // The child picks up default focus (only shader in the tree) which // would subscribe ALL its products; pin it unsubscribed so the // assertion isolates the warming path. - let child = crate::ProjectNodeAddress::parse("/demo.project/list.playlist/glow.shader") + let child = crate::ProjectNodeAddress::parse("/demo.module/list.playlist/glow.shader") .expect("valid address"); project .node_mut(&child) @@ -6590,7 +6607,7 @@ mod tests { #[test] fn binding_removal_clears_bound_state_on_refresh() { - let node = node_address("/demo.project/orbit.shader"); + let node = node_address("/demo.module/orbit.shader"); let time = ProjectSlotAddress::new( node.clone(), ProjectSlotRoot::def(), @@ -6616,7 +6633,7 @@ mod tests { #[test] fn focused_default_node_subscribes_product_preview_probes() { - let node = node_address("/demo.project/orbit.shader"); + let node = node_address("/demo.module/orbit.shader"); let mut view = single_node_view(1, NodeRuntimeStatus::Ok); install_ui_projection_slots(&mut view, 1, Revision::new(4)); let mut project = ProjectController::new(); @@ -6827,7 +6844,7 @@ mod tests { #[test] fn projected_ui_value_updates_while_slot_state_is_preserved() { - let node = node_address("/demo.project/orbit.shader"); + let node = node_address("/demo.module/orbit.shader"); let brightness = ProjectSlotAddress::new( node.clone(), ProjectSlotRoot::def(), @@ -6915,18 +6932,18 @@ mod tests { fn tree_view() -> ProjectView { let mut view = ProjectView::new(); - let mut root = node_entry(1, "/demo.project", None, NodeRuntimeStatus::Ok); + let mut root = node_entry(1, "/demo.module", None, NodeRuntimeStatus::Ok); root.children = vec![NodeId::new(2), NodeId::new(3)]; view.tree.insert(root); view.tree.insert(node_entry( 2, - "/demo.project/clock.clock", + "/demo.module/clock.clock", Some(1), NodeRuntimeStatus::Ok, )); view.tree.insert(node_entry( 3, - "/demo.project/orbit.shader", + "/demo.module/orbit.shader", Some(1), NodeRuntimeStatus::Ok, )); @@ -6935,24 +6952,24 @@ mod tests { fn fixture_tree_view() -> ProjectView { let mut view = ProjectView::new(); - let mut root = node_entry(1, "/demo.project", None, NodeRuntimeStatus::Ok); + let mut root = node_entry(1, "/demo.module", None, NodeRuntimeStatus::Ok); root.children = vec![NodeId::new(2), NodeId::new(3), NodeId::new(4)]; view.tree.insert(root); view.tree.insert(node_entry( 2, - "/demo.project/clock.clock", + "/demo.module/clock.clock", Some(1), NodeRuntimeStatus::Ok, )); view.tree.insert(node_entry( 3, - "/demo.project/orbit.shader", + "/demo.module/orbit.shader", Some(1), NodeRuntimeStatus::Ok, )); view.tree.insert(node_entry( 4, - "/demo.project/pixels.fixture", + "/demo.module/pixels.fixture", Some(1), NodeRuntimeStatus::Ok, )); @@ -6961,18 +6978,18 @@ mod tests { fn clock_output_tree_view() -> ProjectView { let mut view = ProjectView::new(); - let mut root = node_entry(1, "/demo.project", None, NodeRuntimeStatus::Ok); + let mut root = node_entry(1, "/demo.module", None, NodeRuntimeStatus::Ok); root.children = vec![NodeId::new(2), NodeId::new(3)]; view.tree.insert(root); view.tree.insert(node_entry( 2, - "/demo.project/clock.clock", + "/demo.module/clock.clock", Some(1), NodeRuntimeStatus::Ok, )); view.tree.insert(node_entry( 3, - "/demo.project/dmx.output", + "/demo.module/dmx.output", Some(1), NodeRuntimeStatus::Ok, )); @@ -6982,7 +6999,7 @@ mod tests { fn single_node_view(id: u32, status: NodeRuntimeStatus) -> ProjectView { let mut view = ProjectView::new(); view.tree - .insert(node_entry(id, "/demo.project/orbit.shader", None, status)); + .insert(node_entry(id, "/demo.module/orbit.shader", None, status)); view } @@ -7797,7 +7814,7 @@ mod tests { fn brightness_address() -> crate::ProjectSlotAddress { crate::ProjectSlotAddress::new( - node_address("/demo.project/orbit.shader"), + node_address("/demo.module/orbit.shader"), ProjectSlotRoot::def(), SlotPath::parse("brightness").unwrap(), ) @@ -7805,7 +7822,7 @@ mod tests { fn rate_address() -> crate::ProjectSlotAddress { crate::ProjectSlotAddress::new( - node_address("/demo.project/orbit.shader"), + node_address("/demo.module/orbit.shader"), ProjectSlotRoot::def(), SlotPath::parse("rate").unwrap(), ) @@ -8105,18 +8122,18 @@ mod tests { // pending; it lands with the next applied view that contains the // created node. let mut view = three_level_tree_view(); - let mut root = node_entry(1, "/demo.project", None, NodeRuntimeStatus::Ok); + let mut root = node_entry(1, "/demo.module", None, NodeRuntimeStatus::Ok); root.children = vec![NodeId::new(2), NodeId::new(4), NodeId::new(9)]; view.tree.insert(root); view.tree.insert(node_entry( 9, - "/demo.project/clock_2.clock", + "/demo.module/clock_2.clock", Some(1), NodeRuntimeStatus::Ok, )); project.apply_project_view(&view).unwrap(); let created = project - .node(&node_address("/demo.project/clock_2.clock")) + .node(&node_address("/demo.module/clock_2.clock")) .expect("created node landed"); assert!(created.state().focused, "the created node takes focus"); } @@ -8186,7 +8203,7 @@ mod tests { ]); let run = block_on_ready( - project.remove_node(&mut client, &node_address("/demo.project/clock.clock")), + project.remove_node(&mut client, &node_address("/demo.module/clock.clock")), ) .unwrap(); // The scripted refresh carried no tree events, so the controllers @@ -8256,10 +8273,8 @@ mod tests { authoring_inventory_read_response(4), mutation_response(5, vec![accepted(1), accepted(2)], 7), ]); - block_on_ready( - project.remove_node(&mut client, &node_address("/demo.project/clock.clock")), - ) - .unwrap(); + block_on_ready(project.remove_node(&mut client, &node_address("/demo.module/clock.clock"))) + .unwrap(); // Restore the controller tree (the scripted refresh carried no tree // events) so the site address resolves its def artifact. project @@ -8268,7 +8283,7 @@ mod tests { // Revert the NodeRemoved row exactly as the save panel would. let site = crate::ProjectSlotAddress::new( - node_address("/demo.project"), + node_address("/demo.module"), ProjectSlotRoot::def(), SlotPath::parse("nodes[clock]").unwrap(), ); @@ -8337,7 +8352,7 @@ mod tests { )]); let run = block_on_ready( - project.remove_node(&mut client, &node_address("/demo.project/clock.clock")), + project.remove_node(&mut client, &node_address("/demo.module/clock.clock")), ) .unwrap(); @@ -8360,7 +8375,7 @@ mod tests { // carries no playlist slot mirror), so no wire op is sent. let run = block_on_ready(project.remove_node( &mut client, - &node_address("/demo.project/group.playlist/leaf.shader"), + &node_address("/demo.module/group.playlist/leaf.shader"), )) .unwrap(); @@ -8389,7 +8404,7 @@ mod tests { let clock = view .nodes .iter() - .find(|node| node.node_id == "/demo.project/clock.clock") + .find(|node| node.node_id == "/demo.module/clock.clock") .expect("clock card"); let delete = clock .header_actions @@ -8728,7 +8743,7 @@ mod tests { fn set_value_outside_def_root_fails_client_side() { let (mut project, mut client, sent) = editable_project_with_scripted_client(Vec::new()); let state_address = crate::ProjectSlotAddress::new( - node_address("/demo.project/orbit.shader"), + node_address("/demo.module/orbit.shader"), ProjectSlotRoot::state(), SlotPath::parse("output").unwrap(), ); @@ -8898,7 +8913,7 @@ mod tests { fn structural_address(path: &str) -> crate::ProjectSlotAddress { crate::ProjectSlotAddress::new( - node_address("/demo.project/orbit.shader"), + node_address("/demo.module/orbit.shader"), ProjectSlotRoot::def(), SlotPath::parse(path).unwrap(), ) @@ -9054,7 +9069,7 @@ mod tests { assert!(!project.dirty_summary().is_clean()); let run = block_on_ready( - project.revert_node_edits(&mut client, &node_address("/demo.project/orbit.shader")), + project.revert_node_edits(&mut client, &node_address("/demo.module/orbit.shader")), ) .unwrap(); @@ -9109,7 +9124,7 @@ mod tests { ); let run = block_on_ready( - project.revert_node_edits(&mut client, &node_address("/demo.project/other.clock")), + project.revert_node_edits(&mut client, &node_address("/demo.module/other.clock")), ) .unwrap(); @@ -9158,7 +9173,7 @@ mod tests { assert_eq!( actions[0].action.op_as::(), Some(&crate::NodeRevertOp { - node: node_address("/demo.project/orbit.shader"), + node: node_address("/demo.module/orbit.shader"), }) ); } @@ -10503,7 +10518,7 @@ mod tests { project.apply_project_view(&view).unwrap(); project.insert_pending_edit_for_test( crate::ProjectSlotAddress::new( - node_address("/demo.project/group.playlist/leaf.shader"), + node_address("/demo.module/group.playlist/leaf.shader"), ProjectSlotRoot::def(), SlotPath::parse("brightness").unwrap(), ), @@ -10719,12 +10734,12 @@ mod tests { /// Root (1) → group (2) + clock sibling (4), group → leaf shader (3). fn three_level_tree_view() -> ProjectView { let mut view = ProjectView::new(); - let mut root = node_entry(1, "/demo.project", None, NodeRuntimeStatus::Ok); + let mut root = node_entry(1, "/demo.module", None, NodeRuntimeStatus::Ok); root.children = vec![NodeId::new(2), NodeId::new(4)]; view.tree.insert(root); let mut group = node_entry( 2, - "/demo.project/group.playlist", + "/demo.module/group.playlist", Some(1), NodeRuntimeStatus::Ok, ); @@ -10732,13 +10747,13 @@ mod tests { view.tree.insert(group); view.tree.insert(node_entry( 3, - "/demo.project/group.playlist/leaf.shader", + "/demo.module/group.playlist/leaf.shader", Some(2), NodeRuntimeStatus::Ok, )); view.tree.insert(node_entry( 4, - "/demo.project/clock.clock", + "/demo.module/clock.clock", Some(1), NodeRuntimeStatus::Ok, )); diff --git a/lp-app/lpa-studio-core/src/app/project/project_editor_op.rs b/lp-app/lpa-studio-core/src/app/project/project_editor_op.rs index b63cb00cd..7ec45fd78 100644 --- a/lp-app/lpa-studio-core/src/app/project/project_editor_op.rs +++ b/lp-app/lpa-studio-core/src/app/project/project_editor_op.rs @@ -77,7 +77,7 @@ mod tests { #[test] fn node_ui_is_foreground_class_local_mutation() { let op = ProjectEditorOp::NodeUi(crate::NodeUiOp::SetAgentCollapsed { - node: "/demo.project/orbit.shader".into(), + node: "/demo.module/orbit.shader".into(), collapsed: true, }); assert_eq!( diff --git a/lp-app/lpa-studio-core/src/app/project/project_editor_target.rs b/lp-app/lpa-studio-core/src/app/project/project_editor_target.rs index afa20228a..7e28868a4 100644 --- a/lp-app/lpa-studio-core/src/app/project/project_editor_target.rs +++ b/lp-app/lpa-studio-core/src/app/project/project_editor_target.rs @@ -110,13 +110,13 @@ mod tests { ProjectEditorTarget::addressed_node(node_target()) .node_id() .as_str(), - "studio|project|node|nid|3|path|/demo.project/orbit.shader" + "studio|project|node|nid|3|path|/demo.module/orbit.shader" ); assert_eq!( ProjectEditorTarget::addressed_slot(node_target(), slot_address()) .node_id() .as_str(), - "studio|project|node|nid|3|path|/demo.project/orbit.shader|slot|def|path|config.brightness" + "studio|project|node|nid|3|path|/demo.module/orbit.shader|slot|def|path|config.brightness" ); assert_eq!( ProjectEditorTarget::asset("shader_main").node_id().as_str(), @@ -191,14 +191,14 @@ mod tests { fn node_target() -> ProjectNodeTarget { ProjectNodeTarget::new( - ProjectNodeAddress::parse("/demo.project/orbit.shader").unwrap(), + ProjectNodeAddress::parse("/demo.module/orbit.shader").unwrap(), NodeId::new(3), ) } fn slot_address() -> ProjectSlotAddress { ProjectSlotAddress::new( - ProjectNodeAddress::parse("/demo.project/orbit.shader").unwrap(), + ProjectNodeAddress::parse("/demo.module/orbit.shader").unwrap(), ProjectSlotRoot::def(), SlotPath::parse("config.brightness").unwrap(), ) diff --git a/lp-app/lpa-studio-core/src/app/project/project_editor_view.rs b/lp-app/lpa-studio-core/src/app/project/project_editor_view.rs index 6d8d78c5a..9ed588d9b 100644 --- a/lp-app/lpa-studio-core/src/app/project/project_editor_view.rs +++ b/lp-app/lpa-studio-core/src/app/project/project_editor_view.rs @@ -3,6 +3,16 @@ use crate::{ UiConfigSlot, UiMetric, UiNodeView, UiPaneAction, UiPendingEdit, UiStatusKind, }; +/// The open project's container-manifest identity (`project.json`), shown +/// read-only in the project popup's settings section. Post-mitosis these +/// are library-owned workspace metadata, never authored def slots. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct UiProjectManifest { + pub format: Option, + pub uid: Option, + pub name: Option, +} + #[derive(Clone, Debug, PartialEq)] pub struct ProjectEditorView { pub project_id: String, @@ -21,10 +31,14 @@ pub struct ProjectEditorView { /// Channels the binding picker offers (observed ∪ well-known), shared by /// every bindable row in the workspace (M4). pub channel_choices: Vec, - /// The workspace root's own config slot rows (`name` / `format` / - /// `uid` / `nodes` for a project root), rendered as the "Project + /// The workspace root's own config slot rows (post-mitosis just the + /// read-only `nodes` map for a module root), rendered in the "Project /// settings" section of the project pane's detail popup. pub root_slots: Vec, + /// The container manifest identity, when a library package backs the + /// open project. `None` for the storeless demo path and device-hosted + /// projects — those popups skip the identity rows. + pub manifest: Option, /// The open project's library identity (`prj_…` uid, slug), when a /// library package backs it. Drives the popup's share affordances, /// which read the library snapshot rather than the runtime. `None` for @@ -77,6 +91,7 @@ impl ProjectEditorView { nodes, channel_choices: Vec::new(), root_slots: Vec::new(), + manifest: None, library_identity: None, dirty: DirtySummary::clean(), pending_edits: Vec::new(), @@ -105,6 +120,12 @@ impl ProjectEditorView { self } + /// Attach the container-manifest identity rows. + pub fn with_manifest(mut self, manifest: Option) -> Self { + self.manifest = manifest; + self + } + /// Attach the open project's library identity for the share section. pub fn with_library_identity(mut self, identity: Option<(String, String)>) -> Self { self.library_identity = identity; diff --git a/lp-app/lpa-studio-core/src/app/project/project_target_encoding.rs b/lp-app/lpa-studio-core/src/app/project/project_target_encoding.rs index d3a7ce0a8..9e522f2a8 100644 --- a/lp-app/lpa-studio-core/src/app/project/project_target_encoding.rs +++ b/lp-app/lpa-studio-core/src/app/project/project_target_encoding.rs @@ -177,12 +177,12 @@ mod tests { #[test] fn node_target_round_trips_with_node_id_and_path() { let root = ControllerId::new("studio|project"); - let target = node_target(3, "/demo.project/orbit.shader"); + let target = node_target(3, "/demo.module/orbit.shader"); let id = node_target_id(&root, &target); assert_eq!( id.as_str(), - "studio|project|node|nid|3|path|/demo.project/orbit.shader" + "studio|project|node|nid|3|path|/demo.module/orbit.shader" ); assert_eq!( decode_typed_project_target(&tail(&id)).unwrap(), @@ -193,13 +193,13 @@ mod tests { #[test] fn root_slot_target_round_trips() { let root = ControllerId::new("studio|project"); - let target = node_target(3, "/demo.project/orbit.shader"); + let target = node_target(3, "/demo.module/orbit.shader"); let slot = ProjectSlotAddress::root(target.address.clone(), ProjectSlotRoot::def()); let id = slot_target_id(&root, &target, &slot); assert_eq!( id.as_str(), - "studio|project|node|nid|3|path|/demo.project/orbit.shader|slot|def|root" + "studio|project|node|nid|3|path|/demo.module/orbit.shader|slot|def|root" ); assert_eq!( decode_typed_project_target(&tail(&id)).unwrap(), @@ -210,7 +210,7 @@ mod tests { #[test] fn field_slot_path_target_round_trips() { let root = ControllerId::new("studio|project"); - let target = node_target(3, "/demo.project/orbit.shader"); + let target = node_target(3, "/demo.module/orbit.shader"); let slot = ProjectSlotAddress::new( target.address.clone(), ProjectSlotRoot::def(), @@ -220,7 +220,7 @@ mod tests { assert_eq!( id.as_str(), - "studio|project|node|nid|3|path|/demo.project/orbit.shader|slot|def|path|config.brightness" + "studio|project|node|nid|3|path|/demo.module/orbit.shader|slot|def|path|config.brightness" ); assert_eq!( decode_typed_project_target(&tail(&id)).unwrap(), @@ -231,14 +231,14 @@ mod tests { #[test] fn string_map_key_with_dots_round_trips() { let root = ControllerId::new("studio|project"); - let target = node_target(3, "/demo.project/orbit.shader"); + let target = node_target(3, "/demo.module/orbit.shader"); let path = SlotPath::parse(r#"params["phase.offset"].label"#).unwrap(); let slot = ProjectSlotAddress::new(target.address.clone(), ProjectSlotRoot::def(), path); let id = slot_target_id(&root, &target, &slot); assert_eq!( id.as_str(), - r#"studio|project|node|nid|3|path|/demo.project/orbit.shader|slot|def|path|params["phase.offset"].label"# + r#"studio|project|node|nid|3|path|/demo.module/orbit.shader|slot|def|path|params["phase.offset"].label"# ); assert_eq!( decode_typed_project_target(&tail(&id)).unwrap(), @@ -249,7 +249,7 @@ mod tests { #[test] fn payload_escaping_round_trips_pipe_and_percent() { let root = ControllerId::new("studio|project"); - let target = node_target(3, "/demo.project/orbit.shader"); + let target = node_target(3, "/demo.module/orbit.shader"); let path = SlotPath::parse("params") .unwrap() .child_segment(SlotPathSegment::Key(SlotMapKey::String("a|b%".to_string()))); @@ -258,7 +258,7 @@ mod tests { assert_eq!( id.as_str(), - "studio|project|node|nid|3|path|/demo.project/orbit.shader|slot|def|path|params[a%7Cb%25]" + "studio|project|node|nid|3|path|/demo.module/orbit.shader|slot|def|path|params[a%7Cb%25]" ); assert_eq!( decode_typed_project_target(&tail(&id)).unwrap(), @@ -269,7 +269,7 @@ mod tests { #[test] fn numeric_map_key_round_trips() { let root = ControllerId::new("studio|project"); - let target = node_target(3, "/demo.project/orbit.shader"); + let target = node_target(3, "/demo.module/orbit.shader"); let path = SlotPath::parse("touches") .unwrap() .child_segment(SlotPathSegment::Key(SlotMapKey::U32(2))); @@ -285,7 +285,7 @@ mod tests { #[test] fn other_slot_root_round_trips() { let root = ControllerId::new("studio|project"); - let target = node_target(3, "/demo.project/orbit.shader"); + let target = node_target(3, "/demo.module/orbit.shader"); let slot = ProjectSlotAddress::root( target.address.clone(), ProjectSlotRoot::Other("runtime|debug%root".to_string()), @@ -294,7 +294,7 @@ mod tests { assert_eq!( id.as_str(), - "studio|project|node|nid|3|path|/demo.project/orbit.shader|slot|other:runtime%7Cdebug%25root|root" + "studio|project|node|nid|3|path|/demo.module/orbit.shader|slot|other:runtime%7Cdebug%25root|root" ); assert_eq!( decode_typed_project_target(&tail(&id)).unwrap(), diff --git a/lp-app/lpa-studio-core/src/app/project/slot/project_slot_address.rs b/lp-app/lpa-studio-core/src/app/project/slot/project_slot_address.rs index 049c55661..e854c7d00 100644 --- a/lp-app/lpa-studio-core/src/app/project/slot/project_slot_address.rs +++ b/lp-app/lpa-studio-core/src/app/project/slot/project_slot_address.rs @@ -65,7 +65,7 @@ mod tests { #[test] fn root_slot_address_uses_root_path() { let address = ProjectSlotAddress::root( - ProjectNodeAddress::parse("/demo.project/orbit.shader").unwrap(), + ProjectNodeAddress::parse("/demo.module/orbit.shader").unwrap(), ProjectSlotRoot::def(), ); @@ -76,7 +76,7 @@ mod tests { #[test] fn child_addresses_extend_the_path_in_place() { let map = ProjectSlotAddress::new( - ProjectNodeAddress::parse("/demo.project/pixels.fixture").unwrap(), + ProjectNodeAddress::parse("/demo.module/pixels.fixture").unwrap(), ProjectSlotRoot::def(), SlotPath::parse("mapping").unwrap(), ); @@ -101,8 +101,8 @@ mod tests { #[test] fn strictly_under_requires_same_node_root_and_proper_path_prefix() { - let node = ProjectNodeAddress::parse("/demo.project/pixels.fixture").unwrap(); - let other_node = ProjectNodeAddress::parse("/demo.project/clock.clock").unwrap(); + let node = ProjectNodeAddress::parse("/demo.module/pixels.fixture").unwrap(); + let other_node = ProjectNodeAddress::parse("/demo.module/clock.clock").unwrap(); let at = |path: &str| { ProjectSlotAddress::new( node.clone(), diff --git a/lp-app/lpa-studio-core/src/app/project/slot/slot_controller.rs b/lp-app/lpa-studio-core/src/app/project/slot/slot_controller.rs index b5de5b0e9..4a737dcea 100644 --- a/lp-app/lpa-studio-core/src/app/project/slot/slot_controller.rs +++ b/lp-app/lpa-studio-core/src/app/project/slot/slot_controller.rs @@ -1574,7 +1574,7 @@ mod tests { fn slot_address(path: &str) -> ProjectSlotAddress { ProjectSlotAddress::new( - ProjectNodeAddress::parse("/demo.project/pixels.fixture").unwrap(), + ProjectNodeAddress::parse("/demo.module/pixels.fixture").unwrap(), ProjectSlotRoot::def(), lpc_model::SlotPath::parse(path).unwrap(), ) diff --git a/lp-app/lpa-studio-core/src/app/project/slot/slot_edit_join.rs b/lp-app/lpa-studio-core/src/app/project/slot/slot_edit_join.rs index d46098444..a90a5c37d 100644 --- a/lp-app/lpa-studio-core/src/app/project/slot/slot_edit_join.rs +++ b/lp-app/lpa-studio-core/src/app/project/slot/slot_edit_join.rs @@ -404,7 +404,7 @@ mod tests { use super::*; fn node() -> ProjectNodeAddress { - ProjectNodeAddress::parse("/demo.project/pixels.fixture").unwrap() + ProjectNodeAddress::parse("/demo.module/pixels.fixture").unwrap() } fn at(path: &str) -> ProjectSlotAddress { @@ -520,7 +520,7 @@ mod tests { ); assert!( join.dirty_summary_for_node( - &ProjectNodeAddress::parse("/demo.project/clock.clock").unwrap() + &ProjectNodeAddress::parse("/demo.module/clock.clock").unwrap() ) .is_clean(), "entries only count for their own node" @@ -573,7 +573,7 @@ mod tests { assert_eq!(join.unmapped_asset_dirty_summary(), one_persisted); assert!( join.dirty_summary_for_node( - &ProjectNodeAddress::parse("/demo.project/clock.clock").unwrap() + &ProjectNodeAddress::parse("/demo.module/clock.clock").unwrap() ) .is_clean(), "asset entries only count for their owning node" diff --git a/lp-app/lpa-studio-core/src/app/project/slot/slot_edit_op.rs b/lp-app/lpa-studio-core/src/app/project/slot/slot_edit_op.rs index 765094982..3edd170a5 100644 --- a/lp-app/lpa-studio-core/src/app/project/slot/slot_edit_op.rs +++ b/lp-app/lpa-studio-core/src/app/project/slot/slot_edit_op.rs @@ -136,7 +136,7 @@ mod tests { fn test_address() -> ProjectSlotAddress { ProjectSlotAddress::new( - ProjectNodeAddress::parse("/demo.project/clock.clock").unwrap(), + ProjectNodeAddress::parse("/demo.module/clock.clock").unwrap(), ProjectSlotRoot::def(), SlotPath::parse("controls.rate").unwrap(), ) diff --git a/lp-app/lpa-studio-core/src/app/share/package_envelope.rs b/lp-app/lpa-studio-core/src/app/share/package_envelope.rs index 6c6cd9468..b93c43781 100644 --- a/lp-app/lpa-studio-core/src/app/share/package_envelope.rs +++ b/lp-app/lpa-studio-core/src/app/share/package_envelope.rs @@ -113,10 +113,7 @@ mod tests { let files = vec![ ("logo.png".to_string(), vec![0x89, b'P', b'N', b'G', 0x00]), ("orbit.glsl".to_string(), b"void main() {}".to_vec()), - ( - "project.json".to_string(), - br#"{"kind":"Project"}"#.to_vec(), - ), + ("project.json".to_string(), br#"{"kind":"Module"}"#.to_vec()), ]; let json = PackageEnvelope::encode("Demo", &files).to_json().unwrap(); @@ -130,10 +127,7 @@ mod tests { // The reason JSON exists alongside zip: you can read the thing you // pasted. If this regresses, the channel loses its point. let files = vec![ - ( - "project.json".to_string(), - br#"{"kind":"Project"}"#.to_vec(), - ), + ("project.json".to_string(), br#"{"kind":"Module"}"#.to_vec()), ("orbit.glsl".to_string(), b"void main() {}".to_vec()), ]; let json = PackageEnvelope::encode("Demo", &files).to_json().unwrap(); @@ -199,7 +193,7 @@ mod tests { fn the_source_uid_rides_along_for_provenance() { let files = vec![( "project.json".to_string(), - br#"{"kind":"Project","uid":"prj_abc123"}"#.to_vec(), + br#"{"kind":"Module","uid":"prj_abc123"}"#.to_vec(), )]; let envelope = PackageEnvelope::encode("Demo", &files); assert_eq!(envelope.original_uid().as_deref(), Some("prj_abc123")); @@ -207,10 +201,7 @@ mod tests { // A project that never entered a library has no uid to carry. let anonymous = PackageEnvelope::encode( "Demo", - &[( - "project.json".to_string(), - br#"{"kind":"Project"}"#.to_vec(), - )], + &[("project.json".to_string(), br#"{"kind":"Module"}"#.to_vec())], ); assert_eq!(anonymous.original_uid(), None); } diff --git a/lp-app/lpa-studio-core/src/app/studio/studio_actor_tests.rs b/lp-app/lpa-studio-core/src/app/studio/studio_actor_tests.rs index 51e101fb8..ad5a7dcf4 100644 --- a/lp-app/lpa-studio-core/src/app/studio/studio_actor_tests.rs +++ b/lp-app/lpa-studio-core/src/app/studio/studio_actor_tests.rs @@ -208,7 +208,7 @@ impl ClientIo for ScriptedClientIo { fn single_product_project_view(node_id: u32) -> ProjectView { let revision = Revision::new(1); - let path = TreePath::parse("/demo.project/orbit.shader").unwrap(); + let path = TreePath::parse("/demo.module/orbit.shader").unwrap(); let state_shape = SlotShapeId::new(700); let mut view = ProjectView::new(); view.tree.insert(TreeEntryView::new( @@ -661,7 +661,7 @@ fn console_commands_are_never_coalesced_and_keep_their_order() { fn slot_address(path: &str) -> crate::ProjectSlotAddress { crate::ProjectSlotAddress::new( - crate::ProjectNodeAddress::parse("/demo.project/clock.clock").unwrap(), + crate::ProjectNodeAddress::parse("/demo.module/clock.clock").unwrap(), crate::ProjectSlotRoot::def(), lpc_model::SlotPath::parse(path).unwrap(), ) diff --git a/lp-app/lpa-studio-core/src/app/studio/studio_controller.rs b/lp-app/lpa-studio-core/src/app/studio/studio_controller.rs index 65c491351..0f245bd34 100644 --- a/lp-app/lpa-studio-core/src/app/studio/studio_controller.rs +++ b/lp-app/lpa-studio-core/src/app/studio/studio_controller.rs @@ -5601,7 +5601,7 @@ mod tests { .unwrap(); let product = VisualProduct::new(NodeId::new(3), 0); let target = ProjectEditorTarget::addressed_node(ProjectNodeTarget::new( - ProjectNodeAddress::new(TreePath::parse("/demo.project/orbit.shader").unwrap()), + ProjectNodeAddress::new(TreePath::parse("/demo.module/orbit.shader").unwrap()), NodeId::new(3), )); let action = UiAction::from_op(target.node_id(), ProjectEditorOp::Focus); @@ -5860,7 +5860,7 @@ mod tests { fn single_product_project_view(node_id: u32) -> ProjectView { let revision = Revision::new(1); - let path = TreePath::parse("/demo.project/orbit.shader").unwrap(); + let path = TreePath::parse("/demo.module/orbit.shader").unwrap(); let state_shape = SlotShapeId::new(700); let mut view = ProjectView::new(); view.tree.insert(TreeEntryView::new( diff --git a/lp-app/lpa-studio-core/src/app/studio/studio_edit_e2e_tests.rs b/lp-app/lpa-studio-core/src/app/studio/studio_edit_e2e_tests.rs index 9a75b961c..dd8ffc9a5 100644 --- a/lp-app/lpa-studio-core/src/app/studio/studio_edit_e2e_tests.rs +++ b/lp-app/lpa-studio-core/src/app/studio/studio_edit_e2e_tests.rs @@ -57,24 +57,23 @@ fn simulator_session_edit_save_and_revert_end_to_end() { // Flat-root workspace over the real wire: the project root renders no // card (the clock and fixture panes are the top-level entries) and the - // root's own slots ride `root_slots` with the `read_only_persisted` - // policy on `format`/`nodes` intact; `name` stays editable. + // Post-mitosis the root module def carries ONLY `nodes` (read-only); + // format/uid/name live in the project.json container manifest, so they + // must NOT surface as root slots. let editor = project_editor(&snapshot); assert_eq!(editor.nodes.len(), 2, "two child panes, no root card"); let root_slot = |path: &str| { - editor - .root_slots - .iter() - .find(|slot| { - slot.address - .as_ref() - .is_some_and(|address| address.path.to_string() == path) - }) - .unwrap_or_else(|| panic!("root settings should carry {path}")) + editor.root_slots.iter().find(|slot| { + slot.address + .as_ref() + .is_some_and(|address| address.path.to_string() == path) + }) }; - assert!(!root_slot("format").state.editable); - assert!(!root_slot("nodes").state.editable); - assert!(root_slot("name").state.editable); + assert!(!root_slot("nodes").expect("nodes root slot").state.editable); + assert!( + root_slot("format").is_none() && root_slot("name").is_none(), + "container-manifest fields must not surface as root slots" + ); let rate = find_slot(&snapshot, "controls.rate"); assert_eq!(rate.state.dirty, UiNodeDirtyState::Clean); @@ -446,7 +445,12 @@ fn device_connect_pulls_classifies_and_adopts() { let fs = server.base_fs(); fs.write_file( format!("{device_project_dir}/project.json").as_path(), - br#"{"kind":"Project","uid":"prj_devicedevicedevi","name":"Porch Wild","nodes":{}}"#, + br#"{"format":3,"uid":"prj_devicedevicedevi","name":"Porch Wild"}"#, + ) + .unwrap(); + fs.write_file( + format!("{device_project_dir}/module.json").as_path(), + br#"{"kind":"Module","nodes":{}}"#, ) .unwrap(); fs.write_file( @@ -564,7 +568,12 @@ fn d30_verbs_resolve_divergence_without_the_deploy_dialog() { let fs = server.base_fs(); fs.write_file( format!("{device_project_dir}/project.json").as_path(), - br#"{"kind":"Project","uid":"prj_devicedevicedevi","name":"Porch Wild","nodes":{}}"#, + br#"{"format":3,"uid":"prj_devicedevicedevi","name":"Porch Wild"}"#, + ) + .unwrap(); + fs.write_file( + format!("{device_project_dir}/module.json").as_path(), + br#"{"kind":"Module","nodes":{}}"#, ) .unwrap(); fs.write_file( @@ -2163,9 +2172,9 @@ pub(crate) fn asset_e2e_server() -> LpServer { "input": { "source": "bus:control.out" } } }"#; - let project_json = r#"{ - "kind": "Project", - "format": 2, + let project_json = "{\n \"format\": 3\n}\n"; + let module_json = r#"{ + "kind": "Module", "nodes": { "clock": { "ref": "./clock.json" }, "shader": { "ref": "./shader.json" }, @@ -2182,6 +2191,7 @@ pub(crate) fn asset_e2e_server() -> LpServer { }"#; let files: &[(&str, &str)] = &[ ("project.json", project_json), + ("module.json", module_json), ("clock.json", clock_json), ("shader.json", shader_json), ("fixture.json", fixture_json), @@ -2241,11 +2251,11 @@ pub(crate) fn edit_e2e_server() -> LpServer { pub(crate) fn edit_e2e_files() -> &'static [(&'static str, &'static str)] { &[ + ("project.json", "{\n \"format\": 3\n}\n"), ( - "project.json", + "module.json", r#"{ - "kind": "Project", - "format": 2, + "kind": "Module", "nodes": { "clock": { "ref": "./clock.json" }, "pixels": { "ref": "./fixture.json" } diff --git a/lp-app/lpa-studio-core/src/app/studio/studio_face_e2e_tests.rs b/lp-app/lpa-studio-core/src/app/studio/studio_face_e2e_tests.rs index 6e2838709..3c73fd3c2 100644 --- a/lp-app/lpa-studio-core/src/app/studio/studio_face_e2e_tests.rs +++ b/lp-app/lpa-studio-core/src/app/studio/studio_face_e2e_tests.rs @@ -509,9 +509,9 @@ fn face_e2e_server() -> LpServer { graphics, ); - let project_json = r#"{ - "kind": "Project", - "format": 2, + let project_json = "{\n \"format\": 3\n}\n"; + let module_json = r#"{ + "kind": "Module", "nodes": { "clock": { "ref": "./clock.json" }, "shader": { "ref": "./shader.json" }, @@ -577,6 +577,7 @@ fn face_e2e_server() -> LpServer { }"#; let files: &[(&str, &str)] = &[ ("project.json", project_json), + ("module.json", module_json), ("clock.json", clock_json), ("shader.json", shader_json), ("fixture.json", fixture_json), @@ -615,9 +616,9 @@ fn playlist_e2e_server(idle_entry: u32) -> LpServer { graphics, ); - let project_json = r#"{ - "kind": "Project", - "format": 2, + let project_json = "{\n \"format\": 3\n}\n"; + let module_json = r#"{ + "kind": "Module", "nodes": { "clock": { "ref": "./clock.json" }, "playlist": { "ref": "./playlist.json" }, @@ -668,6 +669,7 @@ fn playlist_e2e_server(idle_entry: u32) -> LpServer { }"#; let files: &[(&str, &str)] = &[ ("project.json", project_json), + ("module.json", module_json), ("clock.json", clock_json), ("playlist.json", playlist_json.as_str()), ("idle.json", idle_json), diff --git a/lp-app/lpa-studio-core/src/app/studio/studio_link_e2e_tests.rs b/lp-app/lpa-studio-core/src/app/studio/studio_link_e2e_tests.rs index 75fc20b7c..a9e932fef 100644 --- a/lp-app/lpa-studio-core/src/app/studio/studio_link_e2e_tests.rs +++ b/lp-app/lpa-studio-core/src/app/studio/studio_link_e2e_tests.rs @@ -2611,8 +2611,11 @@ fn project_files(marker: &str) -> Vec<(String, Vec)> { vec![ ( "project.json".to_string(), - format!(r#"{{"kind":"Project","format":2,"name":"Porch {marker}","nodes":{{}}}}"#) - .into_bytes(), + format!(r#"{{"format":3,"name":"Porch {marker}"}}"#).into_bytes(), + ), + ( + "module.json".to_string(), + br#"{"kind":"Module","nodes":{}}"#.to_vec(), ), ("shader.glsl".to_string(), marker.as_bytes().to_vec()), ] diff --git a/lp-app/lpa-studio-core/src/app/studio/studio_node_crud_e2e_tests.rs b/lp-app/lpa-studio-core/src/app/studio/studio_node_crud_e2e_tests.rs index 20b1c861a..6d4927277 100644 --- a/lp-app/lpa-studio-core/src/app/studio/studio_node_crud_e2e_tests.rs +++ b/lp-app/lpa-studio-core/src/app/studio/studio_node_crud_e2e_tests.rs @@ -110,12 +110,12 @@ fn create_every_picker_kind_lands_in_tree_and_on_disk() { fixture_def.contains("Map2d") && fixture_def.contains("fixture_2.map2d.json"), "fixture def references its mapping document: {fixture_def}" ); - // Project root gained every key (the collision case as `clock_2`). - let manifest = read_file(&server, "project.json"); + // The root module gained every key (the collision case as `clock_2`). + let module = read_file(&server, "module.json"); for (_, name, _) in cases { assert!( - manifest.contains(&format!("\"{name}\"")), - "project.json carries {name}: {manifest}" + module.contains(&format!("\"{name}\"")), + "module.json carries {name}: {module}" ); } } diff --git a/lp-app/lpa-studio-core/src/controller/controller_id.rs b/lp-app/lpa-studio-core/src/controller/controller_id.rs index 7fb86ea4e..ce0766e8e 100644 --- a/lp-app/lpa-studio-core/src/controller/controller_id.rs +++ b/lp-app/lpa-studio-core/src/controller/controller_id.rs @@ -161,13 +161,13 @@ mod tests { fn child_accepts_model_path_punctuation() { let node_id = ControllerId::new("studio|project") .child("path") - .child("/demo.project/orbit.shader") + .child("/demo.module/orbit.shader") .child("slot") .child(r#"params["phase.offset"].label"#); assert_eq!( node_id.as_str(), - r#"studio|project|path|/demo.project/orbit.shader|slot|params["phase.offset"].label"# + r#"studio|project|path|/demo.module/orbit.shader|slot|params["phase.offset"].label"# ); } diff --git a/lp-app/lpa-studio-core/src/lib.rs b/lp-app/lpa-studio-core/src/lib.rs index be8c62193..990a14b30 100644 --- a/lp-app/lpa-studio-core/src/lib.rs +++ b/lp-app/lpa-studio-core/src/lib.rs @@ -74,7 +74,7 @@ pub use app::project::{ ProjectSyncPhase, ProjectSyncRun, ProjectSyncSummary, SlotController, SlotControllerState, SlotEditOp, SlotKind, UiAddNodeMenu, UiAddNodeMenuEntry, UiAffordance, UiAssetContent, UiAssetContentBody, UiAttachTarget, UiNodeRemovePreflight, UiPendingEdit, UiPendingEditKind, - UiPendingEditPhase, UiShaderError, + UiPendingEditPhase, UiProjectManifest, UiShaderError, }; pub use app::rich_object::{ RichChip, RichLine, RichObjectView, RichRollup, RichSection, RichWeight, diff --git a/lp-app/lpa-studio-web/src/app/bus/bus_pane_stories.rs b/lp-app/lpa-studio-web/src/app/bus/bus_pane_stories.rs index 04d1f8552..fff20170e 100644 --- a/lp-app/lpa-studio-web/src/app/bus/bus_pane_stories.rs +++ b/lp-app/lpa-studio-web/src/app/bus/bus_pane_stories.rs @@ -14,7 +14,7 @@ fn site(label: &str, slot: Option<&str>, default_origin: bool) -> UiBusSiteView slot: slot.map(str::to_string), default_origin, focus: Some(UiAction::from_op( - ControllerId::new("story.project"), + ControllerId::new("story.module"), ProjectEditorOp::Focus, )), } diff --git a/lp-app/lpa-studio-web/src/app/home/device_card.rs b/lp-app/lpa-studio-web/src/app/home/device_card.rs index cdaa32395..59bb9130c 100644 --- a/lp-app/lpa-studio-web/src/app/home/device_card.rs +++ b/lp-app/lpa-studio-web/src/app/home/device_card.rs @@ -1186,7 +1186,7 @@ fn save_device_name(name: Signal, card_key: &str, on_action: EventHandle fn tab_icon(tab: DeviceCardTab) -> StudioIconName { match tab { DeviceCardTab::Status => StudioIconName::Play, - DeviceCardTab::Project => StudioIconName::NodeKind(NodeKindIcon::Project), + DeviceCardTab::Project => StudioIconName::NodeKind(NodeKindIcon::Module), DeviceCardTab::Settings => StudioIconName::Settings, DeviceCardTab::Performance => StudioIconName::Performance, DeviceCardTab::Console => StudioIconName::Console, diff --git a/lp-app/lpa-studio-web/src/app/home/home_gallery_stories.rs b/lp-app/lpa-studio-web/src/app/home/home_gallery_stories.rs index 59ff99665..150e76fd2 100644 --- a/lp-app/lpa-studio-web/src/app/home/home_gallery_stories.rs +++ b/lp-app/lpa-studio-web/src/app/home/home_gallery_stories.rs @@ -19,7 +19,7 @@ fn examples() -> Vec { vec![UiExampleCard { id: "examples/basic".to_string(), name: "Basic".to_string(), - kind: "Project".to_string(), + kind: "Module".to_string(), }] } @@ -27,7 +27,7 @@ fn packages() -> Vec { vec![ UiPackageCard { uid: "prj_3fKq8Zr21bTxYw0AhVmDpe".to_string(), - kind: "Project".to_string(), + kind: "Module".to_string(), slug: "2026-07-02-0930-porch-sign".to_string(), last_saved_at: Some(STORY_NOW - 2.0 * 3600.0), provenance: None, @@ -38,7 +38,7 @@ fn packages() -> Vec { }, UiPackageCard { uid: "prj_9sLm2Xc44dQnUv7BgWkEyt".to_string(), - kind: "Project".to_string(), + kind: "Module".to_string(), slug: "2026-07-04-1102-basic".to_string(), last_saved_at: Some(STORY_NOW - 5.0 * 86_400.0), provenance: Some("Remixed from Basic".to_string()), @@ -49,7 +49,7 @@ fn packages() -> Vec { }, UiPackageCard { uid: "prj_1aBc3De56fGhIj8KlMnOpq".to_string(), - kind: "Project".to_string(), + kind: "Module".to_string(), slug: "2026-05-28-1740-porch-sign".to_string(), last_saved_at: Some(STORY_NOW - 40.0 * 86_400.0), provenance: Some("Forked from 2026-07-02-0930-porch-sign".to_string()), diff --git a/lp-app/lpa-studio-web/src/app/home/package_card.rs b/lp-app/lpa-studio-web/src/app/home/package_card.rs index 2213b9660..85e54a900 100644 --- a/lp-app/lpa-studio-web/src/app/home/package_card.rs +++ b/lp-app/lpa-studio-web/src/app/home/package_card.rs @@ -390,7 +390,7 @@ mod tests { fn card(connected: Option, running_in_sim: bool) -> UiPackageCard { UiPackageCard { uid: "prj_1".to_string(), - kind: "Project".to_string(), + kind: "Module".to_string(), slug: "2026-07-09-1421-basic".to_string(), last_saved_at: None, provenance: None, diff --git a/lp-app/lpa-studio-web/src/app/node/binding_authoring_stories.rs b/lp-app/lpa-studio-web/src/app/node/binding_authoring_stories.rs index a349e1f1a..86a33abda 100644 --- a/lp-app/lpa-studio-web/src/app/node/binding_authoring_stories.rs +++ b/lp-app/lpa-studio-web/src/app/node/binding_authoring_stories.rs @@ -27,7 +27,7 @@ fn bound_aspect(label: &str, endpoint: &str, default_origin: bool) -> UiSlotAspe fn bindings_map() -> ProjectSlotAddress { ProjectSlotAddress::new( - ProjectNodeAddress::parse("/demo.project/playlist.playlist") + ProjectNodeAddress::parse("/demo.module/playlist.playlist") .expect("valid story node address"), ProjectSlotRoot::def(), SlotPath::parse("bindings").expect("valid story slot path"), diff --git a/lp-app/lpa-studio-web/src/app/node/config_slot_row_stories.rs b/lp-app/lpa-studio-web/src/app/node/config_slot_row_stories.rs index 7092bb12d..ccbd0ff5e 100644 --- a/lp-app/lpa-studio-web/src/app/node/config_slot_row_stories.rs +++ b/lp-app/lpa-studio-web/src/app/node/config_slot_row_stories.rs @@ -14,7 +14,7 @@ use crate::app::node::node_story_fixtures::config_row_states_fixture; fn story_slot_address(path: &str) -> ProjectSlotAddress { ProjectSlotAddress::new( - ProjectNodeAddress::parse("/demo.project/clock.clock").expect("valid story node address"), + ProjectNodeAddress::parse("/demo.module/clock.clock").expect("valid story node address"), ProjectSlotRoot::def(), SlotPath::parse(path).expect("valid story slot path"), ) diff --git a/lp-app/lpa-studio-web/src/app/node/face_story_fixtures.rs b/lp-app/lpa-studio-web/src/app/node/face_story_fixtures.rs index 70cd3baff..28558476a 100644 --- a/lp-app/lpa-studio-web/src/app/node/face_story_fixtures.rs +++ b/lp-app/lpa-studio-web/src/app/node/face_story_fixtures.rs @@ -491,7 +491,7 @@ pub(crate) fn playlist_entries() -> Vec { /// The node-select action a strip entry dispatches (story mock — dispatch /// goes to the story's no-op handler). fn entry_select_action(name: &str) -> UiAction { - UiAction::from_op(ControllerId::new("story.project"), ProjectEditorOp::Focus) + UiAction::from_op(ControllerId::new("story.module"), ProjectEditorOp::Focus) .with_label(format!("Select {name}")) } diff --git a/lp-app/lpa-studio-web/src/app/node/node_stories.rs b/lp-app/lpa-studio-web/src/app/node/node_stories.rs index dc0e5cee6..eb183267c 100644 --- a/lp-app/lpa-studio-web/src/app/node/node_stories.rs +++ b/lp-app/lpa-studio-web/src/app/node/node_stories.rs @@ -11,7 +11,7 @@ use crate::app::node::{NodeDetailPopover, NodeDirtyTint, NodePane}; /// Story stand-in for the controller-built focus action so panes render the /// header select control. fn story_focus_action() -> UiAction { - UiAction::from_op(ControllerId::new("story.project"), ProjectEditorOp::Focus) + UiAction::from_op(ControllerId::new("story.module"), ProjectEditorOp::Focus) } #[story(description = "A composed node pane showing the current node anatomy direction.")] diff --git a/lp-app/lpa-studio-web/src/app/node/node_story_fixtures.rs b/lp-app/lpa-studio-web/src/app/node/node_story_fixtures.rs index 13c012443..3fe9bceab 100644 --- a/lp-app/lpa-studio-web/src/app/node/node_story_fixtures.rs +++ b/lp-app/lpa-studio-web/src/app/node/node_story_fixtures.rs @@ -86,7 +86,7 @@ pub(crate) fn node_revert_pane_action() -> UiPaneAction { UiPaneAction::new( "revert", UiAction::from_op( - ControllerId::new("story.project"), + ControllerId::new("story.module"), NodeRevertOp { node: ProjectNodeAddress::parse("/fyeah_sign.show/playlist.playlist") .expect("valid story node address"), @@ -110,7 +110,7 @@ pub(crate) fn node_delete_pane_action() -> UiPaneAction { UiPaneAction::new( "remove", UiAction::from_op( - ControllerId::new("story.project"), + ControllerId::new("story.module"), NodeRemoveOp { node: ProjectNodeAddress::parse("/fyeah_sign.show/playlist.playlist") .expect("valid story node address"), @@ -217,7 +217,7 @@ fn story_pending_edit( old_value: None, phase, revert: Some(UiAction::from_op( - ControllerId::new("story.project"), + ControllerId::new("story.module"), SlotEditOp::Revert { address }, )), } diff --git a/lp-app/lpa-studio-web/src/app/node/slot_gesture_fields.rs b/lp-app/lpa-studio-web/src/app/node/slot_gesture_fields.rs index 273a3ba72..59dd9d1af 100644 --- a/lp-app/lpa-studio-web/src/app/node/slot_gesture_fields.rs +++ b/lp-app/lpa-studio-web/src/app/node/slot_gesture_fields.rs @@ -398,7 +398,7 @@ mod tests { fn entry_address(path: &str) -> ProjectSlotAddress { ProjectSlotAddress::new( - ProjectNodeAddress::parse("/demo.project/clock.clock").unwrap(), + ProjectNodeAddress::parse("/demo.module/clock.clock").unwrap(), ProjectSlotRoot::def(), SlotPath::parse(path).unwrap(), ) @@ -420,7 +420,7 @@ mod tests { assert_eq!(split_map_entry(&entry_address("mapping.paths")), None); assert_eq!(split_map_entry(&entry_address("paths[3].diameter")), None); let root = ProjectSlotAddress::root( - ProjectNodeAddress::parse("/demo.project/clock.clock").unwrap(), + ProjectNodeAddress::parse("/demo.module/clock.clock").unwrap(), ProjectSlotRoot::def(), ); assert_eq!(split_map_entry(&root), None); diff --git a/lp-app/lpa-studio-web/src/app/node/slot_option_presence.rs b/lp-app/lpa-studio-web/src/app/node/slot_option_presence.rs index 64f441814..a8b9c60d5 100644 --- a/lp-app/lpa-studio-web/src/app/node/slot_option_presence.rs +++ b/lp-app/lpa-studio-web/src/app/node/slot_option_presence.rs @@ -510,7 +510,7 @@ mod tests { use lpa_studio_core::{ProjectNodeAddress, ProjectSlotRoot, SlotPath, UiSlotValue}; let address = ProjectSlotAddress::new( - ProjectNodeAddress::parse("/demo.project/clock.clock").unwrap(), + ProjectNodeAddress::parse("/demo.module/clock.clock").unwrap(), ProjectSlotRoot::def(), SlotPath::parse("brightness").unwrap(), ); diff --git a/lp-app/lpa-studio-web/src/app/node/slot_option_presence_stories.rs b/lp-app/lpa-studio-web/src/app/node/slot_option_presence_stories.rs index f8400184e..b706ef5fd 100644 --- a/lp-app/lpa-studio-web/src/app/node/slot_option_presence_stories.rs +++ b/lp-app/lpa-studio-web/src/app/node/slot_option_presence_stories.rs @@ -279,7 +279,7 @@ fn xy_value(value: [f32; 2]) -> UiSlotValue { fn story_slot_address(path: &str) -> ProjectSlotAddress { ProjectSlotAddress::new( - ProjectNodeAddress::parse("/demo.project/clock.clock").expect("valid story node address"), + ProjectNodeAddress::parse("/demo.module/clock.clock").expect("valid story node address"), ProjectSlotRoot::def(), SlotPath::parse(path).expect("valid story slot path"), ) diff --git a/lp-app/lpa-studio-web/src/app/node/slot_value_editor_stories.rs b/lp-app/lpa-studio-web/src/app/node/slot_value_editor_stories.rs index b2d3090f6..5e3c04791 100644 --- a/lp-app/lpa-studio-web/src/app/node/slot_value_editor_stories.rs +++ b/lp-app/lpa-studio-web/src/app/node/slot_value_editor_stories.rs @@ -12,7 +12,7 @@ use crate::app::node::{SliderSlotField, SlotValueEditor, XySlotField}; fn story_slot_address(path: &str) -> ProjectSlotAddress { ProjectSlotAddress::new( - ProjectNodeAddress::parse("/demo.project/pixels.fixture").expect("valid story address"), + ProjectNodeAddress::parse("/demo.module/pixels.fixture").expect("valid story address"), ProjectSlotRoot::def(), SlotPath::parse(path).expect("valid story slot path"), ) diff --git a/lp-app/lpa-studio-web/src/app/project/pending_edit_section.rs b/lp-app/lpa-studio-web/src/app/project/pending_edit_section.rs index d4aef2375..d1066ea14 100644 --- a/lp-app/lpa-studio-web/src/app/project/pending_edit_section.rs +++ b/lp-app/lpa-studio-web/src/app/project/pending_edit_section.rs @@ -179,7 +179,7 @@ mod tests { fn edit(path: &str, phase: UiPendingEditPhase) -> UiPendingEdit { UiPendingEdit { node_label: "Orbit".to_string(), - node_path: "/demo.project/orbit.shader".to_string(), + node_path: "/demo.module/orbit.shader".to_string(), slot_path_display: path.to_string(), kind: UiPendingEditKind::Added, old_value: None, diff --git a/lp-app/lpa-studio-web/src/app/project/project_node_tree.rs b/lp-app/lpa-studio-web/src/app/project/project_node_tree.rs index 0f70c64d4..ea1219ff4 100644 --- a/lp-app/lpa-studio-web/src/app/project/project_node_tree.rs +++ b/lp-app/lpa-studio-web/src/app/project/project_node_tree.rs @@ -331,7 +331,7 @@ mod tests { ProjectNodeStatusView::new("Running", None, tone), false, UiAction::from_op( - lpa_studio_core::ControllerId::new("story.project"), + lpa_studio_core::ControllerId::new("story.module"), lpa_studio_core::ProjectEditorOp::Focus, ), Vec::new(), diff --git a/lp-app/lpa-studio-web/src/app/project/project_pane.rs b/lp-app/lpa-studio-web/src/app/project/project_pane.rs index edf45ce10..c9cf7d9ee 100644 --- a/lp-app/lpa-studio-web/src/app/project/project_pane.rs +++ b/lp-app/lpa-studio-web/src/app/project/project_pane.rs @@ -72,6 +72,7 @@ pub fn ProjectPane( let project_name = view.project_name.clone(); let pending_edits = view.pending_edits.clone(); let root_slots = view.root_slots.clone(); + let manifest = view.manifest.clone(); let library_identity = view.library_identity.clone(); rsx! { @@ -92,6 +93,7 @@ pub fn ProjectPane( stats, pending_edits, root_slots, + manifest, library_identity, on_action, initially_open, @@ -145,6 +147,9 @@ fn ProjectDetailPopover( stats: Vec, pending_edits: Vec, #[props(default)] root_slots: Vec, + /// The container-manifest identity, when a library package backs it. + #[props(default)] + manifest: Option, /// The open project's library identity, when a package backs it. #[props(default)] library_identity: Option<(String, String)>, @@ -175,12 +180,12 @@ fn ProjectDetailPopover( span { class: status_class, "{status.label}" } } } - if !root_slots.is_empty() { - // The workspace root's own identity rows (flat root, Q5): - // purpose-built controls, NOT the generic slot editor — - // see `project_settings_section`. + if !root_slots.is_empty() || manifest.is_some() { + // The project's identity rows (container manifest, plus the + // root's nodes count): purpose-built controls, NOT the + // generic slot editor — see `project_settings_section`. DetailSection { title: "Project settings", - ProjectSettingsSection { root_slots, on_action } + ProjectSettingsSection { manifest, root_slots } } } // Share is its own section: settings are what the project IS, diff --git a/lp-app/lpa-studio-web/src/app/project/project_pane_stories.rs b/lp-app/lpa-studio-web/src/app/project/project_pane_stories.rs index a315e7596..edcabf79b 100644 --- a/lp-app/lpa-studio-web/src/app/project/project_pane_stories.rs +++ b/lp-app/lpa-studio-web/src/app/project/project_pane_stories.rs @@ -337,7 +337,7 @@ fn pending_edit( phase: UiPendingEditPhase, ) -> UiPendingEdit { let address = ProjectSlotAddress::new( - ProjectNodeAddress::parse("/demo.project/orbit.shader").expect("valid story node address"), + ProjectNodeAddress::parse("/demo.module/orbit.shader").expect("valid story node address"), ProjectSlotRoot::def(), SlotPath::parse(path).expect("valid story slot path"), ); diff --git a/lp-app/lpa-studio-web/src/app/project/project_settings_section.rs b/lp-app/lpa-studio-web/src/app/project/project_settings_section.rs index ccd8c46fc..ad341a8d6 100644 --- a/lp-app/lpa-studio-web/src/app/project/project_settings_section.rs +++ b/lp-app/lpa-studio-web/src/app/project/project_settings_section.rs @@ -1,69 +1,44 @@ //! The project popup's "Project settings" rows. //! -//! **Deliberately not the generic `SlotRecordEditor`.** The project root's -//! own slots are the project's *identity*, and the generic slot machinery -//! dresses identity as authoring: option-presence toggles on `uid`, a full -//! map editor for the `nodes` table, edit chrome on rows nothing may edit. -//! A demo walk read that as "the Studio lets you retype your project's -//! uid" — which, until 2026-07-28, it did (`ProjectDef::uid` carried the -//! default writable policy). -//! -//! So this section renders four purpose-built rows instead: -//! -//! - **Name** — the one authored field, an inline [`StringSlotField`] so it -//! keeps the shared edit dispatch, dirty affordance, and invalid state. -//! - **Format** — read-only; only the loader gate and a future offline -//! upgrader own it. -//! - **UID** — read-only, with a copy button (identity is the thing you -//! actually want on your clipboard when reporting a problem). -//! - **Nodes** — read-only, collapsed to a **count**. The map itself is the -//! node tree, which is the pane's whole body; repeating it as a slot -//! editor in the popup was noise. -//! -//! `uid`, `format`, and `nodes` are all `read_only_persisted` in -//! `lpc_model::ProjectDef`, so the read-only presentation here agrees with -//! the model rather than merely hiding a writable slot. +//! Post-mitosis the project's identity (`format` / `uid` / `name`) lives in +//! the `project.json` container manifest — library-owned workspace metadata, +//! never authored def slots — so this section renders it read-only from +//! [`UiProjectManifest`]. Rename happens where the identity lives: the home +//! gallery's rename, which patches the manifest (`LibraryStore::rename`). +//! The one root-def row left is the `nodes` map, collapsed to a count (the +//! map itself is the node tree, which is the pane's whole body). use dioxus::prelude::*; -use lpa_studio_core::{UiAction, UiConfigSlot, UiConfigSlotBody, UiSlotValueKind}; +use lpa_studio_core::{UiConfigSlot, UiConfigSlotBody, UiProjectManifest}; -use crate::app::node::StringSlotField; use crate::base::{StudioIcon, StudioIconName}; -/// The project root's identity rows, in a fixed order that does not depend -/// on the slot tree's field order. +/// The project's identity rows, in a fixed order that does not depend on +/// the slot tree's field order. #[component] #[allow(non_snake_case, reason = "Dioxus components use PascalCase")] pub fn ProjectSettingsSection( + /// The container-manifest identity, when a library package backs the + /// open project. + #[props(default)] + manifest: Option, /// The project root node's own config slots (`ProjectEditorView::root_slots`). + #[props(default)] root_slots: Vec, - on_action: EventHandler, ) -> Element { - let name = row(&root_slots, "name"); - let format = row(&root_slots, "format"); - let uid = row(&root_slots, "uid"); let nodes = row(&root_slots, "nodes"); + let manifest = manifest.unwrap_or_default(); rsx! { div { class: "tw:grid tw:min-w-0 tw:gap-1.5", - if let Some(name) = name { - div { class: "tw:flex tw:min-w-0 tw:items-baseline tw:justify-between tw:gap-3 tw:text-xs tw:leading-snug", - span { class: "tw:flex-none tw:font-bold tw:text-subtle-foreground", "Name" } - span { class: "tw:min-w-0 tw:flex-1 tw:text-right", - StringSlotField { - value: string_value(name).unwrap_or_default(), - state: name.state.clone(), - address: name.address.clone(), - on_action, - } - } - } + if let Some(name) = manifest.name { + ReadOnlyRow { label: "Name", value: name } } - if let Some(format) = format { - ReadOnlyRow { label: "Format", value: display_value(format) } + if let Some(format) = manifest.format { + ReadOnlyRow { label: "Format", value: format.to_string() } } - if let Some(uid) = uid { - ReadOnlyRow { label: "UID", value: display_value(uid), copyable: true } + if let Some(uid) = manifest.uid { + ReadOnlyRow { label: "UID", value: uid, copyable: true } } if let Some(nodes) = nodes { ReadOnlyRow { label: "Nodes", value: node_count_label(nodes) } @@ -105,29 +80,6 @@ fn row<'a>(root_slots: &'a [UiConfigSlot], key: &str) -> Option<&'a UiConfigSlot root_slots.iter().find(|slot| slot.key == key) } -/// A value row's raw string, for the editable name field. -fn string_value(slot: &UiConfigSlot) -> Option { - match &slot.body { - UiConfigSlotBody::Value(value) => match &value.kind { - UiSlotValueKind::String(text) => Some(text.clone()), - _ => None, - }, - _ => None, - } -} - -/// A row's formatted display string; an absent optional reads as an em -/// dash rather than the slot machinery's "unset". -fn display_value(slot: &UiConfigSlot) -> String { - match &slot.body { - UiConfigSlotBody::Value(value) => match value.kind { - UiSlotValueKind::Unset => "—".to_string(), - _ => value.display.clone(), - }, - _ => "—".to_string(), - } -} - /// The `nodes` map collapsed to a count — the map's contents are the node /// tree in the pane body, not popup material. fn node_count_label(slot: &UiConfigSlot) -> String { @@ -143,7 +95,7 @@ fn node_count_label(slot: &UiConfigSlot) -> String { #[cfg(test)] mod tests { - use lpa_studio_core::{UiSlotFieldState, UiSlotValue}; + use lpa_studio_core::{UiConfigSlot, UiSlotValue}; use super::*; @@ -154,59 +106,13 @@ mod tests { assert_eq!(node_count_label(&nodes_row(4)), "4 nodes"); } - #[test] - fn absent_optionals_read_as_a_dash_not_unset() { - let unset = UiConfigSlot::value("uid", "UID", UiSlotValue::unset()); - assert_eq!(display_value(&unset), "—"); - // A structural row with no value body degrades the same way. - assert_eq!(display_value(&nodes_row(2)), "—"); - } - - #[test] - fn rows_are_found_by_field_key_regardless_of_slot_order() { - let slots = vec![ - UiConfigSlot::value("format", "Format", UiSlotValue::u32(1)), - UiConfigSlot::value("name", "Name", UiSlotValue::string("Demo")), - ]; - assert_eq!( - row(&slots, "name").map(display_value).as_deref(), - Some("Demo") - ); - assert_eq!( - row(&slots, "format").map(display_value).as_deref(), - Some("1") - ); - assert!( - row(&slots, "notes").is_none(), - "no such field on ProjectDef" - ); - } - - #[test] - fn only_string_value_rows_yield_an_editable_name() { - let named = UiConfigSlot::value("name", "Name", UiSlotValue::string("Demo")); - assert_eq!(string_value(&named).as_deref(), Some("Demo")); - // A u32 row must not be coerced into the text field. - let format = UiConfigSlot::value("format", "Format", UiSlotValue::u32(1)); - assert_eq!(string_value(&format), None); - } - - #[test] - fn identity_rows_carry_the_models_read_only_policy() { - // Guards the P1 model change: if `uid` ever goes writable again, - // the fixture stops matching the model and this fails. - let uid = UiConfigSlot::value("uid", "UID", UiSlotValue::string("prj_abc")) - .with_state(UiSlotFieldState::readonly()); - assert!(!uid.state.editable); - } - fn nodes_row(count: usize) -> UiConfigSlot { let fields = (0..count) .map(|index| { UiConfigSlot::value( - format!("nodes[n{index}]"), - format!("n{index}"), - UiSlotValue::string("./n.json"), + format!("node{index}"), + format!("node{index}"), + UiSlotValue::string("x"), ) }) .collect(); diff --git a/lp-app/lpa-studio-web/src/app/readme_stories.rs b/lp-app/lpa-studio-web/src/app/readme_stories.rs index 9bedad77c..c658b3e1f 100644 --- a/lp-app/lpa-studio-web/src/app/readme_stories.rs +++ b/lp-app/lpa-studio-web/src/app/readme_stories.rs @@ -224,7 +224,7 @@ fn readme_home_view() -> UiHomeView { let projects = vec![ UiPackageCard { uid: "prj_3fKq8Zr21bTxYw0AhVmDpe".to_string(), - kind: "Project".to_string(), + kind: "Module".to_string(), slug: "2026-07-02-0930-porch-sign".to_string(), last_saved_at: Some(STORY_NOW - 2.0 * 3600.0), provenance: None, @@ -238,7 +238,7 @@ fn readme_home_view() -> UiHomeView { }, UiPackageCard { uid: "prj_9sLm2Xc44dQnUv7BgWkEyt".to_string(), - kind: "Project".to_string(), + kind: "Module".to_string(), slug: "2026-07-04-1102-evening-glow".to_string(), last_saved_at: Some(STORY_NOW - 5.0 * 86_400.0), provenance: Some("Remixed from Basic".to_string()), @@ -249,7 +249,7 @@ fn readme_home_view() -> UiHomeView { }, UiPackageCard { uid: "prj_1aBc3De56fGhIj8KlMnOpq".to_string(), - kind: "Project".to_string(), + kind: "Module".to_string(), slug: "2026-05-28-1740-porch-sign".to_string(), last_saved_at: Some(STORY_NOW - 40.0 * 86_400.0), provenance: Some("Forked from 2026-07-02-0930-porch-sign".to_string()), @@ -295,7 +295,7 @@ fn readme_home_view() -> UiHomeView { examples: vec![UiExampleCard { id: "examples/basic".to_string(), name: "Basic".to_string(), - kind: "Project".to_string(), + kind: "Module".to_string(), }], library_available: true, opening: None, diff --git a/lp-app/lpa-studio-web/src/app/story_fixtures.rs b/lp-app/lpa-studio-web/src/app/story_fixtures.rs index a282ee232..ee2259b60 100644 --- a/lp-app/lpa-studio-web/src/app/story_fixtures.rs +++ b/lp-app/lpa-studio-web/src/app/story_fixtures.rs @@ -216,7 +216,7 @@ pub(crate) fn project_editor_fixture(phase: ProjectSyncPhase) -> ProjectEditorVi ); let project = tree_item( 1, - "/demo.project", + "/demo.module", "Demo", "Project", running.clone(), @@ -224,7 +224,7 @@ pub(crate) fn project_editor_fixture(phase: ProjectSyncPhase) -> ProjectEditorVi vec![ tree_item( 2, - "/demo.project/clock.clock", + "/demo.module/clock.clock", "Clock", "Clock", running.clone(), @@ -233,7 +233,7 @@ pub(crate) fn project_editor_fixture(phase: ProjectSyncPhase) -> ProjectEditorVi ), tree_item( 3, - "/demo.project/orbit.shader", + "/demo.module/orbit.shader", "Orbit shader", "Shader", running.clone(), @@ -242,7 +242,7 @@ pub(crate) fn project_editor_fixture(phase: ProjectSyncPhase) -> ProjectEditorVi ), tree_item( 4, - "/demo.project/palette.visual", + "/demo.module/palette.visual", "Sunrise palette", "Visual", warning.clone(), @@ -251,7 +251,7 @@ pub(crate) fn project_editor_fixture(phase: ProjectSyncPhase) -> ProjectEditorVi ), tree_item( 5, - "/demo.project/output.output", + "/demo.module/output.output", "Output", "Output", running.clone(), @@ -271,6 +271,7 @@ pub(crate) fn project_editor_fixture(phase: ProjectSyncPhase) -> ProjectEditorVi ) .with_project_name("Demo") .with_root_slots(project_root_slots()) + .with_manifest(Some(project_manifest())) } pub(crate) fn project_editor_empty_fixture(phase: ProjectSyncPhase) -> ProjectEditorView { @@ -354,16 +355,11 @@ pub(crate) fn project_workspace_nodes() -> Vec { /// The project root's own config rows for the project popup's settings /// section: `name` editable, `format`/`uid`/`nodes` read-only — matching -/// the `read_only_persisted` policy each carries on `lpc_model::ProjectDef` +/// the `read_only_persisted` policy each carries on `lpc_model::ModuleDef` /// (`uid` joined them 2026-07-28; it had been writable by default). pub(crate) fn project_root_slots() -> Vec { use lpa_studio_core::UiSlotFieldState; vec![ - UiConfigSlot::value("name", "Name", UiSlotValue::string("Demo")), - UiConfigSlot::value("format", "Format", UiSlotValue::u32(1)) - .with_state(UiSlotFieldState::readonly()), - UiConfigSlot::value("uid", "UID", UiSlotValue::string("prj_7k2mQx4vN8pL")) - .with_state(UiSlotFieldState::readonly()), UiConfigSlot::record( "nodes", "Nodes", @@ -379,6 +375,15 @@ pub(crate) fn project_root_slots() -> Vec { ] } +/// Container-manifest identity for the settings-section stories. +pub(crate) fn project_manifest() -> lpa_studio_core::UiProjectManifest { + lpa_studio_core::UiProjectManifest { + format: Some(3), + uid: Some("prj_7k2mQx4vN8pL".to_string()), + name: Some("Demo".to_string()), + } +} + /// One top-level workspace pane built from a child fixture (the same /// projection `NodeChildren` applies when a child renders as a nested pane). fn workspace_node(child: UiNodeChild) -> UiNodeView { @@ -404,7 +409,7 @@ fn clock_node_child() -> UiNodeChild { node_child( "Clock", "Clock", - "/demo.project/clock.clock", + "/demo.module/clock.clock", UiStatus::good("Running"), ) .with_sections(vec![ @@ -427,7 +432,7 @@ fn orbit_shader_child() -> UiNodeChild { node_child( "Orbit shader", "Shader", - "/demo.project/orbit.shader", + "/demo.module/orbit.shader", UiStatus::good("Running"), ) .active("focused") @@ -469,7 +474,7 @@ fn palette_node_child() -> UiNodeChild { node_child( "Sunrise palette", "Visual", - "/demo.project/palette.visual", + "/demo.module/palette.visual", UiStatus::warning("Warning"), ) .with_sections(vec![ @@ -499,7 +504,7 @@ fn output_node_child() -> UiNodeChild { node_child( "Output", "Output", - "/demo.project/output.output", + "/demo.module/output.output", UiStatus::good("Running"), ) .with_sections(vec![UiNodeSection::ConfigSlots(vec![ diff --git a/lp-app/lpa-studio-web/src/base/icon.rs b/lp-app/lpa-studio-web/src/base/icon.rs index b63804c89..f78f7b024 100644 --- a/lp-app/lpa-studio-web/src/base/icon.rs +++ b/lp-app/lpa-studio-web/src/base/icon.rs @@ -48,7 +48,7 @@ pub fn StudioIcon(name: StudioIconName, size: u32) -> Element { NodeKindIcon::Compute => rsx! { Cpu { size } }, NodeKindIcon::Output => rsx! { Zap { size } }, NodeKindIcon::Playlist => rsx! { ListMusic { size } }, - NodeKindIcon::Project => rsx! { Folder { size } }, + NodeKindIcon::Module => rsx! { Folder { size } }, NodeKindIcon::Texture => rsx! { Image { size } }, NodeKindIcon::Radio => rsx! { Radio { size } }, NodeKindIcon::Button => rsx! { MousePointerClick { size } }, @@ -198,7 +198,7 @@ pub enum NodeKindIcon { Compute, Output, Playlist, - Project, + Module, Texture, Radio, Button, @@ -220,7 +220,7 @@ pub fn node_kind_icon(kind_label: &str) -> StudioIconName { "Compute" | "Compute shader" | "compute_shader" => NodeKindIcon::Compute, "Output" | "output" => NodeKindIcon::Output, "Playlist" | "playlist" => NodeKindIcon::Playlist, - "Project" | "project" => NodeKindIcon::Project, + "Module" | "module" => NodeKindIcon::Module, "Texture" | "texture" => NodeKindIcon::Texture, "Control Radio" | "Radio" | "radio" => NodeKindIcon::Radio, "Button" | "button" => NodeKindIcon::Button, diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list-empty__lg.png b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list-empty__lg.png index 2a1d00a5b..35c6f98c8 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list-empty__lg.png and b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list-empty__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list-empty__md.png b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list-empty__md.png index 035cfe3e8..21a087b9e 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list-empty__md.png and b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list-empty__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list-empty__sm.png b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list-empty__sm.png index 111802b39..9e4372422 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list-empty__sm.png and b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list-empty__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list-overflow__lg.png b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list-overflow__lg.png index 6f5a0250a..1b358a2e4 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list-overflow__lg.png and b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list-overflow__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list-overflow__md.png b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list-overflow__md.png index 165fa6a80..7543153bb 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list-overflow__md.png and b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list-overflow__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list-overflow__sm.png b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list-overflow__sm.png index 49d99bd47..9d7450525 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list-overflow__sm.png and b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list-overflow__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list__lg.png b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list__lg.png index ff4a2f0ff..31dbe6279 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list__lg.png and b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list__md.png b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list__md.png index 724faab25..c75c0743e 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list__md.png and b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list__sm.png b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list__sm.png index e3c44e588..1edc53313 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list__sm.png and b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__detail-popup__lg.png b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__detail-popup__lg.png index a98ac9ca0..8d6a8b8db 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__detail-popup__lg.png and b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__detail-popup__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__detail-popup__md.png b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__detail-popup__md.png index 8fb6894a2..a9ff2680f 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__detail-popup__md.png and b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__detail-popup__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__detail-popup__sm.png b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__detail-popup__sm.png index 7d9135005..047954512 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__detail-popup__sm.png and b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__detail-popup__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__in-progress__sm.png b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__in-progress__sm.png index 51e0c852d..201dd4fdf 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__in-progress__sm.png and b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__in-progress__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__live-only__sm.png b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__live-only__sm.png index 0161cce50..9ecfe0a31 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__live-only__sm.png and b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__live-only__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__staged-node-removal__lg.png b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__staged-node-removal__lg.png index d5dccc034..eed47accb 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__staged-node-removal__lg.png and b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__staged-node-removal__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__staged-node-removal__md.png b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__staged-node-removal__md.png index 36e1bc1a3..14743780b 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__staged-node-removal__md.png and b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__staged-node-removal__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__staged-node-removal__sm.png b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__staged-node-removal__sm.png index 12809f93c..8b3105255 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__staged-node-removal__sm.png and b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__staged-node-removal__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__unchanged__sm.png b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__unchanged__sm.png index 72ca903e8..20540611d 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__unchanged__sm.png and b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__unchanged__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__uncommitted__sm.png b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__uncommitted__sm.png index 51e0c852d..201dd4fdf 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__uncommitted__sm.png and b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__uncommitted__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__sidebar-dirty-tree__sm.png b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__sidebar-dirty-tree__sm.png index 1169b8698..06970a4ec 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__sidebar-dirty-tree__sm.png and b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__sidebar-dirty-tree__sm.png differ diff --git a/lp-cli/src/commands/create/project.rs b/lp-cli/src/commands/create/project.rs index 8297025b9..1a1fcdb1a 100644 --- a/lp-cli/src/commands/create/project.rs +++ b/lp-cli/src/commands/create/project.rs @@ -94,12 +94,12 @@ mod tests { assert!(project_dir.join("project.json").exists()); let project_json = std::fs::read_to_string(project_dir.join("project.json")).unwrap(); - let def = NodeDef::from_json_str(&project_json).unwrap(); - let NodeDef::Project(project) = def else { - panic!("expected project def"); - }; - assert_eq!(project.name(), Some("my-project")); - assert!(!project_json.contains("\"uid\"")); + let manifest = lpc_model::ProjectManifest::read_json(&project_json).unwrap(); + assert_eq!(manifest.name.as_deref(), Some("my-project")); + assert!(manifest.uid.is_none()); + let module_json = std::fs::read_to_string(project_dir.join("module.json")).unwrap(); + let def = NodeDef::from_json_str(&module_json).unwrap(); + assert!(matches!(def, NodeDef::Module(_))); assert!(project_dir.join("shader.glsl").exists()); } @@ -111,12 +111,9 @@ mod tests { create_project_structure(&project_dir, Some("Custom Name")).unwrap(); let project_json = std::fs::read_to_string(project_dir.join("project.json")).unwrap(); - let def = NodeDef::from_json_str(&project_json).unwrap(); - let NodeDef::Project(project) = def else { - panic!("expected project def"); - }; - assert_eq!(project.name(), Some("Custom Name")); - assert!(!project_json.contains("\"uid\"")); + let manifest = lpc_model::ProjectManifest::read_json(&project_json).unwrap(); + assert_eq!(manifest.name.as_deref(), Some("Custom Name")); + assert!(manifest.uid.is_none()); } #[test] diff --git a/lp-cli/src/commands/schema/generate.rs b/lp-cli/src/commands/schema/generate.rs index 45adf761a..6d429d573 100644 --- a/lp-cli/src/commands/schema/generate.rs +++ b/lp-cli/src/commands/schema/generate.rs @@ -3,11 +3,13 @@ //! Generates, from the model's static slot shape catalog and the board //! manifest serde types: //! -//! - `project.schema.json` — authored `project.json` roots: top-level -//! `kind: "Project"` const, `format` pinned to -//! [`lpc_model::PROJECT_FORMAT_VERSION`], and the compiled `ProjectDef` -//! shape (mirrors the loader gate in `lpc-registry`, which rejects project -//! roots whose `format` is missing or mismatched). +//! - `project.schema.json` — the `project.json` container manifest (not a +//! node envelope): `format` pinned to +//! [`lpc_model::PROJECT_FORMAT_VERSION`], optional `uid`/`name`, nothing +//! else (mirrors the loader gate in `lpc-registry`, which hard-refuses a +//! missing/malformed manifest and rejects mismatched formats). +//! - `module.schema.json` — the `module.json` root module node artifact: +//! top-level `kind: "Module"` const plus the compiled `ModuleDef` shape. //! - `node.schema.json` — any authored node artifact: `oneOf` over every //! registered node kind, discriminated by top-level `kind`. //! - `hardware.schema.json` — the plain-serde board manifest @@ -35,7 +37,7 @@ use anyhow::{Context, Result, anyhow, bail}; use lpc_hardware::HardwareManifestFile; use lpc_model::schema_gen::{compile_registered_slot_shape_schema, compile_slot_shape_schema}; use lpc_model::{ - NodeArtifact, PROJECT_FORMAT_VERSION, ProjectDef, Revision, SlotEnumEncoding, SlotMeta, + ModuleDef, NodeArtifact, PROJECT_FORMAT_VERSION, Revision, SlotEnumEncoding, SlotMeta, SlotShape, SlotShapeRegistry, SlotVariantShape, StaticSlotShape, }; use serde_json::{Map, Value, json}; @@ -71,7 +73,11 @@ fn generate_outputs() -> Result> { outputs.insert( String::from("project.schema.json"), - render_schema(project_schema(®istry)?, "project.schema.json")?, + render_schema(project_schema()?, "project.schema.json")?, + ); + outputs.insert( + String::from("module.schema.json"), + render_schema(module_schema(®istry)?, "module.schema.json")?, ); outputs.insert( String::from("node.schema.json"), @@ -133,63 +139,73 @@ fn populated_registry() -> Result { Ok(registry) } -/// Schema for authored `project.json` roots. +/// Schema for the `project.json` container manifest. +/// +/// The container is NOT a node envelope (docs/design/modules.md §1/§6): it +/// carries the workspace identity — `format` pinned to the current +/// [`PROJECT_FORMAT_VERSION`], optional `uid` and `name` — and nothing +/// else (`additionalProperties: false` mirrors the strict streaming reader +/// in `lpc_model::ProjectManifest`, which rejects unknown fields). +fn project_schema() -> Result { + Ok(json!({ + "title": "LightPlayer project container manifest", + "description": "Workspace identity of a project folder: format \ + version, library uid, and display name. Not a node artifact — the root \ + module node lives in module.json.", + "type": "object", + "properties": { + "format": { + "title": "Authored format version", + "const": PROJECT_FORMAT_VERSION, + }, + "uid": { + "title": "Stable project identity (prj_…, base-62)", + "type": "string", + }, + "name": { + "title": "Human-readable project name", + "type": "string", + }, + }, + "required": ["format"], + "additionalProperties": false, + })) +} + +/// Schema for the `module.json` root module node artifact. /// -/// Compiled as a single-variant `kind`-tagged enum over `ProjectDef`, so the +/// Compiled as a single-variant `kind`-tagged enum over `ModuleDef`, so the /// discriminator/flattening semantics come from the same compiler that -/// produces `node.schema.json`; then `format` is pinned to the current -/// [`PROJECT_FORMAT_VERSION`] and required alongside `kind`, mirroring the -/// P1 loader gate (`lpc-registry` rejects project roots whose `format` is -/// missing or does not match). -fn project_schema(registry: &SlotShapeRegistry) -> Result { +/// produces `node.schema.json`. +fn module_schema(registry: &SlotShapeRegistry) -> Result { let root_shape = SlotShape::Enum { meta: SlotMeta::empty(), encoding: SlotEnumEncoding::tagged_kind(), variants: vec![ - SlotVariantShape::new("Project", SlotShape::reference(ProjectDef::SHAPE_ID)) - .map_err(|error| anyhow!("project variant name: {error}"))?, + SlotVariantShape::new("Module", SlotShape::reference(ModuleDef::SHAPE_ID)) + .map_err(|error| anyhow!("module variant name: {error}"))?, ], }; let compiled = compile_slot_shape_schema(registry, &root_shape); let Value::Object(mut envelope) = compiled else { - bail!("compiled project schema is not a JSON object"); + bail!("compiled module schema is not a JSON object"); }; // Unwrap the single oneOf branch into the document root, keeping the // compiler's envelope keys. let branches = envelope.remove("oneOf"); let Some(Value::Array(mut branches)) = branches else { - bail!("compiled project schema has no oneOf envelope"); + bail!("compiled module schema has no oneOf envelope"); }; let (Some(Value::Object(mut root)), true) = (branches.pop(), branches.is_empty()) else { - bail!("compiled project schema is not a single-variant oneOf"); + bail!("compiled module schema is not a single-variant oneOf"); }; for key in ["$schema", "$defs"] { if let Some(value) = envelope.remove(key) { root.insert(String::from(key), value); } } - - let properties = root - .get_mut("properties") - .and_then(Value::as_object_mut) - .ok_or_else(|| anyhow!("compiled project schema has no properties object"))?; - let compiled_format = properties - .remove("format") - .ok_or_else(|| anyhow!("compiled ProjectDef shape has no `format` field"))?; - let mut format = Map::new(); - // Keep compiler-emitted presentation text on the pinned constant. - if let Value::Object(compiled_format) = compiled_format { - for key in ["title", "description"] { - if let Some(value) = compiled_format.get(key) { - format.insert(String::from(key), value.clone()); - } - } - } - format.insert(String::from("const"), json!(PROJECT_FORMAT_VERSION)); - properties.insert(String::from("format"), Value::Object(format)); - - root.insert(String::from("required"), json!(["kind", "format"])); + root.insert(String::from("required"), json!(["kind"])); Ok(Value::Object(root)) } @@ -401,7 +417,7 @@ mod tests { }) .collect(); let expected = [ - "Project", + "Module", "Button", "Clock", "Texture", @@ -420,21 +436,41 @@ mod tests { } #[test] - fn project_schema_pins_kind_and_format() { + fn project_schema_is_a_closed_container_manifest() { let outputs = generate_outputs().unwrap(); let project: Value = serde_json::from_str(&outputs["project.schema.json"]).unwrap(); - assert_eq!(project["properties"]["kind"]["const"], json!("Project")); assert_eq!( project["properties"]["format"]["const"], json!(PROJECT_FORMAT_VERSION) ); - assert_eq!(project["required"], json!(["kind", "format"])); + assert_eq!(project["required"], json!(["format"])); + assert_eq!(project["additionalProperties"], json!(false)); + assert!( + project["properties"]["kind"].is_null(), + "not a node envelope" + ); assert_eq!( project["$id"], json!(format!("{SCHEMA_ID_BASE}project.schema.json")) ); } + #[test] + fn module_schema_pins_kind() { + let outputs = generate_outputs().unwrap(); + let module: Value = serde_json::from_str(&outputs["module.schema.json"]).unwrap(); + assert_eq!(module["properties"]["kind"]["const"], json!("Module")); + assert_eq!(module["required"], json!(["kind"])); + assert!( + module["properties"]["format"].is_null(), + "format is a container concern" + ); + assert_eq!( + module["$id"], + json!(format!("{SCHEMA_ID_BASE}module.schema.json")) + ); + } + #[test] fn shape_index_lists_every_static_shape() { let outputs = generate_outputs().unwrap(); diff --git a/lp-cli/src/debug_ui/node_cards.rs b/lp-cli/src/debug_ui/node_cards.rs index c520430df..4099bb4ee 100644 --- a/lp-cli/src/debug_ui/node_cards.rs +++ b/lp-cli/src/debug_ui/node_cards.rs @@ -166,7 +166,7 @@ fn render_node_type_badge(ui: &mut egui::Ui, kind: &'static str, accent: egui::C painter.circle_stroke(rect.center(), 17.0, egui::Stroke::new(1.5_f32, accent)); let stroke = egui::Stroke::new(1.8_f32, accent); match kind { - "Project" => render_project_icon(painter, rect, accent), + "Module" => render_project_icon(painter, rect, accent), "Clock" => render_clock_icon(painter, rect, stroke), "Texture" => render_texture_icon(painter, rect, accent), "Shader" => render_shader_icon(painter, rect, stroke), @@ -620,7 +620,7 @@ fn node_kind_label(view: &ProjectView, id: NodeId) -> &'static str { return "Node"; }; if entry.parent.is_none() { - return "Project"; + return "Module"; } if let Some(kind) = entry .path @@ -635,7 +635,7 @@ fn node_kind_label(view: &ProjectView, id: NodeId) -> &'static str { }; match data { SlotData::Enum(value) => match value.variant.as_str() { - "Project" => "Project", + "Module" => "Module", "Clock" => "Clock", "Texture" => "Texture", "Shader" => "Shader", @@ -651,7 +651,7 @@ fn node_kind_label(view: &ProjectView, id: NodeId) -> &'static str { fn node_kind_from_path_tag(tag: &str) -> Option<&'static str> { match tag { - "project" | "show" => Some("Project"), + "module" | "project" | "show" => Some("Module"), "clock" => Some("Clock"), "texture" => Some("Texture"), "shader" => Some("Shader"), diff --git a/lp-cli/tests/dev_command.rs b/lp-cli/tests/dev_command.rs index 95194e938..472fe82c0 100644 --- a/lp-cli/tests/dev_command.rs +++ b/lp-cli/tests/dev_command.rs @@ -10,16 +10,19 @@ use std::fs; use std::path::PathBuf; use tempfile::TempDir; -/// Create a test project directory with project.json +/// Create a test project directory with a split project.json container +/// manifest and module.json root module (project/module mitosis). fn create_test_project_dir() -> (TempDir, PathBuf) { let temp_dir = TempDir::new().unwrap(); let project_dir = temp_dir.path().join("test-project"); fs::create_dir_all(&project_dir).unwrap(); - let project_json = r#"{ "kind": "Project", "format": 2, "name": "test-project" } -"#; + let project_json = "{\n \"format\": 3,\n \"name\": \"test-project\"\n}\n"; fs::write(project_dir.join("project.json"), project_json).unwrap(); + let module_json = "{\n \"kind\": \"Module\",\n \"nodes\": {}\n}\n"; + fs::write(project_dir.join("module.json"), module_json).unwrap(); + (temp_dir, project_dir) } diff --git a/lp-cli/tests/examples_valid.rs b/lp-cli/tests/examples_valid.rs index ab29951a4..543502e52 100644 --- a/lp-cli/tests/examples_valid.rs +++ b/lp-cli/tests/examples_valid.rs @@ -41,6 +41,62 @@ fn checked_in_examples_load_as_core_projects() -> Result<()> { Ok(()) } +#[test] +fn checked_in_examples_rewrite_byte_identically() -> Result<()> { + // Mitosis invariant: loading and re-writing an unchanged project + // produces identical bytes for BOTH split files — the container + // manifest through `ProjectManifest::write_json`, the root module + // through the canonical slot writer. + use lpc_model::{NodeDef, ProjectManifest, SlotShapeRegistry}; + + let workspace_dir = workspace_dir(); + let examples_dir = workspace_dir.join("examples"); + let mut project_dirs = Vec::new(); + collect_project_dirs(&examples_dir, &mut project_dirs)?; + project_dirs.sort(); + let registry = SlotShapeRegistry::default(); + + let mut failures = Vec::new(); + for project_dir in project_dirs { + let rel = project_dir + .strip_prefix(&workspace_dir) + .unwrap_or(&project_dir) + .display() + .to_string(); + + let manifest_text = std::fs::read_to_string(project_dir.join("project.json")) + .with_context(|| format!("{rel}: read project.json"))?; + match ProjectManifest::read_json(&manifest_text) { + Ok(manifest) if manifest.write_json() != manifest_text => { + failures.push(format!("{rel}: project.json is not canonical")); + } + Ok(_) => {} + Err(err) => failures.push(format!("{rel}: project.json: {err}")), + } + + let module_text = std::fs::read_to_string(project_dir.join("module.json")) + .with_context(|| format!("{rel}: read module.json"))?; + match NodeDef::read_json(®istry, &module_text) { + Ok(def) => match def.write_json(®istry) { + Ok(rewritten) if rewritten != module_text => { + failures.push(format!("{rel}: module.json is not canonical")); + } + Ok(_) => {} + Err(err) => failures.push(format!("{rel}: module.json rewrite: {err}")), + }, + Err(err) => failures.push(format!("{rel}: module.json: {err}")), + } + } + + if !failures.is_empty() { + anyhow::bail!( + "example projects failed the byte-identity rewrite:\n{}", + failures.join("\n") + ); + } + Ok(()) +} + fn workspace_dir() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")) .parent() diff --git a/lp-cli/tests/integration.rs b/lp-cli/tests/integration.rs index 798ed655c..e82a4a57b 100644 --- a/lp-cli/tests/integration.rs +++ b/lp-cli/tests/integration.rs @@ -70,19 +70,21 @@ fn process_messages( /// Create a test project on a filesystem /// -/// Creates a minimal project with project.json. +/// Creates a minimal project: the `project.json` container manifest plus the +/// `module.json` root module (project/module mitosis). #[allow( dead_code, reason = "async client integration tests are being rewritten" )] fn create_test_project(fs: &mut LpFsMemory, name: &str) -> Result<(), ClientError> { - let project_json = format!( - r#"{{ "kind": "Project", "format": 2, "name": "{name}" }} -"# - ); + let project_json = format!("{{\n \"format\": 3,\n \"name\": \"{name}\"\n}}\n"); fs.write_file_mut("/project.json".as_path(), project_json.as_bytes()) .map_err(|_| todo!())?; + let module_json = "{\n \"kind\": \"Module\",\n \"nodes\": {}\n}\n"; + fs.write_file_mut("/module.json".as_path(), module_json.as_bytes()) + .map_err(|_| todo!())?; + Ok(()) } @@ -130,14 +132,16 @@ fn test_create_command_structure() { let project_json = format!( r#"{{ - "kind": "Project", - "format": 2, + "format": 3, "name": "{project_name}" }} "# ); fs.write_file_mut("/project.json".as_path(), project_json.as_bytes()) .unwrap(); + let module_json = "{\n \"kind\": \"Module\",\n \"nodes\": {}\n}\n"; + fs.write_file_mut("/module.json".as_path(), module_json.as_bytes()) + .unwrap(); // Verify project.json exists and is valid let content = fs.read_file("/project.json".as_path()).unwrap(); diff --git a/lp-cli/tests/schema_conformance.rs b/lp-cli/tests/schema_conformance.rs index a341ee4ed..9d091980c 100644 --- a/lp-cli/tests/schema_conformance.rs +++ b/lp-cli/tests/schema_conformance.rs @@ -5,8 +5,10 @@ //! //! Corpus layout (see P1 of the schema/shape-gen hygiene plan): //! -//! - [`ARTIFACT_ROOTS`] hold authored projects: `project.json` roots validate -//! against `schemas/project.schema.json`, `*.map2d.json` files are fixture +//! - [`ARTIFACT_ROOTS`] hold authored projects: `project.json` container +//! manifests validate against `schemas/project.schema.json`, `module.json` +//! root modules validate against `schemas/module.schema.json`, +//! `*.map2d.json` files are fixture //! mapping documents and validate by parsing with the real `lpc-mapping` //! parser (which owns that format), and every other `*.json` is a node //! artifact and validates against `schemas/node.schema.json`. Non-JSON @@ -42,6 +44,7 @@ const SKIP_JSON: &[&str] = &[]; fn authored_artifacts_conform_to_checked_in_schemas() -> Result<()> { let workspace = workspace_dir(); let project_validator = load_validator(&workspace, "schemas/project.schema.json")?; + let module_validator = load_validator(&workspace, "schemas/module.schema.json")?; let node_validator = load_validator(&workspace, "schemas/node.schema.json")?; assert_skip_list_matches(&workspace)?; @@ -55,6 +58,7 @@ fn authored_artifacts_conform_to_checked_in_schemas() -> Result<()> { "no artifact JSON found under {root}/ — walk is vacuous" ); let mut projects = 0usize; + let mut modules = 0usize; let mut nodes = 0usize; let mut mappings = 0usize; for file in &files { @@ -70,6 +74,9 @@ fn authored_artifacts_conform_to_checked_in_schemas() -> Result<()> { let validator = if file.file_name().is_some_and(|name| name == "project.json") { projects += 1; &project_validator + } else if file.file_name().is_some_and(|name| name == "module.json") { + modules += 1; + &module_validator } else { nodes += 1; &node_validator @@ -77,7 +84,7 @@ fn authored_artifacts_conform_to_checked_in_schemas() -> Result<()> { validate_file(validator, file, &rel, &mut failures)?; } println!( - "{root}: validated {projects} project roots + {nodes} node artifacts + {mappings} mapping documents" + "{root}: validated {projects} container manifests + {modules} module roots + {nodes} node artifacts + {mappings} mapping documents" ); } diff --git a/lp-core/lpc-engine/src/dataflow/resolver/query_intern.rs b/lp-core/lpc-engine/src/dataflow/resolver/query_intern.rs index 9447f1768..3b205786f 100644 --- a/lp-core/lpc-engine/src/dataflow/resolver/query_intern.rs +++ b/lp-core/lpc-engine/src/dataflow/resolver/query_intern.rs @@ -62,8 +62,9 @@ impl Hasher for FnvHasher { fn hash_query(query: &QueryKey) -> u64 { let mut hasher = FnvHasher::default(); match query { - QueryKey::Bus(channel) => { + QueryKey::Bus { scope, channel } => { hasher.write_u8(0); + scope.hash(&mut hasher); channel.hash(&mut hasher); } QueryKey::ProducedSlot { node, slot } => { @@ -163,7 +164,10 @@ mod tests { use lpc_model::{ChannelName, NodeId}; fn bus(name: &str) -> QueryKey { - QueryKey::Bus(ChannelName(String::from(name))) + QueryKey::Bus { + scope: None, + channel: ChannelName(String::from(name)), + } } fn produced(node: u32, slot: &str) -> QueryKey { diff --git a/lp-core/lpc-engine/src/dataflow/resolver/query_key.rs b/lp-core/lpc-engine/src/dataflow/resolver/query_key.rs index 07d66f02b..4ce53b16f 100644 --- a/lp-core/lpc-engine/src/dataflow/resolver/query_key.rs +++ b/lp-core/lpc-engine/src/dataflow/resolver/query_key.rs @@ -5,10 +5,22 @@ use lpc_model::{ChannelName, NodeId, SlotAccessor, SlotPath}; +use crate::node::ScopeRef; + /// Demand/cache key for one resolved value in the engine resolver. +/// +/// Bus demand carries the READING scope (modules.md R5): the resolver +/// stays scope-dumb — the host answers "which providers win for a read +/// from this scope" — but the scope is part of the cache and +/// cycle-detection identity, so same-named channels in different scopes +/// can never collide or fake a cycle. `scope: None` is the unscoped form +/// test fakes use; engine paths always carry the reading node's scope. #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] pub enum QueryKey { - Bus(ChannelName), + Bus { + scope: Option, + channel: ChannelName, + }, ProducedSlot { node: NodeId, slot: SlotPath, @@ -46,8 +58,14 @@ mod tests { #[test] fn query_key_works_as_btree_map_key() { let mut m = VecMap::new(); - let k1 = QueryKey::Bus(ChannelName(String::from("a"))); - let k2 = QueryKey::Bus(ChannelName(String::from("b"))); + let k1 = QueryKey::Bus { + scope: None, + channel: ChannelName(String::from("a")), + }; + let k2 = QueryKey::Bus { + scope: None, + channel: ChannelName(String::from("b")), + }; m.insert(k1.clone(), 1u32); m.insert( QueryKey::ProducedSlot { diff --git a/lp-core/lpc-engine/src/dataflow/resolver/resolve_error.rs b/lp-core/lpc-engine/src/dataflow/resolver/resolve_error.rs index 2cef63bf8..dd33028ca 100644 --- a/lp-core/lpc-engine/src/dataflow/resolver/resolve_error.rs +++ b/lp-core/lpc-engine/src/dataflow/resolver/resolve_error.rs @@ -113,6 +113,15 @@ impl ResolveError { )) } + /// True when this error is the "channel has no provider anywhere" + /// case — the one shape callers may treat as a legitimate empty + /// channel (module mirrors render cleared, R7). Keyed off the + /// [`SessionResolveError::NoBusProvider`] display form, which the + /// tick-resolver bridge flattens to a message; keep the two in sync. + pub fn is_no_bus_provider(&self) -> bool { + self.message.starts_with("no bus provider") + } + /// Create an error for unresolvable binding. pub fn unresolvable(prop_path: impl Into) -> Self { Self::new(format!( diff --git a/lp-core/lpc-engine/src/dataflow/resolver/resolve_host.rs b/lp-core/lpc-engine/src/dataflow/resolver/resolve_host.rs index 571397fb4..e770ae13b 100644 --- a/lp-core/lpc-engine/src/dataflow/resolver/resolve_host.rs +++ b/lp-core/lpc-engine/src/dataflow/resolver/resolve_host.rs @@ -14,6 +14,7 @@ use alloc::vec::Vec; use lpc_model::{ChannelName, NodeId, Revision, SlotMerge, SlotPath}; use crate::dataflow::binding::{BindingEntry, BindingRef}; +use crate::node::ScopeRef; /// Engine or test fake that can satisfy demand for uncached queries. pub trait ResolveHost { @@ -45,7 +46,24 @@ pub trait ResolveHost { SlotMerge::Latest } - fn providers_for_bus(&self, _channel: &ChannelName) -> Vec<(BindingRef, BindingEntry)> { + /// The bus scope `node` writes into and reads from (its inhabited + /// scope; the root module reads its own introduced scope). `None` + /// means the host has no scope model — test fakes — and every read + /// shares the unscoped key. + fn node_scope(&self, _node: NodeId) -> Option { + None + } + + /// The winning provider set for a bus read performed from `scope`: + /// writer-shadowing (modules.md R5) resolves outward to the nearest + /// enclosing scope with at least one provider — entirely host-side, so + /// the resolver itself stays scope-dumb. `scope: None` (scopeless + /// hosts) answers with the flat provider set. + fn providers_for_bus( + &self, + _scope: Option, + _channel: &ChannelName, + ) -> Vec<(BindingRef, BindingEntry)> { Vec::new() } diff --git a/lp-core/lpc-engine/src/dataflow/resolver/resolve_session.rs b/lp-core/lpc-engine/src/dataflow/resolver/resolve_session.rs index 29ad7cbd6..503f37eec 100644 --- a/lp-core/lpc-engine/src/dataflow/resolver/resolve_session.rs +++ b/lp-core/lpc-engine/src/dataflow/resolver/resolve_session.rs @@ -174,7 +174,7 @@ impl<'a> EngineSession<'a> { query: &QueryKey, ) -> Result { match query { - QueryKey::Bus(channel) => self.compute_bus_route(host, channel), + QueryKey::Bus { scope, channel } => self.compute_bus_route(host, *scope, channel), QueryKey::ConsumedSlot { node, slot } => { self.compute_consumed_route(host, *node, slot, query) } @@ -188,12 +188,15 @@ impl<'a> EngineSession<'a> { fn compute_bus_route( &mut self, host: &mut H, + scope: Option, channel: &ChannelName, ) -> Result { self.resolver.counters_mut().binding_lookups += 1; - let candidates = host.providers_for_bus(channel); + let candidates = host.providers_for_bus(scope, channel); let (binding_ref, entry) = select_highest_priority_bus_provider(channel, &candidates)?; - let target = self.route_target(&entry.source); + // A provider that itself sources another channel reads it from its + // OWN scope (R4/R5) — the winning entry's owner supplies it. + let target = self.route_target(host, entry.owner, &entry.source); Ok(ResolvedRoute::Binding { binding_ref, target, @@ -246,15 +249,22 @@ impl<'a> EngineSession<'a> { Ok(match host.binding_for_consumed_slot(node, slot) { Some((binding_ref, entry)) => ResolvedRoute::Binding { binding_ref, - target: self.route_target(&entry.source), + target: self.route_target(host, entry.owner, &entry.source), }, None => ResolvedRoute::Produce, }) } /// Reduce a binding source to what the frame needs: a literal to - /// materialize, or the id of the query to resolve. - fn route_target(&mut self, source: &BindingSource) -> RouteTarget { + /// materialize, or the id of the query to resolve. `reader` is the + /// binding's owner — a bus source resolves from ITS scope (R5), which + /// becomes part of the interned key. + fn route_target( + &mut self, + host: &H, + reader: NodeId, + source: &BindingSource, + ) -> RouteTarget { match source { BindingSource::Literal(spec) => RouteTarget::Literal(spec.clone()), BindingSource::ProducedSlot { node, slot } => { @@ -264,7 +274,10 @@ impl<'a> EngineSession<'a> { })) } BindingSource::BusChannel(channel) => { - RouteTarget::Query(self.resolver.intern_query(&QueryKey::Bus(channel.clone()))) + RouteTarget::Query(self.resolver.intern_query(&QueryKey::Bus { + scope: host.node_scope(reader), + channel: channel.clone(), + })) } } } @@ -282,18 +295,22 @@ impl<'a> EngineSession<'a> { out: &mut Vec<(BindingRef, RouteTarget)>, ) -> Result<(), SessionResolveError> { let BindingSource::BusChannel(channel) = source else { - let target = self.route_target(source); + let target = self.route_target(host, binding_ref.owner, source); out.push((binding_ref, target)); return Ok(()); }; - let bus_query = QueryKey::Bus(channel.clone()); + let scope = host.node_scope(binding_ref.owner); + let bus_query = QueryKey::Bus { + scope, + channel: channel.clone(), + }; let bus_id = self.resolver.intern_query(&bus_query); self.trace .try_push_active(bus_id, &bus_query) .map_err(SessionResolveError::from)?; self.resolver.counters_mut().binding_lookups += 1; - let mut providers = host.providers_for_bus(channel); + let mut providers = host.providers_for_bus(scope, channel); providers.sort_by_key(|(provider_ref, entry)| (entry.priority, *provider_ref)); for (provider_ref, provider) in providers.iter() { if let Err(err) = self.expand_merge_inputs(host, *provider_ref, &provider.source, out) { @@ -532,7 +549,11 @@ mod tests { .collect() } - fn providers_for_bus(&self, channel: &ChannelName) -> Vec<(BindingRef, BindingEntry)> { + fn providers_for_bus( + &self, + _scope: Option, + channel: &ChannelName, + ) -> Vec<(BindingRef, BindingEntry)> { self.entries .iter() .filter_map(|(binding_ref, entry)| { @@ -582,8 +603,12 @@ mod tests { self.bindings.bindings_for_consumed_slot(node, slot) } - fn providers_for_bus(&self, channel: &ChannelName) -> Vec<(BindingRef, BindingEntry)> { - self.bindings.providers_for_bus(channel) + fn providers_for_bus( + &self, + scope: Option, + channel: &ChannelName, + ) -> Vec<(BindingRef, BindingEntry)> { + self.bindings.providers_for_bus(scope, channel) } } @@ -655,7 +680,13 @@ mod tests { ResolveTrace::new(ResolveLogLevel::Off), ); let pv = session - .resolve(&mut host, &QueryKey::Bus(c)) + .resolve( + &mut host, + &QueryKey::Bus { + scope: None, + channel: c, + }, + ) .expect("resolve bus"); assert!(pv.as_value().expect("value").eq(&LpsValueF32::F32(9.0))); assert_eq!(host.produce_calls, 0); @@ -729,7 +760,13 @@ mod tests { ResolveTrace::new(ResolveLogLevel::Off), ); let pv = session - .resolve(&mut host, &QueryKey::Bus(outer)) + .resolve( + &mut host, + &QueryKey::Bus { + scope: None, + channel: outer, + }, + ) .expect("bus chain"); assert!(pv.as_value().expect("value").eq(&LpsValueF32::F32(3.25))); } @@ -749,8 +786,12 @@ mod tests { )) } - fn providers_for_bus(&self, channel: &ChannelName) -> Vec<(BindingRef, BindingEntry)> { - self.bindings.providers_for_bus(channel) + fn providers_for_bus( + &self, + scope: Option, + channel: &ChannelName, + ) -> Vec<(BindingRef, BindingEntry)> { + self.bindings.providers_for_bus(scope, channel) } } @@ -789,7 +830,13 @@ mod tests { ResolveTrace::new(ResolveLogLevel::Off), ); let err = session - .resolve(&mut host, &QueryKey::Bus(a)) + .resolve( + &mut host, + &QueryKey::Bus { + scope: None, + channel: a, + }, + ) .expect_err("cycle"); assert!(matches!(err, SessionResolveError::Cycle { .. })); } @@ -849,8 +896,12 @@ mod tests { self.bindings.binding_for_consumed_slot(node, slot) } - fn providers_for_bus(&self, channel: &ChannelName) -> Vec<(BindingRef, BindingEntry)> { - self.bindings.providers_for_bus(channel) + fn providers_for_bus( + &self, + scope: Option, + channel: &ChannelName, + ) -> Vec<(BindingRef, BindingEntry)> { + self.bindings.providers_for_bus(scope, channel) } fn merge_policy_for_consumed_slot(&self, node: NodeId, slot: &SlotPath) -> SlotMerge { @@ -1032,8 +1083,12 @@ mod tests { } } - fn providers_for_bus(&self, channel: &ChannelName) -> Vec<(BindingRef, BindingEntry)> { - self.bindings.providers_for_bus(channel) + fn providers_for_bus( + &self, + scope: Option, + channel: &ChannelName, + ) -> Vec<(BindingRef, BindingEntry)> { + self.bindings.providers_for_bus(scope, channel) } } @@ -1063,14 +1118,28 @@ mod tests { let mut host = TraceHost { node, bindings }; let mut session = ResolveSession::new(frame, &mut resolver, trace); session - .resolve(&mut host, &QueryKey::Bus(bus.clone())) + .resolve( + &mut host, + &QueryKey::Bus { + scope: None, + channel: bus.clone(), + }, + ) .unwrap(); // Second resolve — cache hit on bus - session.resolve(&mut host, &QueryKey::Bus(bus)).unwrap(); + session + .resolve( + &mut host, + &QueryKey::Bus { + scope: None, + channel: bus, + }, + ) + .unwrap(); let evs = session.trace().events(); assert!(evs.iter().any(|e| { - matches!(e, ResolveTraceEvent::BeginQuery(QueryKey::Bus(b)) if b.0 == "out") + matches!(e, ResolveTraceEvent::BeginQuery(QueryKey::Bus { scope: None, channel: b }) if b.0 == "out") })); assert!( evs.iter() @@ -1086,7 +1155,7 @@ mod tests { ))); assert!(evs.iter().any(|e| matches!( e, - ResolveTraceEvent::CacheHit(QueryKey::Bus(b)) if b.0 == "out" + ResolveTraceEvent::CacheHit(QueryKey::Bus { scope: None, channel: b }) if b.0 == "out" ))); } } diff --git a/lp-core/lpc-engine/src/dataflow/resolver/resolver_cache.rs b/lp-core/lpc-engine/src/dataflow/resolver/resolver_cache.rs index b44bb2686..d978d2534 100644 --- a/lp-core/lpc-engine/src/dataflow/resolver/resolver_cache.rs +++ b/lp-core/lpc-engine/src/dataflow/resolver/resolver_cache.rs @@ -146,7 +146,10 @@ mod tests { } fn id(table: &mut QueryInternTable, name: &str) -> QueryId { - table.intern(&QueryKey::Bus(ChannelName(String::from(name)))) + table.intern(&QueryKey::Bus { + scope: None, + channel: ChannelName(String::from(name)), + }) } #[test] diff --git a/lp-core/lpc-engine/src/engine/engine.rs b/lp-core/lpc-engine/src/engine/engine.rs index 1cf06d68a..f068b6092 100644 --- a/lp-core/lpc-engine/src/engine/engine.rs +++ b/lp-core/lpc-engine/src/engine/engine.rs @@ -18,7 +18,6 @@ use lpc_shared::time::TimeProvider; use lpc_wire::{ControlDisplayLayoutProbeResult, ControlDisplayLayoutRead, NodeRuntimeStatus}; use crate::dataflow::binding::{BindingDraft, BindingError, BindingRef}; -use crate::dataflow::bus::Bus; use crate::dataflow::resolver::{ EngineSession, Production, ProductionSource, QueryKey, ResolveHost, ResolveLogLevel, ResolveTrace, Resolver, SessionHostResolver, SessionResolveError, TickResolver, @@ -213,8 +212,7 @@ impl Engine { match state { NodeEntryState::Alive(mut runtime) => { - let bus = Bus::new(); - let mut ctx = crate::node::DestroyCtx::new(node, frame, &bus); + let mut ctx = crate::node::DestroyCtx::new(node, frame); runtime .destroy(&mut ctx) .map_err(|err| EngineError::node(node, err))?; @@ -366,16 +364,17 @@ impl Engine { fs.write_file(node_path.as_path(), text.as_bytes()) .map_err(|e| e.to_string())?; } - let project = - format!("{{ \"kind\": \"Project\", \"format\": 2, \"nodes\": {{ {node_lines} }} }}"); - fs.write_file("/project.json".as_path(), project.as_bytes()) + fs.write_file("/project.json".as_path(), b"{\n \"format\": 3\n}\n") + .map_err(|e| e.to_string())?; + let module = format!("{{ \"kind\": \"Module\", \"nodes\": {{ {node_lines} }} }}"); + fs.write_file("/module.json".as_path(), module.as_bytes()) .map_err(|e| e.to_string())?; let ctx = ParseCtx { shapes: &self.slot_shapes, }; registry - .load_root(&fs, "/project.json".as_path(), frame, &ctx) + .load_root(&fs, "/module.json".as_path(), frame, &ctx) .map_err(|e| format!("{e:?}"))?; for (index, (node_id, _)) in defs.iter().enumerate() { @@ -551,7 +550,10 @@ impl Engine { registry: &ProjectRegistry, channel: &str, ) -> Result { - let key = QueryKey::Bus(lpc_model::ChannelName(channel.to_string())); + let key = QueryKey::Bus { + scope: self.tree.node_scope(self.tree.root()), + channel: lpc_model::ChannelName(channel.to_string()), + }; let fid = self.revision; let mut resolver_tmp = core::mem::replace(&mut self.resolver, Resolver::new()); // A forced-fresh read, not an invalidation: the caller wants values @@ -668,7 +670,14 @@ impl Engine { radio_service, frame_time_seconds: time_s, }; - let result = session.resolve(&mut host, &QueryKey::Bus(channel.clone())); + let scope = host.tree.node_scope(host.tree.root()); + let result = session.resolve( + &mut host, + &QueryKey::Bus { + scope, + channel: channel.clone(), + }, + ); self.resolver = resolver; result } @@ -911,7 +920,7 @@ impl ResolveHost for EngineResolveHost<'_> { QueryKey::ConsumedSlotAccessor { node, accessor } => { self.produce_consumed_slot_accessor(*node, accessor) } - QueryKey::Bus(_) => Err(SessionResolveError::other( + QueryKey::Bus { .. } => Err(SessionResolveError::other( "engine host cannot satisfy bus query", )), } @@ -939,12 +948,26 @@ impl ResolveHost for EngineResolveHost<'_> { .collect() } + fn node_scope(&self, node: NodeId) -> Option { + // Reading scope: a scope INTRODUCER's bus reads face inward — that + // is R7's export semantics (a module republishing an inner channel + // reads it from the scope it introduces; the root's unscoped reads + // are root-scope reads). Every other node reads from the scope it + // inhabits. Provider (write-side) classification stays on + // `NodeTree::node_scope` — module produces land in the parent + // scope per R4. + self.tree + .scope_introduced_by(node) + .or_else(|| self.tree.node_scope(node)) + } + fn providers_for_bus( &self, + scope: Option, channel: &lpc_model::ChannelName, ) -> Vec<(BindingRef, crate::dataflow::binding::BindingEntry)> { self.tree - .providers_for_bus(channel) + .providers_for_bus_read(scope, channel) .into_iter() .map(|(binding_ref, entry)| (binding_ref, entry.clone())) .collect() @@ -2220,7 +2243,10 @@ mod tests { let out = path("outputs[0]"); let (_, trace) = h - .resolve_with_trace(QueryKey::Bus(lpc_model::ChannelName(String::from("video")))) + .resolve_with_trace(QueryKey::Bus { + scope: None, + channel: lpc_model::ChannelName(String::from("video")), + }) .expect("resolve with trace"); assert!(trace_has_value_origin_path( @@ -2275,14 +2301,14 @@ mod tests { Err(NodeError::msg("intentional tick failure")) } - fn destroy(&mut self, _ctx: &mut crate::node::DestroyCtx<'_>) -> Result<(), NodeError> { + fn destroy(&mut self, _ctx: &mut crate::node::DestroyCtx) -> Result<(), NodeError> { Ok(()) } fn handle_memory_pressure( &mut self, _level: crate::node::PressureLevel, - _ctx: &mut crate::node::MemPressureCtx<'_>, + _ctx: &mut crate::node::MemPressureCtx, ) -> Result<(), NodeError> { Ok(()) } diff --git a/lp-core/lpc-engine/src/engine/mod.rs b/lp-core/lpc-engine/src/engine/mod.rs index eb2a13aad..14490ba1e 100644 --- a/lp-core/lpc-engine/src/engine/mod.rs +++ b/lp-core/lpc-engine/src/engine/mod.rs @@ -23,6 +23,8 @@ mod project_read_stream; mod project_runtime_index; #[cfg(test)] mod resolution_persistence_tests; +#[cfg(test)] +mod scoped_resolution_tests; mod srgb8_lut; #[cfg(test)] pub(crate) mod test_support; diff --git a/lp-core/lpc-engine/src/engine/output_flush_tests.rs b/lp-core/lpc-engine/src/engine/output_flush_tests.rs index 59bda894f..6cf2949e3 100644 --- a/lp-core/lpc-engine/src/engine/output_flush_tests.rs +++ b/lp-core/lpc-engine/src/engine/output_flush_tests.rs @@ -201,14 +201,14 @@ impl NodeRuntime for SolidFixtureProducer { Ok(ProduceResult::Produced) } - fn destroy(&mut self, _ctx: &mut DestroyCtx<'_>) -> Result<(), NodeError> { + fn destroy(&mut self, _ctx: &mut DestroyCtx) -> Result<(), NodeError> { Ok(()) } fn handle_memory_pressure( &mut self, _level: PressureLevel, - _ctx: &mut MemPressureCtx<'_>, + _ctx: &mut MemPressureCtx, ) -> Result<(), NodeError> { Ok(()) } diff --git a/lp-core/lpc-engine/src/engine/project_apply.rs b/lp-core/lpc-engine/src/engine/project_apply.rs index c55e55317..26bae10b0 100644 --- a/lp-core/lpc-engine/src/engine/project_apply.rs +++ b/lp-core/lpc-engine/src/engine/project_apply.rs @@ -13,7 +13,6 @@ use lpc_registry::ProjectRegistry; use lpfs::LpFs; use crate::node::{AssetRefreshContext, AssetRefreshResult, NodeEntryState}; -use crate::nodes::CorePlaceholderNode; use super::{Engine, ProjectLoadError, ProjectLoader}; @@ -142,7 +141,7 @@ impl Engine { if reattach_roots.contains(&location) { self.reattach_runtime_node( self.tree().root(), - alloc::boxed::Box::new(CorePlaceholderNode::new_leaf(NodeKind::Project)), + crate::nodes::ModuleNode::boxed(self.tree().root()), frame, ) .map_err(|e| { diff --git a/lp-core/lpc-engine/src/engine/project_loader.rs b/lp-core/lpc-engine/src/engine/project_loader.rs index d68d43561..27ce00813 100644 --- a/lp-core/lpc-engine/src/engine/project_loader.rs +++ b/lp-core/lpc-engine/src/engine/project_loader.rs @@ -1,4 +1,4 @@ -//! Load authored `project.json` node-artifact trees into [`super::Engine`]. +//! Load authored module node-artifact trees into [`super::Engine`]. use alloc::boxed::Box; use alloc::format; @@ -45,7 +45,6 @@ use crate::nodes::ButtonNode; use crate::nodes::ClockNode; #[cfg(feature = "node-radio")] use crate::nodes::ControlRadioNode; -use crate::nodes::CorePlaceholderNode; #[cfg(feature = "node-fluid")] use crate::nodes::FluidNode; use crate::nodes::OutputNode; @@ -111,12 +110,6 @@ pub(super) enum ProjectedNodeOwnership { PlaylistEntry { playlist: NodeId, entry: u32 }, } -impl ProjectedNodeOwnership { - fn suppress_visual_default_output(self) -> bool { - matches!(self, Self::PlaylistEntry { .. }) - } -} - /// Loads the authored project artifact tree into a core engine-backed runtime. pub struct ProjectLoader; @@ -125,7 +118,7 @@ impl ProjectLoader { root: &dyn LpFs, services: EngineServices, ) -> Result { - Self::load_project_artifact(root, services, ArtifactSpec::path("/project.json")) + Self::load_project_artifact(root, services, ArtifactSpec::path("/module.json")) } pub fn load_project_artifact( @@ -170,10 +163,10 @@ impl ProjectLoader { })?; match &entry.state { - NodeDefState::Loaded(NodeDef::Project(_)) => Ok(()), + NodeDefState::Loaded(NodeDef::Module(_)) => Ok(()), NodeDefState::Loaded(other) => Err(ProjectLoadError::ProjectParse { file: path.as_str().to_string(), - error: format!("root artifact must be Project, got {:?}", other.kind()), + error: format!("root artifact must be Module, got {:?}", other.kind()), }), state => Err(project_load_error_for_root_state(path, state)), } @@ -201,11 +194,7 @@ impl ProjectLoader { } } runtime - .attach_runtime_node( - root, - Box::new(CorePlaceholderNode::new_leaf(NodeKind::Project)), - frame, - ) + .attach_runtime_node(root, crate::nodes::ModuleNode::boxed(root), frame) .map_err(|e| ProjectLoadError::InvalidProjectReference { path: artifact_specifier_label(&project_specifier), reason: format!("attach project runtime: {e}"), @@ -247,7 +236,7 @@ impl ProjectLoader { // per-kind attach loops below — it is present in the tree but // drives nothing. `mark_node_load_error` is what makes that // visible; do not read this fallback as "it is a project". - let kind = def_entry.state.kind().unwrap_or(NodeKind::Project); + let kind = def_entry.state.kind().unwrap_or(NodeKind::Module); let state_error = def_entry .state .is_error() @@ -337,6 +326,39 @@ impl ProjectLoader { project_node.def_location.clone(), ); } + + // Structural scope (modules.md R1/R2), recomputed identically on + // BOTH entry points — fresh load and apply both run this spine + // pass, so an edited project can never wear different scopes + // than a reloaded one. Ownership already carries the answer: + // project children live in their parent module's scope; a + // playlist entry's child lives in that entry's sink scope. + { + let scope = match ownership { + ProjectedNodeOwnership::Root => None, + ProjectedNodeOwnership::ProjectChild => { + parent.map(|owner| crate::node::ScopeRef::Module { owner }) + } + ProjectedNodeOwnership::PlaylistEntry { playlist, entry } => { + Some(crate::node::ScopeRef::Sink { + owner: playlist, + entry, + }) + } + }; + // The root introduces the root scope even while its def is + // broken (R1: the engine always answers); other nodes + // introduce iff their def is known module-kinded — the + // failed-def kind fallback above must not mint scopes. + let introduces = matches!(ownership, ProjectedNodeOwnership::Root) + || matches!(def_entry.state.kind(), Some(NodeKind::Module)); + let entry = runtime + .tree_mut() + .get_mut(node_id) + .ok_or(ProjectLoadError::Tree(TreeError::UnknownNode(node_id)))?; + entry.scope = scope; + entry.introduces_scope = introduces; + } if let Some(message) = state_error { mark_node_load_error( runtime, @@ -408,6 +430,28 @@ impl ProjectLoader { targets: Option<&VecSet>, frame: Revision, ) -> Result<(), ProjectLoadError> { + for node in projected_nodes { + if !should_attach_projected_node(node, targets) { + continue; + } + if node.kind != NodeKind::Module || node.ownership == ProjectedNodeOwnership::Root { + // Root already wears its ModuleNode from the spine pass — + // it must have a runtime even when its def is broken. + continue; + } + // Broken defs project with the module FALLBACK kind — they must + // stay error nodes, not wear a live module runtime. + let Ok(NodeDef::Module(_)) = projected_node_config(registry, node) else { + continue; + }; + // Never feature-gated: every build carries the module runtime. + runtime + .attach_runtime_node(node.id, crate::nodes::ModuleNode::boxed(node.id), frame) + .map_err(|e| ProjectLoadError::InvalidProjectReference { + path: node_label(node), + reason: format!("attach module runtime: {e}"), + })?; + } for node in projected_nodes { if !should_attach_projected_node(node, targets) { continue; @@ -432,7 +476,7 @@ impl ProjectLoader { runtime .attach_runtime_node( node.id, - Box::new(CorePlaceholderNode::new_leaf(NodeKind::Clock)), + Box::new(crate::nodes::CorePlaceholderNode::new_leaf(NodeKind::Clock)), frame, ) .map_err(|e| ProjectLoadError::InvalidProjectReference { @@ -466,7 +510,9 @@ impl ProjectLoader { runtime .attach_runtime_node( node.id, - Box::new(CorePlaceholderNode::new_leaf(NodeKind::Button)), + Box::new(crate::nodes::CorePlaceholderNode::new_leaf( + NodeKind::Button, + )), frame, ) .map_err(|e| ProjectLoadError::InvalidProjectReference { @@ -501,7 +547,9 @@ impl ProjectLoader { runtime .attach_runtime_node( node.id, - Box::new(CorePlaceholderNode::new_leaf(NodeKind::ControlRadio)), + Box::new(crate::nodes::CorePlaceholderNode::new_leaf( + NodeKind::ControlRadio, + )), frame, ) .map_err(|e| ProjectLoadError::InvalidProjectReference { @@ -532,7 +580,9 @@ impl ProjectLoader { runtime .attach_runtime_node( node.id, - Box::new(CorePlaceholderNode::new_leaf(NodeKind::Texture)), + Box::new(crate::nodes::CorePlaceholderNode::new_leaf( + NodeKind::Texture, + )), frame, ) .map_err(|e| ProjectLoadError::InvalidProjectReference { @@ -605,7 +655,9 @@ impl ProjectLoader { runtime .attach_runtime_node( node.id, - Box::new(CorePlaceholderNode::new_leaf(NodeKind::Shader)), + Box::new(crate::nodes::CorePlaceholderNode::new_leaf( + NodeKind::Shader, + )), frame, ) .map_err(|e| ProjectLoadError::InvalidProjectReference { @@ -665,7 +717,9 @@ impl ProjectLoader { runtime .attach_runtime_node( node.id, - Box::new(CorePlaceholderNode::new_leaf(NodeKind::ComputeShader)), + Box::new(crate::nodes::CorePlaceholderNode::new_leaf( + NodeKind::ComputeShader, + )), frame, ) .map_err(|e| ProjectLoadError::InvalidProjectReference { @@ -699,7 +753,7 @@ impl ProjectLoader { runtime .attach_runtime_node( node.id, - Box::new(CorePlaceholderNode::new_leaf(NodeKind::Fluid)), + Box::new(crate::nodes::CorePlaceholderNode::new_leaf(NodeKind::Fluid)), frame, ) .map_err(|e| ProjectLoadError::InvalidProjectReference { @@ -749,7 +803,9 @@ impl ProjectLoader { runtime .attach_runtime_node( node.id, - Box::new(CorePlaceholderNode::new_leaf(NodeKind::Playlist)), + Box::new(crate::nodes::CorePlaceholderNode::new_leaf( + NodeKind::Playlist, + )), frame, ) .map_err(|e| ProjectLoadError::InvalidProjectReference { @@ -803,7 +859,9 @@ impl ProjectLoader { runtime .attach_runtime_node( node.id, - Box::new(CorePlaceholderNode::new_leaf(NodeKind::Fixture)), + Box::new(crate::nodes::CorePlaceholderNode::new_leaf( + NodeKind::Fixture, + )), frame, ) .map_err(|e| ProjectLoadError::InvalidProjectReference { @@ -1346,10 +1404,28 @@ fn register_node_bindings( node: &ProjectedNode, frame: Revision, ) -> Result<(), ProjectLoadError> { - // Unloaded/errored defs project with the `Project` fallback kind and - // register nothing — same tolerance the attach arms' kind filters gave - // them (the node renders as an error node; the load must not fail). - if node.kind == NodeKind::Project { + // Module nodes register their R7 output interface: authored bindings + // (the contention pick), authored exports, and — for NON-root modules — + // the automatic `output` → `visual.out` publish at fallback priority + // that makes an embedded module drop-in (root is excluded: its + // containing scope does not exist; its mirror is what playback reads). + // Unloaded/errored defs project with the module fallback kind and + // register nothing — the node renders as an error node; the load must + // not fail. + if node.kind == NodeKind::Module { + if let Ok(NodeDef::Module(config)) = projected_node_config(registry, node) { + let config = config.clone(); + register_target_binding( + runtime, + projected_nodes, + node, + "output", + &config.bindings, + frame, + )?; + register_module_exports(runtime, node, &config, frame)?; + register_module_output_default(runtime, node, &config.bindings, frame)?; + } return Ok(()); } match projected_node_config(registry, node)?.clone() { @@ -1590,7 +1666,7 @@ fn register_node_bindings( )?; register_declared_defaults(runtime, projected_nodes, node, &config.bindings, frame)?; } - NodeDef::Project(_) | NodeDef::Texture(_) => {} + NodeDef::Module(_) | NodeDef::Texture(_) => {} } Ok(()) } @@ -1673,9 +1749,7 @@ fn register_default_bind( reason: format!("invalid default_bind slot `{name}`: {e}"), })?; let draft = if direction == SlotDirection::Produced { - if current.ownership.suppress_visual_default_output() - || binding_target(bindings, name).is_some() - { + if binding_target(bindings, name).is_some() { return Ok(()); } let source = BindingSource::ProducedSlot { @@ -1881,6 +1955,94 @@ fn register_target_binding( /// guess stamped e.g. `trigger` as Color because only the time-family /// names were listed (2026-07-16: bus pane showed "trigger COLOR"). /// Endpoints outside the registry fall back to the slot-name heuristic. +/// An embedded module contributes its visual to its host by default (R7): +/// the mirror's produced `output` publishes `visual.out` at fallback +/// priority. Per R4 that lands in the module NODE's own nearest scope (the +/// parent's) — the node sits there; only its children are inside the scope +/// it introduces. The ROOT module is skipped: its containing scope does +/// not exist, and its mirror is what playback reads. An authored `output` +/// binding (the contention pick) suppresses the default, same as every +/// declared default bind. +fn register_module_output_default( + engine: &mut Engine, + node: &ProjectedNode, + bindings: &BindingDefs, + frame: Revision, +) -> Result<(), ProjectLoadError> { + if node.ownership == ProjectedNodeOwnership::Root { + return Ok(()); + } + if binding_target(bindings, "output").is_some() { + return Ok(()); + } + let slot = SlotPath::parse("output").expect("module output path"); + let channel = lpc_model::ChannelName(String::from(lpc_model::PRIMARY_VISUAL_CHANNEL)); + let source = BindingSource::ProducedSlot { + node: node.id, + slot, + }; + let target = BindingTarget::BusChannel(channel); + engine + .add_binding( + BindingDraft { + kind: binding_kind(&source, &target, "output"), + source, + target, + priority: BindingPriority::default_fallback(), + owner: node.id, + }, + frame, + ) + .map_err(|e| ProjectLoadError::InvalidProjectReference { + path: node_label(node), + reason: format!("register module output mirror default: {e}"), + })?; + Ok(()) +} + +/// Authored exports (R7): each entry republishes an inner-scope channel +/// outward under the export's name. The binding's SOURCE is the inner +/// channel — module-owned bus reads resolve from the introduced scope (the +/// engine host's reading-scope rule) — and its TARGET is the export-named +/// channel in the module's own nearest scope. Deliberate curation, so it +/// registers at authored priority. +fn register_module_exports( + engine: &mut Engine, + node: &ProjectedNode, + config: &lpc_model::ModuleDef, + frame: Revision, +) -> Result<(), ProjectLoadError> { + for (name, inner) in config.exports.entries.iter() { + let inner_channel = match inner.value() { + lpc_model::BindingRef::Bus(bus) => bus.channel().clone(), + other => { + return Err(ProjectLoadError::InvalidProjectReference { + path: node_label(node), + reason: format!("export `{name}` must name a bus channel, got {other:?}"), + }); + } + }; + let source = BindingSource::BusChannel(inner_channel); + let target = BindingTarget::BusChannel(lpc_model::ChannelName(String::from(name.as_str()))); + engine + .add_binding( + BindingDraft { + kind: binding_kind(&source, &target, name), + source, + target, + priority: BindingPriority::new(0), + owner: node.id, + }, + frame, + ) + .map_err(|e| ProjectLoadError::InvalidProjectReference { + path: node_label(node), + reason: format!("register module export `{name}`: {e}"), + })?; + } + Ok(()) +} + fn binding_kind(source: &BindingSource, target: &BindingTarget, slot_name: &str) -> Kind { let channel = match (source, target) { (BindingSource::BusChannel(channel), _) => Some(channel), @@ -2055,12 +2217,13 @@ mod tests { fn fixture_project_fs() -> LpFsMemory { let fs = LpFsMemory::new(); + fs.write_file("/project.json".as_path(), b"{\n \"format\": 3\n}\n") + .expect("container manifest"); fs.write_file( - "/project.json".as_path(), + "/module.json".as_path(), br#" { - "kind": "Project", - "format": 2, + "kind": "Module", "nodes": { "fixture": { "ref": "./fixture.json" @@ -2180,12 +2343,13 @@ mod tests { fn playlist_project_fs() -> LpFsMemory { let fs = LpFsMemory::new(); + fs.write_file("/project.json".as_path(), b"{\n \"format\": 3\n}\n") + .expect("container manifest"); fs.write_file( - "/project.json".as_path(), + "/module.json".as_path(), br#" { - "kind": "Project", - "format": 2, + "kind": "Module", "nodes": { "playlist": { "ref": "./playlist.json" @@ -2277,12 +2441,13 @@ mod tests { fn button_playlist_project_fs() -> LpFsMemory { let fs = playlist_project_fs(); + fs.write_file("/project.json".as_path(), b"{\n \"format\": 3\n}\n") + .expect("container manifest"); fs.write_file( - "/project.json".as_path(), + "/module.json".as_path(), br#" { - "kind": "Project", - "format": 2, + "kind": "Module", "nodes": { "clock": { "ref": "./clock.json" @@ -2456,12 +2621,13 @@ mod tests { #[test] fn project_loader_loads_inline_clock_and_default_time_bus() { let fs = LpFsMemory::new(); + fs.write_file("/project.json".as_path(), b"{\n \"format\": 3\n}\n") + .expect("container manifest"); fs.write_file( - "/project.json".as_path(), + "/module.json".as_path(), br#" { - "kind": "Project", - "format": 2, + "kind": "Module", "nodes": { "clock": { "ref": "./clock.json" @@ -2531,7 +2697,10 @@ mod tests { rt.tick(1000).expect("first tick"); let first = rt .resolve_with_engine_host( - QueryKey::Bus(ChannelName(String::from("time"))), + QueryKey::Bus { + scope: None, + channel: ChannelName(String::from("time")), + }, ResolveLogLevel::Off, ) .expect("resolve time bus") @@ -2558,7 +2727,10 @@ mod tests { rt.tick(1000).expect("second tick"); let second = rt .resolve_with_engine_host( - QueryKey::Bus(ChannelName(String::from("time"))), + QueryKey::Bus { + scope: None, + channel: ChannelName(String::from("time")), + }, ResolveLogLevel::Off, ) .expect("resolve time bus") @@ -2586,12 +2758,13 @@ mod tests { #[test] fn project_loader_rejects_inline_child_def() { let fs = LpFsMemory::new(); + fs.write_file("/project.json".as_path(), b"{\n \"format\": 3\n}\n") + .expect("container manifest"); fs.write_file( - "/project.json".as_path(), + "/module.json".as_path(), br#" { - "kind": "Project", - "format": 2, + "kind": "Module", "nodes": { "shader": { "def": { @@ -2615,12 +2788,13 @@ mod tests { #[test] fn top_level_shader_gets_default_visual_output_binding() { let fs = LpFsMemory::new(); + fs.write_file("/project.json".as_path(), b"{\n \"format\": 3\n}\n") + .expect("container manifest"); fs.write_file( - "/project.json".as_path(), + "/module.json".as_path(), br#" { - "kind": "Project", - "format": 2, + "kind": "Module", "nodes": { "shader": { "ref": "./shader.json" @@ -2743,7 +2917,11 @@ mod tests { } #[test] - fn playlist_entry_children_do_not_get_default_visual_output_binding() { + fn playlist_entry_children_publish_into_their_sink_scope_only() { + // The old ownership-suppression rule is GONE: entry children + // default-publish `visual.out` like every producer — into their + // entry's sink scope, where writer-shadowing (R2/R5) keeps them + // invisible to any enclosing read by construction. let fs = playlist_project_fs(); let services = EngineServices::new(TreePath::parse("/playlist.show").expect("path")); let rt = ProjectLoader::load_from_root(&fs, services).expect("load playlist"); @@ -2757,7 +2935,8 @@ mod tests { .lookup_sibling(playlist, NodeName::parse("active").unwrap()) .expect("active"); - assert!(!rt.tree().bindings().any(|binding| { + // The binding exists… + assert!(rt.tree().bindings().any(|binding| { matches!( (&binding.source, &binding.target), ( @@ -2769,6 +2948,20 @@ mod tests { && binding.priority == BindingPriority::default_fallback() ) })); + // …its owner writes a SINK scope… + let active_scope = rt.tree().node_scope(active).expect("active scope"); + assert!(active_scope.is_sink()); + // …and a root-scoped read never selects it: the winning provider + // set for the root scope contains no entry-child publisher. + let root_scope = rt.tree().node_scope(root).expect("root scope"); + let winners = rt.tree().providers_for_bus_read( + Some(root_scope), + &lpc_model::ChannelName(String::from("visual.out")), + ); + assert!( + winners.iter().all(|(_, entry)| entry.owner != active), + "sink-scope publishers must be invisible to root-scope demand" + ); } #[test] @@ -2971,12 +3164,13 @@ mod tests { #[test] fn malformed_child_node_json_projects_error_node() { let fs = LpFsMemory::new(); + fs.write_file("/project.json".as_path(), b"{\n \"format\": 3\n}\n") + .expect("container manifest"); fs.write_file( - "/project.json".as_path(), + "/module.json".as_path(), br#" { - "kind": "Project", - "format": 2, + "kind": "Module", "nodes": { "broken": { "ref": "./broken.json" @@ -2997,7 +3191,9 @@ mod tests { } #[test] - fn missing_project_json_returns_io_error() { + fn missing_project_json_refuses_via_the_manifest_gate() { + // D-A: no `project.json` container manifest = hard refuse before + // anything parses; the error names the manifest, not a deep Io path. let fs = LpFsMemory::new(); let root_path = TreePath::parse("/p.show").expect("path"); let services = EngineServices::new(root_path); @@ -3005,21 +3201,41 @@ mod tests { Err(e) => e, Ok(_) => panic!("expected load error"), }; + let text = err.to_string(); + assert!(text.contains("project.json"), "{text}"); + assert!(text.contains("manifest"), "{text}"); + } + + #[test] + fn missing_module_json_returns_io_error() { + let fs = LpFsMemory::new(); + fs.write_file("/project.json".as_path(), b"{\n \"format\": 3\n}\n") + .expect("container manifest"); + let root_path = TreePath::parse("/p.show").expect("path"); + let services = EngineServices::new(root_path); + let err = match ProjectLoader::load_from_root(&fs, services) { + Err(e) => e, + Ok(_) => panic!("expected load error"), + }; assert!( - matches!(err, ProjectLoadError::Io { .. }), - "expected Io, got {err:?}" + matches!( + err, + ProjectLoadError::Io { .. } | ProjectLoadError::ProjectParse { .. } + ), + "expected Io/parse, got {err:?}" ); } #[test] fn unknown_child_kind_projects_error_node() { let fs = LpFsMemory::new(); + fs.write_file("/project.json".as_path(), b"{\n \"format\": 3\n}\n") + .expect("container manifest"); fs.write_file( - "/project.json".as_path(), + "/module.json".as_path(), br#" { - "kind": "Project", - "format": 2, + "kind": "Module", "nodes": { "weird": { "ref": "./weird.json" @@ -3184,12 +3400,13 @@ mod tests { #[test] fn project_loader_attaches_compute_shader_node() { let fs = LpFsMemory::new(); + fs.write_file("/project.json".as_path(), b"{\n \"format\": 3\n}\n") + .expect("container manifest"); fs.write_file( - "/project.json".as_path(), + "/module.json".as_path(), br#" { - "kind": "Project", - "format": 2, + "kind": "Module", "nodes": { "compute": { "ref": "./compute.json" @@ -3735,12 +3952,13 @@ mod tests { #[test] fn button_node_publishes_held_and_up_from_virtual_d9() { let fs = LpFsMemory::new(); + fs.write_file("/project.json".as_path(), b"{\n \"format\": 3\n}\n") + .expect("container manifest"); fs.write_file( - "/project.json".as_path(), + "/module.json".as_path(), br#" { - "kind": "Project", - "format": 2, + "kind": "Module", "nodes": { "button": { "ref": "./button.json" @@ -3805,12 +4023,13 @@ mod tests { #[test] fn control_radio_bidirectional_bus_binding_broadcasts_button_event() { let fs = LpFsMemory::new(); + fs.write_file("/project.json".as_path(), b"{\n \"format\": 3\n}\n") + .expect("container manifest"); fs.write_file( - "/project.json".as_path(), + "/module.json".as_path(), br#" { - "kind": "Project", - "format": 2, + "kind": "Module", "nodes": { "button": { "ref": "./button.json" @@ -4098,13 +4317,13 @@ mod tests { } fn write_flat_basic_files(fs: &LpFsMemory) { + fs.write_file("/project.json".as_path(), b"{\n \"format\": 3\n}\n") + .expect("container manifest"); fs.write_file( - "/project.json".as_path(), + "/module.json".as_path(), br#" { - "kind": "Project", - "format": 2, - "name": "basic", + "kind": "Module", "nodes": { "output": { "ref": "./output.json" @@ -4292,11 +4511,11 @@ mod tests { } entries.push_str(&format!(" \"{name}\": {{ \"ref\": \"./{name}.json\" }}")); } - let project = format!( - "{{\n \"kind\": \"Project\",\n \"format\": 2,\n \"nodes\": {{\n{entries}\n }}\n}}\n" - ); - fs.write_file("/project.json".as_path(), project.as_bytes()) - .expect("project.json"); + let module = format!("{{\n \"kind\": \"Module\",\n \"nodes\": {{\n{entries}\n }}\n}}\n"); + fs.write_file("/project.json".as_path(), b"{\n \"format\": 3\n}\n") + .expect("container manifest"); + fs.write_file("/module.json".as_path(), module.as_bytes()) + .expect("module.json"); for (name, json) in nodes { fs.write_file(format!("/{name}.json").as_str().as_path(), json.as_bytes()) .unwrap_or_else(|_| panic!("{name}.json")); @@ -4466,9 +4685,9 @@ mod tests { }) .count(); assert_eq!( - default_publishers, 1, - "only the playlist itself default-publishes visual.out; entry \ - children are ownership-suppressed" + default_publishers, 3, + "the playlist plus each entry child default-publishes visual.out \ + (entries into their own sink scopes)" ); } @@ -4486,7 +4705,7 @@ mod tests { fn every_node_kind_is_explicitly_gated_or_always_on() { fn classify(kind: NodeKind) -> &'static str { match kind { - NodeKind::Project => "always-on", + NodeKind::Module => "always-on", NodeKind::Output => "always-on", NodeKind::Button => "node-button", NodeKind::Clock => "node-clock", @@ -4500,7 +4719,7 @@ mod tests { } } for kind in [ - NodeKind::Project, + NodeKind::Module, NodeKind::Output, NodeKind::Button, NodeKind::Clock, @@ -4541,12 +4760,13 @@ mod tests { #[cfg(not(feature = "node-button"))] fn disabled_node_kind_still_loads_project() { let fs = LpFsMemory::new(); + fs.write_file("/project.json".as_path(), b"{\n \"format\": 3\n}\n") + .expect("container manifest"); fs.write_file( - "/project.json".as_path(), + "/module.json".as_path(), br#" { - "kind": "Project", - "format": 2, + "kind": "Module", "nodes": { "button": { "ref": "./button.json" diff --git a/lp-core/lpc-engine/src/engine/project_read_probes.rs b/lp-core/lpc-engine/src/engine/project_read_probes.rs index 12bbe03ba..727833ad8 100644 --- a/lp-core/lpc-engine/src/engine/project_read_probes.rs +++ b/lp-core/lpc-engine/src/engine/project_read_probes.rs @@ -102,6 +102,21 @@ impl Engine { .map(|(name, kind)| (name.clone(), kind)) .collect(); + // Root-scope role, decided engine-side ONCE: the primary visual is + // the root scope's listing of the vocabulary channel the root + // module's output interface mirrors. Consumers read the flag; the + // name comparison lives only here, next to the vocabulary. + let root_scope = self.tree().node_scope(self.tree().root()); + let root_channels: Vec = root_scope + .map(|scope| { + self.tree() + .scope_channels(scope) + .into_iter() + .map(|(name, _)| name) + .collect() + }) + .unwrap_or_default(); + let mut channels = Vec::with_capacity(channel_names.len()); for (name, kind) in channel_names { let mut providers = self.tree().providers_for_bus(&name); @@ -135,12 +150,15 @@ impl Engine { } }); + let primary_visual = name.0 == lpc_model::PRIMARY_VISUAL_CHANNEL + && (root_channels.contains(&name) || root_scope.is_none()); channels.push(WireBusChannel { name: name.0.clone(), kind: Some(kind), providers, consumers, value, + primary_visual, }); } diff --git a/lp-core/lpc-engine/src/engine/project_read_stream.rs b/lp-core/lpc-engine/src/engine/project_read_stream.rs index 11e21eb26..d12695685 100644 --- a/lp-core/lpc-engine/src/engine/project_read_stream.rs +++ b/lp-core/lpc-engine/src/engine/project_read_stream.rs @@ -611,7 +611,7 @@ mod tests { #[test] fn event_stream_chunks_runtime_buffer_payloads() { - let mut engine = Engine::new(TreePath::parse("/basic.project").unwrap()); + let mut engine = Engine::new(TreePath::parse("/basic.module").unwrap()); let buffer_id = engine.runtime_buffers_mut().insert(WithRevision::new( Revision::new(1), RuntimeBuffer::raw(vec![42; 5 * 1024]), @@ -826,7 +826,7 @@ mod tests { .mutate( &fs, lpc_model::MutationOp::PutSlotEdit { - artifact: lpc_model::ArtifactLocation::file("/project.json"), + artifact: lpc_model::ArtifactLocation::file("/module.json"), edit: lpc_model::SlotEdit::ensure_present( lpc_model::SlotPath::parse("nodes[clock]").expect("slot path"), ), @@ -1175,6 +1175,11 @@ mod tests { let mut engine = Engine::new(TreePath::parse("/t.show").expect("path")); let mut registry = ProjectRegistry::new(); let mut fs = lpfs::LpFsMemory::new(); + fs.write_file_mut( + lpfs::LpPath::new("/project.json"), + b"{\n \"format\": 3\n}\n", + ) + .expect("write container manifest"); fs.write_file_mut( lpfs::LpPath::new("/clock.json"), br#"{ "kind": "Clock", "controls": { "rate": 1.0 } }"#, @@ -1314,7 +1319,7 @@ mod tests { fn empty_registry_engine() -> (Engine, ProjectRegistry) { ( - Engine::new(TreePath::parse("/shapes.project").unwrap()), + Engine::new(TreePath::parse("/shapes.module").unwrap()), ProjectRegistry::new(), ) } @@ -1433,7 +1438,7 @@ mod tests { fn mutating_one_resource_sends_exactly_that_resource() { use lpc_model::set_current_revision; - let mut engine = Engine::new(TreePath::parse("/basic.project").unwrap()); + let mut engine = Engine::new(TreePath::parse("/basic.module").unwrap()); let registry = ProjectRegistry::new(); set_current_revision(Revision::new(5)); @@ -1476,7 +1481,7 @@ mod tests { fn removing_a_resource_emits_membership_without_it() { use lpc_model::set_current_revision; - let mut engine = Engine::new(TreePath::parse("/basic.project").unwrap()); + let mut engine = Engine::new(TreePath::parse("/basic.module").unwrap()); let registry = ProjectRegistry::new(); set_current_revision(Revision::new(5)); @@ -1522,7 +1527,7 @@ mod tests { fn unchanged_resources_send_no_summaries_or_membership() { use lpc_model::set_current_revision; - let mut engine = Engine::new(TreePath::parse("/basic.project").unwrap()); + let mut engine = Engine::new(TreePath::parse("/basic.module").unwrap()); let registry = ProjectRegistry::new(); set_current_revision(Revision::new(5)); @@ -1545,7 +1550,7 @@ mod tests { fn by_refs_payload_bypasses_since() { use lpc_model::set_current_revision; - let mut engine = Engine::new(TreePath::parse("/basic.project").unwrap()); + let mut engine = Engine::new(TreePath::parse("/basic.module").unwrap()); let registry = ProjectRegistry::new(); set_current_revision(Revision::new(5)); @@ -1581,7 +1586,7 @@ mod tests { fn fresh_read_includes_all_resources() { use lpc_model::set_current_revision; - let mut engine = Engine::new(TreePath::parse("/basic.project").unwrap()); + let mut engine = Engine::new(TreePath::parse("/basic.module").unwrap()); let registry = ProjectRegistry::new(); set_current_revision(Revision::new(5)); diff --git a/lp-core/lpc-engine/src/engine/scoped_resolution_tests.rs b/lp-core/lpc-engine/src/engine/scoped_resolution_tests.rs new file mode 100644 index 000000000..ec66f522e --- /dev/null +++ b/lp-core/lpc-engine/src/engine/scoped_resolution_tests.rs @@ -0,0 +1,215 @@ +//! Scoped-channel correctness obligations (engine C2, modules.md R2/R5). +//! +//! The three properties pinned here are exactly the silent failure modes +//! of rekeying the resolver: (1) cache soundness — the same channel name +//! in two scopes with different writers must produce distinct cached +//! resolutions in ONE session, no collision and no spurious cycle; +//! (2) writer-shadowing — a consumer resolves the nearest enclosing scope +//! with a writer, inheriting outward when its own scope has none; +//! (3) sink no-demand — a probe read with `include_values: true` never +//! resolves (never ticks) a sink-scope producer. +//! +//! Scopes are hand-assigned on the harness tree — loader-side assignment +//! has its own differential coverage in `tests/structural_scope.rs`; the +//! subject here is resolution itself. Harness bus bindings are owned by +//! their producer nodes and consumed bindings by their consumers, so a +//! node's assigned scope is exactly its bindings' scope. + +use super::test_support::{EngineTestBuilder, EngineTestHarness, bus, output, produced_slot}; +use crate::node::ScopeRef; +use lpc_model::NodeId; +use lpc_wire::{BindingGraphProbeRequest, BindingGraphProbeResult}; + +/// Mark the root as introducing the root scope and place `members` in it. +fn assign_root_scope(harness: &mut EngineTestHarness, members: &[NodeId]) -> ScopeRef { + let root = harness.engine.tree().root(); + harness + .engine + .tree_mut() + .get_mut(root) + .expect("root entry") + .introduces_scope = true; + let scope = ScopeRef::Module { owner: root }; + place(harness, members, scope); + scope +} + +/// Introduce a module scope owned by `owner` (which itself inhabits the +/// root scope) and place `members` in it. +fn introduce_module_scope( + harness: &mut EngineTestHarness, + owner: NodeId, + members: &[NodeId], +) -> ScopeRef { + let root = harness.engine.tree().root(); + { + let entry = harness + .engine + .tree_mut() + .get_mut(owner) + .expect("owner entry"); + entry.introduces_scope = true; + entry.scope = Some(ScopeRef::Module { owner: root }); + } + let scope = ScopeRef::Module { owner }; + place(harness, members, scope); + scope +} + +fn place(harness: &mut EngineTestHarness, members: &[NodeId], scope: ScopeRef) { + for member in members { + harness + .engine + .tree_mut() + .get_mut(*member) + .expect("member entry") + .scope = Some(scope); + } +} + +#[test] +fn same_channel_in_two_scopes_resolves_distinctly_in_one_session() { + // Two sibling module scopes, each with its own writer for the SAME + // channel name, each with its own reader. One tick = one session = one + // cache. A scope-blind cache key would hand one scope's value to the + // other; a scope-blind cycle key would abort resolution entirely. + let mut h = EngineTestBuilder::new() + .shader("holder_a", output("outputs[0]", 0.0)) + .shader("holder_b", output("outputs[0]", 0.0)) + .shader("writer_a", output("outputs[0]", 1.0)) + .shader("writer_b", output("outputs[0]", 9.0)) + .bind_bus("chan", produced_slot("writer_a", "outputs[0]")) + .bind_bus("chan", produced_slot("writer_b", "outputs[0]")) + .output_node("out_a") + .bind_demand_input("out_a", bus("chan")) + .demand_root("out_a") + .output_node("out_b") + .bind_demand_input("out_b", bus("chan")) + .demand_root("out_b") + .build(); + + let holder_a = h.node("holder_a"); + let holder_b = h.node("holder_b"); + let writer_a = h.node("writer_a"); + let writer_b = h.node("writer_b"); + let out_a = h.node("out_a"); + let out_b = h.node("out_b"); + + assign_root_scope(&mut h, &[holder_a, holder_b]); + let scope_a = introduce_module_scope(&mut h, holder_a, &[writer_a, out_a]); + let scope_b = introduce_module_scope(&mut h, holder_b, &[writer_b, out_b]); + assert_ne!(scope_a, scope_b); + + h.tick(16).expect("tick"); + assert_eq!(h.output_f32("out_a"), Some(1.0), "scope A reads A's writer"); + assert_eq!(h.output_f32("out_b"), Some(9.0), "scope B reads B's writer"); +} + +#[test] +fn consumer_inherits_the_nearest_enclosing_writer() { + // Depth-2 writer-shadowing (the E5 shape, minus the P6 output mirror): + // the inner scope's writer shadows root's for the inner reader; a + // reader in a writerless sibling scope inherits root's writer. + let mut h = EngineTestBuilder::new() + .shader("holder_a", output("outputs[0]", 0.0)) + .shader("holder_b", output("outputs[0]", 0.0)) + .shader("root_writer", output("outputs[0]", 0.25)) + .shader("inner_writer", output("outputs[0]", 0.75)) + .bind_bus("chan", produced_slot("root_writer", "outputs[0]")) + .bind_bus("chan", produced_slot("inner_writer", "outputs[0]")) + .output_node("inner_reader") + .bind_demand_input("inner_reader", bus("chan")) + .demand_root("inner_reader") + .output_node("sibling_reader") + .bind_demand_input("sibling_reader", bus("chan")) + .demand_root("sibling_reader") + .build(); + + let holder_a = h.node("holder_a"); + let holder_b = h.node("holder_b"); + let root_writer = h.node("root_writer"); + let inner_writer = h.node("inner_writer"); + let inner_reader = h.node("inner_reader"); + let sibling_reader = h.node("sibling_reader"); + + assign_root_scope(&mut h, &[holder_a, holder_b, root_writer]); + introduce_module_scope(&mut h, holder_a, &[inner_writer, inner_reader]); + introduce_module_scope(&mut h, holder_b, &[sibling_reader]); + + h.tick(16).expect("tick"); + assert_eq!( + h.output_f32("inner_reader"), + Some(0.75), + "the inner scope's writer shadows root's" + ); + assert_eq!( + h.output_f32("sibling_reader"), + Some(0.25), + "a writerless scope inherits the nearest enclosing writer" + ); +} + +#[test] +fn probe_values_never_tick_sink_scope_producers() { + // R2 no-demand BY CONSTRUCTION: a probe read with include_values + // resolves every listed channel from the ROOT scope; a sink-scope + // publisher must never be selected, so its producer never ticks — + // the "every Studio refresh renders every inactive playlist entry" + // failure class, pinned at the resolution layer rather than by a + // probe-side filter. + let mut h = EngineTestBuilder::new() + .shader("playlist_standin", output("outputs[0]", 0.0)) + .shader("root_writer", output("outputs[0]", 0.5)) + .shader("entry_shader", output("outputs[0]", 0.9)) + .bind_bus("visual.out", produced_slot("root_writer", "outputs[0]")) + .bind_bus("visual.out", produced_slot("entry_shader", "outputs[0]")) + .output_node("reader") + .bind_demand_input("reader", bus("visual.out")) + .demand_root("reader") + .build(); + + let playlist_standin = h.node("playlist_standin"); + let root_writer = h.node("root_writer"); + let entry_shader = h.node("entry_shader"); + let reader = h.node("reader"); + + assign_root_scope(&mut h, &[playlist_standin, root_writer, reader]); + place( + &mut h, + &[entry_shader], + ScopeRef::Sink { + owner: playlist_standin, + entry: 1, + }, + ); + + h.tick(16).expect("tick"); + let before = h.shader_ticks("entry_shader"); + + let result = h.engine.read_project_binding_graph_probe( + &h.registry, + BindingGraphProbeRequest { + include_values: true, + }, + ); + let BindingGraphProbeResult::Graph(graph) = result else { + panic!("expected graph result"); + }; + let channel = graph + .channels + .iter() + .find(|channel| channel.name == "visual.out") + .expect("channel listed"); + let value = channel.value.as_ref().expect("value requested"); + assert_eq!(value.error, None, "the root-scope read resolves cleanly"); + + assert_eq!( + h.shader_ticks("entry_shader"), + before, + "probe demand must never tick a sink-scope producer" + ); + assert!( + h.shader_ticks("root_writer") > 0, + "the root writer is what the probe resolves" + ); +} diff --git a/lp-core/lpc-engine/src/engine/test_support.rs b/lp-core/lpc-engine/src/engine/test_support.rs index 01d91077b..f933fc3eb 100644 --- a/lp-core/lpc-engine/src/engine/test_support.rs +++ b/lp-core/lpc-engine/src/engine/test_support.rs @@ -273,7 +273,10 @@ impl EngineTestHarness { } pub(crate) fn resolve_bus(&mut self, channel: &str) -> Result { - self.resolve(QueryKey::Bus(channel_name(channel))) + self.resolve(QueryKey::Bus { + scope: None, + channel: channel_name(channel), + }) } pub(crate) fn resolve(&mut self, query: QueryKey) -> Result { @@ -403,7 +406,10 @@ pub(crate) fn trace_has_value_origin_path( shader: NodeId, output_path: &SlotPath, ) -> bool { - let bus_query = QueryKey::Bus(channel_name(bus_name)); + let bus_query = QueryKey::Bus { + scope: None, + channel: channel_name(bus_name), + }; let output_query = QueryKey::ProducedSlot { node: shader, slot: output_path.clone(), @@ -457,14 +463,14 @@ impl NodeRuntime for DummyShaderNode { Ok(ProduceResult::Produced) } - fn destroy(&mut self, _ctx: &mut DestroyCtx<'_>) -> Result<(), NodeError> { + fn destroy(&mut self, _ctx: &mut DestroyCtx) -> Result<(), NodeError> { Ok(()) } fn handle_memory_pressure( &mut self, _level: PressureLevel, - _ctx: &mut MemPressureCtx<'_>, + _ctx: &mut MemPressureCtx, ) -> Result<(), NodeError> { Ok(()) } @@ -504,14 +510,14 @@ impl NodeRuntime for DummyFixtureNode { Ok(()) } - fn destroy(&mut self, _ctx: &mut DestroyCtx<'_>) -> Result<(), NodeError> { + fn destroy(&mut self, _ctx: &mut DestroyCtx) -> Result<(), NodeError> { Ok(()) } fn handle_memory_pressure( &mut self, _level: PressureLevel, - _ctx: &mut MemPressureCtx<'_>, + _ctx: &mut MemPressureCtx, ) -> Result<(), NodeError> { Ok(()) } @@ -540,14 +546,14 @@ impl NodeRuntime for DummyOutputNode { Ok(()) } - fn destroy(&mut self, _ctx: &mut DestroyCtx<'_>) -> Result<(), NodeError> { + fn destroy(&mut self, _ctx: &mut DestroyCtx) -> Result<(), NodeError> { Ok(()) } fn handle_memory_pressure( &mut self, _level: PressureLevel, - _ctx: &mut MemPressureCtx<'_>, + _ctx: &mut MemPressureCtx, ) -> Result<(), NodeError> { Ok(()) } @@ -591,14 +597,14 @@ impl NodeRuntime for DummySelectorNode { Ok(()) } - fn destroy(&mut self, _ctx: &mut DestroyCtx<'_>) -> Result<(), NodeError> { + fn destroy(&mut self, _ctx: &mut DestroyCtx) -> Result<(), NodeError> { Ok(()) } fn handle_memory_pressure( &mut self, _level: PressureLevel, - _ctx: &mut MemPressureCtx<'_>, + _ctx: &mut MemPressureCtx, ) -> Result<(), NodeError> { Ok(()) } diff --git a/lp-core/lpc-engine/src/features.rs b/lp-core/lpc-engine/src/features.rs index 754c65a8b..b645db162 100644 --- a/lp-core/lpc-engine/src/features.rs +++ b/lp-core/lpc-engine/src/features.rs @@ -100,7 +100,7 @@ mod tests { fn node_kind_features_are_engine_owned() { use lpc_model::NodeKind; for kind in [ - NodeKind::Project, + NodeKind::Module, NodeKind::Output, NodeKind::Button, NodeKind::Clock, diff --git a/lp-core/lpc-engine/src/node/contexts.rs b/lp-core/lpc-engine/src/node/contexts.rs index b127ba648..89f156efc 100644 --- a/lp-core/lpc-engine/src/node/contexts.rs +++ b/lp-core/lpc-engine/src/node/contexts.rs @@ -6,7 +6,6 @@ use alloc::rc::Rc; use alloc::sync::Arc; -use crate::dataflow::bus::Bus; use crate::dataflow::resolver::{ Production, ProductionSource, QueryKey, ResolveError, TickResolver, }; @@ -22,12 +21,11 @@ use crate::resource::{RuntimeBuffer, RuntimeBufferId, RuntimeBufferStore}; use lp_gfx::{LpGraphics, TextureHandle}; use lpc_model::{ AssetLocation, FromLpValue, NodeId, Revision, SlotAccess, SlotAccessor, SlotPath, - SlotShapeRegistry, WithRevision, bus::ChannelName, lookup_slot_data_and_shape, + SlotShapeRegistry, WithRevision, lookup_slot_data_and_shape, }; use lpc_registry::{AssetBytes, AssetReadError, AssetText, ProjectRegistry}; use lpc_shared::time::TimeProvider; use lpfs::LpFs; -use lps_shared::LpsValueF32; use super::node_error::NodeError; @@ -601,19 +599,17 @@ impl<'a> RenderContext<'a> { } /// Context for [`super::Node::destroy`](super::NodeRuntime::destroy). -pub struct DestroyCtx<'a> { +pub struct DestroyCtx { node_id: NodeId, revision: Revision, - bus: &'a Bus, } -impl<'a> DestroyCtx<'a> { +impl DestroyCtx { /// Create a new destroy context. - pub fn new(node_id: NodeId, frame_id: Revision, bus: &'a Bus) -> Self { + pub fn new(node_id: NodeId, frame_id: Revision) -> Self { Self { node_id, revision: frame_id, - bus, } } @@ -626,27 +622,20 @@ impl<'a> DestroyCtx<'a> { pub fn frame_id(&self) -> Revision { self.revision } - - /// Read the current value from a bus channel. - pub fn bus_read(&self, channel: &ChannelName) -> Option<&LpsValueF32> { - self.bus.read(channel) - } } /// Context for [`super::Node::handle_memory_pressure`](super::NodeRuntime::handle_memory_pressure). -pub struct MemPressureCtx<'a> { +pub struct MemPressureCtx { node_id: NodeId, revision: Revision, - bus: &'a Bus, } -impl<'a> MemPressureCtx<'a> { +impl MemPressureCtx { /// Create a new memory pressure context. - pub fn new(node_id: NodeId, frame_id: Revision, bus: &'a Bus) -> Self { + pub fn new(node_id: NodeId, frame_id: Revision) -> Self { Self { node_id, revision: frame_id, - bus, } } @@ -659,11 +648,6 @@ impl<'a> MemPressureCtx<'a> { pub fn revision(&self) -> Revision { self.revision } - - /// Read the current value from a bus channel. - pub fn bus_read(&self, channel: &ChannelName) -> Option<&LpsValueF32> { - self.bus.read(channel) - } } #[cfg(test)] @@ -681,6 +665,7 @@ mod tests { use alloc::string::String; use alloc::vec::Vec; use lpc_model::{Kind, LpValue, SlotPath, SlotShapeRegistry, Slotted, ValueSlot}; + use lps_shared::LpsValueF32; #[derive(Default, Slotted)] struct TestRuntimeState { @@ -725,6 +710,7 @@ mod tests { fn providers_for_bus( &self, + _scope: Option, channel: &lpc_model::ChannelName, ) -> Vec<(BindingRef, BindingEntry)> { self.entries @@ -763,9 +749,10 @@ mod tests { fn providers_for_bus( &self, + scope: Option, channel: &lpc_model::ChannelName, ) -> Vec<(BindingRef, BindingEntry)> { - self.bindings.providers_for_bus(channel) + self.bindings.providers_for_bus(scope, channel) } } @@ -827,7 +814,10 @@ mod tests { &slot_shapes, ); let pv = ctx - .resolve(&QueryKey::Bus(channel.clone())) + .resolve(&QueryKey::Bus { + scope: None, + channel: channel.clone(), + }) .expect("resolve bus"); assert!(pv.as_value().expect("value").eq(&LpsValueF32::F32(7.8))); } @@ -960,17 +950,14 @@ mod tests { Ok(super::super::ProduceResult::Produced) } - fn destroy( - &mut self, - _ctx: &mut super::DestroyCtx<'_>, - ) -> Result<(), crate::node::NodeError> { + fn destroy(&mut self, _ctx: &mut super::DestroyCtx) -> Result<(), crate::node::NodeError> { Ok(()) } fn handle_memory_pressure( &mut self, _level: super::super::PressureLevel, - _ctx: &mut super::MemPressureCtx<'_>, + _ctx: &mut super::MemPressureCtx, ) -> Result<(), crate::node::NodeError> { Ok(()) } @@ -998,7 +985,10 @@ mod tests { let slot_shapes = SlotShapeRegistry::default(); let mut node = QueryResolvingNode { - query: QueryKey::Bus(channel), + query: QueryKey::Bus { + scope: None, + channel: channel, + }, resolved_value: None, }; @@ -1058,55 +1048,15 @@ mod tests { #[test] fn destroy_ctx_accessors() { - let bus = Bus::new(); - let ctx = DestroyCtx::new(NodeId::new(1), Revision::new(99), &bus); + let ctx = DestroyCtx::new(NodeId::new(1), Revision::new(99)); assert_eq!(ctx.node_id(), NodeId::new(1)); assert_eq!(ctx.frame_id(), Revision::new(99)); } - #[test] - fn destroy_ctx_bus_read() { - let mut bus = Bus::new(); - let channel = ChannelName(String::from("test")); - - bus.claim_writer( - &channel, - NodeId::new(1), - SlotPath::parse("outputs[0]").unwrap(), - lpc_model::Kind::Amplitude, - ) - .unwrap(); - bus.publish(&channel, LpsValueF32::F32(2.5), Revision::new(5)); - - let ctx = DestroyCtx::new(NodeId::new(1), Revision::new(99), &bus); - let val = ctx.bus_read(&channel).unwrap(); - assert!(matches!(val, LpsValueF32::F32(2.5))); - } - #[test] fn mem_pressure_ctx_accessors() { - let bus = Bus::new(); - let ctx = MemPressureCtx::new(NodeId::new(2), Revision::new(100), &bus); + let ctx = MemPressureCtx::new(NodeId::new(2), Revision::new(100)); assert_eq!(ctx.node_id(), NodeId::new(2)); assert_eq!(ctx.revision(), Revision::new(100)); } - - #[test] - fn mem_pressure_ctx_bus_read() { - let mut bus = Bus::new(); - let channel = ChannelName(String::from("pressure")); - - bus.claim_writer( - &channel, - NodeId::new(1), - SlotPath::parse("outputs[0]").unwrap(), - lpc_model::Kind::Amplitude, - ) - .unwrap(); - bus.publish(&channel, LpsValueF32::F32(0.8), Revision::new(2)); - - let ctx = MemPressureCtx::new(NodeId::new(2), Revision::new(100), &bus); - let val = ctx.bus_read(&channel).unwrap(); - assert!(matches!(val, LpsValueF32::F32(0.8))); - } } diff --git a/lp-core/lpc-engine/src/node/mod.rs b/lp-core/lpc-engine/src/node/mod.rs index 0030799d8..4035dddc9 100644 --- a/lp-core/lpc-engine/src/node/mod.rs +++ b/lp-core/lpc-engine/src/node/mod.rs @@ -13,6 +13,7 @@ mod node_runtime; pub mod node_tree; mod render_node; mod runtime_state_shape; +pub mod scope; pub mod sync; pub mod tree_error; @@ -31,6 +32,7 @@ pub use node_runtime::{AssetRefreshResult, NodeRuntime, ProduceResult}; pub use node_tree::RuntimeNodeTree; pub use render_node::RenderNode; pub use runtime_state_shape::RuntimeStateShape; +pub use scope::ScopeRef; pub use sync::tree_deltas_since; pub use tree_error::TreeError; diff --git a/lp-core/lpc-engine/src/node/node_entry.rs b/lp-core/lpc-engine/src/node/node_entry.rs index 69b58ed1e..53d8e639c 100644 --- a/lp-core/lpc-engine/src/node/node_entry.rs +++ b/lp-core/lpc-engine/src/node/node_entry.rs @@ -8,6 +8,7 @@ use lpc_wire::{NodeRuntimeStatus, WireChildKind}; use crate::dataflow::binding::BindingSet; use crate::node::node_entry_state::NodeEntryState; +use crate::node::scope::ScopeRef; /// Server-side metadata for a node instance. /// @@ -29,6 +30,18 @@ pub struct RuntimeNodeEntry { pub created_at: Revision, + /// The bus scope this node INHABITS (`None` for the root module, which + /// no scope contains). Structural per modules.md R1: assigned by + /// `ensure_runtime_spine` on both the load and apply paths, present for + /// `Pending`/`Failed` entries, and untouched by payload reattach. + pub scope: Option, + + /// True when this node introduces a module scope around its project + /// children (module-kinded nodes). Playlist nodes introduce per-entry + /// SINK scopes instead, discoverable through their children's + /// [`Self::scope`] values. + pub introduces_scope: bool, + /// Stable project-side identity for this runtime node, when projected from a project. pub project_use: Option, @@ -69,6 +82,8 @@ impl RuntimeNodeEntry { state: WithRevision::new(revision, NodeEntryState::Pending), bindings: WithRevision::new(revision, BindingSet::new()), created_at: revision, + scope: None, + introduces_scope: false, project_use, def_location, } diff --git a/lp-core/lpc-engine/src/node/node_runtime.rs b/lp-core/lpc-engine/src/node/node_runtime.rs index b8a081f40..411acaad1 100644 --- a/lp-core/lpc-engine/src/node/node_runtime.rs +++ b/lp-core/lpc-engine/src/node/node_runtime.rs @@ -96,12 +96,12 @@ pub trait NodeRuntime { Ok(AssetRefreshResult::Unused) } - fn destroy(&mut self, ctx: &mut DestroyCtx<'_>) -> Result<(), NodeError>; + fn destroy(&mut self, ctx: &mut DestroyCtx) -> Result<(), NodeError>; fn handle_memory_pressure( &mut self, level: PressureLevel, - ctx: &mut MemPressureCtx<'_>, + ctx: &mut MemPressureCtx, ) -> Result<(), NodeError>; /// Current runtime health, when the node has a more specific status than "ok". @@ -182,14 +182,14 @@ mod tests { } impl NodeRuntime for DummyNode { - fn destroy(&mut self, _ctx: &mut DestroyCtx<'_>) -> Result<(), NodeError> { + fn destroy(&mut self, _ctx: &mut DestroyCtx) -> Result<(), NodeError> { Ok(()) } fn handle_memory_pressure( &mut self, _level: PressureLevel, - _ctx: &mut MemPressureCtx<'_>, + _ctx: &mut MemPressureCtx, ) -> Result<(), NodeError> { Ok(()) } diff --git a/lp-core/lpc-engine/src/node/node_tree.rs b/lp-core/lpc-engine/src/node/node_tree.rs index 75566ac81..0858175e3 100644 --- a/lp-core/lpc-engine/src/node/node_tree.rs +++ b/lp-core/lpc-engine/src/node/node_tree.rs @@ -306,6 +306,141 @@ impl RuntimeNodeTree { self.binding_index.channels() } + /// The bus scope `node` inhabits (modules.md R1). `None` for the root + /// module — no scope contains it — and for unknown nodes. Answered + /// from structural entry state, so it holds for `Pending`/`Failed` + /// nodes and across payload reattach. + pub fn scope_of(&self, node: NodeId) -> Option { + self.get(node).and_then(|entry| entry.scope) + } + + /// The module scope `node` introduces around its project children, + /// when it is a module-kinded node (the root always introduces the + /// root scope). Playlist nodes introduce per-entry SINK scopes + /// instead — those surface through their children's [`Self::scope_of`]. + pub fn scope_introduced_by(&self, node: NodeId) -> Option { + self.get(node) + .filter(|entry| entry.introduces_scope) + .map(|entry| crate::node::ScopeRef::Module { owner: entry.id }) + } + + /// Every scope in the tree: each introducer's module scope plus every + /// sink scope some node inhabits. Sorted and deduplicated so callers + /// get a stable listing. + pub fn scopes(&self) -> Vec { + let mut scopes = Vec::new(); + for entry in self.entries() { + if entry.introduces_scope { + scopes.push(crate::node::ScopeRef::Module { owner: entry.id }); + } + if let Some(scope @ crate::node::ScopeRef::Sink { .. }) = entry.scope { + scopes.push(scope); + } + } + scopes.sort(); + scopes.dedup(); + scopes + } + + /// The channels listed in `scope`: every bus channel named by a + /// binding whose owner inhabits it (R3 — a public slot's channel + /// exists in the slot's scope; R4 — a module node's own endpoints land + /// in its PARENT scope, which is the scope it inhabits). Listing only: + /// resolution semantics stay unscoped until the scoped-channel phase. + pub fn scope_channels( + &self, + scope: crate::node::ScopeRef, + ) -> Vec<(ChannelName, lpc_model::Kind)> { + let mut channels: Vec<(ChannelName, lpc_model::Kind)> = Vec::new(); + for binding in self.bindings() { + if self.scope_of(binding.owner) != Some(scope) { + continue; + } + let named = match (&binding.source, &binding.target) { + (crate::dataflow::binding::BindingSource::BusChannel(channel), _) => Some(channel), + (_, crate::dataflow::binding::BindingTarget::BusChannel(channel)) => Some(channel), + _ => None, + }; + let Some(channel) = named else { continue }; + if !channels.iter().any(|(existing, _)| existing == channel) { + channels.push((channel.clone(), binding.kind)); + } + } + channels.sort_by(|(a, _), (b, _)| a.cmp(b)); + channels + } + + /// The bus scope `node` writes into and reads from: its inhabited + /// scope (R4 — produces write locally; module nodes reside in their + /// PARENT scope, so their own endpoints land there). The root module + /// inhabits nothing and operates in the scope it introduces. + pub fn node_scope(&self, node: NodeId) -> Option { + let entry = self.get(node)?; + if let Some(scope) = entry.scope { + return Some(scope); + } + entry + .introduces_scope + .then_some(crate::node::ScopeRef::Module { owner: entry.id }) + } + + /// The scope enclosing `scope`: for a module scope, the scope its + /// owner inhabits; for a playlist entry's sink scope, the playlist's + /// scope. `None` at the root. + pub fn parent_scope(&self, scope: crate::node::ScopeRef) -> Option { + match scope { + crate::node::ScopeRef::Module { owner } => self.get(owner)?.scope, + crate::node::ScopeRef::Sink { owner, .. } => self.node_scope(owner), + } + } + + /// Writer-shadowing provider lookup (R5): the provider set for a bus + /// read performed from `scope` is the nearest enclosing scope with at + /// least one provider for the channel, walking outward from `scope` to + /// the root. Demand never walks INTO a scope, which is what keeps sink + /// scopes invisible to enclosing readers by construction (R2). A + /// scopeless read (`None`) answers with the flat provider set — the + /// pre-scope behavior test fakes rely on. + pub fn providers_for_bus_read( + &self, + scope: Option, + channel: &ChannelName, + ) -> Vec<(BindingRef, &BindingEntry)> { + let Some(mut scope) = scope else { + return self.providers_for_bus(channel); + }; + loop { + let candidates: Vec<(BindingRef, &BindingEntry)> = self + .binding_index + .bus_targets(channel) + .iter() + .copied() + .filter_map(|binding_ref| { + binding_by_ref(&self.nodes, binding_ref).map(|entry| (binding_ref, entry)) + }) + .filter(|(_, entry)| self.node_scope(entry.owner) == Some(scope)) + .collect(); + if !candidates.is_empty() { + return candidates; + } + match self.parent_scope(scope) { + Some(parent) => scope = parent, + None => return Vec::new(), + } + } + } + + /// The stable persisted identity of `scope` (`` — see + /// [`crate::node::ScopeRef::persist_path`] for the stability + /// rationale). `None` when the owner is unknown. + pub fn scope_persist_path( + &self, + scope: crate::node::ScopeRef, + ) -> Option { + self.get(scope.owner()) + .map(|entry| scope.persist_path(&entry.path)) + } + /// Resolve the binding for one consumed slot, if one exists. /// /// When multiple owners bind the same consumed slot, the owner closest to diff --git a/lp-core/lpc-engine/src/node/scope.rs b/lp-core/lpc-engine/src/node/scope.rs new file mode 100644 index 000000000..f52a6928d --- /dev/null +++ b/lp-core/lpc-engine/src/node/scope.rs @@ -0,0 +1,65 @@ +//! Structural bus-scope identity (modules.md R1/R2). +//! +//! A scope is introduced either by a module node (one scope enclosing its +//! project children) or by an isolating invocation site — today exactly a +//! playlist entry, which wraps its owned child in an anonymous **sink** +//! scope. Scope identity is engine state stored on the runtime node entry +//! (`RuntimeNodeEntry::scope`), assigned by `ensure_runtime_spine` on BOTH +//! load and apply so an edited project can never wear different scopes than +//! a reloaded one. It is NOT a load-time side table: `Pending`/`Failed` +//! entries carry it too (R1 — the engine always answers), and reattach +//! replaces payloads, never entries. + +use alloc::format; +use alloc::string::String; + +use lpc_model::{NodeId, TreePath}; + +/// Identity of one bus scope. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ScopeRef { + /// The scope a module node introduces around its project children. + Module { + /// The introducing module node. + owner: NodeId, + }, + /// The anonymous sink scope one playlist entry wraps its child in + /// (R2). `Sink` is a modeled property — resolution and probes honor it + /// by construction, never via per-layer filters. + Sink { + /// The isolating playlist node. + owner: NodeId, + /// The authored entry key introducing this sink. + entry: u32, + }, +} + +impl ScopeRef { + /// The node that introduces this scope. + pub fn owner(&self) -> NodeId { + match self { + Self::Module { owner } | Self::Sink { owner, .. } => *owner, + } + } + + /// R2's isolating property: channels in a sink scope never surface on + /// enclosing listings and are never resolved by unscoped demand. + pub fn is_sink(&self) -> bool { + matches!(self, Self::Sink { .. }) + } + + /// The stable string identity of this scope, given its owner's tree + /// path. This becomes the persisted panel-state key prefix + /// (`/`), so it must be stable under sibling + /// reorder (names and authored entry keys, never indices) and across + /// reattach/reload (tree paths, never runtime ids). A sink scope keys + /// by the ENTRY (`…/entries[k]`), not the entry's child node path, so + /// swapping which node an entry plays keeps the entry's panel state — + /// state follows the slot, not the content. + pub fn persist_path(&self, owner_path: &TreePath) -> String { + match self { + Self::Module { .. } => format!("{owner_path}"), + Self::Sink { entry, .. } => format!("{owner_path}/entries[{entry}]"), + } + } +} diff --git a/lp-core/lpc-engine/src/nodes/button/button_node.rs b/lp-core/lpc-engine/src/nodes/button/button_node.rs index 865c331f8..dcf6c4fb3 100644 --- a/lp-core/lpc-engine/src/nodes/button/button_node.rs +++ b/lp-core/lpc-engine/src/nodes/button/button_node.rs @@ -139,7 +139,7 @@ impl NodeRuntime for ButtonNode { Ok(ProduceResult::Produced) } - fn destroy(&mut self, _ctx: &mut DestroyCtx<'_>) -> Result<(), NodeError> { + fn destroy(&mut self, _ctx: &mut DestroyCtx) -> Result<(), NodeError> { self.input = None; self.opened = None; self.held_id_seq = None; @@ -149,7 +149,7 @@ impl NodeRuntime for ButtonNode { fn handle_memory_pressure( &mut self, _level: PressureLevel, - _ctx: &mut MemPressureCtx<'_>, + _ctx: &mut MemPressureCtx, ) -> Result<(), NodeError> { Ok(()) } diff --git a/lp-core/lpc-engine/src/nodes/clock/clock_node.rs b/lp-core/lpc-engine/src/nodes/clock/clock_node.rs index d1b385f23..f22b5b8fc 100644 --- a/lp-core/lpc-engine/src/nodes/clock/clock_node.rs +++ b/lp-core/lpc-engine/src/nodes/clock/clock_node.rs @@ -71,14 +71,14 @@ impl NodeRuntime for ClockNode { Ok(ProduceResult::Produced) } - fn destroy(&mut self, _ctx: &mut DestroyCtx<'_>) -> Result<(), NodeError> { + fn destroy(&mut self, _ctx: &mut DestroyCtx) -> Result<(), NodeError> { Ok(()) } fn handle_memory_pressure( &mut self, _level: PressureLevel, - _ctx: &mut MemPressureCtx<'_>, + _ctx: &mut MemPressureCtx, ) -> Result<(), NodeError> { Ok(()) } diff --git a/lp-core/lpc-engine/src/nodes/fixture/fixture_node.rs b/lp-core/lpc-engine/src/nodes/fixture/fixture_node.rs index a0b4828ff..e1b126f51 100644 --- a/lp-core/lpc-engine/src/nodes/fixture/fixture_node.rs +++ b/lp-core/lpc-engine/src/nodes/fixture/fixture_node.rs @@ -381,14 +381,14 @@ impl NodeRuntime for FixtureNode { Ok(()) } - fn destroy(&mut self, _ctx: &mut DestroyCtx<'_>) -> Result<(), NodeError> { + fn destroy(&mut self, _ctx: &mut DestroyCtx) -> Result<(), NodeError> { Ok(()) } fn handle_memory_pressure( &mut self, _level: PressureLevel, - _ctx: &mut MemPressureCtx<'_>, + _ctx: &mut MemPressureCtx, ) -> Result<(), NodeError> { self.precomputed = None; self.direct_points = None; @@ -1477,14 +1477,14 @@ mod tests { Ok(ProduceResult::Produced) } - fn destroy(&mut self, _ctx: &mut DestroyCtx<'_>) -> Result<(), NodeError> { + fn destroy(&mut self, _ctx: &mut DestroyCtx) -> Result<(), NodeError> { Ok(()) } fn handle_memory_pressure( &mut self, _level: PressureLevel, - _ctx: &mut MemPressureCtx<'_>, + _ctx: &mut MemPressureCtx, ) -> Result<(), NodeError> { Ok(()) } @@ -1560,14 +1560,14 @@ mod tests { Ok(ProduceResult::Produced) } - fn destroy(&mut self, _ctx: &mut DestroyCtx<'_>) -> Result<(), NodeError> { + fn destroy(&mut self, _ctx: &mut DestroyCtx) -> Result<(), NodeError> { Ok(()) } fn handle_memory_pressure( &mut self, _level: PressureLevel, - _ctx: &mut MemPressureCtx<'_>, + _ctx: &mut MemPressureCtx, ) -> Result<(), NodeError> { Ok(()) } diff --git a/lp-core/lpc-engine/src/nodes/fluid/fluid_node.rs b/lp-core/lpc-engine/src/nodes/fluid/fluid_node.rs index 35ee8a1e6..e56d88e81 100644 --- a/lp-core/lpc-engine/src/nodes/fluid/fluid_node.rs +++ b/lp-core/lpc-engine/src/nodes/fluid/fluid_node.rs @@ -123,14 +123,14 @@ impl NodeRuntime for FluidNode { Ok(ProduceResult::Produced) } - fn destroy(&mut self, _ctx: &mut DestroyCtx<'_>) -> Result<(), NodeError> { + fn destroy(&mut self, _ctx: &mut DestroyCtx) -> Result<(), NodeError> { Ok(()) } fn handle_memory_pressure( &mut self, _level: PressureLevel, - _ctx: &mut MemPressureCtx<'_>, + _ctx: &mut MemPressureCtx, ) -> Result<(), NodeError> { self.solver = None; self.solver_config = None; @@ -334,12 +334,13 @@ mod tests { #[test] fn fluid_node_loaded_from_project_produces_sampleable_visual_product() { let fs = LpFsMemory::new(); + fs.write_file("/project.json".as_path(), b"{\n \"format\": 3\n}\n") + .expect("container manifest"); fs.write_file( - "/project.json".as_path(), + "/module.json".as_path(), br#" { - "kind": "Project", - "format": 2, + "kind": "Module", "nodes": { "clock": { "ref": "./clock.json" @@ -441,12 +442,13 @@ mod tests { #[test] fn fluid_node_consumes_compute_emitter_map_through_bus() { let fs = LpFsMemory::new(); + fs.write_file("/project.json".as_path(), b"{\n \"format\": 3\n}\n") + .expect("container manifest"); fs.write_file( - "/project.json".as_path(), + "/module.json".as_path(), br#" { - "kind": "Project", - "format": 2, + "kind": "Module", "nodes": { "clock": { "ref": "./clock.json" diff --git a/lp-core/lpc-engine/src/nodes/mod.rs b/lp-core/lpc-engine/src/nodes/mod.rs index 4f7a78535..ac9dd96bb 100644 --- a/lp-core/lpc-engine/src/nodes/mod.rs +++ b/lp-core/lpc-engine/src/nodes/mod.rs @@ -6,6 +6,7 @@ pub mod clock; pub mod fixture; #[cfg(feature = "node-fluid")] pub mod fluid; +pub mod module; pub mod output; mod placeholder; // Always declared — `playlist::playlist_output_path` stays compiled even @@ -27,6 +28,7 @@ pub use clock::{ClockNode, clock_seconds_path}; pub use fixture::fixture_node::{FixtureMap2dSource, FixtureNode, fixture_input_path}; #[cfg(feature = "node-fluid")] pub use fluid::{FluidNode, MsaFluidSolver, fluid_emitters_path, fluid_output_path}; +pub use module::ModuleNode; pub use output::output_node::{OutputNode, output_input_path}; pub use placeholder::CorePlaceholderNode; pub use playlist::playlist_output_path; diff --git a/lp-core/lpc-engine/src/nodes/module/mod.rs b/lp-core/lpc-engine/src/nodes/module/mod.rs new file mode 100644 index 000000000..b186d818f --- /dev/null +++ b/lp-core/lpc-engine/src/nodes/module/mod.rs @@ -0,0 +1,10 @@ +//! Module runtime node — the bus-scope output mirror (modules.md R7). +//! +//! Deliberately NOT feature-gated: every project has a root module, so the +//! runtime links into every firmware build including the C6 minimal image. + +mod module_mirror_state; +mod module_node; + +pub use module_mirror_state::ModuleMirrorState; +pub use module_node::ModuleNode; diff --git a/lp-core/lpc-engine/src/nodes/module/module_mirror_state.rs b/lp-core/lpc-engine/src/nodes/module/module_mirror_state.rs new file mode 100644 index 000000000..d1c2ea40e --- /dev/null +++ b/lp-core/lpc-engine/src/nodes/module/module_mirror_state.rs @@ -0,0 +1,25 @@ +//! Runtime state root for [`super::ModuleNode`]. + +use lpc_model::{Slotted, VisualProductSlot}; + +/// Runtime state exposed by a module node: the scope's `visual.out` +/// mirrored as a produced `output` (modules.md R7). The row carries the +/// module node's own product handle (playlist parity); render dispatch +/// forwards to the scope's actual producer, and a scope with no visual +/// writer renders cleared (a module without a visual is a legitimate +/// shape, not an error). +/// +/// Engine-side on purpose — the mirror is a runtime convention, not an +/// authored artifact surface, so it stays out of the model's static shape +/// catalog (and therefore out of `schemas/`). +#[derive(Default, Slotted)] +#[slot(default_policy = "read_only_transient")] +pub struct ModuleMirrorState { + /// This module node's own renderable handle. No `default_bind`: the + /// mirror reads its scope's channel directly and never writes a bus + /// channel of its own (that would make it a writer of the channel it + /// mirrors); the outward `visual.out` publish is a loader-registered + /// binding on this produced slot (R7), not part of the mirror. + #[slot(produced)] + pub output: VisualProductSlot, +} diff --git a/lp-core/lpc-engine/src/nodes/module/module_node.rs b/lp-core/lpc-engine/src/nodes/module/module_node.rs new file mode 100644 index 000000000..5c9af0e29 --- /dev/null +++ b/lp-core/lpc-engine/src/nodes/module/module_node.rs @@ -0,0 +1,199 @@ +//! The module node's runtime: mirror the introduced scope's `visual.out` +//! as produced `output` (modules.md R7). +//! +//! Attached to every module node, root included — uniformity is the point +//! (the root is an ordinary module that happens to be at the root; its +//! mirror is what "play the project" renders). Harvested from the +//! superseded composite-effects spike's `ProjectNode` with the scope +//! arriving as a [`QueryKey::Bus`] key instead of the spike's side-table +//! `ScopedChannel`. + +use alloc::boxed::Box; +use alloc::format; +use alloc::string::String; + +use lp_gfx::TextureHandle; +use lpc_model::{ChannelName, FromLpValue, NodeId, SlotPath, VisualProduct, VisualProductSlot}; +use lps_shared::TextureStorageFormat; + +use crate::dataflow::resolver::QueryKey; +use crate::node::RuntimeStateShape; +use crate::node::{ + DestroyCtx, MemPressureCtx, NodeError, NodeRuntime, PressureLevel, ProduceResult, + RenderContext, RenderNode, ScopeRef, TickContext, err_ctx, +}; +use crate::products::visual::{ + RenderTextureRequest, TextureRenderProduct, VisualSampleBufferRequest, VisualSampleTarget, +}; +use lpc_model::{SlotAccess, SlotShapeRegistry, SlotShapeRegistryError}; + +use super::ModuleMirrorState; + +/// Runtime node attached to every module node. +/// +/// The playlist pattern, minus the blending: `produce` resolves the +/// module's own introduced scope's `visual.out` channel and remembers the +/// producing node's [`VisualProduct`] handle, while the published `output` +/// row always carries the module node's *own* handle — render dispatch +/// then forwards to the remembered producer. A scope with no visual +/// writer forwards nothing and renders cleared. +pub struct ModuleNode { + /// The scoped demand key for this module's own `visual.out`. + channel: QueryKey, + /// The scope's current visual producer, refreshed each `produce`. + mirrored: Option, + state: ModuleMirrorState, +} + +impl ModuleNode { + pub fn new(node_id: NodeId) -> Self { + Self { + channel: QueryKey::Bus { + scope: Some(ScopeRef::Module { owner: node_id }), + channel: ChannelName(String::from(lpc_model::PRIMARY_VISUAL_CHANNEL)), + }, + mirrored: None, + state: ModuleMirrorState { + output: VisualProductSlot::new(VisualProduct::new(node_id, 0)), + }, + } + } + + pub fn boxed(node_id: NodeId) -> Box { + Box::new(Self::new(node_id)) + } + + fn output_path() -> SlotPath { + SlotPath::parse("output").expect("module output path") + } +} + +impl NodeRuntime for ModuleNode { + fn produce( + &mut self, + _slot: &SlotPath, + ctx: &mut TickContext<'_>, + ) -> Result { + // No writer anywhere for the scope's visual.out = a legitimate + // module-without-a-visual: mirror cleared (R7), never an error. + self.mirrored = match ctx.resolve(&self.channel) { + Ok(production) => { + let value = production + .value_leaf() + .ok_or_else(|| NodeError::msg("scope visual channel is not a value"))?; + Some( + VisualProduct::from_lp_value(value.value()) + .map_err(|e| NodeError::msg(format!("scope visual channel: {e}")))?, + ) + } + Err(error) if error.is_no_bus_provider() => None, + Err(error) => { + return Err(NodeError::msg(format!( + "resolve scope visual channel: {}", + error.message + ))); + } + }; + self.state + .output + .set_with_version(ctx.revision(), *self.state.output.value()); + ctx.publish_runtime_slot(&self.state, Self::output_path())?; + Ok(ProduceResult::Produced) + } + + fn destroy(&mut self, _ctx: &mut DestroyCtx) -> Result<(), NodeError> { + Ok(()) + } + + fn handle_memory_pressure( + &mut self, + _level: PressureLevel, + _ctx: &mut MemPressureCtx, + ) -> Result<(), NodeError> { + Ok(()) + } + + fn runtime_state_slots(&self) -> Option<&dyn SlotAccess> { + Some(&self.state) + } + + fn register_runtime_state_shapes( + &self, + registry: &mut SlotShapeRegistry, + ) -> Result<(), SlotShapeRegistryError> { + ModuleMirrorState::register_runtime_state_shape(registry).map(|_| ()) + } + + fn render_node(&mut self) -> Option<&mut dyn RenderNode> { + Some(self) + } +} + +impl RenderNode for ModuleNode { + fn render_texture( + &mut self, + product: VisualProduct, + request: &RenderTextureRequest, + ctx: &mut RenderContext<'_>, + ) -> Result { + if request.format != TextureStorageFormat::Rgba16Unorm { + return Err(NodeError::msg( + "module mirror texture render only supports RGBA16 unorm", + )); + } + let mut texture = { + let graphics = ctx + .graphics() + .ok_or_else(|| NodeError::msg("missing graphics backend"))?; + graphics + .create_render_target(request.width, request.height) + .map_err(err_ctx("module mirror texture"))? + }; + self.render_texture_into(product, request, &mut texture, ctx)?; + let graphics = ctx.graphics().expect("graphics checked above"); + if !graphics.supports_read_back() { + return TextureRenderProduct::gpu_resident(texture) + .map_err(err_ctx("module mirror gpu texture product")); + } + let bytes = graphics + .read_back(&texture) + .map_err(err_ctx("module mirror read back"))? + .into_bytes(); + TextureRenderProduct::rgba16_unorm(request.width, request.height, bytes) + .map_err(err_ctx("module mirror texture product")) + } + + fn render_texture_into( + &mut self, + _product: VisualProduct, + request: &RenderTextureRequest, + target: &mut TextureHandle, + ctx: &mut RenderContext<'_>, + ) -> Result<(), NodeError> { + let Some(mirrored) = self.mirrored else { + return ctx + .graphics() + .ok_or_else(|| NodeError::msg("missing graphics backend"))? + .clear_texture(target) + .map_err(err_ctx("module mirror clear target")); + }; + ctx.render_texture_into(mirrored, request, target) + } + + fn sample_visual_into( + &mut self, + _product: VisualProduct, + request: VisualSampleBufferRequest<'_>, + target: VisualSampleTarget<'_>, + ctx: &mut RenderContext<'_>, + ) -> Result<(), NodeError> { + let Some(mirrored) = self.mirrored else { + return ctx + .graphics() + .ok_or_else(|| NodeError::msg("missing graphics backend"))? + .clear_sample_out(target.samples) + .map_err(err_ctx("module mirror clear samples")); + }; + ctx.sample_visual_into(mirrored, request, target) + } +} diff --git a/lp-core/lpc-engine/src/nodes/output/output_node.rs b/lp-core/lpc-engine/src/nodes/output/output_node.rs index fbf42b16d..f02045138 100644 --- a/lp-core/lpc-engine/src/nodes/output/output_node.rs +++ b/lp-core/lpc-engine/src/nodes/output/output_node.rs @@ -111,14 +111,14 @@ impl NodeRuntime for OutputNode { Ok(()) } - fn destroy(&mut self, _ctx: &mut DestroyCtx<'_>) -> Result<(), NodeError> { + fn destroy(&mut self, _ctx: &mut DestroyCtx) -> Result<(), NodeError> { Ok(()) } fn handle_memory_pressure( &mut self, _level: PressureLevel, - _ctx: &mut MemPressureCtx<'_>, + _ctx: &mut MemPressureCtx, ) -> Result<(), NodeError> { Ok(()) } diff --git a/lp-core/lpc-engine/src/nodes/placeholder/mod.rs b/lp-core/lpc-engine/src/nodes/placeholder/mod.rs index ba9f1e371..92056310e 100644 --- a/lp-core/lpc-engine/src/nodes/placeholder/mod.rs +++ b/lp-core/lpc-engine/src/nodes/placeholder/mod.rs @@ -26,14 +26,14 @@ impl CorePlaceholderNode { } impl NodeRuntime for CorePlaceholderNode { - fn destroy(&mut self, _ctx: &mut DestroyCtx<'_>) -> Result<(), NodeError> { + fn destroy(&mut self, _ctx: &mut DestroyCtx) -> Result<(), NodeError> { Ok(()) } fn handle_memory_pressure( &mut self, _level: PressureLevel, - _ctx: &mut MemPressureCtx<'_>, + _ctx: &mut MemPressureCtx, ) -> Result<(), NodeError> { Ok(()) } diff --git a/lp-core/lpc-engine/src/nodes/playlist/playlist_node.rs b/lp-core/lpc-engine/src/nodes/playlist/playlist_node.rs index 58b8e53b1..f475b4f23 100644 --- a/lp-core/lpc-engine/src/nodes/playlist/playlist_node.rs +++ b/lp-core/lpc-engine/src/nodes/playlist/playlist_node.rs @@ -227,14 +227,14 @@ impl NodeRuntime for PlaylistNode { } } - fn destroy(&mut self, _ctx: &mut DestroyCtx<'_>) -> Result<(), NodeError> { + fn destroy(&mut self, _ctx: &mut DestroyCtx) -> Result<(), NodeError> { Ok(()) } fn handle_memory_pressure( &mut self, _level: PressureLevel, - _ctx: &mut MemPressureCtx<'_>, + _ctx: &mut MemPressureCtx, ) -> Result<(), NodeError> { Ok(()) } diff --git a/lp-core/lpc-engine/src/nodes/radio/control_radio_node.rs b/lp-core/lpc-engine/src/nodes/radio/control_radio_node.rs index 835ce4850..114d9da62 100644 --- a/lp-core/lpc-engine/src/nodes/radio/control_radio_node.rs +++ b/lp-core/lpc-engine/src/nodes/radio/control_radio_node.rs @@ -274,7 +274,7 @@ impl NodeRuntime for ControlRadioNode { Ok(()) } - fn destroy(&mut self, _ctx: &mut DestroyCtx<'_>) -> Result<(), NodeError> { + fn destroy(&mut self, _ctx: &mut DestroyCtx) -> Result<(), NodeError> { self.device = None; self.opened = None; self.pending.clear(); @@ -285,7 +285,7 @@ impl NodeRuntime for ControlRadioNode { fn handle_memory_pressure( &mut self, _level: PressureLevel, - _ctx: &mut MemPressureCtx<'_>, + _ctx: &mut MemPressureCtx, ) -> Result<(), NodeError> { Ok(()) } diff --git a/lp-core/lpc-engine/src/nodes/shader/compute_shader_node.rs b/lp-core/lpc-engine/src/nodes/shader/compute_shader_node.rs index 8789bb938..2ee62ab23 100644 --- a/lp-core/lpc-engine/src/nodes/shader/compute_shader_node.rs +++ b/lp-core/lpc-engine/src/nodes/shader/compute_shader_node.rs @@ -319,14 +319,14 @@ impl NodeRuntime for ComputeShaderNode { Ok(AssetRefreshResult::Refreshed) } - fn destroy(&mut self, _ctx: &mut DestroyCtx<'_>) -> Result<(), NodeError> { + fn destroy(&mut self, _ctx: &mut DestroyCtx) -> Result<(), NodeError> { Ok(()) } fn handle_memory_pressure( &mut self, _level: PressureLevel, - _ctx: &mut MemPressureCtx<'_>, + _ctx: &mut MemPressureCtx, ) -> Result<(), NodeError> { Ok(()) } diff --git a/lp-core/lpc-engine/src/nodes/shader/shader_node.rs b/lp-core/lpc-engine/src/nodes/shader/shader_node.rs index 39e4cc14a..580a76144 100644 --- a/lp-core/lpc-engine/src/nodes/shader/shader_node.rs +++ b/lp-core/lpc-engine/src/nodes/shader/shader_node.rs @@ -317,14 +317,14 @@ impl NodeRuntime for ShaderNode { Ok(AssetRefreshResult::Refreshed) } - fn destroy(&mut self, _ctx: &mut DestroyCtx<'_>) -> Result<(), NodeError> { + fn destroy(&mut self, _ctx: &mut DestroyCtx) -> Result<(), NodeError> { Ok(()) } fn handle_memory_pressure( &mut self, _level: PressureLevel, - _ctx: &mut MemPressureCtx<'_>, + _ctx: &mut MemPressureCtx, ) -> Result<(), NodeError> { Ok(()) } diff --git a/lp-core/lpc-engine/src/nodes/texture/texture_node.rs b/lp-core/lpc-engine/src/nodes/texture/texture_node.rs index d384a44be..7094934e1 100644 --- a/lp-core/lpc-engine/src/nodes/texture/texture_node.rs +++ b/lp-core/lpc-engine/src/nodes/texture/texture_node.rs @@ -101,14 +101,14 @@ impl NodeRuntime for TextureNode { Ok(ProduceResult::Produced) } - fn destroy(&mut self, _ctx: &mut DestroyCtx<'_>) -> Result<(), NodeError> { + fn destroy(&mut self, _ctx: &mut DestroyCtx) -> Result<(), NodeError> { Ok(()) } fn handle_memory_pressure( &mut self, _level: PressureLevel, - _ctx: &mut MemPressureCtx<'_>, + _ctx: &mut MemPressureCtx, ) -> Result<(), NodeError> { Ok(()) } diff --git a/lp-core/lpc-engine/tests/runtime_spine.rs b/lp-core/lpc-engine/tests/runtime_spine.rs index d7e5c1c72..86990cac1 100644 --- a/lp-core/lpc-engine/tests/runtime_spine.rs +++ b/lp-core/lpc-engine/tests/runtime_spine.rs @@ -60,7 +60,11 @@ fn runtime_spine_tick_context_resolve_bus_query() { Err(SessionResolveError::other("unexpected produce")) } - fn providers_for_bus(&self, channel: &ChannelName) -> Vec<(BindingRef, BindingEntry)> { + fn providers_for_bus( + &self, + _scope: Option, + channel: &ChannelName, + ) -> Vec<(BindingRef, BindingEntry)> { if channel == &self.channel { Vec::from([(BindingRef::new(self.binding.owner, 0), self.binding.clone())]) } else { @@ -75,7 +79,10 @@ fn runtime_spine_tick_context_resolve_bus_query() { }; let slot_shapes = lpc_model::SlotShapeRegistry::default(); let mut node = ProduceProbeNode { - query: QueryKey::Bus(channel), + query: QueryKey::Bus { + scope: None, + channel: channel, + }, last: None, }; @@ -171,12 +178,13 @@ fn project_apply_added_node_use_preserves_existing_runtime_node() { .node_id(&clock_use) .expect("clock runtime node"); + fs.write_file_mut(LpPath::new("/project.json"), b"{\n \"format\": 3\n}\n") + .expect("write container manifest"); fs.write_file_mut( - LpPath::new("/project.json"), + LpPath::new("/module.json"), br#" { - "kind": "Project", - "format": 2, + "kind": "Module", "nodes": { "clock": { "ref": "./clock.json" @@ -208,7 +216,7 @@ fn project_apply_added_node_use_preserves_existing_runtime_node() { let changes = registry.refresh_artifacts( &fs, &[FsEvent { - path: LpPathBuf::from("/project.json"), + path: LpPathBuf::from("/module.json"), kind: FsEventKind::Modify, }], Revision::new(2), @@ -438,12 +446,13 @@ fn project_apply_remove_node_op_tears_down_runtime_node() { fn fixture_map2d_project_fs() -> LpFsMemory { let mut fs = LpFsMemory::new(); + fs.write_file_mut(LpPath::new("/project.json"), b"{\n \"format\": 3\n}\n") + .expect("write container manifest"); fs.write_file_mut( - LpPath::new("/project.json"), + LpPath::new("/module.json"), br#" { - "kind": "Project", - "format": 2, + "kind": "Module", "nodes": { "fixture": { "ref": "./fixture.json" @@ -480,12 +489,13 @@ fn fixture_map2d_project_fs() -> LpFsMemory { fn clock_project_fs() -> LpFsMemory { let mut fs = LpFsMemory::new(); + fs.write_file_mut(LpPath::new("/project.json"), b"{\n \"format\": 3\n}\n") + .expect("write container manifest"); fs.write_file_mut( - LpPath::new("/project.json"), + LpPath::new("/module.json"), br#" { - "kind": "Project", - "format": 2, + "kind": "Module", "nodes": { "clock": { "ref": "./clock.json" @@ -512,12 +522,13 @@ fn clock_project_fs() -> LpFsMemory { fn shader_project_fs() -> LpFsMemory { let mut fs = LpFsMemory::new(); + fs.write_file_mut(LpPath::new("/project.json"), b"{\n \"format\": 3\n}\n") + .expect("write container manifest"); fs.write_file_mut( - LpPath::new("/project.json"), + LpPath::new("/module.json"), br#" { - "kind": "Project", - "format": 2, + "kind": "Module", "nodes": { "shader": { "ref": "./shader.json" @@ -565,14 +576,14 @@ impl NodeRuntime for ProduceProbeNode { Ok(ProduceResult::Produced) } - fn destroy(&mut self, _ctx: &mut lpc_engine::node::DestroyCtx<'_>) -> Result<(), NodeError> { + fn destroy(&mut self, _ctx: &mut lpc_engine::node::DestroyCtx) -> Result<(), NodeError> { Ok(()) } fn handle_memory_pressure( &mut self, _level: PressureLevel, - _ctx: &mut MemPressureCtx<'_>, + _ctx: &mut MemPressureCtx, ) -> Result<(), NodeError> { Ok(()) } diff --git a/lp-core/lpc-engine/tests/structural_scope.rs b/lp-core/lpc-engine/tests/structural_scope.rs new file mode 100644 index 000000000..9e3dbbca6 --- /dev/null +++ b/lp-core/lpc-engine/tests/structural_scope.rs @@ -0,0 +1,521 @@ +//! Structural scope (engine C1, modules.md R1/R2): scope identity lives on +//! `RuntimeNodeEntry`, is queryable after load AND after apply, survives +//! reattach and broken defs, and models playlist-entry sink scopes as a +//! property — never a probe filter. + +use lpc_engine::engine::LoadedProjectRuntime; +use lpc_engine::node::ScopeRef; +use lpc_engine::{EngineServices, ProjectLoader}; +use lpc_model::{NodeUseLocation, SlotPath, TreePath, current_revision}; +use lpc_registry::ParseCtx; +use lpfs::{AsLpPath, FsEvent, FsEventKind, LpFs, LpFsMemory, LpPathBuf}; + +fn project_fs() -> LpFsMemory { + let fs = LpFsMemory::new(); + fs.write_file("/project.json".as_path(), b"{\n \"format\": 3\n}\n") + .expect("container manifest"); + fs.write_file( + "/module.json".as_path(), + br#" +{ + "kind": "Module", + "nodes": { + "clock": { + "ref": "./clock.json" + }, + "list": { + "ref": "./playlist.json" + } + } +} +"#, + ) + .expect("module.json"); + fs.write_file( + "/playlist.json".as_path(), + br#" +{ + "kind": "Playlist", + "entries": { + "1": { + "name": "idle", + "node": { + "ref": "./idle.json" + } + }, + "7": { + "name": "active", + "node": { + "ref": "./active.json" + } + } + } +} +"#, + ) + .expect("playlist.json"); + fs.write_file("/clock.json".as_path(), br#"{ "kind": "Clock" }"#) + .expect("clock.json"); + fs.write_file( + "/idle.json".as_path(), + br#"{ "kind": "Shader", "source": { "path": "idle.glsl" } }"#, + ) + .expect("idle.json"); + fs.write_file( + "/active.json".as_path(), + br#"{ "kind": "Shader", "source": { "path": "active.glsl" } }"#, + ) + .expect("active.json"); + fs.write_file( + "/idle.glsl".as_path(), + b"vec4 render(vec2 p) { return vec4(1.0); }", + ) + .expect("idle.glsl"); + fs.write_file( + "/active.glsl".as_path(), + b"vec4 render(vec2 p) { return vec4(0.5); }", + ) + .expect("active.glsl"); + fs +} + +fn load(fs: &LpFsMemory) -> LoadedProjectRuntime { + let services = EngineServices::new(TreePath::parse("/scope_test.show").expect("path")); + ProjectLoader::load_from_root(fs, services).expect("load scope project") +} + +fn use_location(path: &str) -> NodeUseLocation { + NodeUseLocation::root().child(SlotPath::parse(path).expect("slot path")) +} + +/// `(persist-path, is_sink)` per scope-carrying node, keyed by a stable +/// label — the comparable "scope table" for differential assertions. +fn scope_table(engine: &lpc_engine::Engine) -> Vec<(String, Option, bool)> { + let mut rows = Vec::new(); + for entry in engine.tree().entries() { + let scope = entry.scope; + let persist = scope.and_then(|scope| engine.tree().scope_persist_path(scope)); + rows.push(( + entry.path.to_string(), + persist, + scope.is_some_and(|scope| scope.is_sink()), + )); + } + rows.sort(); + rows +} + +#[test] +fn scope_is_queryable_after_load_with_sink_entries_modeled() { + let fs = project_fs(); + let rt = load(&fs); + let engine = rt.engine(); + let tree = engine.tree(); + let root = tree.root(); + + // Root: no containing scope, introduces the root scope. + assert_eq!(tree.scope_of(root), None); + let root_scope = tree.scope_introduced_by(root).expect("root introduces"); + assert_eq!(root_scope, ScopeRef::Module { owner: root }); + assert!(!root_scope.is_sink()); + + // Project children inhabit the root module's scope. + let clock = engine + .project_runtime_index() + .node_id(&use_location("nodes[clock]")) + .expect("clock projected"); + let list = engine + .project_runtime_index() + .node_id(&use_location("nodes[list]")) + .expect("playlist projected"); + assert_eq!(tree.scope_of(clock), Some(root_scope)); + assert_eq!(tree.scope_of(list), Some(root_scope)); + assert_eq!(tree.scope_introduced_by(clock), None); + assert_eq!( + tree.scope_introduced_by(list), + None, + "playlists introduce per-entry sinks, not a module scope" + ); + + // Playlist entries inhabit per-entry sink scopes keyed by the + // authored entry key. + let entries = tree + .scopes() + .into_iter() + .filter(ScopeRef::is_sink) + .collect::>(); + assert_eq!( + entries, + vec![ + ScopeRef::Sink { + owner: list, + entry: 1 + }, + ScopeRef::Sink { + owner: list, + entry: 7 + }, + ] + ); + for scope in &entries { + let path = engine + .tree() + .scope_persist_path(*scope) + .expect("sink scope persist path"); + assert!( + path.ends_with(&format!( + "/entries[{}]", + match scope { + ScopeRef::Sink { entry, .. } => *entry, + ScopeRef::Module { .. } => unreachable!(), + } + )), + "sink scopes key by authored entry, got {path}" + ); + } +} + +#[test] +fn load_and_trivial_apply_produce_identical_scope_tables() { + // The critical invariant: scope is recomputed identically on BOTH + // entry points — a project must never wear different scopes after an + // edit than after a reload. + let fs = project_fs(); + let baseline = scope_table(load(&fs).engine()); + + let fs = project_fs(); + let rt = load(&fs); + let (mut engine, mut registry) = rt.into_parts(); + // Trivial content change: touch the clock def body. + fs.write_file( + "/clock.json".as_path(), + br#"{ "kind": "Clock", "controls": { "rate": 2.0 } }"#, + ) + .expect("rewrite clock"); + let shapes = engine.slot_shapes().clone(); + let changes = registry.refresh_artifacts( + &fs, + &[FsEvent { + path: LpPathBuf::from("/clock.json"), + kind: FsEventKind::Modify, + }], + current_revision(), + &ParseCtx { shapes: &shapes }, + ); + engine + .apply_project_changes(&fs, &mut registry, &changes) + .expect("apply"); + + let applied = scope_table(&engine); + assert_eq!(applied, baseline, "load vs load+apply scope tables differ"); +} + +#[test] +fn load_and_apply_produce_identical_bus_wiring() { + // Extends the scope-table differential to RESOLVED WIRING: for every + // (scope, channel) pair, the winning provider set must be identical + // through fresh load and through load + apply. + fn winner_table(engine: &lpc_engine::Engine) -> Vec<(String, String, Vec)> { + let tree = engine.tree(); + let mut rows = Vec::new(); + for scope in tree.scopes() { + let scope_path = tree.scope_persist_path(scope).expect("scope path"); + for (channel, _) in tree.scope_channels(scope) { + let winners = tree + .providers_for_bus_read(Some(scope), &channel) + .into_iter() + .map(|(_, entry)| { + tree.get(entry.owner) + .map(|owner| owner.path.to_string()) + .unwrap_or_default() + }) + .collect::>(); + rows.push((scope_path.clone(), channel.0.clone(), winners)); + } + } + rows.sort(); + rows + } + + let fs = project_fs(); + let baseline = winner_table(load(&fs).engine()); + assert!( + !baseline.is_empty(), + "the wiring table must actually cover channels" + ); + + let fs = project_fs(); + let rt = load(&fs); + let (mut engine, mut registry) = rt.into_parts(); + fs.write_file( + "/clock.json".as_path(), + br#"{ "kind": "Clock", "controls": { "rate": 2.0 } }"#, + ) + .expect("rewrite clock"); + let shapes = engine.slot_shapes().clone(); + let changes = registry.refresh_artifacts( + &fs, + &[FsEvent { + path: LpPathBuf::from("/clock.json"), + kind: FsEventKind::Modify, + }], + current_revision(), + &ParseCtx { shapes: &shapes }, + ); + engine + .apply_project_changes(&fs, &mut registry, &changes) + .expect("apply"); + assert_eq!( + winner_table(&engine), + baseline, + "load vs load+apply bus wiring differs" + ); +} + +#[test] +fn failed_defs_still_carry_scope() { + // R1: the engine must always answer — a node whose def is broken still + // inhabits its structural scope. + let fs = project_fs(); + fs.write_file("/clock.json".as_path(), b"not valid json {{{") + .expect("break clock def"); + let rt = load(&fs); + let engine = rt.engine(); + let tree = engine.tree(); + let root_scope = tree + .scope_introduced_by(tree.root()) + .expect("root introduces"); + let clock = engine + .project_runtime_index() + .node_id(&use_location("nodes[clock]")) + .expect("broken clock still projected"); + assert_eq!(tree.scope_of(clock), Some(root_scope)); +} + +#[test] +fn scope_persist_paths_are_tree_path_stable() { + let fs = project_fs(); + let rt = load(&fs); + let engine = rt.engine(); + let tree = engine.tree(); + let root_scope = tree.scope_introduced_by(tree.root()).expect("root scope"); + let root_path = tree + .scope_persist_path(root_scope) + .expect("root persist path"); + assert!( + root_path.starts_with("/scope_test."), + "module scopes key by the owner's tree path, got {root_path}" + ); +} + +#[test] +fn e5_depth_2_consumer_resolves_the_sibling_modules_publish() { + // modules.md E5, the exact shape that must be pinned: H contains + // M_outer; M_outer contains M_inner (which has a visual writer) and a + // consumer C beside it. C's `visual.out` read finds M_inner's R7 + // publish IN Scope(M_outer) — module publishes count as writers like + // any producer — and never walks to root. The failure mode being + // pinned: writer accounting that omits module publishes works at + // depth 1 by coincidence and resolves C to ROOT's visual at depth 2. + let fs = LpFsMemory::new(); + fs.write_file("/project.json".as_path(), b"{\n \"format\": 3\n}\n") + .expect("container manifest"); + fs.write_file( + "/module.json".as_path(), + br#" +{ + "kind": "Module", + "nodes": { + "root_shader": { "ref": "./root_shader.json" }, + "outer": { "ref": "./outer/module.json" } + } +} +"#, + ) + .expect("module.json"); + fs.write_file( + "/root_shader.json".as_path(), + br#"{ "kind": "Shader", "source": { "path": "root.glsl" } }"#, + ) + .expect("root shader"); + fs.write_file( + "/root.glsl".as_path(), + b"vec4 render(vec2 p) { return vec4(1.0); }", + ) + .expect("root glsl"); + fs.write_file( + "/outer/module.json".as_path(), + br#" +{ + "kind": "Module", + "nodes": { + "inner": { "ref": "./inner/module.json" }, + "analyzer": { "ref": "./analyzer.json" } + } +} +"#, + ) + .expect("outer module"); + fs.write_file( + "/outer/inner/module.json".as_path(), + br#" +{ + "kind": "Module", + "nodes": { + "plasma": { "ref": "./plasma.json" } + } +} +"#, + ) + .expect("inner module"); + fs.write_file( + "/outer/inner/plasma.json".as_path(), + br#"{ "kind": "Shader", "source": { "path": "plasma.glsl" } }"#, + ) + .expect("plasma shader"); + fs.write_file( + "/outer/inner/plasma.glsl".as_path(), + b"vec4 render(vec2 p) { return vec4(0.5); }", + ) + .expect("plasma glsl"); + fs.write_file( + "/outer/analyzer.json".as_path(), + br#" +{ + "kind": "Texture", + "size": { "width": 2, "height": 2 }, + "bindings": { + "input": { "source": "bus:visual.out" } + } +} +"#, + ) + .expect("analyzer"); + + let rt = load(&fs); + let engine = rt.engine(); + let tree = engine.tree(); + + let outer = engine + .project_runtime_index() + .node_id(&use_location("nodes[outer]")) + .expect("outer module projected"); + let inner = engine + .project_runtime_index() + .node_id( + &use_location("nodes[outer]").child(SlotPath::parse("nodes[inner]").expect("path")), + ) + .expect("inner module projected"); + let analyzer = engine + .project_runtime_index() + .node_id( + &use_location("nodes[outer]").child(SlotPath::parse("nodes[analyzer]").expect("path")), + ) + .expect("analyzer projected"); + + // The analyzer reads from Scope(outer); the winning provider set for + // that read must be M_inner's R7 publish — not root's shader. + let scope_outer = tree.scope_of(analyzer).expect("analyzer scope"); + assert_eq!( + scope_outer, + lpc_engine::node::ScopeRef::Module { owner: outer } + ); + let winners = tree.providers_for_bus_read( + Some(scope_outer), + &lpc_model::ChannelName(String::from("visual.out")), + ); + assert!( + !winners.is_empty(), + "Scope(outer) must see the inner module's publish as a writer" + ); + assert!( + winners.iter().all(|(_, entry)| entry.owner == inner), + "the depth-2 read must resolve to the sibling module's publish, \ + never walk to root: {winners:?}" + ); +} + +#[test] +fn r7_authored_export_and_root_module_runtime() { + // R7: an authored export republishes an inner channel outward under + // the export's name; the root wears a real module runtime (its output + // interface exists like any module's). + let fs = LpFsMemory::new(); + fs.write_file("/project.json".as_path(), b"{\n \"format\": 3\n}\n") + .expect("container manifest"); + fs.write_file( + "/module.json".as_path(), + br#" +{ + "kind": "Module", + "nodes": { + "clock": { "ref": "./clock.json" }, + "audio": { "ref": "./audio/module.json" } + } +} +"#, + ) + .expect("module.json"); + fs.write_file("/clock.json".as_path(), br#"{ "kind": "Clock" }"#) + .expect("clock"); + fs.write_file( + "/audio/module.json".as_path(), + br#" +{ + "kind": "Module", + "nodes": { + "beat_clock": { "ref": "./beat_clock.json" } + }, + "exports": { + "energy": "bus:time" + } +} +"#, + ) + .expect("audio module"); + fs.write_file( + "/audio/beat_clock.json".as_path(), + br#"{ "kind": "Clock" }"#, + ) + .expect("beat clock"); + + let rt = load(&fs); + let engine = rt.engine(); + let tree = engine.tree(); + let audio = engine + .project_runtime_index() + .node_id(&use_location("nodes[audio]")) + .expect("audio module projected"); + + // The export surfaces as a writer of `energy` in the module's own + // nearest scope (root), owned by the module node. + let root_scope = tree.node_scope(tree.root()).expect("root scope"); + let winners = tree.providers_for_bus_read( + Some(root_scope), + &lpc_model::ChannelName(String::from("energy")), + ); + assert!( + winners.iter().any(|(_, entry)| entry.owner == audio), + "the authored export must appear as a writer in the containing scope" + ); + + // The embedded module also default-publishes visual.out outward (R7 + // automatic publish), and the ROOT is alive with a real module + // runtime exposing the mirror's `output` state row. + let winners = tree.providers_for_bus_read( + Some(root_scope), + &lpc_model::ChannelName(String::from("visual.out")), + ); + assert!( + winners.iter().any(|(_, entry)| entry.owner == audio), + "an embedded module default-publishes visual.out at fallback priority" + ); + let root_entry = tree.get(tree.root()).expect("root entry"); + assert!( + matches!( + root_entry.state.value(), + lpc_engine::node::NodeEntryState::Alive(_) + ), + "the root wears a live module runtime" + ); +} diff --git a/lp-core/lpc-history/src/hash/package_hasher.rs b/lp-core/lpc-history/src/hash/package_hasher.rs index 0de2c093c..664f2975c 100644 --- a/lp-core/lpc-history/src/hash/package_hasher.rs +++ b/lp-core/lpc-history/src/hash/package_hasher.rs @@ -103,13 +103,13 @@ mod tests { write( &fs, "/project.json", - b"{\"kind\":\"Project\",\"name\":\"kat\"}", + b"{\"kind\":\"Module\",\"name\":\"kat\"}", ); write(&fs, "/shader.glsl", b"void main() {}\n"); let (hash, _) = hash_package(&fs).unwrap(); assert_eq!( alloc::format!("{hash}"), - "4871c9a4ff89d732ed427d084e3ccbffb72d4ee5821a7ff81d36530f9d98bfc7" + "60f376ec4c9a27e0aae36f58714d7b35c083456d637a403d9b158dfbb088ca10" ); } } diff --git a/lp-core/lpc-model/src/feature.rs b/lp-core/lpc-model/src/feature.rs index 2f8c5cf61..eeca8d6b6 100644 --- a/lp-core/lpc-model/src/feature.rs +++ b/lp-core/lpc-model/src/feature.rs @@ -122,7 +122,7 @@ impl LpFeature { /// `lpc-engine/Cargo.toml`). pub const fn for_node_kind(kind: NodeKind) -> Option { match kind { - NodeKind::Project => None, + NodeKind::Module => None, NodeKind::Output => None, NodeKind::Button => Some(LpFeature::NodeButton), NodeKind::Clock => Some(LpFeature::NodeClock), @@ -230,7 +230,7 @@ mod tests { #[test] fn node_kind_mapping_matches_gate_classification() { let cases = [ - (NodeKind::Project, None), + (NodeKind::Module, None), (NodeKind::Output, None), (NodeKind::Button, Some(LpFeature::NodeButton)), (NodeKind::Clock, Some(LpFeature::NodeClock)), diff --git a/lp-core/lpc-model/src/lib.rs b/lp-core/lpc-model/src/lib.rs index 7d2b3dfa4..b1d06e45c 100644 --- a/lp-core/lpc-model/src/lib.rs +++ b/lp-core/lpc-model/src/lib.rs @@ -108,22 +108,21 @@ pub use node::{ }; pub use nodes::{ ArtifactPathResolutionError, Brightness, ButtonDef, ButtonDefView, ButtonState, - ButtonStateView, ClockControls, ClockDef, ClockDefView, ClockState, ColorOrder, - ComputeShaderDef, ComputeShaderDefView, ControlRadioDef, ControlRadioDefView, - ControlRadioState, ControlRadioStateView, FixtureDef, FixtureDefView, FixtureDiagnosticMode, - FixtureSamplingConfig, FixtureState, FixtureStateView, FloatMode, FluidDef, FluidDefView, - FluidEmitter, FluidState, InvocationSite, MappingConfig, NodeDefParseError, NodeStarter, - OutputDef, OutputDefView, OutputDriverOptionsConfig, OutputDriverOptionsConfigView, - PROJECT_FORMAT_VERSION, PathSpec, PlaylistDef, PlaylistDefView, PlaylistEntry, - PlaylistEntryView, PlaylistState, PlaylistStateView, ProjectDef, ProjectDefView, - ProjectFormatProbe, STARTER_SHADER_GLSL, STARTER_STEM_PLACEHOLDER, ScalarHint, ScalarHintView, + ButtonStateView, ChannelMetaDef, ChannelMetaDefView, ClockControls, ClockDef, ClockDefView, + ClockState, ColorOrder, ComputeShaderDef, ComputeShaderDefView, ControlRadioDef, + ControlRadioDefView, ControlRadioState, ControlRadioStateView, FixtureDef, FixtureDefView, + FixtureDiagnosticMode, FixtureSamplingConfig, FixtureState, FixtureStateView, FloatMode, + FluidDef, FluidDefView, FluidEmitter, FluidState, InvocationSite, MappingConfig, ModuleDef, + ModuleDefView, NodeDefParseError, NodeStarter, OutputDef, OutputDefView, + OutputDriverOptionsConfig, OutputDriverOptionsConfigView, PathSpec, PlaylistDef, + PlaylistDefView, PlaylistEntry, PlaylistEntryView, PlaylistState, PlaylistStateView, + ProvenanceDef, STARTER_SHADER_GLSL, STARTER_STEM_PLACEHOLDER, ScalarHint, ScalarHintView, ShaderDef, ShaderDefView, ShaderHeaderGenError, ShaderMapKeyDef, ShaderParamDef, ShaderParamDefView, ShaderSlotDef, ShaderSlotKind, ShaderSlotMappingDef, ShaderSlotMappingKind, ShaderState, ShaderStateView, ShaderValueShapeRef, TextureDef, TextureDefView, TextureFormat, TextureState, TextureStateView, generate_compute_shader_header, glsl_type_for_lp_type, - node_def_asset_ref, read_project_format_json, resolve_artifact_specifier, - set_node_def_asset_ref, shader_panel_step, starter_def_for_kind, starter_for_kind, - starter_project_files, + node_def_asset_ref, resolve_artifact_specifier, set_node_def_asset_ref, shader_panel_step, + starter_def_for_kind, starter_for_kind, starter_project_files, }; pub use product::{ ControlDisplayLayout, ControlExtent, ControlLamp2d, ControlLayout2d, ControlPathSpan2d, @@ -139,9 +138,10 @@ pub use project::overlay_mutation::{ StoredSlotEdit, }; pub use project::{ - ChangeSummary, CommitResult, LocationSeg, MutationBatchResults, MutationResult, NodeAttachSite, - NodeUseLocation, ProjectChangeSummary, ProjectConfig, ProjectInventory, ProjectNode, - ProjectNodeOrigin, ProjectNodePlacement, ProjectTree, Revision, + ChangeSummary, CommitResult, LocationSeg, ManifestParseError, MutationBatchResults, + MutationResult, NodeAttachSite, NodeUseLocation, PROJECT_FORMAT_VERSION, ProjectChangeSummary, + ProjectConfig, ProjectInventory, ProjectManifest, ProjectNode, ProjectNodeOrigin, + ProjectNodePlacement, ProjectTree, Revision, }; pub use project::{advance_revision, current_revision, set_current_revision}; pub use resource::{ResourceDomain, ResourceRef, RuntimeBufferId, runtime_buffer_resource_shape}; diff --git a/lp-core/lpc-model/src/node/kind.rs b/lp-core/lpc-model/src/node/kind.rs index 3cf952d4d..c9fcdb7de 100644 --- a/lp-core/lpc-model/src/node/kind.rs +++ b/lp-core/lpc-model/src/node/kind.rs @@ -5,7 +5,7 @@ /// being removed. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub enum NodeKind { - Project, + Module, Button, Clock, Texture, diff --git a/lp-core/lpc-model/src/nodes/mod.rs b/lp-core/lpc-model/src/nodes/mod.rs index 07d4c1b98..955b4ff43 100644 --- a/lp-core/lpc-model/src/nodes/mod.rs +++ b/lp-core/lpc-model/src/nodes/mod.rs @@ -2,10 +2,11 @@ pub mod button; pub mod clock; pub mod fixture; pub mod fluid; +pub mod module; pub mod node_def; pub mod output; pub mod playlist; -pub mod project; +pub mod provenance_def; pub mod radio; pub mod shader; pub mod starter; @@ -19,9 +20,10 @@ pub use fixture::{ FixtureSamplingConfig, FixtureState, FixtureStateView, MappingConfig, PathSpec, }; pub use fluid::{FluidDef, FluidDefView, FluidEmitter, FluidState}; +pub use module::{ChannelMetaDef, ChannelMetaDefView, ModuleDef, ModuleDefView}; pub use node_def::{ ArtifactPathResolutionError, InvocationSite, NodeArtifact, NodeDef, NodeDefParseError, - NodeDefWriteError, ProjectFormatProbe, read_project_format_json, resolve_artifact_specifier, + NodeDefWriteError, resolve_artifact_specifier, }; pub use output::{ OutputDef, OutputDefView, OutputDriverOptionsConfig, OutputDriverOptionsConfigView, @@ -30,7 +32,7 @@ pub use playlist::{ PlaylistDef, PlaylistDefView, PlaylistEntry, PlaylistEntryView, PlaylistState, PlaylistStateView, }; -pub use project::{PROJECT_FORMAT_VERSION, ProjectDef, ProjectDefView}; +pub use provenance_def::ProvenanceDef; pub use radio::{ControlRadioDef, ControlRadioDefView, ControlRadioState, ControlRadioStateView}; pub use shader::{ ComputeShaderDef, ComputeShaderDefView, FloatMode, ScalarHint, ScalarHintView, ShaderDef, diff --git a/lp-core/lpc-model/src/nodes/module/channel_meta_def.rs b/lp-core/lpc-model/src/nodes/module/channel_meta_def.rs new file mode 100644 index 000000000..51687e296 --- /dev/null +++ b/lp-core/lpc-model/src/nodes/module/channel_meta_def.rs @@ -0,0 +1,28 @@ +//! Per-channel authored meta overrides: the module author's curation +//! escape hatch (modules.md R9 / Q1 lean). +//! +//! Control meta normally derives from the currently-bound slots +//! (widest-range merge, panel.md P6); an entry here overrides the derived +//! presentation for one channel of the module's scope. The vocabulary +//! survives from the superseded spike's `PromotedControlDef` display +//! fields — aliasing itself is dead (binding is publicity, R3), so the +//! map key IS the channel name and no target exists. + +use alloc::string::String; + +use crate::{OptionSlot, Slotted, ValueSlot}; + +/// Display overrides for one channel of the module's scope. Absent fields +/// keep the meta derived from bound slots. +#[derive(Clone, Debug, Default, PartialEq, Slotted)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +pub struct ChannelMetaDef { + /// Display label override; the derived label applies when absent. + pub label: OptionSlot>, + /// Display unit override (e.g. `"Hz"`). + pub unit: OptionSlot>, + /// Control-range override for the rendered widget. + pub min: OptionSlot>, + /// Control-range override for the rendered widget. + pub max: OptionSlot>, +} diff --git a/lp-core/lpc-model/src/nodes/module/mod.rs b/lp-core/lpc-model/src/nodes/module/mod.rs new file mode 100644 index 000000000..e00b132f7 --- /dev/null +++ b/lp-core/lpc-model/src/nodes/module/mod.rs @@ -0,0 +1,6 @@ +pub mod channel_meta_def; +pub mod module_def; + +pub use crate::slot_views::{ChannelMetaDefView, ModuleDefView}; +pub use channel_meta_def::ChannelMetaDef; +pub use module_def::ModuleDef; diff --git a/lp-core/lpc-model/src/nodes/module/module_def.rs b/lp-core/lpc-model/src/nodes/module/module_def.rs new file mode 100644 index 000000000..ead0f85a0 --- /dev/null +++ b/lp-core/lpc-model/src/nodes/module/module_def.rs @@ -0,0 +1,223 @@ +use alloc::string::String; + +use crate::nodes::ProvenanceDef; +use crate::{BindingDefs, BindingRef, MapSlot, NodeInvocationSlot, OptionSlot, Slotted, ValueSlot}; + +use super::ChannelMetaDef; + +/// Authored root module node definition. +/// +/// A module is a node artifact with `kind = "Module"`. Its `nodes` table +/// owns named child [`crate::NodeInvocationSlot`] entries; the runtime no +/// longer discovers children from filesystem directories. +/// +/// The module carries the *technical spec* of its subtree. The project's +/// workspace identity (`format`, `uid`, `name`) lives in the non-node +/// `project.json` container manifest ([`crate::ProjectManifest`]) beside the +/// root `module.json` — the mitosis of docs/design/modules.md §1/§6. +#[derive(Clone, Debug, Default, PartialEq, Slotted)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +pub struct ModuleDef { + /// Named child node positions owned by this module. + /// + /// Read-only through mutations: node create/remove arrive as dedicated + /// project operations, never as raw slot edits under this map. + #[slot(policy = "read_only_persisted")] + pub nodes: MapSlot, + /// Authored slot bindings on the module node itself, like any node — + /// this is where R7's contention pick ("two writers on `visual.out` = + /// ambiguous until the author picks") becomes authorable, and where an + /// export slot publishes outward. + pub bindings: BindingDefs, + /// Authored exports (R7): local produced-slot name → the inner-scope + /// channel it mirrors (`bus:` ref form). The runtime interpretation — + /// the slot mirroring the inner channel and publishing per R4 — + /// arrives with the module runtime; here the def only carries data. + pub exports: MapSlot>, + /// Per-channel authored meta overrides for this module's scope + /// (R9 / Q1 curation escape hatch). + pub meta: MapSlot, + /// Authorship metadata (R14); normally carried by modules. + pub provenance: OptionSlot, +} + +impl ModuleDef { + pub const KIND: &'static str = "module"; + + pub fn kind(&self) -> crate::NodeKind { + crate::NodeKind::Module + } + + pub fn is_module_kind(&self) -> bool { + true + } +} + +#[cfg(test)] +mod tests { + use crate::{NodeDef, SlotShapeRegistry}; + use alloc::string::ToString; + + #[test] + fn module_def_deserializes_named_nodes() { + let json = r#"{ + "kind": "Module", + "nodes": { + "texture": { "ref": "./texture.json" }, + "shader": { "ref": "./shader.json" } + } + }"#; + let def = NodeDef::read_json(®istry(), json).unwrap(); + let NodeDef::Module(def) = def else { + panic!("expected module def"); + }; + assert!(def.is_module_kind()); + assert_eq!(def.nodes.entries.len(), 2); + assert!(def.nodes.entries.contains_key("texture")); + assert!(def.nodes.entries.contains_key("shader")); + } + + #[test] + fn module_def_rejects_container_manifest_fields() { + // format/uid/name are container-manifest (`project.json`) concerns; + // a module artifact carrying them is a pre-mitosis root and must + // fail loudly rather than silently dropping identity fields. + for json in [ + r#"{ "kind": "Module", "format": 2, "nodes": {} }"#, + r#"{ "kind": "Module", "uid": "prj_0000000000000042", "nodes": {} }"#, + r#"{ "kind": "Module", "name": "basic", "nodes": {} }"#, + ] { + let err = NodeDef::read_json(®istry(), json) + .expect_err("container field on a module artifact"); + let text = err.to_string(); + assert!( + text.contains("format") || text.contains("uid") || text.contains("name"), + "{text}" + ); + } + } + + #[test] + fn module_def_writes_kind_and_nodes_only() { + // Default (empty) nodes map skips serialization entirely. + let text = NodeDef::Module(crate::ModuleDef::default()) + .write_json(®istry()) + .unwrap(); + assert_eq!(text, "{\n \"kind\": \"Module\"\n}\n"); + + let json = r#"{ "kind": "Module", "nodes": { "clock": { "ref": "./clock.json" } } }"#; + let def = NodeDef::read_json(®istry(), json).unwrap(); + let text = def.write_json(®istry()).unwrap(); + assert!( + text.starts_with("{\n \"kind\": \"Module\",\n \"nodes\": {"), + "{text}" + ); + } + + #[test] + fn module_def_rejects_legacy_artifact_field() { + let json = r#"{ + "kind": "Module", + "nodes": { + "texture": { "artifact": "./texture.json" } + } + }"#; + let err = NodeDef::read_json(®istry(), json).unwrap_err(); + assert!(err.to_string().contains("ref")); + } + + #[test] + fn module_def_rejects_inline_node_definition() { + let json = r#"{ + "kind": "Module", + "nodes": { + "clock": { "def": { "kind": "Clock" } } + } + }"#; + let err = NodeDef::read_json(®istry(), json).unwrap_err(); + assert!(err.to_string().contains("def"), "{err}"); + } + + #[test] + fn module_def_nodes_are_read_only_persisted() { + use crate::{SlotPolicy, SlotShape, StaticSlotShape}; + + let SlotShape::Record { fields, .. } = crate::ModuleDef::slot_shape() else { + panic!("module def shape must be a record"); + }; + let nodes = fields + .iter() + .find(|field| field.name.as_str() == "nodes") + .expect("nodes field"); + assert_eq!(nodes.policy, SlotPolicy::read_only_persisted()); + } + + #[test] + fn module_def_capability_fields_round_trip_byte_identically() { + // P3: bindings / exports / meta / provenance survive load→save + // byte-identically; absent fields serialize to nothing. + let registry = registry(); + let json = r#"{ + "kind": "Module", + "nodes": { + "plasma": { + "ref": "./modules/plasma/module.json" + } + }, + "bindings": { + "output": { + "target": "bus:visual.out" + } + }, + "exports": { + "energy": "bus:audio.energy" + }, + "meta": { + "speed": { + "label": "Master speed", + "unit": "Hz", + "min": 0, + "max": 10 + } + }, + "provenance": { + "author": "Yona", + "version": "0.1", + "license": "CC0-1.0", + "created": "2026-08-01" + } +} +"#; + let def = NodeDef::read_json(®istry, json).expect("capability fields parse"); + let NodeDef::Module(module) = &def else { + panic!("expected module def"); + }; + assert!(module.bindings.0.entries.contains_key("output")); + assert!(module.exports.entries.contains_key("energy")); + let meta = module.meta.entries.get("speed").expect("meta entry"); + assert_eq!( + meta.label.data.as_ref().map(|slot| slot.value().as_str()), + Some("Master speed") + ); + let provenance = module.provenance.data.as_ref().expect("provenance"); + assert!(!provenance.is_empty()); + + let rewritten = def.write_json(®istry).expect("re-write"); + assert_eq!(rewritten, json, "capability fields must be byte-stable"); + + // Absent capability fields serialize to nothing. + let bare = NodeDef::read_json(®istry, r#"{ "kind": "Module", "nodes": {} }"#) + .expect("bare module"); + let text = bare.write_json(®istry).expect("write bare"); + for key in ["bindings", "exports", "meta", "provenance"] { + assert!( + !text.contains(key), + "{key} must not serialize when absent: {text}" + ); + } + } + + fn registry() -> SlotShapeRegistry { + SlotShapeRegistry::default() + } +} diff --git a/lp-core/lpc-model/src/nodes/node_def.rs b/lp-core/lpc-model/src/nodes/node_def.rs index 26e3ff93a..38bb44af9 100644 --- a/lp-core/lpc-model/src/nodes/node_def.rs +++ b/lp-core/lpc-model/src/nodes/node_def.rs @@ -16,9 +16,9 @@ use crate::nodes::button::ButtonDef; use crate::nodes::clock::ClockDef; use crate::nodes::fixture::{FixtureDef, MappingConfig}; use crate::nodes::fluid::FluidDef; +use crate::nodes::module::ModuleDef; use crate::nodes::output::OutputDef; use crate::nodes::playlist::PlaylistDef; -use crate::nodes::project::ProjectDef; use crate::nodes::radio::ControlRadioDef; use crate::nodes::shader::{ComputeShaderDef, ShaderDef}; use crate::nodes::texture::TextureDef; @@ -29,7 +29,7 @@ use crate::{ SlotShapeRegistry, Slotted, StaticSlotShape, }; -const PROJECT_VARIANT: &str = "Project"; +const MODULE_VARIANT: &str = "Module"; const BUTTON_VARIANT: &str = "Button"; const CLOCK_VARIANT: &str = "Clock"; const TEXTURE_VARIANT: &str = "Texture"; @@ -41,7 +41,7 @@ const CONTROL_RADIO_VARIANT: &str = "ControlRadio"; const OUTPUT_VARIANT: &str = "Output"; const FIXTURE_VARIANT: &str = "Fixture"; const NODE_DEF_VARIANT_NAMES: &[&str] = &[ - PROJECT_VARIANT, + MODULE_VARIANT, BUTTON_VARIANT, CLOCK_VARIANT, TEXTURE_VARIANT, @@ -57,12 +57,12 @@ const NODE_DEF_VARIANT_NAMES: &[&str] = &[ /// Authored body of a node artifact. /// /// A `NodeDef` is source data: it is what a JSON artifact defines before the -/// engine instantiates a runtime node. Project artifacts are included because +/// engine instantiates a runtime node. Module artifacts are included because /// a project defines the root project node and its child node invocations. #[derive(Clone, Debug, PartialEq, Slotted)] pub enum NodeDef { #[default] - Project(ProjectDef), + Module(ModuleDef), Button(ButtonDef), Clock(ClockDef), Texture(TextureDef), @@ -165,7 +165,7 @@ impl NodeDef { /// Default-authored node definition for a kind. pub fn default_for_kind(kind: NodeKind) -> Self { match kind { - NodeKind::Project => Self::Project(ProjectDef::default()), + NodeKind::Module => Self::Module(ModuleDef::default()), NodeKind::Button => Self::Button(ButtonDef::default()), NodeKind::Clock => Self::Clock(ClockDef::default()), NodeKind::Texture => Self::Texture(TextureDef::default()), @@ -182,7 +182,7 @@ impl NodeDef { /// Core node kind for this definition. pub fn kind(&self) -> NodeKind { match self { - Self::Project(_) => NodeKind::Project, + Self::Module(_) => NodeKind::Module, Self::Button(_) => NodeKind::Button, Self::Clock(_) => NodeKind::Clock, Self::Texture(_) => NodeKind::Texture, @@ -199,7 +199,7 @@ impl NodeDef { /// Stable authored `kind` string used in TOML and diagnostics. pub fn kind_name(&self) -> &'static str { match self { - Self::Project(_) => ProjectDef::KIND, + Self::Module(_) => ModuleDef::KIND, Self::Button(_) => ButtonDef::KIND, Self::Clock(_) => ClockDef::KIND, Self::Texture(_) => TextureDef::KIND, @@ -216,7 +216,7 @@ impl NodeDef { /// Slot enum discriminator used by authored TOML. pub fn variant_name(&self) -> &'static str { match self { - Self::Project(_) => PROJECT_VARIANT, + Self::Module(_) => MODULE_VARIANT, Self::Button(_) => BUTTON_VARIANT, Self::Clock(_) => CLOCK_VARIANT, Self::Texture(_) => TEXTURE_VARIANT, @@ -242,7 +242,7 @@ impl NodeDef { let base = SlotPath::root(); let base = &base; match self { - Self::Project(project) => project + Self::Module(project) => project .nodes .entries .iter() @@ -321,9 +321,9 @@ impl NodeDef { Self::body_changed(before, after) } - pub fn as_project(&self) -> Option<&ProjectDef> { + pub fn as_module(&self) -> Option<&ModuleDef> { match self { - Self::Project(def) => Some(def), + Self::Module(def) => Some(def), _ => None, } } @@ -488,7 +488,7 @@ pub fn resolve_artifact_specifier( impl SlotAccess for NodeDef { fn shape_id(&self) -> SlotShapeId { match self { - Self::Project(def) => def.shape_id(), + Self::Module(def) => def.shape_id(), Self::Button(def) => def.shape_id(), Self::Clock(def) => def.shape_id(), Self::Texture(def) => def.shape_id(), @@ -504,7 +504,7 @@ impl SlotAccess for NodeDef { fn data(&self) -> SlotDataAccess<'_> { match self { - Self::Project(def) => def.data(), + Self::Module(def) => def.data(), Self::Button(def) => def.data(), Self::Clock(def) => def.data(), Self::Texture(def) => def.data(), @@ -530,7 +530,7 @@ impl SlotAccess for NodeDef { impl SlotMutAccess for NodeDef { fn data_mut(&mut self) -> SlotDataMutAccess<'_> { match self { - Self::Project(def) => def.data_mut(), + Self::Module(def) => def.data_mut(), Self::Button(def) => def.data_mut(), Self::Clock(def) => def.data_mut(), Self::Texture(def) => def.data_mut(), @@ -575,102 +575,6 @@ impl core::fmt::Display for NodeDefWriteError { impl core::error::Error for NodeDefWriteError {} -/// Result of probing an authored JSON artifact for the project format version. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum ProjectFormatProbe { - /// The top-level `kind` is `Project`; carries the top-level `format` key. - Project { format: Option }, - /// Not a project artifact; format gating does not apply. - NotProject, -} - -/// Streaming probe for the top-level `"format"` version in an authored JSON -/// project root. Uses syntax events like the `kind` probe so loaders can gate -/// [`crate::PROJECT_FORMAT_VERSION`] without materializing a value tree. -/// -/// Kinds other than `Project` (or a missing/non-string `kind`) probe as -/// [`ProjectFormatProbe::NotProject`]: format gating applies only to project -/// roots, and the real parse owns diagnostics for malformed artifacts. A -/// present but non-integer `format` is a syntax error. -pub fn read_project_format_json(text: &str) -> Result { - use crate::slot_codec::{JsonSyntaxSource, SyntaxEvent, SyntaxEventSource}; - - let syntax_error = |error: crate::slot_codec::SyntaxError| NodeDefParseError::Syntax { - error: error.to_string(), - }; - - let mut source = JsonSyntaxSource::new(text).map_err(syntax_error)?; - match source.next_event().map_err(syntax_error)? { - Some(SyntaxEvent::StartObject { .. }) => {} - _ => { - return Err(NodeDefParseError::Syntax { - error: String::from("node definition JSON root must be an object"), - }); - } - } - - // Scan top-level props, skipping nested values by depth. - let mut is_project = false; - let mut format = None; - let mut depth = 0usize; - loop { - let Some(event) = source.next_event().map_err(syntax_error)? else { - break; - }; - match event { - SyntaxEvent::Prop { name, .. } if depth == 0 && name == "kind" => { - let mut kind = String::new(); - loop { - match source.next_event().map_err(syntax_error)? { - Some(SyntaxEvent::StringChunk { text, is_last, .. }) => { - kind.push_str(&text); - if is_last { - break; - } - } - // Non-string kind: the real parse owns that diagnostic. - _ => return Ok(ProjectFormatProbe::NotProject), - } - } - if kind != PROJECT_VARIANT { - return Ok(ProjectFormatProbe::NotProject); - } - is_project = true; - } - SyntaxEvent::Prop { name, .. } if depth == 0 && name == "format" => { - let value = match source.next_event().map_err(syntax_error)? { - Some(SyntaxEvent::Number { text, .. }) => text.parse::().ok(), - _ => None, - }; - let Some(value) = value else { - return Err(NodeDefParseError::Syntax { - error: String::from("field `format` must be an unsigned integer"), - }); - }; - format = Some(value); - } - SyntaxEvent::StartObject { .. } | SyntaxEvent::StartArray { .. } => depth += 1, - SyntaxEvent::EndArray { .. } => depth = depth.saturating_sub(1), - SyntaxEvent::EndObject { .. } => { - if depth == 0 { - break; - } - depth -= 1; - } - _ => {} - } - if is_project && format.is_some() { - break; - } - } - - if is_project { - Ok(ProjectFormatProbe::Project { format }) - } else { - Ok(ProjectFormatProbe::NotProject) - } -} - fn downcast_node_artifact( object: alloc::boxed::Box, ) -> Result { @@ -903,14 +807,14 @@ mod tests { let project = NodeDef::read_json( ®istry, r#"{ - "kind": "Project", + "kind": "Module", "nodes": { "texture": { "ref": "./texture.json" } } }"#, ) .expect("project"); - assert!(matches!(project, NodeDef::Project(_))); + assert!(matches!(project, NodeDef::Module(_))); let texture = NodeDef::read_json( ®istry, @@ -987,46 +891,6 @@ mod tests { assert!(matches!(fixture, NodeDef::Fixture(_))); } - #[test] - fn project_format_probe_reads_top_level_format() { - // Deliberately not PROJECT_FORMAT_VERSION: the probe reports whatever - // the file says, and the gate compares — the probe must not know the - // current version. - let probe = read_project_format_json(r#"{ "kind": "Project", "format": 1, "nodes": {} }"#) - .expect("probe"); - assert_eq!(probe, ProjectFormatProbe::Project { format: Some(1) }); - - let probe = read_project_format_json(r#"{ "kind": "Project", "nodes": {} }"#) - .expect("probe missing format"); - assert_eq!(probe, ProjectFormatProbe::Project { format: None }); - - let probe = read_project_format_json( - r#"{ "kind": "Texture", "size": { "width": 1, "height": 2 } }"#, - ) - .expect("probe non-project"); - assert_eq!(probe, ProjectFormatProbe::NotProject); - } - - #[test] - fn project_format_probe_skips_nested_format_keys() { - let probe = read_project_format_json( - r#"{ "kind": "Project", "nodes": { "child": { "format": 7 } } }"#, - ) - .expect("probe"); - assert_eq!(probe, ProjectFormatProbe::Project { format: None }); - } - - #[test] - fn project_format_probe_rejects_non_integer_format() { - let err = read_project_format_json(r#"{ "kind": "Project", "format": "one" }"#) - .expect_err("string format"); - assert!(err.to_string().contains("unsigned integer"), "{err}"); - - let err = read_project_format_json(r#"{ "kind": "Project", "format": -1 }"#) - .expect_err("negative format"); - assert!(err.to_string().contains("unsigned integer"), "{err}"); - } - #[test] fn node_def_writes_pretty_authored_json() { let registry = registry(); @@ -1107,7 +971,7 @@ mod tests { fn node_def_invocation_sites_cover_project_and_playlist() { let project = NodeDef::from_json_str( r#"{ - "kind": "Project", + "kind": "Module", "nodes": { "clock": { "ref": "./clock.json" } } @@ -1230,11 +1094,11 @@ mod tests { #[test] fn node_def_shell_change_tracks_child_ref_changes() { let before = NodeDef::from_json_str( - r#"{ "kind": "Project", "nodes": { "a": { "ref": "./a.json" } } }"#, + r#"{ "kind": "Module", "nodes": { "a": { "ref": "./a.json" } } }"#, ) .expect("before"); let ref_changed = NodeDef::from_json_str( - r#"{ "kind": "Project", "nodes": { "a": { "ref": "./b.json" } } }"#, + r#"{ "kind": "Module", "nodes": { "a": { "ref": "./b.json" } } }"#, ) .expect("ref changed"); @@ -1246,7 +1110,7 @@ mod tests { #[test] fn node_def_default_for_kind_covers_every_kind() { for kind in [ - NodeKind::Project, + NodeKind::Module, NodeKind::Button, NodeKind::Clock, NodeKind::Texture, diff --git a/lp-core/lpc-model/src/nodes/project/mod.rs b/lp-core/lpc-model/src/nodes/project/mod.rs deleted file mode 100644 index a50900716..000000000 --- a/lp-core/lpc-model/src/nodes/project/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub mod project_def; - -pub use crate::slot_views::ProjectDefView; -pub use project_def::{PROJECT_FORMAT_VERSION, ProjectDef}; diff --git a/lp-core/lpc-model/src/nodes/project/project_def.rs b/lp-core/lpc-model/src/nodes/project/project_def.rs deleted file mode 100644 index 52e6c0fc2..000000000 --- a/lp-core/lpc-model/src/nodes/project/project_def.rs +++ /dev/null @@ -1,184 +0,0 @@ -use alloc::string::String; - -use crate::{MapSlot, NodeInvocationSlot, OptionSlot, Slotted, ValueSlot}; - -/// Monotonic format version of authored `project.json` artifacts. -/// -/// The project root carries this as its top-level `format` key; child node -/// files are versioned transitively through their project root. Loaders -/// reject roots whose format is missing or does not match, so bump this when -/// making a format-breaking change to authored artifacts. -/// -/// History: -/// - `2` — shader nodes replaced the `glsl_opts` record (`add_sub`/`mul`/`div` -/// Q32 mode slots) with a single `float_mode` slot. Artifacts at version `1` -/// are refused, not migrated (alpha format posture: bump and refuse). -pub const PROJECT_FORMAT_VERSION: u32 = 2; - -/// Authored root project node definition. -/// -/// A project is a node artifact with `kind = "Project"`. Its `nodes` table -/// owns named child [`crate::NodeInvocationSlot`] entries; the runtime no -/// longer discovers children from filesystem directories. -#[derive(Clone, Debug, Default, PartialEq, Slotted)] -#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] -pub struct ProjectDef { - /// Authored format version; see [`PROJECT_FORMAT_VERSION`]. - /// - /// Read-only through mutations: only the loader format gate and the - /// (future) offline upgrader own this value. - #[slot(policy = "read_only_persisted")] - pub format: OptionSlot>, - /// Stable project identity (`prj_…`, base-62), minted by the library - /// when a project enters it. Travels with the files: parity checks, - /// history, and device associations key off it (PM roadmap M1/M3). - /// - /// Read-only through mutations: identity is minted by the library on - /// entry (and re-minted on import, so a shared copy never collides - /// with its source). Editing it in place would silently reassign a - /// project's history and device associations, so no surface may offer - /// it — the constraint lives here rather than in each view. - #[slot(policy = "read_only_persisted")] - pub uid: OptionSlot>, - /// Human-readable project name — the one authored field of the root's - /// identity, and the Studio project pane's title. - pub name: OptionSlot>, - /// Named child node positions owned by this project. - /// - /// Read-only through mutations: node create/remove will arrive as - /// dedicated project operations (Studio authoring M2), never as raw - /// slot edits under this map. - #[slot(policy = "read_only_persisted")] - pub nodes: MapSlot, -} - -impl ProjectDef { - pub const KIND: &'static str = "project"; - - pub fn kind(&self) -> crate::NodeKind { - crate::NodeKind::Project - } - - pub fn is_project_kind(&self) -> bool { - true - } - - pub fn name(&self) -> Option<&str> { - self.name.data.as_ref().map(|name| name.value().as_str()) - } - - /// Authored format version, when the artifact carries one. - pub fn format(&self) -> Option { - self.format.data.as_ref().map(|format| *format.value()) - } - - /// Format slot carrying the current [`PROJECT_FORMAT_VERSION`]. - /// - /// Every writer of a new project root must set this so freshly authored - /// projects pass the loader format gate. - pub fn current_format_slot() -> OptionSlot> { - OptionSlot::some(ValueSlot::new(PROJECT_FORMAT_VERSION)) - } -} - -#[cfg(test)] -mod tests { - use crate::{NodeDef, SlotShapeRegistry}; - use alloc::string::ToString; - - #[test] - fn project_def_deserializes_named_nodes() { - let json = r#"{ - "kind": "Project", - "format": 2, - "name": "basic", - "nodes": { - "texture": { "ref": "./texture.json" }, - "shader": { "ref": "./shader.json" } - } - }"#; - let def = NodeDef::read_json(®istry(), json).unwrap(); - let NodeDef::Project(def) = def else { - panic!("expected project def"); - }; - assert!(def.is_project_kind()); - assert_eq!(def.format(), Some(super::PROJECT_FORMAT_VERSION)); - assert_eq!(def.name(), Some("basic")); - assert_eq!(def.nodes.entries.len(), 2); - assert!(def.nodes.entries.contains_key("texture")); - assert!(def.nodes.entries.contains_key("shader")); - } - - #[test] - fn project_def_format_is_none_when_absent() { - let json = r#"{ - "kind": "Project", - "nodes": {} - }"#; - let def = NodeDef::read_json(®istry(), json).unwrap(); - let NodeDef::Project(def) = def else { - panic!("expected project def"); - }; - assert_eq!(def.format(), None); - } - - #[test] - fn project_def_writes_format_alongside_kind() { - let def = crate::ProjectDef { - format: crate::ProjectDef::current_format_slot(), - ..crate::ProjectDef::default() - }; - let text = NodeDef::Project(def).write_json(®istry()).unwrap(); - assert!( - text.starts_with("{\n \"kind\": \"Project\",\n \"format\": 2"), - "{text}" - ); - } - - #[test] - fn project_def_rejects_legacy_artifact_field() { - let json = r#"{ - "kind": "Project", - "nodes": { - "texture": { "artifact": "./texture.json" } - } - }"#; - let err = NodeDef::read_json(®istry(), json).unwrap_err(); - assert!(err.to_string().contains("ref")); - } - - #[test] - fn project_def_rejects_inline_node_definition() { - let json = r#"{ - "kind": "Project", - "nodes": { - "clock": { "def": { "kind": "Clock" } } - } - }"#; - let err = NodeDef::read_json(®istry(), json).unwrap_err(); - assert!(err.to_string().contains("def"), "{err}"); - } - - #[test] - fn project_def_format_and_nodes_are_read_only_persisted_name_writable() { - use crate::{SlotPolicy, SlotShape, StaticSlotShape}; - - let SlotShape::Record { fields, .. } = crate::ProjectDef::slot_shape() else { - panic!("project def shape must be a record"); - }; - let policy = |name: &str| { - fields - .iter() - .find(|field| field.name.as_str() == name) - .unwrap_or_else(|| panic!("{name} field")) - .policy - }; - assert_eq!(policy("format"), SlotPolicy::read_only_persisted()); - assert_eq!(policy("nodes"), SlotPolicy::read_only_persisted()); - assert_eq!(policy("name"), SlotPolicy::writable_persisted()); - } - - fn registry() -> SlotShapeRegistry { - SlotShapeRegistry::default() - } -} diff --git a/lp-core/lpc-model/src/nodes/provenance_def.rs b/lp-core/lpc-model/src/nodes/provenance_def.rs new file mode 100644 index 000000000..04357cf74 --- /dev/null +++ b/lp-core/lpc-model/src/nodes/provenance_def.rs @@ -0,0 +1,35 @@ +//! Provenance: authorship metadata as a capability of any node (R14). +//! +//! The §8 field set (settled as Q7): `author`, `version`, `license`, +//! `created` — all optional strings, no semver semantics yet. Modules +//! normally carry it; extraction copies the host project's provenance onto +//! the copy unless the node already has its own (copy-on-extract, R14 — +//! mechanics land with the vendoring flows). The `project.json` container +//! manifest carries the same four fields at its top level. + +use alloc::string::String; + +use crate::{OptionSlot, Slotted, ValueSlot}; + +/// Authorship metadata block: optional on any node definition. +#[derive(Clone, Debug, Default, PartialEq, Slotted)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +pub struct ProvenanceDef { + /// Author attribution (plain string). + pub author: OptionSlot>, + /// Authored version string; no semver semantics yet. + pub version: OptionSlot>, + /// License identifier (e.g. `"CC0-1.0"`). + pub license: OptionSlot>, + /// ISO date the work was created (e.g. `"2026-08-01"`). + pub created: OptionSlot>, +} + +impl ProvenanceDef { + pub fn is_empty(&self) -> bool { + self.author.data.is_none() + && self.version.data.is_none() + && self.license.data.is_none() + && self.created.data.is_none() + } +} diff --git a/lp-core/lpc-model/src/nodes/starter.rs b/lp-core/lpc-model/src/nodes/starter.rs index 3c1a075dc..3a298a290 100644 --- a/lp-core/lpc-model/src/nodes/starter.rs +++ b/lp-core/lpc-model/src/nodes/starter.rs @@ -232,7 +232,7 @@ mod tests { use alloc::string::ToString; const ALL_KINDS: &[NodeKind] = &[ - NodeKind::Project, + NodeKind::Module, NodeKind::Button, NodeKind::Clock, NodeKind::Texture, diff --git a/lp-core/lpc-model/src/nodes/starter_project.rs b/lp-core/lpc-model/src/nodes/starter_project.rs index e791c011d..122c154b4 100644 --- a/lp-core/lpc-model/src/nodes/starter_project.rs +++ b/lp-core/lpc-model/src/nodes/starter_project.rs @@ -21,8 +21,8 @@ use crate::nodes::fixture::{ColorOrder, FixtureSamplingConfig}; use crate::nodes::node_def::NodeDefWriteError; use crate::nodes::starter::{starter_def_for_kind, starter_for_kind}; use crate::{ - ArtifactSpec, BindingDef, BindingDefs, BindingRef, BusSlotRef, ChannelName, MapSlot, NodeDef, - NodeInvocation, NodeInvocationSlot, OptionSlot, ProjectDef, SlotShapeRegistry, ValueSlot, + ArtifactSpec, BindingDef, BindingDefs, BindingRef, BusSlotRef, ChannelName, MapSlot, ModuleDef, + NodeDef, NodeInvocation, NodeInvocationSlot, ProjectManifest, SlotShapeRegistry, ValueSlot, }; /// The starter project's complete file set as `(relative path, bytes)` — @@ -36,7 +36,12 @@ pub fn starter_project_files( files.push(( String::from("project.json"), - node_json(&starter_project_def(name), registry)?, + ProjectManifest::new_current(name).write_json().into_bytes(), + )); + + files.push(( + String::from("module.json"), + node_json(&starter_module_def(), registry)?, )); files.push(( @@ -91,7 +96,7 @@ pub fn starter_project_files( Ok(files) } -fn starter_project_def(name: &str) -> NodeDef { +fn starter_module_def() -> NodeDef { let mut nodes = VecMap::new(); for node in ["output", "clock", "texture", "shader", "fixture"] { nodes.insert( @@ -101,11 +106,9 @@ fn starter_project_def(name: &str) -> NodeDef { )))), ); } - NodeDef::Project(ProjectDef { - format: ProjectDef::current_format_slot(), - uid: OptionSlot::none(), - name: OptionSlot::some(ValueSlot::new(String::from(name))), + NodeDef::Module(ModuleDef { nodes: MapSlot::new(nodes), + ..ModuleDef::default() }) } @@ -223,6 +226,7 @@ mod tests { names, [ "project.json", + "module.json", "clock.json", "texture.json", "shader.json", @@ -235,8 +239,10 @@ mod tests { for (name, bytes) in &files { // Only node-definition artifacts parse as NodeDef; the mapping - // document is an opaque asset (D1). - if !name.ends_with(".json") || name.ends_with(".map2d.json") { + // document is an opaque asset (D1) and `project.json` is the + // non-node container manifest (its canonical form is + // `ProjectManifest::write_json`, asserted below). + if !name.ends_with(".json") || name.ends_with(".map2d.json") || name == "project.json" { continue; } let text = core::str::from_utf8(bytes).expect("utf-8"); @@ -250,23 +256,29 @@ mod tests { } #[test] - fn starter_project_root_carries_format_and_name() { + fn starter_project_manifest_carries_format_and_name_root_module_the_nodes() { let registry = SlotShapeRegistry::default(); let files = starter_project_files("porch sign", ®istry).expect("compose"); + let (_, bytes) = files .iter() .find(|(name, _)| name == "project.json") .unwrap(); + let text = core::str::from_utf8(bytes).unwrap(); + let manifest = ProjectManifest::read_json(text).expect("container manifest"); + assert_eq!(manifest.format, Some(PROJECT_FORMAT_VERSION)); + assert_eq!(manifest.name.as_deref(), Some("porch sign")); + assert_eq!(manifest.write_json(), text, "manifest must be canonical"); + + let (_, bytes) = files + .iter() + .find(|(name, _)| name == "module.json") + .unwrap(); let def = NodeDef::read_json(®istry, core::str::from_utf8(bytes).unwrap()).unwrap(); - let NodeDef::Project(project) = def else { - panic!("expected project root"); + let NodeDef::Module(module) = def else { + panic!("expected module root"); }; - assert_eq!( - project.format.data.as_ref().map(|slot| *slot.value()), - Some(PROJECT_FORMAT_VERSION) - ); - assert_eq!(project.name(), Some("porch sign")); - assert_eq!(project.nodes.entries.len(), 5); + assert_eq!(module.nodes.entries.len(), 5); } #[test] diff --git a/lp-core/lpc-model/src/project/config.rs b/lp-core/lpc-model/src/project/config.rs index 976115788..2139db3b5 100644 --- a/lp-core/lpc-model/src/project/config.rs +++ b/lp-core/lpc-model/src/project/config.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; /// Lightweight metadata that can travel with a project. /// /// This is separate from the authored project node definition in -/// [`crate::ProjectDef`]. The project node defines runtime graph structure; +/// [`crate::ModuleDef`]. The project node defines runtime graph structure; /// `ProjectConfig` holds user-facing metadata. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ProjectConfig { diff --git a/lp-core/lpc-model/src/project/inventory/project_node_placement.rs b/lp-core/lpc-model/src/project/inventory/project_node_placement.rs index b405f4f06..fe619ed39 100644 --- a/lp-core/lpc-model/src/project/inventory/project_node_placement.rs +++ b/lp-core/lpc-model/src/project/inventory/project_node_placement.rs @@ -8,7 +8,7 @@ use alloc::string::String; #[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "snake_case")] pub enum ProjectNodePlacement { - /// Child from `ProjectDef.nodes[name]`. + /// Child from `ModuleDef.nodes[name]`. ProjectChild { name: String }, /// Child from `PlaylistDef.entries[entry].node`. PlaylistEntry { entry: u32, name: Option }, diff --git a/lp-core/lpc-model/src/project/manifest.rs b/lp-core/lpc-model/src/project/manifest.rs new file mode 100644 index 000000000..ba93c71eb --- /dev/null +++ b/lp-core/lpc-model/src/project/manifest.rs @@ -0,0 +1,336 @@ +//! `project.json` container manifest: the project's workspace identity. +//! +//! The container manifest is **not a node artifact** (docs/design/modules.md +//! §1/§6): it carries the workspace concerns — `format`, `uid`, `name` — +//! while the root module node lives in `module.json`. It is deliberately NOT +//! a `#[derive(Slotted)]` type: the container is read by a streaming +//! [`crate::slot_codec::JsonSyntaxSource`] probe and written by a hand-rolled +//! deterministic writer, so no second shape/codec surface links into device +//! firmware for three fields (serde surface is the flash lever). +//! +//! Reading is strict about shape (root must be an object; known fields must +//! have the right type) and strict about vocabulary: unknown top-level keys +//! are an error, which is what makes read→modify→write patching lossless by +//! construction (there is nothing the writer could drop). + +use alloc::string::{String, ToString}; + +use crate::slot_codec::{JsonSyntaxSource, SyntaxEvent, SyntaxEventSource}; + +/// Monotonic format version of an authored project (the container and every +/// artifact transitively inside it). +/// +/// The container manifest (`project.json`) carries this as its `format` key; +/// module and node files are versioned transitively through their project's +/// container. Loaders reject projects whose format is missing or does not +/// match, so bump this when making a format-breaking change to authored +/// artifacts (alpha posture: bump and refuse, never migrate). +/// +/// History: +/// - `3` — project/module mitosis: `project.json` became the non-node +/// container manifest (`format`/`uid`/`name`), and the root module node +/// moved to `module.json` (kind `Module`, renamed from `project` in the +/// same train). Version-2 roots are single-file kind-tagged artifacts and +/// are refused. +/// - `2` — shader nodes replaced the `glsl_opts` record (`add_sub`/`mul`/ +/// `div` Q32 mode slots) with a single `float_mode` slot. Artifacts at +/// version `1` are refused, not migrated. +pub const PROJECT_FORMAT_VERSION: u32 = 3; + +/// Parsed `project.json` container manifest. +/// +/// All fields are optional at the parse layer; the loader format gate is +/// what enforces `format` presence/match ([`PROJECT_FORMAT_VERSION`]). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ProjectManifest { + /// Authored format version; see [`PROJECT_FORMAT_VERSION`]. + pub format: Option, + /// Stable project identity (`prj_…`, base-62), minted by the library + /// when a project enters it. Parity checks, history, and device + /// associations key off it. + pub uid: Option, + /// Human-readable project name — the Studio project pane's title. + pub name: Option, + /// Provenance (§8, settled Q7): author attribution. + pub author: Option, + /// Provenance: authored version string; no semver semantics yet. + pub version: Option, + /// Provenance: license identifier (e.g. `"CC0-1.0"`). + pub license: Option, + /// Provenance: ISO date the project was created. + pub created: Option, +} + +impl ProjectManifest { + /// Manifest for a freshly authored project at the current format. + pub fn new_current(name: &str) -> Self { + Self { + format: Some(PROJECT_FORMAT_VERSION), + name: Some(String::from(name)), + ..Self::default() + } + } + + /// Parse a `project.json` container manifest. + /// + /// Streaming, no value tree; strict: the root must be a JSON object, + /// known fields must carry the right type, and unknown top-level keys + /// are rejected (which keeps read→modify→write lossless). + pub fn read_json(text: &str) -> Result { + let syntax_error = + |error: crate::slot_codec::SyntaxError| ManifestParseError::Syntax(error.to_string()); + + let mut source = JsonSyntaxSource::new(text).map_err(syntax_error)?; + match source.next_event().map_err(syntax_error)? { + Some(SyntaxEvent::StartObject { .. }) => {} + _ => { + return Err(ManifestParseError::Syntax(String::from( + "project manifest root must be an object", + ))); + } + } + + let mut manifest = Self::default(); + loop { + match source.next_event().map_err(syntax_error)? { + Some(SyntaxEvent::Prop { name, .. }) => match name.as_str() { + "format" => { + let value = match source.next_event().map_err(syntax_error)? { + Some(SyntaxEvent::Number { text, .. }) => text.parse::().ok(), + _ => None, + }; + let Some(value) = value else { + return Err(ManifestParseError::Syntax(String::from( + "field `format` must be an unsigned integer", + ))); + }; + manifest.format = Some(value); + } + "uid" => manifest.uid = Some(read_string(&mut source, "uid")?), + "name" => manifest.name = Some(read_string(&mut source, "name")?), + "author" => manifest.author = Some(read_string(&mut source, "author")?), + "version" => manifest.version = Some(read_string(&mut source, "version")?), + "license" => manifest.license = Some(read_string(&mut source, "license")?), + "created" => manifest.created = Some(read_string(&mut source, "created")?), + other => { + return Err(ManifestParseError::UnknownField { + field: other.to_string(), + }); + } + }, + Some(SyntaxEvent::EndObject { .. }) => break, + Some(_) => { + return Err(ManifestParseError::Syntax(String::from( + "unexpected token in project manifest", + ))); + } + None => { + return Err(ManifestParseError::Syntax(String::from( + "unterminated project manifest object", + ))); + } + } + } + Ok(manifest) + } + + /// Write the manifest as canonical authored JSON: pretty-printed, fixed + /// field order (`format`, `uid`, `name`, `author`, `version`, + /// `license`, `created`), absent fields omitted, + /// trailing newline. Deterministic so unchanged models produce + /// byte-identical files. + pub fn write_json(&self) -> String { + let mut out = String::from("{"); + let mut first = true; + let mut field = |name: &str, value: &str, quote: bool, out: &mut String| { + if !first { + out.push(','); + } + first = false; + out.push_str("\n \""); + out.push_str(name); + out.push_str("\": "); + if quote { + push_json_string(out, value); + } else { + out.push_str(value); + } + }; + if let Some(format) = self.format { + field("format", &format.to_string(), false, &mut out); + } + if let Some(uid) = &self.uid { + field("uid", uid, true, &mut out); + } + if let Some(name) = &self.name { + field("name", name, true, &mut out); + } + if let Some(author) = &self.author { + field("author", author, true, &mut out); + } + if let Some(version) = &self.version { + field("version", version, true, &mut out); + } + if let Some(license) = &self.license { + field("license", license, true, &mut out); + } + if let Some(created) = &self.created { + field("created", created, true, &mut out); + } + if first { + out.push_str("}\n"); + } else { + out.push_str("\n}\n"); + } + out + } +} + +/// Failure parsing a `project.json` container manifest. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ManifestParseError { + /// Malformed JSON or a wrong-typed known field. + Syntax(String), + /// A top-level key outside the container vocabulary. + UnknownField { field: String }, +} + +impl core::fmt::Display for ManifestParseError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::Syntax(error) => f.write_str(error), + Self::UnknownField { field } => { + write!(f, "unknown project manifest field `{field}`") + } + } + } +} + +impl core::error::Error for ManifestParseError {} + +fn read_string( + source: &mut JsonSyntaxSource<'_>, + field: &str, +) -> Result { + let syntax_error = + |error: crate::slot_codec::SyntaxError| ManifestParseError::Syntax(error.to_string()); + let mut value = String::new(); + loop { + match source.next_event().map_err(syntax_error)? { + Some(SyntaxEvent::StringChunk { text, is_last, .. }) => { + value.push_str(&text); + if is_last { + return Ok(value); + } + } + _ => { + return Err(ManifestParseError::Syntax(alloc::format!( + "field `{field}` must be a string" + ))); + } + } + } +} + +fn push_json_string(out: &mut String, value: &str) { + out.push('"'); + for ch in value.chars() { + match ch { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + '\u{08}' => out.push_str("\\b"), + '\u{0c}' => out.push_str("\\f"), + ch if ch <= '\u{1f}' => { + let n = ch as u8; + out.push_str("\\u00"); + let hex = |nibble: u8| char::from_digit(u32::from(nibble), 16).unwrap_or('0'); + out.push(hex(n >> 4)); + out.push(hex(n & 0x0f)); + } + ch => out.push(ch), + } + } + out.push('"'); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn manifest_round_trips_byte_identically() { + let manifest = ProjectManifest { + format: Some(PROJECT_FORMAT_VERSION), + uid: Some(String::from("prj_0000000000000042")), + name: Some(String::from("Porch sign")), + author: Some(String::from("Yona")), + version: Some(String::from("0.1")), + license: Some(String::from("CC0-1.0")), + created: Some(String::from("2026-08-01")), + }; + let text = manifest.write_json(); + assert_eq!( + text, + "{\n \"format\": 3,\n \"uid\": \"prj_0000000000000042\",\n \"name\": \"Porch sign\",\n \"author\": \"Yona\",\n \"version\": \"0.1\",\n \"license\": \"CC0-1.0\",\n \"created\": \"2026-08-01\"\n}\n" + ); + let read = ProjectManifest::read_json(&text).expect("read back"); + assert_eq!(read, manifest); + assert_eq!(read.write_json(), text); + } + + #[test] + fn manifest_absent_fields_serialize_to_nothing() { + let manifest = ProjectManifest { + format: Some(3), + ..ProjectManifest::default() + }; + assert_eq!(manifest.write_json(), "{\n \"format\": 3\n}\n"); + assert_eq!(ProjectManifest::default().write_json(), "{}\n"); + } + + #[test] + fn manifest_rejects_unknown_fields() { + let err = ProjectManifest::read_json(r#"{ "format": 3, "nodes": {} }"#) + .expect_err("nodes is not a container field"); + assert_eq!( + err, + ManifestParseError::UnknownField { + field: String::from("nodes") + } + ); + // The pre-mitosis root is diagnosable by its `kind` key. + let err = ProjectManifest::read_json(r#"{ "kind": "Module", "format": 2 }"#) + .expect_err("kind is not a container field"); + assert_eq!( + err, + ManifestParseError::UnknownField { + field: String::from("kind") + } + ); + } + + #[test] + fn manifest_rejects_wrong_typed_fields() { + let err = ProjectManifest::read_json(r#"{ "format": "three" }"#).expect_err("string"); + assert!(err.to_string().contains("unsigned integer"), "{err}"); + let err = ProjectManifest::read_json(r#"{ "name": 7 }"#).expect_err("number name"); + assert!(err.to_string().contains("string"), "{err}"); + let err = ProjectManifest::read_json(r#"[1]"#).expect_err("array root"); + assert!(err.to_string().contains("object"), "{err}"); + } + + #[test] + fn manifest_name_escapes_round_trip() { + let manifest = ProjectManifest { + format: Some(3), + name: Some(String::from("a \"b\"\\\n\tc")), + ..ProjectManifest::default() + }; + let text = manifest.write_json(); + let read = ProjectManifest::read_json(&text).expect("read escaped"); + assert_eq!(read, manifest); + } +} diff --git a/lp-core/lpc-model/src/project/mod.rs b/lp-core/lpc-model/src/project/mod.rs index 85e0c96d8..9e49110fb 100644 --- a/lp-core/lpc-model/src/project/mod.rs +++ b/lp-core/lpc-model/src/project/mod.rs @@ -19,6 +19,7 @@ pub mod change_summary; pub mod config; pub mod inventory; +pub mod manifest; pub mod node_attach_site; pub mod overlay; pub mod overlay_commit; @@ -33,6 +34,7 @@ pub use inventory::project_inventory::ProjectInventory; pub use inventory::project_node::{ProjectNode, ProjectNodeOrigin}; pub use inventory::project_node_placement::ProjectNodePlacement; pub use inventory::project_tree::ProjectTree; +pub use manifest::{ManifestParseError, PROJECT_FORMAT_VERSION, ProjectManifest}; pub use node_attach_site::NodeAttachSite; pub use overlay_commit::commit_result::CommitResult; pub use overlay_mutation::mutation_result::{MutationBatchResults, MutationResult}; diff --git a/lp-core/lpc-model/src/schema_gen_smoke.rs b/lp-core/lpc-model/src/schema_gen_smoke.rs index 9ed95649a..fada43f0e 100644 --- a/lp-core/lpc-model/src/schema_gen_smoke.rs +++ b/lp-core/lpc-model/src/schema_gen_smoke.rs @@ -7,8 +7,7 @@ #[cfg(test)] mod tests { use crate::{ - ColorOrder, ControlLamp2d, NodeInvocation, ProjectDef, SlotMapDyn, - SlotShapeRegistrySnapshot, + ColorOrder, ControlLamp2d, ModuleDef, NodeInvocation, SlotMapDyn, SlotShapeRegistrySnapshot, }; macro_rules! assert_schema_compiles { @@ -26,8 +25,8 @@ mod tests { } #[test] - fn schema_project_def() { - assert_schema_compiles!(ProjectDef); + fn schema_module_def() { + assert_schema_compiles!(ModuleDef); } #[test] diff --git a/lp-core/lpc-model/src/slot/slot_shape_registry.rs b/lp-core/lpc-model/src/slot/slot_shape_registry.rs index 871205838..4f59344f1 100644 --- a/lp-core/lpc-model/src/slot/slot_shape_registry.rs +++ b/lp-core/lpc-model/src/slot/slot_shape_registry.rs @@ -861,7 +861,7 @@ mod tests { assert_eq!(snapshot.shapes.len(), 1); assert!(next.is_some()); - let project_shape = ::SHAPE_ID; + let project_shape = ::SHAPE_ID; assert!(!registry.contains(&project_shape)); } diff --git a/lp-core/lpc-model/src/slots/node_invocation_slot.rs b/lp-core/lpc-model/src/slots/node_invocation_slot.rs index bd3d23610..b8a3804af 100644 --- a/lp-core/lpc-model/src/slots/node_invocation_slot.rs +++ b/lp-core/lpc-model/src/slots/node_invocation_slot.rs @@ -140,7 +140,7 @@ mod tests { #[test] fn node_invocation_round_trips_unset_form() { let text = r#"{ - "kind": "Project", + "kind": "Module", "nodes": { "placeholder": { "unset": {} } } @@ -151,7 +151,7 @@ mod tests { #[test] fn node_invocation_round_trips_ref_form() { let text = r#"{ - "kind": "Project", + "kind": "Module", "nodes": { "shader": { "ref": "./shader.json" } } diff --git a/lp-core/lpc-registry/src/registry/node_authoring.rs b/lp-core/lpc-registry/src/registry/node_authoring.rs index 2692e6c9b..95b5feac4 100644 --- a/lp-core/lpc-registry/src/registry/node_authoring.rs +++ b/lp-core/lpc-registry/src/registry/node_authoring.rs @@ -1,6 +1,6 @@ //! Dedicated node-authoring semantics on the project registry. //! -//! `create_node` and `remove_node` are the operations the `ProjectDef.nodes` +//! `create_node` and `remove_node` are the operations the `ModuleDef.nodes` //! `read_only_persisted` policy promises: node creation and removal never //! arrive as raw slot edits, they arrive here. Both address the node through //! a [`NodeAttachSite`] — either the policy-locked project `nodes` map (the @@ -125,7 +125,7 @@ impl ProjectRegistry { .unwrap_or_else(|| LpPath::new("/")) .to_path_buf(); - // Body parses as a node definition; `Project` cannot be created as a + // Body parses as a node definition; `Module` cannot be created as a // child node (nested sub-projects are future work). let text = core::str::from_utf8(body).map_err(|_| { reject( @@ -139,10 +139,10 @@ impl ProjectRegistry { format!("node body does not parse as a node definition: {err}"), ) })?; - if matches!(def, NodeDef::Project(_)) { + if matches!(def, NodeDef::Module(_)) { return Err(reject( MutationRejectionReason::InvalidBody, - "kind Project cannot be created as a child node".into(), + "kind Module cannot be created as a child node".into(), )); } @@ -174,7 +174,7 @@ impl ProjectRegistry { )); } let def = self.loaded_def_for_mutation(&root_artifact)?; - let Some(project) = def.as_project() else { + let Some(project) = def.as_module() else { return Err(reject( MutationRejectionReason::UnknownArtifact, "project root definition is not a Project".into(), @@ -544,7 +544,7 @@ impl ProjectRegistry { }; let root_artifact = root.artifact.clone(); let def = self.loaded_def_for_mutation(&root_artifact)?; - let Some(project) = def.as_project() else { + let Some(project) = def.as_module() else { return Err(reject( MutationRejectionReason::UnknownArtifact, "project root definition is not a Project".into(), @@ -828,7 +828,7 @@ mod tests { ); assert_eq!( outcome.artifact_changes.changed, - vec![ArtifactLocation::file("/project.json")] + vec![ArtifactLocation::file("/module.json")] ); // Files byte-match the canonical writer: the def is written verbatim @@ -838,7 +838,7 @@ mod tests { fs.read_file(LpPath::new("/texture.json")).unwrap(), body.as_bytes() ); - let project_bytes = fs.read_file(LpPath::new("/project.json")).unwrap(); + let project_bytes = fs.read_file(LpPath::new("/module.json")).unwrap(); let project_text = core::str::from_utf8(&project_bytes).unwrap(); let project = NodeDef::read_json(&shapes, project_text).expect("project reparses"); assert_eq!( @@ -847,7 +847,7 @@ mod tests { "attachment rewrite must be canonical-writer output" ); let invocation = project - .as_project() + .as_module() .unwrap() .nodes .entries @@ -857,7 +857,7 @@ mod tests { .ref_specifier() .expect("ref invocation"); assert_eq!( - lpc_model::resolve_artifact_specifier(LpPath::new("/project.json"), &invocation) + lpc_model::resolve_artifact_specifier(LpPath::new("/module.json"), &invocation) .unwrap(), LpPathBuf::from("/texture.json") ); @@ -950,25 +950,28 @@ mod tests { } #[test] - fn attach_rewrite_preserves_pending_overlay_edits_on_the_attach_artifact() { + fn attach_rewrite_preserves_pending_overlay_edits() { + // Post-mitosis the root module def has no writable slots (`nodes` + // is policy-locked), so the pending edit rides the clock artifact; + // the guarantee under test is that `create_node`'s attach rewrite + // leaves unrelated pending overlays pending, not baked into files. + // P3's authored module fields will make a root-artifact variant of + // this scenario expressible again. let shapes = SlotShapeRegistry::default(); let (fs, mut registry) = clock_project(&shapes); - let project = ArtifactLocation::file("/project.json"); - let name_path = SlotPath::parse("name.some").unwrap(); + let clock = ArtifactLocation::file("/clock.json"); + let rate_path = SlotPath::parse("controls.rate").unwrap(); registry .mutate( &fs, MutationOp::PutSlotEdit { - artifact: project.clone(), - edit: SlotEdit::assign_value( - name_path.clone(), - LpValue::String("Renamed".into()), - ), + artifact: clock.clone(), + edit: SlotEdit::assign_value(rate_path.clone(), LpValue::F32(2.5)), }, Revision::new(4), &ParseCtx { shapes: &shapes }, ) - .expect("stage name edit"); + .expect("stage rate edit"); create( &fs, @@ -985,24 +988,22 @@ mod tests { let overlay = registry .overlay() .get() - .artifact(&project) + .artifact(&clock) .and_then(lpc_model::ArtifactOverlay::as_slot) - .expect("project slot overlay survives"); - assert!(overlay.contains_path(&name_path)); - let written = - String::from_utf8(fs.read_file(LpPath::new("/project.json")).unwrap()).unwrap(); + .expect("clock slot overlay survives"); + assert!(overlay.contains_path(&rate_path)); + let written = String::from_utf8(fs.read_file(LpPath::new("/clock.json")).unwrap()).unwrap(); assert!( - !written.contains("Renamed"), + !written.contains("2.5"), "pending overlay edit must not be baked into the base file: {written}" ); - // The effective def carries both the pending rename and the new node. + // The effective defs carry both the pending edit and the new node. let effective = registry - .def(&root_def("/project.json")) + .def(&root_def("/module.json")) .and_then(|entry| entry.state.loaded_def()) - .and_then(NodeDef::as_project) - .expect("effective project def"); - assert_eq!(effective.name(), Some("Renamed")); + .and_then(NodeDef::as_module) + .expect("effective module def"); assert!(effective.nodes.entries.contains_key("texture")); } @@ -1033,7 +1034,7 @@ mod tests { .mutate( &fs, MutationOp::PutSlotEdit { - artifact: ArtifactLocation::file("/project.json"), + artifact: ArtifactLocation::file("/module.json"), edit: SlotEdit::ensure_present(SlotPath::parse("nodes[strip]").unwrap()), }, Revision::new(4), @@ -1092,7 +1093,7 @@ mod tests { assert_eq!(rejection.reason, MutationRejectionReason::InvalidBody); assert_nothing_written(&fs, "/new.json"); - let project_body = br#"{ "kind": "Project", "format": 2, "nodes": {} }"#; + let project_body = br#"{ "kind": "Module", "nodes": {} }"#; let rejection = create( &fs, &mut registry, @@ -1124,7 +1125,7 @@ mod tests { body.as_bytes(), &[], &NodeAttachSite::Slot { - artifact: ArtifactLocation::file("/project.json"), + artifact: ArtifactLocation::file("/module.json"), path: SlotPath::parse("nodes[new]").unwrap(), }, ) @@ -1264,7 +1265,7 @@ mod tests { let site_overlay = registry .overlay() .get() - .artifact(&ArtifactLocation::file("/project.json")) + .artifact(&ArtifactLocation::file("/module.json")) .and_then(ArtifactOverlay::as_slot) .expect("site slot overlay"); assert_eq!( @@ -1380,13 +1381,13 @@ mod tests { .contains(&ArtifactLocation::file("/clock.json")) ); - // Files materialized: the def is deleted and project.json no longer + // Files materialized: the def is deleted and module.json no longer // references it. assert!(!fs.file_exists(LpPath::new("/clock.json")).unwrap()); - let project_bytes = fs.read_file(LpPath::new("/project.json")).unwrap(); + let project_bytes = fs.read_file(LpPath::new("/module.json")).unwrap(); let project_text = core::str::from_utf8(&project_bytes).unwrap(); let project = NodeDef::read_json(&shapes, project_text).expect("project reparses"); - assert!(project.as_project().unwrap().nodes.entries.is_empty()); + assert!(project.as_module().unwrap().nodes.entries.is_empty()); assert!(registry.overlay().get().is_empty()); } @@ -1458,7 +1459,7 @@ mod tests { let mut commands = vec![MutationCmd { id: MutationCmdId::new(1), mutation: MutationOp::RemoveSlotEdit { - artifact: ArtifactLocation::file("/project.json"), + artifact: ArtifactLocation::file("/module.json"), path: SlotPath::parse("nodes[shader]").unwrap(), }, }]; @@ -1536,7 +1537,7 @@ mod tests { &mut registry, &shapes, &NodeAttachSite::Slot { - artifact: ArtifactLocation::file("/project.json"), + artifact: ArtifactLocation::file("/module.json"), path: SlotPath::parse("nodes[clock]").unwrap(), }, ) @@ -1577,12 +1578,12 @@ mod tests { fn clock_project(shapes: &SlotShapeRegistry) -> (LpFsMemory, ProjectRegistry) { let mut fs = LpFsMemory::new(); + crate::test::fixtures::write_container_manifest(&mut fs); crate::test::fixtures::write_file( &mut fs, - "/project.json", + "/module.json", r#"{ - "kind": "Project", - "format": 2, + "kind": "Module", "nodes": { "clock": { "ref": "./clock.json" } } @@ -1602,12 +1603,12 @@ mod tests { fn playlist_project(shapes: &SlotShapeRegistry) -> (LpFsMemory, ProjectRegistry) { let mut fs = LpFsMemory::new(); + crate::test::fixtures::write_container_manifest(&mut fs); crate::test::fixtures::write_file( &mut fs, - "/project.json", + "/module.json", r#"{ - "kind": "Project", - "format": 2, + "kind": "Module", "nodes": { "playlist": { "ref": "./playlist.json" } } @@ -1639,12 +1640,12 @@ mod tests { fn shader_project(shapes: &SlotShapeRegistry) -> (LpFsMemory, ProjectRegistry) { let mut fs = LpFsMemory::new(); + crate::test::fixtures::write_container_manifest(&mut fs); crate::test::fixtures::write_file( &mut fs, - "/project.json", + "/module.json", r#"{ - "kind": "Project", - "format": 2, + "kind": "Module", "nodes": { "shader": { "ref": "./shader.json" } } @@ -1669,12 +1670,12 @@ mod tests { fn shared_asset_project(shapes: &SlotShapeRegistry) -> (LpFsMemory, ProjectRegistry) { let mut fs = LpFsMemory::new(); + crate::test::fixtures::write_container_manifest(&mut fs); crate::test::fixtures::write_file( &mut fs, - "/project.json", + "/module.json", r#"{ - "kind": "Project", - "format": 2, + "kind": "Module", "nodes": { "a": { "ref": "./a.json" }, "b": { "ref": "./b.json" } @@ -1702,12 +1703,12 @@ mod tests { fn playlist_two_entry_project(shapes: &SlotShapeRegistry) -> (LpFsMemory, ProjectRegistry) { let mut fs = LpFsMemory::new(); + crate::test::fixtures::write_container_manifest(&mut fs); crate::test::fixtures::write_file( &mut fs, - "/project.json", + "/module.json", r#"{ - "kind": "Project", - "format": 2, + "kind": "Module", "nodes": { "playlist": { "ref": "./playlist.json" } } @@ -1760,7 +1761,7 @@ mod tests { registry .load_root( fs, - LpPath::new("/project.json"), + LpPath::new("/module.json"), Revision::new(1), &ParseCtx { shapes }, ) @@ -1816,11 +1817,11 @@ mod tests { /// content (only the `clock`/`playlist` entry, no additions). fn assert_nothing_written(fs: &LpFsMemory, new_file: &str) { assert!(!fs.file_exists(LpPath::new(new_file)).unwrap()); - let bytes = fs.read_file(LpPath::new("/project.json")).unwrap(); + let bytes = fs.read_file(LpPath::new("/module.json")).unwrap(); let text = core::str::from_utf8(&bytes).unwrap(); let project = NodeDef::read_json(&SlotShapeRegistry::default(), text).unwrap(); assert_eq!( - project.as_project().unwrap().nodes.entries.len(), + project.as_module().unwrap().nodes.entries.len(), 1, "project root gained an entry despite the rejection" ); diff --git a/lp-core/lpc-registry/src/registry/project_registry.rs b/lp-core/lpc-registry/src/registry/project_registry.rs index ba271467c..dddc4deef 100644 --- a/lp-core/lpc-registry/src/registry/project_registry.rs +++ b/lp-core/lpc-registry/src/registry/project_registry.rs @@ -10,11 +10,10 @@ use lpc_model::{ MutationBatchResults, MutationCmdBatch, MutationCmdBatchResult, MutationCmdResult, MutationEffect, MutationOp, MutationRejection, MutationRejectionReason, MutationResult, NodeArtifact, NodeDef, NodeDefEntry, NodeDefLocation, NodeDefState, PROJECT_FORMAT_VERSION, - ProjectFormatProbe, ProjectInventory, ProjectOverlay, Revision, SlotAccess, SlotDataAccess, + ProjectInventory, ProjectManifest, ProjectOverlay, Revision, SlotAccess, SlotDataAccess, SlotEditOp, SlotMapKey, SlotName, SlotPath, SlotPathSegment, SlotPolicyResolution, SlotShapeLookup, SlotShapeView, StaticSlotShape, StoredSlotEdit, WithRevision, - lookup_slot_data, lp_value_matches_type, read_project_format_json, - resolve_slot_policy_and_leaf, + lookup_slot_data, lp_value_matches_type, resolve_slot_policy_and_leaf, }; use lpfs::{FsEvent, FsEventKind, LpFs, LpPath}; @@ -36,6 +35,13 @@ pub struct ProjectRegistry { } impl ProjectRegistry { + /// Project-root path of the `project.json` container manifest the load + /// gate reads. Not a node artifact — see [`lpc_model::ProjectManifest`]. + pub const CONTAINER_MANIFEST_PATH: &'static str = "/project.json"; + + /// Project-root path of the root module node artifact. + pub const ROOT_MODULE_PATH: &'static str = "/module.json"; + pub fn new() -> Self { Self { artifacts: ArtifactStore::new(), @@ -52,9 +58,9 @@ impl ProjectRegistry { frame: Revision, ctx: &ParseCtx<'_>, ) -> Result { + self.check_container_manifest(fs)?; let artifact = self.artifacts.register_file(root_path.to_path_buf(), frame); let root = NodeDefLocation::artifact_root(artifact); - self.check_root_format(fs, &root)?; let before = ProjectInventory::new(); self.root = Some(root.clone()); @@ -65,32 +71,39 @@ impl ProjectRegistry { Ok(LoadResult::new(root, changes)) } - /// Reject project roots whose authored `format` is missing or unsupported. + /// Reject projects whose `project.json` container manifest is missing, + /// unreadable, or carries a missing/unsupported `format`. /// - /// The probe runs on the raw root bytes before anything parses, so a - /// future-format project fails with the dedicated error instead of a deep - /// parse failure. Unreadable, malformed, or non-`Project` roots skip the - /// check and keep their existing diagnostics. - fn check_root_format( - &mut self, - fs: &dyn LpFs, - root: &NodeDefLocation, - ) -> Result<(), RegistryError> { - let Ok(bytes) = self.artifacts.read_bytes(&root.artifact, fs) else { - return Ok(()); - }; - let Ok(text) = core::str::from_utf8(&bytes) else { - return Ok(()); - }; - let Ok(ProjectFormatProbe::Project { format }) = read_project_format_json(text) else { - return Ok(()); - }; - if format == Some(PROJECT_FORMAT_VERSION) { + /// The manifest is read via the streaming probe on the raw bytes before + /// anything parses — one code path on host, browser, and device — so a + /// future-format project fails with the dedicated error instead of a + /// deep parse failure. A missing or malformed container manifest is a + /// HARD refuse (settled D-A): the manifest carries the format gate, so + /// skipping it would let unversioned projects load ungated. + fn check_container_manifest(&self, fs: &dyn LpFs) -> Result<(), RegistryError> { + use lpfs::AsLpPath; + + let bytes = fs + .read_file(Self::CONTAINER_MANIFEST_PATH.as_path()) + .map_err(|error| RegistryError::Manifest { + message: format!( + "missing or unreadable {}: {error:?}", + Self::CONTAINER_MANIFEST_PATH + ), + })?; + let text = core::str::from_utf8(&bytes).map_err(|_| RegistryError::Manifest { + message: format!("{} is not UTF-8", Self::CONTAINER_MANIFEST_PATH), + })?; + let manifest = + ProjectManifest::read_json(text).map_err(|error| RegistryError::Manifest { + message: format!("{}: {error}", Self::CONTAINER_MANIFEST_PATH), + })?; + if manifest.format == Some(PROJECT_FORMAT_VERSION) { Ok(()) } else { Err(RegistryError::FormatVersion { expected: PROJECT_FORMAT_VERSION, - found: format, + found: manifest.format, }) } } @@ -1605,16 +1618,15 @@ mod tests { } #[test] - fn project_root_format_and_nodes_reject_writes_but_name_stays_writable() { - // P6 flat-root policy: `ProjectDef.format` and `ProjectDef.nodes` are - // `read_only_persisted` — only the loader format gate / future - // upgrader (format) and dedicated project ops (nodes, Studio - // authoring M2) own them. The policy inherits into the subtree (map - // entries, option interior); `name` stays writable (project rename - // is legitimate). + fn module_root_nodes_reject_writes_and_container_fields_are_unknown() { + // Post-mitosis policy: `ModuleDef.nodes` is `read_only_persisted` — + // only dedicated project ops own it. The container identity fields + // (`format`/`uid`/`name`) are no longer slots on the root at all + // (they live in the `project.json` manifest), so edits addressed at + // them reject as unknown paths rather than policy violations. let shapes = SlotShapeRegistry::default(); let (fs, mut registry) = clock_project(&shapes); - let project = ArtifactLocation::file("/project.json"); + let module = ArtifactLocation::file("/module.json"); let results = mutate_batch( &fs, @@ -1622,55 +1634,53 @@ mod tests { &shapes, vec![ MutationOp::PutSlotEdit { - artifact: project.clone(), - // Deliberately not PROJECT_FORMAT_VERSION: the write must - // be rejected, so it has to be distinguishable from the - // value the fixture already carries. - edit: SlotEdit::assign_value( - SlotPath::parse("format.some").unwrap(), - LpValue::U32(999), - ), - }, - MutationOp::PutSlotEdit { - artifact: project.clone(), + artifact: module.clone(), edit: SlotEdit::ensure_present(SlotPath::parse("nodes[strip]").unwrap()), }, MutationOp::PutSlotEdit { - artifact: project.clone(), + artifact: module.clone(), edit: SlotEdit::remove(SlotPath::parse("nodes[clock]").unwrap()), }, - MutationOp::PutSlotEdit { - artifact: project.clone(), - edit: SlotEdit::assign_value( - SlotPath::parse("name.some").unwrap(), - LpValue::String("Renamed".into()), - ), - }, ], ); - - for rejected in &results[0..3] { + for rejected in &results { assert_eq!( rejection_reason(rejected), &MutationRejectionReason::NotWritable ); } - assert_accepted(&results[3], true); + + for path in ["format.some", "name.some"] { + let results = mutate_batch( + &fs, + &mut registry, + &shapes, + vec![MutationOp::PutSlotEdit { + artifact: module.clone(), + edit: SlotEdit::assign_value( + SlotPath::parse(path).unwrap(), + LpValue::String("x".into()), + ), + }], + ); + assert!( + matches!( + rejection_reason(&results[0]), + MutationRejectionReason::UnknownSlotPath | MutationRejectionReason::NotWritable + ), + "container field {path} must not be editable on the module root: {:?}", + results[0] + ); + } let def = registry - .def(&NodeDefLocation::artifact_root(project)) - .expect("project def entry") + .def(&NodeDefLocation::artifact_root(module)) + .expect("module def entry") .state .loaded_def() - .expect("project def loaded") - .as_project() - .expect("project def"); - assert_eq!(def.name(), Some("Renamed")); - assert_eq!( - def.format(), - Some(PROJECT_FORMAT_VERSION), - "format edit never applied" - ); + .expect("module def loaded") + .as_module() + .expect("module def"); assert!(def.nodes.entries.contains_key("clock")); assert!(!def.nodes.entries.contains_key("strip")); } @@ -1981,7 +1991,7 @@ mod tests { .mutate( &fs, MutationOp::PutSlotEdit { - artifact: ArtifactLocation::file("/project.json"), + artifact: ArtifactLocation::file("/module.json"), edit: SlotEdit::ensure_present(SlotPath::parse("nodes[clock]").unwrap()), }, Revision::new(14), @@ -2091,7 +2101,7 @@ mod tests { .mutate( &fs, MutationOp::PutSlotEdit { - artifact: ArtifactLocation::file("/project.json"), + artifact: ArtifactLocation::file("/module.json"), edit: SlotEdit::ensure_present(SlotPath::parse("nodes[pixels]").unwrap()), }, Revision::new(14), @@ -3445,12 +3455,12 @@ mod tests { fn clock_project(shapes: &SlotShapeRegistry) -> (LpFsMemory, ProjectRegistry) { let mut fs = LpFsMemory::new(); + crate::test::fixtures::write_container_manifest(&mut fs); crate::test::fixtures::write_file( &mut fs, - "/project.json", + "/module.json", r#"{ - "kind": "Project", - "format": 2, + "kind": "Module", "nodes": { "clock": { "ref": "./clock.json" } } @@ -3469,7 +3479,7 @@ mod tests { registry .load_root( &fs, - lpfs::LpPath::new("/project.json"), + lpfs::LpPath::new("/module.json"), Revision::new(1), &ParseCtx { shapes }, ) @@ -3486,12 +3496,12 @@ mod tests { /// BindingDef default None). fn bound_clock_project(shapes: &SlotShapeRegistry) -> (LpFsMemory, ProjectRegistry) { let mut fs = LpFsMemory::new(); + crate::test::fixtures::write_container_manifest(&mut fs); crate::test::fixtures::write_file( &mut fs, - "/project.json", + "/module.json", r#"{ - "kind": "Project", - "format": 2, + "kind": "Module", "nodes": { "clock": { "ref": "./clock.json" } } @@ -3511,7 +3521,7 @@ mod tests { registry .load_root( &fs, - lpfs::LpPath::new("/project.json"), + lpfs::LpPath::new("/module.json"), Revision::new(1), &ParseCtx { shapes }, ) @@ -3523,12 +3533,12 @@ mod tests { /// authored to a non-default value ("rgb"; the shape default is "grb"). fn fixture_project(shapes: &SlotShapeRegistry) -> (LpFsMemory, ProjectRegistry) { let mut fs = LpFsMemory::new(); + crate::test::fixtures::write_container_manifest(&mut fs); crate::test::fixtures::write_file( &mut fs, - "/project.json", + "/module.json", r#"{ - "kind": "Project", - "format": 2, + "kind": "Module", "nodes": { "pixels": { "ref": "./fixture.json" } } @@ -3547,7 +3557,7 @@ mod tests { registry .load_root( &fs, - lpfs::LpPath::new("/project.json"), + lpfs::LpPath::new("/module.json"), Revision::new(1), &ParseCtx { shapes }, ) @@ -3565,12 +3575,12 @@ mod tests { /// entry) — the move-op fixtures. fn path_points_fixture_project(shapes: &SlotShapeRegistry) -> (LpFsMemory, ProjectRegistry) { let mut fs = LpFsMemory::new(); + crate::test::fixtures::write_container_manifest(&mut fs); crate::test::fixtures::write_file( &mut fs, - "/project.json", + "/module.json", r#"{ - "kind": "Project", - "format": 2, + "kind": "Module", "nodes": { "pixels": { "ref": "./fixture.json" } } @@ -3604,7 +3614,7 @@ mod tests { registry .load_root( &fs, - lpfs::LpPath::new("/project.json"), + lpfs::LpPath::new("/module.json"), Revision::new(1), &ParseCtx { shapes }, ) diff --git a/lp-core/lpc-registry/src/registry/registry_error.rs b/lp-core/lpc-registry/src/registry/registry_error.rs index ca84cda45..2e014458d 100644 --- a/lp-core/lpc-registry/src/registry/registry_error.rs +++ b/lp-core/lpc-registry/src/registry/registry_error.rs @@ -17,6 +17,13 @@ pub enum RegistryError { expected: u32, found: Option, }, + /// The `project.json` container manifest is missing or unreadable. + /// + /// A hard refuse, never a skip: the manifest carries the format gate, + /// so a project without one is unloadable by construction (D-A). + Manifest { + message: String, + }, } impl core::fmt::Display for RegistryError { @@ -42,10 +49,13 @@ impl core::fmt::Display for RegistryError { } => { write!( f, - "project root is missing the top-level `format` key \ + "project manifest is missing the top-level `format` key \ (expected {expected}); regenerate or upgrade the project" ) } + Self::Manifest { message } => { + write!(f, "project manifest: {message}") + } } } } diff --git a/lp-core/lpc-registry/src/test/fixtures.rs b/lp-core/lpc-registry/src/test/fixtures.rs index ed1908bdc..3d0c9d982 100644 --- a/lp-core/lpc-registry/src/test/fixtures.rs +++ b/lp-core/lpc-registry/src/test/fixtures.rs @@ -7,6 +7,12 @@ pub fn write_file(fs: &mut LpFsMemory, path: &str, contents: &str) { .unwrap(); } +/// Write the `/project.json` container manifest every loadable project +/// needs post-mitosis (the root module node lives in `/module.json`). +pub fn write_container_manifest(fs: &mut LpFsMemory) { + write_file(fs, "/project.json", "{\n \"format\": 3\n}\n"); +} + pub fn load_shader_project() -> LpFsMemory { let mut fs = LpFsMemory::new(); write_file( diff --git a/lp-core/lpc-registry/tests/apply.rs b/lp-core/lpc-registry/tests/apply.rs index bf19ecf39..42049c625 100644 --- a/lp-core/lpc-registry/tests/apply.rs +++ b/lp-core/lpc-registry/tests/apply.rs @@ -18,13 +18,13 @@ fn write_file(fs: &mut LpFsMemory, path: &str, contents: &str) { fn shader_project() -> (LpFsMemory, SlotShapeRegistry, ProjectRegistry) { let shapes = SlotShapeRegistry::default(); let mut fs = LpFsMemory::new(); + write_file(&mut fs, "/project.json", "{\n \"format\": 3\n}\n"); write_file( &mut fs, - "/project.json", + "/module.json", r#" { - "kind": "Project", - "format": 2, + "kind": "Module", "nodes": { "shader": { "ref": "./shader.json" @@ -51,7 +51,7 @@ fn shader_project() -> (LpFsMemory, SlotShapeRegistry, ProjectRegistry) { registry .load_root( &fs, - LpPath::new("/project.json"), + LpPath::new("/module.json"), Revision::new(1), &parse_ctx(&shapes), ) @@ -62,13 +62,13 @@ fn shader_project() -> (LpFsMemory, SlotShapeRegistry, ProjectRegistry) { fn clock_project() -> (LpFsMemory, SlotShapeRegistry, ProjectRegistry) { let shapes = SlotShapeRegistry::default(); let mut fs = LpFsMemory::new(); + write_file(&mut fs, "/project.json", "{\n \"format\": 3\n}\n"); write_file( &mut fs, - "/project.json", + "/module.json", r#" { - "kind": "Project", - "format": 2, + "kind": "Module", "nodes": { "clock": { "ref": "./clock.json" @@ -94,7 +94,7 @@ fn clock_project() -> (LpFsMemory, SlotShapeRegistry, ProjectRegistry) { registry .load_root( &fs, - LpPath::new("/project.json"), + LpPath::new("/module.json"), Revision::new(1), &parse_ctx(&shapes), ) diff --git a/lp-core/lpc-registry/tests/load.rs b/lp-core/lpc-registry/tests/load.rs index f55a9611a..f65f4d2aa 100644 --- a/lp-core/lpc-registry/tests/load.rs +++ b/lp-core/lpc-registry/tests/load.rs @@ -19,13 +19,13 @@ fn load_root_discovers_root_external_and_asset_entries() { let shapes = SlotShapeRegistry::default(); let ctx = parse_ctx(&shapes); let mut fs = LpFsMemory::new(); + write_file(&mut fs, "/project.json", "{\n \"format\": 3\n}\n"); write_file( &mut fs, - "/project.json", + "/module.json", r#" { - "kind": "Project", - "format": 2, + "kind": "Module", "nodes": { "shader": { "ref": "./shader.json" @@ -63,10 +63,10 @@ fn load_root_discovers_root_external_and_asset_entries() { let mut registry = ProjectRegistry::new(); let result = registry - .load_root(&fs, LpPath::new("/project.json"), Revision::new(1), &ctx) + .load_root(&fs, LpPath::new("/module.json"), Revision::new(1), &ctx) .unwrap(); - let root = NodeDefLocation::artifact_root(ArtifactLocation::file("/project.json")); + let root = NodeDefLocation::artifact_root(ArtifactLocation::file("/module.json")); let shader = NodeDefLocation::artifact_root(ArtifactLocation::file("/shader.json")); let clock = NodeDefLocation::artifact_root(ArtifactLocation::file("/clock.json")); let shader_asset = AssetLocation::artifact(ArtifactLocation::file("/shader.glsl")); @@ -77,7 +77,7 @@ fn load_root_discovers_root_external_and_asset_entries() { assert_eq!(registry.inventory().defs.len(), 3); assert!(matches!( registry.def(&root).unwrap().state, - NodeDefState::Loaded(lpc_model::NodeDef::Project(_)) + NodeDefState::Loaded(lpc_model::NodeDef::Module(_)) )); assert!(matches!( registry.def(&shader).unwrap().state, @@ -102,13 +102,13 @@ fn load_root_reports_parse_error_for_inline_child_def() { let shapes = SlotShapeRegistry::default(); let ctx = parse_ctx(&shapes); let mut fs = LpFsMemory::new(); + write_file(&mut fs, "/project.json", "{\n \"format\": 3\n}\n"); write_file( &mut fs, - "/project.json", + "/module.json", r#" { - "kind": "Project", - "format": 2, + "kind": "Module", "nodes": { "shader": { "def": { @@ -123,10 +123,10 @@ fn load_root_reports_parse_error_for_inline_child_def() { let mut registry = ProjectRegistry::new(); let result = registry - .load_root(&fs, LpPath::new("/project.json"), Revision::new(1), &ctx) + .load_root(&fs, LpPath::new("/module.json"), Revision::new(1), &ctx) .expect("load records the parse error as a def entry"); - let root = NodeDefLocation::artifact_root(ArtifactLocation::file("/project.json")); + let root = NodeDefLocation::artifact_root(ArtifactLocation::file("/module.json")); assert_eq!(result.root, root); let state = ®istry.def(&root).unwrap().state; let NodeDefState::ParseError(err) = state else { @@ -139,13 +139,13 @@ fn load_root_keeps_missing_referenced_def_as_error_entry() { let shapes = SlotShapeRegistry::default(); let ctx = parse_ctx(&shapes); let mut fs = LpFsMemory::new(); + write_file(&mut fs, "/project.json", "{\n \"format\": 3\n}\n"); write_file( &mut fs, - "/project.json", + "/module.json", r#" { - "kind": "Project", - "format": 2, + "kind": "Module", "nodes": { "shader": { "ref": "./missing.json" @@ -157,7 +157,7 @@ fn load_root_keeps_missing_referenced_def_as_error_entry() { let mut registry = ProjectRegistry::new(); registry - .load_root(&fs, LpPath::new("/project.json"), Revision::new(1), &ctx) + .load_root(&fs, LpPath::new("/module.json"), Revision::new(1), &ctx) .unwrap(); let missing = NodeDefLocation::artifact_root(ArtifactLocation::file("/missing.json")); @@ -172,13 +172,13 @@ fn load_root_keeps_missing_referenced_asset_as_error_entry() { let shapes = SlotShapeRegistry::default(); let ctx = parse_ctx(&shapes); let mut fs = LpFsMemory::new(); + write_file(&mut fs, "/project.json", "{\n \"format\": 3\n}\n"); write_file( &mut fs, - "/project.json", + "/module.json", r#" { - "kind": "Project", - "format": 2, + "kind": "Module", "nodes": { "shader": { "ref": "./shader.json" @@ -202,7 +202,7 @@ fn load_root_keeps_missing_referenced_asset_as_error_entry() { let mut registry = ProjectRegistry::new(); registry - .load_root(&fs, LpPath::new("/project.json"), Revision::new(1), &ctx) + .load_root(&fs, LpPath::new("/module.json"), Revision::new(1), &ctx) .unwrap(); let missing = AssetLocation::artifact(ArtifactLocation::file("/missing.glsl")); @@ -217,13 +217,13 @@ fn load_root_accepts_current_project_format() { let shapes = SlotShapeRegistry::default(); let ctx = parse_ctx(&shapes); let mut fs = LpFsMemory::new(); + write_file(&mut fs, "/project.json", "{\n \"format\": 3\n}\n"); write_file( &mut fs, - "/project.json", + "/module.json", r#" { - "kind": "Project", - "format": 2, + "kind": "Module", "nodes": {} } "#, @@ -231,28 +231,29 @@ fn load_root_accepts_current_project_format() { let mut registry = ProjectRegistry::new(); let result = registry - .load_root(&fs, LpPath::new("/project.json"), Revision::new(1), &ctx) + .load_root(&fs, LpPath::new("/module.json"), Revision::new(1), &ctx) .expect("current format loads"); - let root = NodeDefLocation::artifact_root(ArtifactLocation::file("/project.json")); + let root = NodeDefLocation::artifact_root(ArtifactLocation::file("/module.json")); assert_eq!(result.root, root); assert!(matches!( registry.def(&root).unwrap().state, - NodeDefState::Loaded(lpc_model::NodeDef::Project(_)) + NodeDefState::Loaded(lpc_model::NodeDef::Module(_)) )); } #[test] -fn load_root_rejects_missing_project_format() { +fn load_root_rejects_missing_manifest_format() { let shapes = SlotShapeRegistry::default(); let ctx = parse_ctx(&shapes); let mut fs = LpFsMemory::new(); + write_file(&mut fs, "/project.json", "{\n \"name\": \"x\"\n}\n"); write_file( &mut fs, - "/project.json", + "/module.json", r#" { - "kind": "Project", + "kind": "Module", "nodes": {} } "#, @@ -260,7 +261,7 @@ fn load_root_rejects_missing_project_format() { let mut registry = ProjectRegistry::new(); let err = registry - .load_root(&fs, LpPath::new("/project.json"), Revision::new(1), &ctx) + .load_root(&fs, LpPath::new("/module.json"), Revision::new(1), &ctx) .expect_err("missing format must be rejected"); assert_eq!( @@ -274,25 +275,130 @@ fn load_root_rejects_missing_project_format() { } #[test] -fn load_root_rejects_mismatched_project_format() { +fn vendored_module_folder_loads_under_the_projects_gate() { + // Q10 disposition: format is a container-level concept. A copied/ + // vendored module folder inside a project carries no project.json of + // its own — it is gated by the HOST project's container manifest, and + // the loader never re-runs the gate for child artifacts. let shapes = SlotShapeRegistry::default(); let ctx = parse_ctx(&shapes); let mut fs = LpFsMemory::new(); + write_file(&mut fs, "/project.json", "{\n \"format\": 3\n}\n"); write_file( &mut fs, - "/project.json", + "/module.json", r#" { - "kind": "Project", - "format": 999, + "kind": "Module", + "nodes": { + "plasma": { "ref": "./modules/plasma/module.json" } + } +} +"#, + ); + write_file( + &mut fs, + "/modules/plasma/module.json", + r#" +{ + "kind": "Module", + "nodes": {} +} +"#, + ); + + let mut registry = ProjectRegistry::new(); + registry + .load_root(&fs, LpPath::new("/module.json"), Revision::new(1), &ctx) + .expect("vendored module folder loads under the host gate"); + + let child = + NodeDefLocation::artifact_root(ArtifactLocation::file("/modules/plasma/module.json")); + assert!( + matches!( + registry.def(&child).expect("child def entry").state, + NodeDefState::Loaded(lpc_model::NodeDef::Module(_)) + ), + "vendored module def must load without its own manifest" + ); +} + +#[test] +fn load_root_rejects_missing_container_manifest() { + // D-A: a project with no `project.json` container manifest is a HARD + // refuse — the manifest carries the format gate, so skipping it would + // let unversioned projects load ungated. + let shapes = SlotShapeRegistry::default(); + let ctx = parse_ctx(&shapes); + let mut fs = LpFsMemory::new(); + write_file( + &mut fs, + "/module.json", + r#" +{ + "kind": "Module", "nodes": {} } "#, ); + let mut registry = ProjectRegistry::new(); + let err = registry + .load_root(&fs, LpPath::new("/module.json"), Revision::new(1), &ctx) + .expect_err("missing container manifest must be rejected"); + + assert!( + matches!(err, RegistryError::Manifest { .. }), + "expected a manifest error, got {err:?}" + ); + assert!(err.to_string().contains("project.json"), "{err}"); +} + +#[test] +fn load_root_rejects_pre_mitosis_kind_tagged_manifest() { + // A format-2 root (single-file, kind-tagged `project.json`) must fail + // with a diagnosable manifest error, not a deep parse failure. + let shapes = SlotShapeRegistry::default(); + let ctx = parse_ctx(&shapes); + let mut fs = LpFsMemory::new(); + write_file( + &mut fs, + "/project.json", + r#"{ "kind": "Module", "format": 2, "nodes": {} }"#, + ); + let mut registry = ProjectRegistry::new(); let err = registry .load_root(&fs, LpPath::new("/project.json"), Revision::new(1), &ctx) + .expect_err("pre-mitosis root must be rejected"); + + assert!( + matches!(err, RegistryError::Manifest { .. }), + "expected a manifest error, got {err:?}" + ); + assert!(err.to_string().contains("kind"), "{err}"); +} + +#[test] +fn load_root_rejects_mismatched_project_format() { + let shapes = SlotShapeRegistry::default(); + let ctx = parse_ctx(&shapes); + let mut fs = LpFsMemory::new(); + write_file(&mut fs, "/project.json", "{\n \"format\": 999\n}\n"); + write_file( + &mut fs, + "/module.json", + r#" +{ + "kind": "Module", + "nodes": {} +} +"#, + ); + + let mut registry = ProjectRegistry::new(); + let err = registry + .load_root(&fs, LpPath::new("/module.json"), Revision::new(1), &ctx) .expect_err("mismatched format must be rejected"); assert_eq!( diff --git a/lp-core/lpc-registry/tests/project_bootstrap.rs b/lp-core/lpc-registry/tests/project_bootstrap.rs index 9a1ac8dec..36cfdbcc8 100644 --- a/lp-core/lpc-registry/tests/project_bootstrap.rs +++ b/lp-core/lpc-registry/tests/project_bootstrap.rs @@ -20,18 +20,18 @@ fn can_create_fyeah_sign_project_from_empty_fs_with_artifact_body_mutations() { assert!( scenario .fs() - .file_exists(LpPath::new("/project.json")) + .file_exists(LpPath::new("/module.json")) .unwrap() ); - let load = scenario.load_root("/project.json"); + let load = scenario.load_root("/module.json"); assert_eq!(load.changes.defs.added.len(), 9); assert_eq!(load.changes.assets.added.len(), 3); assert_loaded_def_kinds( scenario.registry(), &[ - ("/project.json", NodeKind::Project), + ("/module.json", NodeKind::Module), ("/blast.json", NodeKind::Shader), ("/button.json", NodeKind::Button), ("/clock.json", NodeKind::Clock), diff --git a/lp-core/lpc-registry/tests/project_change_sets.rs b/lp-core/lpc-registry/tests/project_change_sets.rs index 8de0669d9..1ca868c96 100644 --- a/lp-core/lpc-registry/tests/project_change_sets.rs +++ b/lp-core/lpc-registry/tests/project_change_sets.rs @@ -56,12 +56,12 @@ fn unreferenced_file_refresh_does_not_change_effective_project() { #[test] fn changed_registered_def_discovers_newly_referenced_file() { let mut scenario = RegistryScenario::empty(); + scenario.write_container_manifest(); scenario.write_file( - "/project.json", + "/module.json", br#" { - "kind": "Project", - "format": 2 + "kind": "Module" } "#, ); @@ -73,14 +73,13 @@ fn changed_registered_def_discovers_newly_referenced_file() { } "#, ); - scenario.load_root("/project.json"); + scenario.load_root("/module.json"); let changes = scenario.replace_file_and_refresh( - "/project.json", + "/module.json", br#" { - "kind": "Project", - "format": 2, + "kind": "Module", "nodes": { "clock": { "ref": "./clock.json" @@ -94,7 +93,7 @@ fn changed_registered_def_discovers_newly_referenced_file() { assert_eq!( changes.defs.changed, vec![NodeDefChange::new( - root_def("/project.json"), + root_def("/module.json"), NodeDefChangeKind::Body, )] ); @@ -107,12 +106,12 @@ fn changed_registered_def_discovers_newly_referenced_file() { #[test] fn missing_referenced_def_recovers_when_file_is_created() { let mut scenario = RegistryScenario::empty(); + scenario.write_container_manifest(); scenario.write_file( - "/project.json", + "/module.json", br#" { - "kind": "Project", - "format": 2, + "kind": "Module", "nodes": { "clock": { "ref": "./clock.json" @@ -121,7 +120,7 @@ fn missing_referenced_def_recovers_when_file_is_created() { } "#, ); - scenario.load_root("/project.json"); + scenario.load_root("/module.json"); assert_eq!( scenario @@ -261,12 +260,10 @@ fn changing_project_child_ref_reports_node_use_definition_change() { let (mut scenario, _) = RegistryScenario::load_fixture("fyeah-sign"); let changes = scenario.replace_file_and_refresh( - "/project.json", + "/module.json", br#" { - "kind": "Project", - "format": 2, - "name": "fyeah-sign", + "kind": "Module", "nodes": { "output": { "ref": "./output.json" diff --git a/lp-core/lpc-registry/tests/project_discovery.rs b/lp-core/lpc-registry/tests/project_discovery.rs index c57846ac3..7011bd146 100644 --- a/lp-core/lpc-registry/tests/project_discovery.rs +++ b/lp-core/lpc-registry/tests/project_discovery.rs @@ -9,7 +9,7 @@ fn fyeah_sign_discovers_referenced_node_defs_and_assets() { let (scenario, load) = RegistryScenario::load_fixture("fyeah-sign"); let registry = scenario.registry(); - assert_eq!(registry.root(), Some(&support::root_def("/project.json"))); + assert_eq!(registry.root(), Some(&support::root_def("/module.json"))); assert!(load.changes.defs.changed.is_empty()); assert!(load.changes.defs.removed.is_empty()); assert!(load.changes.assets.changed.is_empty()); @@ -18,7 +18,7 @@ fn fyeah_sign_discovers_referenced_node_defs_and_assets() { assert_loaded_def_kinds( registry, &[ - ("/project.json", NodeKind::Project), + ("/module.json", NodeKind::Module), ("/blast.json", NodeKind::Shader), ("/button.json", NodeKind::Button), ("/clock.json", NodeKind::Clock), diff --git a/lp-core/lpc-registry/tests/project_graph.rs b/lp-core/lpc-registry/tests/project_graph.rs index 1f22eff48..3fd315643 100644 --- a/lp-core/lpc-registry/tests/project_graph.rs +++ b/lp-core/lpc-registry/tests/project_graph.rs @@ -18,7 +18,7 @@ fn fyeah_sign_graph_contains_project_children_playlist_entries_and_asset_consume assert_eq!(graph.root, root); assert_eq!(graph.nodes.len(), 9); - assert_eq!(graph.nodes[&root].def_location, root_def("/project.json")); + assert_eq!(graph.nodes[&root].def_location, root_def("/module.json")); assert_project_child( graph.nodes.get(&playlist).unwrap(), "playlist", @@ -65,8 +65,7 @@ fn duplicate_external_refs_share_def_entry_but_create_distinct_graph_nodes() { let (registry, _) = load_inline_project( r#" { - "kind": "Project", - "format": 2, + "kind": "Module", "nodes": { "a": { "ref": "./shader.json" @@ -112,8 +111,7 @@ fn missing_children_are_graph_nodes() { let (registry, _) = load_inline_project( r#" { - "kind": "Project", - "format": 2, + "kind": "Module", "nodes": { "missing": { "ref": "./missing.json" @@ -174,7 +172,9 @@ fn load_inline_project( let shapes = lpc_model::SlotShapeRegistry::default(); let ctx = ParseCtx { shapes: &shapes }; let mut fs = LpFsMemory::new(); - fs.write_file_mut(LpPath::new("/project.json"), project.as_bytes()) + fs.write_file_mut(LpPath::new("/project.json"), b"{\n \"format\": 3\n}\n") + .unwrap(); + fs.write_file_mut(LpPath::new("/module.json"), project.as_bytes()) .unwrap(); for (path, contents) in json_files { fs.write_file_mut(LpPath::new(path), contents.as_bytes()) @@ -188,7 +188,7 @@ fn load_inline_project( registry .load_root( &fs, - LpPath::new("/project.json"), + LpPath::new("/module.json"), lpc_model::Revision::new(1), &ctx, ) diff --git a/lp-core/lpc-registry/tests/runtime_harness.rs b/lp-core/lpc-registry/tests/runtime_harness.rs index 303633c9c..2355d1d84 100644 --- a/lp-core/lpc-registry/tests/runtime_harness.rs +++ b/lp-core/lpc-registry/tests/runtime_harness.rs @@ -84,13 +84,13 @@ fn fake_runtime_consumes_load_apply_and_commit_change_summaries() { let shapes = SlotShapeRegistry::default(); let ctx = parse_ctx(&shapes); let mut fs = LpFsMemory::new(); + write_file(&mut fs, "/project.json", "{\n \"format\": 3\n}\n"); write_file( &mut fs, - "/project.json", + "/module.json", r#" { - "kind": "Project", - "format": 2, + "kind": "Module", "nodes": { "shader": { "ref": "./shader.json" @@ -115,7 +115,7 @@ fn fake_runtime_consumes_load_apply_and_commit_change_summaries() { let mut registry = ProjectRegistry::new(); let load = registry - .load_root(&fs, LpPath::new("/project.json"), Revision::new(1), &ctx) + .load_root(&fs, LpPath::new("/module.json"), Revision::new(1), &ctx) .unwrap(); let mut runtime = FakeRuntime::default(); runtime.apply(®istry, &load.changes); diff --git a/lp-core/lpc-registry/tests/snapshot_overlay.rs b/lp-core/lpc-registry/tests/snapshot_overlay.rs index 1f0a6484a..5a53c2cff 100644 --- a/lp-core/lpc-registry/tests/snapshot_overlay.rs +++ b/lp-core/lpc-registry/tests/snapshot_overlay.rs @@ -13,11 +13,10 @@ fn snapshot_overlay_can_bootstrap_project_files() { let base = ProjectSnapshot::empty(); let mut target = ProjectSnapshot::empty(); target.insert( - LpPathBuf::from("/project.json"), + LpPathBuf::from("/module.json"), br#" { - "kind": "Project", - "format": 2, + "kind": "Module", "nodes": { "clock": { "ref": "./clock.json" @@ -38,7 +37,12 @@ fn snapshot_overlay_can_bootstrap_project_files() { ); let overlay = derive_overlay_between_snapshots(&base, &target); - let fs = LpFsMemory::new(); + let mut fs = LpFsMemory::new(); + // The container manifest is not a node artifact, so it rides beside the + // snapshot-derived files rather than through the overlay. + fs.write_file_mut(LpPath::new("/project.json"), b"{\n \"format\": 3\n}\n") + .unwrap(); + let fs = fs; let mut registry = ProjectRegistry::new(); for (artifact, artifact_overlay) in overlay.iter() { let ArtifactOverlay::Asset { overlay: edit } = artifact_overlay else { @@ -62,7 +66,7 @@ fn snapshot_overlay_can_bootstrap_project_files() { let mut loaded = ProjectRegistry::new(); loaded - .load_root(&fs, LpPath::new("/project.json"), Revision::new(3), &ctx) + .load_root(&fs, LpPath::new("/module.json"), Revision::new(3), &ctx) .unwrap(); assert_eq!(loaded.inventory().defs.len(), 2); } diff --git a/lp-core/lpc-registry/tests/support/scenario.rs b/lp-core/lpc-registry/tests/support/scenario.rs index a5fadd7dd..cced73018 100644 --- a/lp-core/lpc-registry/tests/support/scenario.rs +++ b/lp-core/lpc-registry/tests/support/scenario.rs @@ -32,7 +32,7 @@ impl RegistryScenario { shapes: SlotShapeRegistry::default(), next_revision: 1, }; - let load = scenario.load_root("/project.json"); + let load = scenario.load_root("/module.json"); (scenario, load) } @@ -48,6 +48,10 @@ impl RegistryScenario { &self.fs } + pub fn write_container_manifest(&mut self) { + self.write_file("/project.json", b"{\n \"format\": 3\n}\n"); + } + pub fn write_file(&mut self, path: &str, bytes: impl AsRef<[u8]>) { self.fs .write_file_mut(LpPath::new(path), bytes.as_ref()) diff --git a/lp-core/lpc-shared/src/project/builder.rs b/lp-core/lpc-shared/src/project/builder.rs index 91817f47b..a3caada2b 100644 --- a/lp-core/lpc-shared/src/project/builder.rs +++ b/lp-core/lpc-shared/src/project/builder.rs @@ -11,8 +11,9 @@ use lpc_model::nodes::texture::TextureDef; use lpc_model::{ Affine2d, Affine2dSlot, ArtifactSpec, AsLpPath, AssetSlot, BindingDef, BindingDefs, BindingRef, BusSlotRef, ChannelName, Dim2u, Dim2uSlot, EnumSlot, FixtureDiagnosticMode, - FixtureSamplingConfig, HwEndpointSpec, MapSlot, NodeDef, NodeInvocation, NodeInvocationSlot, - OptionSlot, ProjectDef, RenderOrder, RenderOrderSlot, SlotShapeRegistry, ValueSlot, + FixtureSamplingConfig, HwEndpointSpec, MapSlot, ModuleDef, NodeDef, NodeInvocation, + NodeInvocationSlot, OptionSlot, ProjectManifest, RenderOrder, RenderOrderSlot, + SlotShapeRegistry, ValueSlot, }; use lpfs::LpFs; use lpfs::lp_path::LpPathBuf; @@ -193,7 +194,8 @@ impl ProjectBuilder { self.fixture(output_path, texture_path).add(self) } - /// Build completes - writes project.json and all node artifact files. + /// Build completes - writes the `project.json` container manifest, the + /// `module.json` root module, and all node artifact files. pub fn build(self) { let registry = slot_shape_registry(); let mut nodes = VecMap::new(); @@ -206,15 +208,16 @@ impl ProjectBuilder { )))), ); } - let project = ProjectDef { - format: ProjectDef::current_format_slot(), - uid: OptionSlot::none(), - name: OptionSlot::some(ValueSlot::new(self.name.clone())), + let manifest = ProjectManifest::new_current(&self.name); + self.write_file_helper("/project.json", manifest.write_json().as_bytes()) + .expect("Failed to write project.json"); + let module = ModuleDef { nodes: MapSlot::new(nodes), + ..ModuleDef::default() }; - let project_json = authored_node_json(®istry, &NodeDef::Project(project)); - self.write_file_helper("/project.json", project_json.as_bytes()) - .expect("Failed to write project.json"); + let module_json = authored_node_json(®istry, &NodeDef::Module(module)); + self.write_file_helper("/module.json", module_json.as_bytes()) + .expect("Failed to write module.json"); } fn register_node(&mut self, name: String, path: LpPathBuf) { @@ -515,20 +518,23 @@ mod tests { use lpfs::LpFsMemory; #[test] - fn test_project_builder_creates_valid_project_json() { + fn test_project_builder_creates_valid_project_and_module_json() { let fs = Rc::new(RefCell::new(LpFsMemory::new())); let mut builder = ProjectBuilder::new(fs.clone()); builder.texture_basic(); builder.build(); - let project_json_bytes = fs.borrow().read_file("/project.json".as_path()).unwrap(); - let project_json_str = core::str::from_utf8(&project_json_bytes).unwrap(); + let manifest_bytes = fs.borrow().read_file("/project.json".as_path()).unwrap(); + let manifest = + ProjectManifest::read_json(core::str::from_utf8(&manifest_bytes).unwrap()).unwrap(); + assert_eq!(manifest.name.as_deref(), Some("Test Project")); - let def = NodeDef::read_json(&slot_shape_registry(), project_json_str).unwrap(); - let NodeDef::Project(def) = def else { - panic!("expected project def"); + let module_bytes = fs.borrow().read_file("/module.json".as_path()).unwrap(); + let module_str = core::str::from_utf8(&module_bytes).unwrap(); + let def = NodeDef::read_json(&slot_shape_registry(), module_str).unwrap(); + let NodeDef::Module(def) = def else { + panic!("expected module def"); }; - assert_eq!(def.name(), Some("Test Project")); assert!(def.nodes.entries.contains_key("texture")); } } diff --git a/lp-core/lpc-slot-codegen/src/discover/type_path.rs b/lp-core/lpc-slot-codegen/src/discover/type_path.rs index 74e64818d..742451998 100644 --- a/lp-core/lpc-slot-codegen/src/discover/type_path.rs +++ b/lp-core/lpc-slot-codegen/src/discover/type_path.rs @@ -21,7 +21,7 @@ pub(crate) fn infer_type_path( // Concept files are expected to re-export their headline type from the // parent module, so `source/project_def.rs` becomes - // `crate::source::ProjectDef`. `mod.rs` files naturally use their parent + // `crate::source::ModuleDef`. `mod.rs` files naturally use their parent // directory path as the module path. components.pop().expect("rust file has a filename"); diff --git a/lp-core/lpc-slot-codegen/src/lib.rs b/lp-core/lpc-slot-codegen/src/lib.rs index b1e72dbf8..9d4249b8d 100644 --- a/lp-core/lpc-slot-codegen/src/lib.rs +++ b/lp-core/lpc-slot-codegen/src/lib.rs @@ -105,7 +105,7 @@ pub struct Wrapped(Nested); src.join("node").join("project").join("mod.rs"), r#" #[derive(Slotted)] -pub struct ProjectDef {} +pub struct ModuleDef {} "#, ) .unwrap(); @@ -115,7 +115,7 @@ pub struct ProjectDef {} assert_eq!( shapes, vec![StaticRegisteredShape { - type_path: String::from("crate::node::project::ProjectDef"), + type_path: String::from("crate::node::project::ModuleDef"), has_default_factory: true, }] ); diff --git a/lp-core/lpc-wire/src/messages/project_read/probe/binding_graph_probe.rs b/lp-core/lpc-wire/src/messages/project_read/probe/binding_graph_probe.rs index 1155208f2..33f6aa184 100644 --- a/lp-core/lpc-wire/src/messages/project_read/probe/binding_graph_probe.rs +++ b/lp-core/lpc-wire/src/messages/project_read/probe/binding_graph_probe.rs @@ -123,6 +123,10 @@ pub struct WireBusChannel { pub consumers: Vec, /// Resolved current value, present when the request asked for values. pub value: Option, + /// Engine-reported root-scope role: true for THE channel the root + /// module's output interface mirrors (the project's primary visual). + /// Consumers must key off this flag, never off the channel name. + pub primary_visual: bool, } /// A channel's resolved value (or the resolution failure) at the snapshot @@ -171,6 +175,7 @@ mod tests { value: None, error: None, }), + primary_visual: false, }], }; let result = BindingGraphProbeResult::Graph(graph.clone()); diff --git a/lp-core/lpc-wire/src/project_command/create_node.rs b/lp-core/lpc-wire/src/project_command/create_node.rs index 436ad7bea..ad47e0230 100644 --- a/lp-core/lpc-wire/src/project_command/create_node.rs +++ b/lp-core/lpc-wire/src/project_command/create_node.rs @@ -103,7 +103,7 @@ mod tests { let created = WireCreateNodeResponse::Created { artifact_changes: ArtifactChangeSummary { added: alloc::vec![ArtifactLocation::file("/shader-2.json")], - changed: alloc::vec![ArtifactLocation::file("/project.json")], + changed: alloc::vec![ArtifactLocation::file("/module.json")], removed: Vec::new(), }, revision: Revision::new(7), diff --git a/lp-core/lpc-wire/src/server/hello.rs b/lp-core/lpc-wire/src/server/hello.rs index f6a127da4..08e5cbf60 100644 --- a/lp-core/lpc-wire/src/server/hello.rs +++ b/lp-core/lpc-wire/src/server/hello.rs @@ -23,6 +23,9 @@ use serde::{Deserialize, Serialize}; /// /// # History /// +/// - 5: node kind `project` renamed to `module` — `NodeKind::Module` +/// serializes as `"Module"` in inventory frames, and `TreePath` +/// segments carry `.module` instead of `.project`. /// - 4: `OutputDriverOptionsConfig.brightness` removed. Brightness is a /// fixture-level control only; the output node no longer carries one. /// - 3: merge of the two independent "2" bumps below — one wire surface @@ -35,7 +38,7 @@ use serde::{Deserialize, Serialize}; /// runtime command channel (playlist activate-entry; /// `docs/adr/2026-07-27-runtime-node-command-channel.md`). /// - 1: hello handshake introduced. -pub const WIRE_PROTO_VERSION: u32 = 4; +pub const WIRE_PROTO_VERSION: u32 = 5; /// Unsolicited/boot-time server identity and version report. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/lp-core/lpc-wire/tests/source_slot_sync.rs b/lp-core/lpc-wire/tests/source_slot_sync.rs index a85d2799a..ffec1c753 100644 --- a/lp-core/lpc-wire/tests/source_slot_sync.rs +++ b/lp-core/lpc-wire/tests/source_slot_sync.rs @@ -1,7 +1,7 @@ use lpc_model::nodes::fixture::FixtureDef; +use lpc_model::nodes::module::module_def::ModuleDef; use lpc_model::nodes::node_def::NodeDef; use lpc_model::nodes::output::OutputDef; -use lpc_model::nodes::project::project_def::ProjectDef; use lpc_model::nodes::shader::ShaderDef; use lpc_model::{ LpValue, SlotAccess, SlotData, SlotMapKey, SlotShape, SlotShapeRegistry, StaticSlotShape, @@ -10,7 +10,7 @@ use lpc_wire::build_slot_full_sync; #[test] fn real_source_defs_sync_as_slot_roots() { - let project = read_basic_project("project.json"); + let project = read_basic_project("module.json"); let shader = read_basic_shader("shader.json"); let output = read_basic_output("output.json"); let fixture = read_basic_fixture("fixture.json"); @@ -21,8 +21,8 @@ fn real_source_defs_sync_as_slot_roots() { println!("server loaded"); print_root( "project", - ProjectDef::SHAPE_ID.slot_shape_from(&shape_registry), - &project.data().into_owned(&ProjectDef::SHAPE_ID, ®istry), + ModuleDef::SHAPE_ID.slot_shape_from(&shape_registry), + &project.data().into_owned(&ModuleDef::SHAPE_ID, ®istry), &shape_registry, ); print_root( @@ -51,7 +51,7 @@ fn real_source_defs_sync_as_slot_roots() { assert_value( select( &project_data, - ProjectDef::SHAPE_ID.slot_shape_from(&shape_registry), + ModuleDef::SHAPE_ID.slot_shape_from(&shape_registry), &shape_registry, "nodes[shader].ref", ), @@ -183,9 +183,9 @@ fn paged_static_shape_registry_for_test() -> SlotShapeRegistry { } } -fn read_basic_project(name: &str) -> ProjectDef { +fn read_basic_project(name: &str) -> ModuleDef { match read_basic_node_def(name) { - NodeDef::Project(def) => def, + NodeDef::Module(def) => def, other => panic!("expected project, got {:?}", other.kind()), } } diff --git a/lp-fw/fw-browser/src/tests.rs b/lp-fw/fw-browser/src/tests.rs index f26a5c9f5..86f8fd8a1 100644 --- a/lp-fw/fw-browser/src/tests.rs +++ b/lp-fw/fw-browser/src/tests.rs @@ -647,14 +647,15 @@ fn load_project_tolerates_library_artifacts() { let project_fs = build_smoke_project(); for (path, mut content) in collect_project_files(&project_fs.borrow()) { if add_uid && path == "project.json" { - // canonical insertion: kind stays first (streaming codec) + // canonical insertion: format stays first, uid second + // (ProjectManifest::write_json field order). let text = String::from_utf8(content).unwrap(); let patched = text.replacen( - "\"kind\": \"Project\",", - "\"kind\": \"Project\",\n \"uid\": \"prj_0000000000000042\",", + "\"format\": 3", + "\"format\": 3,\n \"uid\": \"prj_0000000000000042\"", 1, ); - assert_ne!(patched, text, "kind anchor not found in manifest"); + assert_ne!(patched, text, "format anchor not found in manifest"); content = patched.into_bytes(); } let full_path = format!("/projects/{label}/{path}").as_path_buf(); diff --git a/lp-fw/fw-browser/www/smoke-project/module.json b/lp-fw/fw-browser/www/smoke-project/module.json new file mode 100644 index 000000000..2443ff6d0 --- /dev/null +++ b/lp-fw/fw-browser/www/smoke-project/module.json @@ -0,0 +1,17 @@ +{ + "kind": "Module", + "nodes": { + "clock": { + "ref": "./clock.json" + }, + "fixture": { + "ref": "./fixture.json" + }, + "output": { + "ref": "./output.json" + }, + "shader": { + "ref": "./shader.json" + } + } +} diff --git a/lp-fw/fw-browser/www/smoke-project/project.json b/lp-fw/fw-browser/www/smoke-project/project.json index 560534f02..7f26b3cc2 100644 --- a/lp-fw/fw-browser/www/smoke-project/project.json +++ b/lp-fw/fw-browser/www/smoke-project/project.json @@ -1,19 +1,4 @@ { - "kind": "Project", - "format": 2, - "name": "fw-browser smoke", - "nodes": { - "clock": { - "ref": "./clock.json" - }, - "fixture": { - "ref": "./fixture.json" - }, - "output": { - "ref": "./output.json" - }, - "shader": { - "ref": "./shader.json" - } - } + "format": 3, + "name": "fw-browser smoke" } diff --git a/lp-fw/fw-browser/www/smoke.html b/lp-fw/fw-browser/www/smoke.html index 0fe948a5e..1ed007edc 100644 --- a/lp-fw/fw-browser/www/smoke.html +++ b/lp-fw/fw-browser/www/smoke.html @@ -670,6 +670,7 @@

Worker log

async function fetchSmokeProject() { const paths = [ 'project.json', + 'module.json', 'clock.json', 'shader.json', 'shader.glsl', diff --git a/lp-fw/fw-host/src/host_runtime.rs b/lp-fw/fw-host/src/host_runtime.rs index b670c8d74..093d827d4 100644 --- a/lp-fw/fw-host/src/host_runtime.rs +++ b/lp-fw/fw-host/src/host_runtime.rs @@ -208,11 +208,13 @@ mod tests { let mut runtime = HostRuntime::start_memory().unwrap(); let client = TokioLpClient::new_shared(runtime.client_transport()); - // Manifest missing `format: 1` fails to load server-side. + // Pre-mitosis root shape (`kind`/`nodes`) written directly as the + // container manifest: the container manifest gate rejects unknown + // fields, so this fails to load server-side. client .fs_write( "/projects/bad/project.json".as_path(), - br#"{ "kind": "Project", "nodes": {} }"#.to_vec(), + br#"{ "kind": "Module", "nodes": {} }"#.to_vec(), ) .await .unwrap(); diff --git a/lp-fw/fw-tests/tests/scene_render_emu.rs b/lp-fw/fw-tests/tests/scene_render_emu.rs index 8d094cbaf..3229d0e4e 100644 --- a/lp-fw/fw-tests/tests/scene_render_emu.rs +++ b/lp-fw/fw-tests/tests/scene_render_emu.rs @@ -126,6 +126,51 @@ async fn test_scene_render_fw_emu() { // serialization path — the producer splits the texture into bounded // `ResultBytes` chunks and the client collector reassembles them. read_large_render_probe_crossing_frame_boundary(&client, project_handle, shader_id).await; + + // Format gate on the DEVICE path (D-A): a wrong-format container + // manifest must refuse to load on firmware — same streaming probe, same + // hard refusal as the host. Rides the already-booted emulator. + client + .fs_write( + "/projects/badformat/project.json".as_path(), + b"{\n \"format\": 999\n}\n".to_vec(), + ) + .await + .expect("write bad manifest"); + client + .fs_write( + "/projects/badformat/module.json".as_path(), + b"{\n \"kind\": \"Module\"\n}\n".to_vec(), + ) + .await + .expect("write module root"); + let err = client + .project_load("badformat") + .await + .expect_err("wrong-format project must refuse on device"); + let text = format!("{err:?}"); + assert!( + text.contains("format") || text.contains("999"), + "device refusal should name the format gate: {text}" + ); + + // A missing container manifest is a hard refuse too, not a skip. + client + .fs_write( + "/projects/nomanifest/module.json".as_path(), + b"{\n \"kind\": \"Module\"\n}\n".to_vec(), + ) + .await + .expect("write module root"); + let err = client + .project_load("nomanifest") + .await + .expect_err("missing container manifest must refuse on device"); + let text = format!("{err:?}"); + assert!( + text.contains("project.json") || text.contains("manifest"), + "device refusal should name the missing manifest: {text}" + ); } /// A render-product probe whose RGBA16 bytes (64·64·8 = 32 KiB) far exceed one diff --git a/projects/test/fyeah-sign/module.json b/projects/test/fyeah-sign/module.json new file mode 100644 index 000000000..4c7bc567a --- /dev/null +++ b/projects/test/fyeah-sign/module.json @@ -0,0 +1,23 @@ +{ + "kind": "Module", + "nodes": { + "button": { + "ref": "./button.json" + }, + "clock": { + "ref": "./clock.json" + }, + "fixture": { + "ref": "./fixture.json" + }, + "output": { + "ref": "./output.json" + }, + "playlist": { + "ref": "./playlist.json" + }, + "radio": { + "ref": "./radio.json" + } + } +} diff --git a/projects/test/fyeah-sign/project.json b/projects/test/fyeah-sign/project.json index b6c816169..ec0ed1fd4 100644 --- a/projects/test/fyeah-sign/project.json +++ b/projects/test/fyeah-sign/project.json @@ -1,25 +1,4 @@ { - "kind": "Project", - "format": 2, - "name": "fyeah-sign", - "nodes": { - "button": { - "ref": "./button.json" - }, - "clock": { - "ref": "./clock.json" - }, - "fixture": { - "ref": "./fixture.json" - }, - "output": { - "ref": "./output.json" - }, - "playlist": { - "ref": "./playlist.json" - }, - "radio": { - "ref": "./radio.json" - } - } + "format": 3, + "name": "fyeah-sign" } diff --git a/projects/test/quad-strips-1fix/module.json b/projects/test/quad-strips-1fix/module.json new file mode 100644 index 000000000..06c8140c7 --- /dev/null +++ b/projects/test/quad-strips-1fix/module.json @@ -0,0 +1,17 @@ +{ + "kind": "Module", + "nodes": { + "clock": { + "ref": "./clock.json" + }, + "shader": { + "ref": "./shader.json" + }, + "fixture1": { + "ref": "./fixture1.json" + }, + "output1": { + "ref": "./output1.json" + } + } +} diff --git a/projects/test/quad-strips-1fix/project.json b/projects/test/quad-strips-1fix/project.json index 6fd153189..5cf75e2d2 100644 --- a/projects/test/quad-strips-1fix/project.json +++ b/projects/test/quad-strips-1fix/project.json @@ -1,19 +1,4 @@ { - "kind": "Project", - "format": 2, - "name": "Quad strips (1 fixture)", - "nodes": { - "clock": { - "ref": "./clock.json" - }, - "shader": { - "ref": "./shader.json" - }, - "fixture1": { - "ref": "./fixture1.json" - }, - "output1": { - "ref": "./output1.json" - } - } + "format": 3, + "name": "Quad strips (1 fixture)" } diff --git a/projects/test/quad-strips/module.json b/projects/test/quad-strips/module.json new file mode 100644 index 000000000..2d6711830 --- /dev/null +++ b/projects/test/quad-strips/module.json @@ -0,0 +1,35 @@ +{ + "kind": "Module", + "nodes": { + "clock": { + "ref": "./clock.json" + }, + "shader": { + "ref": "./shader.json" + }, + "fixture1": { + "ref": "./fixture1.json" + }, + "fixture2": { + "ref": "./fixture2.json" + }, + "fixture3": { + "ref": "./fixture3.json" + }, + "fixture4": { + "ref": "./fixture4.json" + }, + "output1": { + "ref": "./output1.json" + }, + "output2": { + "ref": "./output2.json" + }, + "output3": { + "ref": "./output3.json" + }, + "output4": { + "ref": "./output4.json" + } + } +} diff --git a/projects/test/quad-strips/project.json b/projects/test/quad-strips/project.json index 5af9fdb85..b455474ad 100644 --- a/projects/test/quad-strips/project.json +++ b/projects/test/quad-strips/project.json @@ -1,37 +1,4 @@ { - "kind": "Project", - "format": 2, - "name": "Quad strips", - "nodes": { - "clock": { - "ref": "./clock.json" - }, - "shader": { - "ref": "./shader.json" - }, - "fixture1": { - "ref": "./fixture1.json" - }, - "fixture2": { - "ref": "./fixture2.json" - }, - "fixture3": { - "ref": "./fixture3.json" - }, - "fixture4": { - "ref": "./fixture4.json" - }, - "output1": { - "ref": "./output1.json" - }, - "output2": { - "ref": "./output2.json" - }, - "output3": { - "ref": "./output3.json" - }, - "output4": { - "ref": "./output4.json" - } - } + "format": 3, + "name": "Quad strips" } diff --git a/schemas/README.md b/schemas/README.md index b8936bad9..1b2d80f06 100644 --- a/schemas/README.md +++ b/schemas/README.md @@ -12,7 +12,8 @@ artifact format). Decision record: | Path | What it is | |---|---| -| `project.schema.json` | JSON Schema (2020-12) for a `project.json` artifact root: `kind: "Project"` plus the required `"format": N` version key. | +| `project.schema.json` | JSON Schema (2020-12) for the `project.json` container manifest (not a node envelope): required `"format": N`, optional `uid`/`name`, nothing else. | +| `module.schema.json` | JSON Schema for the `module.json` root module node artifact: `kind: "Module"` plus the compiled `ModuleDef` shape. | | `node.schema.json` | JSON Schema for any node artifact file — a `oneOf` over every registered node kind, discriminated by the `kind` field. | | `hardware.schema.json` | JSON Schema for board hardware manifests (`lp-core/lpc-hardware/boards/**/*.json`, `/hardware.json` device override). | | `shapes/*.json` | Serialized `SlotShape` registry dumps — the exact structure the slot codec parses against, including on-disk enum encodings. One file per registered shape; `::` in shape names flattens to `.` in filenames. | @@ -51,7 +52,7 @@ Two more guards keep the schemas honest: ## Format version and the bump procedure `project.json` carries `"format": N` (`PROJECT_FORMAT_VERSION` in -`lp-core/lpc-model/src/nodes/project/project_def.rs`); loaders reject a +`lp-core/lpc-model/src/nodes/module/module_def.rs`); loaders reject a missing or mismatched version before parsing. To make a breaking format change: diff --git a/schemas/history/v2/board-display.schema.json b/schemas/history/v2/board-display.schema.json new file mode 100644 index 000000000..2ce24bf28 --- /dev/null +++ b/schemas/history/v2/board-display.schema.json @@ -0,0 +1,534 @@ +{ + "$defs": { + "BoardDrawing": { + "description": "The drawing block the row-engine diagram renderer consumes.\n\nGeometry is in board-local units; the renderer derives every other size\nfrom the pin pitch (`u`). See `docs/design/board-diagrams.md`.", + "properties": { + "buttons": { + "items": { + "$ref": "#/$defs/DrawnButton" + }, + "type": "array" + }, + "left": { + "items": { + "$ref": "#/$defs/DrawnPin" + }, + "type": "array" + }, + "module": { + "$ref": "#/$defs/DrawnModule" + }, + "rgb": { + "anyOf": [ + { + "$ref": "#/$defs/DrawnRgb" + }, + { + "type": "null" + } + ] + }, + "right": { + "items": { + "$ref": "#/$defs/DrawnPin" + }, + "type": "array" + }, + "terminals": { + "description": "Top-edge screw terminals (band rows + leader lines).", + "items": { + "$ref": "#/$defs/DrawnTerminal" + }, + "type": "array" + }, + "usb": { + "items": { + "$ref": "#/$defs/DrawnUsb" + }, + "type": "array" + }, + "width": { + "description": "Board width in drawing units. Height derives from pin counts.", + "format": "float", + "type": "number" + } + }, + "required": [ + "width", + "module", + "left", + "right" + ], + "type": "object" + }, + "BoardNote": { + "description": "One detail-view note. Deliberately minimal: text plus optional OS (and\nfreeform OS-version qualifier) tags — the whole \"note system\".", + "properties": { + "os": { + "anyOf": [ + { + "$ref": "#/$defs/NoteOs" + }, + { + "type": "null" + } + ] + }, + "os_version": { + "description": "Freeform version qualifier shown with the OS tag (\"Sequoia+\").", + "type": [ + "string", + "null" + ] + }, + "text": { + "type": "string" + } + }, + "required": [ + "text" + ], + "type": "object" + }, + "CapKind": { + "description": "Cell type: drives the capability cell's color family.", + "oneOf": [ + { + "enum": [ + "adc", + "dac", + "touch", + "spi", + "i2c", + "uart", + "usb", + "strap", + "pwr", + "note" + ], + "type": "string" + }, + { + "const": "warn", + "description": "Caution: input-only, XTAL, PSRAM-reserved, JTAG, onboard LED.", + "type": "string" + } + ] + }, + "DrawnButton": { + "properties": { + "label": { + "type": "string" + }, + "x": { + "format": "float", + "type": "number" + }, + "y": { + "description": "Negative y = offset from the board's bottom edge.", + "format": "float", + "type": "number" + } + }, + "required": [ + "x", + "y", + "label" + ], + "type": "object" + }, + "DrawnModule": { + "description": "The SoC module / can outline.", + "properties": { + "antenna": { + "default": false, + "type": "boolean" + }, + "h": { + "format": "float", + "type": "number" + }, + "label": { + "type": "string" + }, + "w": { + "format": "float", + "type": "number" + }, + "x": { + "format": "float", + "type": "number" + }, + "y": { + "format": "float", + "type": "number" + } + }, + "required": [ + "x", + "y", + "w", + "h", + "label" + ], + "type": "object" + }, + "DrawnPin": { + "description": "One pin on a left/right rail.", + "properties": { + "caps": { + "items": { + "$ref": "#/$defs/PinCap" + }, + "type": "array" + }, + "gpio": { + "format": "uint8", + "maximum": 255, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "label": { + "description": "Silkscreen label (\"4\", \"3V3\", \"D10\", \"RST\").", + "type": "string" + }, + "pad_style": { + "$ref": "#/$defs/PadStyle", + "description": "The pin's physical connector. Header/solder pads are the default;\nscrew terminals (DIN-rail controllers like the DOM-Z-102) draw as a\nsquare block with a screw head." + }, + "role": { + "$ref": "#/$defs/PinRole" + } + }, + "required": [ + "label", + "role" + ], + "type": "object" + }, + "DrawnRgb": { + "properties": { + "gpio": { + "format": "uint8", + "maximum": 255, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "x": { + "format": "float", + "type": "number" + }, + "y": { + "format": "float", + "type": "number" + } + }, + "required": [ + "x", + "y" + ], + "type": "object" + }, + "DrawnTerminal": { + "description": "A top-edge screw terminal. Terminal rows keep a leading name cell because\nthere is no inside-the-board space for their labels.", + "properties": { + "caps": { + "items": { + "$ref": "#/$defs/PinCap" + }, + "type": "array" + }, + "gpio": { + "format": "uint8", + "maximum": 255, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "label": { + "type": "string" + }, + "role": { + "$ref": "#/$defs/PinRole" + } + }, + "required": [ + "label", + "role" + ], + "type": "object" + }, + "DrawnUsb": { + "properties": { + "label": { + "type": "string" + }, + "x": { + "format": "float", + "type": "number" + } + }, + "required": [ + "x", + "label" + ], + "type": "object" + }, + "NoteOs": { + "description": "The OS a note applies to.", + "enum": [ + "macos", + "windows", + "linux" + ], + "type": "string" + }, + "PadStyle": { + "description": "How a rail pin physically presents on the board.", + "oneOf": [ + { + "const": "pad", + "description": "Header pin / solder pad.", + "type": "string" + }, + { + "const": "screw", + "description": "Screw terminal.", + "type": "string" + } + ] + }, + "PinCap": { + "description": "A capability cell in the pin's row (\"ADC1_3\", \"Touch4\", \"U0_TXD\").", + "properties": { + "kind": { + "$ref": "#/$defs/CapKind" + }, + "text": { + "type": "string" + } + }, + "required": [ + "text", + "kind" + ], + "type": "object" + }, + "PinRole": { + "description": "Pin role: drives pad/label color and output eligibility.\n\nOutput-eligible for LED discovery: `io` and `strap` only.", + "oneOf": [ + { + "enum": [ + "pwr5", + "pwr3", + "gnd", + "io", + "nc" + ], + "type": "string" + }, + { + "const": "io-in", + "description": "Input-only (classic ESP32 34-39).", + "type": "string" + }, + { + "const": "strap", + "description": "Boot-strap pin: usable, with care.", + "type": "string" + }, + { + "const": "usb", + "description": "USB D+/D- — driving it drops the link.", + "type": "string" + }, + { + "const": "ctl", + "description": "EN / RST.", + "type": "string" + }, + { + "const": "rsvd", + "description": "Connected but reserved (in-package flash/PSRAM pins on boards that\nexpose them). Never claimable, never discovery-eligible.", + "type": "string" + } + ] + }, + "PurchaseUrl": { + "properties": { + "href": { + "type": "string" + }, + "label": { + "type": "string" + } + }, + "required": [ + "label", + "href" + ], + "type": "object" + }, + "SupportTier": { + "description": "Support tier shown on the catalog. Definitions live in\n`lp-core/lpc-hardware/boards/README.md`.", + "oneOf": [ + { + "const": "gold", + "description": "First-class: tested every release.", + "type": "string" + }, + { + "const": "silver", + "description": "Supported: tested occasionally.", + "type": "string" + }, + { + "const": "bronze", + "description": "Community-verified: should work.", + "type": "string" + } + ] + }, + "UsbBridge": { + "description": "The USB-UART bridge (or native USB) a board's USB connector goes through.", + "oneOf": [ + { + "const": "native-usb-jtag", + "description": "The SoC's own USB-Serial-JTAG peripheral (S3, C6). No bridge chip,\nno driver anywhere.", + "type": "string" + }, + { + "const": "ch340g", + "description": "WCH CH340G — covered by Apple's built-in driver on modern macOS.", + "type": "string" + }, + { + "const": "ch340c", + "description": "WCH CH340C — covered by Apple's built-in driver on modern macOS.", + "type": "string" + }, + { + "const": "ch340k", + "description": "WCH CH340K — NOT covered by Apple's built-in driver (it matches only\n0x7523 and CH9102F); needs WCH's DriverKit extension on macOS.", + "type": "string" + }, + { + "const": "ch9102f", + "description": "WCH CH9102F — covered by Apple's built-in driver on modern macOS.", + "type": "string" + }, + { + "const": "cp2102", + "description": "Silicon Labs CP2102 — driverless on modern macOS.", + "type": "string" + } + ] + } + }, + "$id": "https://lightplayer.dev/schemas/board-display.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "One board's display sidecar (`/.display.json`).", + "properties": { + "blurb": { + "type": "string" + }, + "board_id": { + "description": "Must equal the sibling runtime manifest id and the `vendor/product`\nfile path stem.", + "type": "string" + }, + "capabilities": { + "description": "Catalog capability chips (\"Wi-Fi 6\", \"level shifter\", ...).", + "items": { + "type": "string" + }, + "type": "array" + }, + "display_name": { + "type": "string" + }, + "family": { + "description": "Filter family for the provisioning picker: \"esp32\", \"esp32s3\",\n\"esp32c6\". Kept a plain string so display data never gates on firmware\ntarget enums.", + "type": "string" + }, + "flash": { + "type": "string" + }, + "hw": { + "$ref": "#/$defs/BoardDrawing" + }, + "manufacturer": { + "type": "string" + }, + "notes": { + "description": "Freeform board notes shown on the detail view, optionally OS-tagged\n(an untagged note shows everywhere; a tagged one only on that OS).", + "items": { + "$ref": "#/$defs/BoardNote" + }, + "type": "array" + }, + "price_usd": { + "description": "Approximate street price, USD, for catalog sorting.", + "format": "double", + "type": "number" + }, + "psram": { + "type": [ + "string", + "null" + ] + }, + "purchase_urls": { + "items": { + "$ref": "#/$defs/PurchaseUrl" + }, + "type": "array" + }, + "soc": { + "description": "Human SoC name, e.g. \"ESP32-C6\".", + "type": "string" + }, + "support_note": { + "description": "Honest support caveat shown with the tier, e.g. \"runs when classic\nESP32 firmware lands\".", + "type": [ + "string", + "null" + ] + }, + "tier": { + "$ref": "#/$defs/SupportTier" + }, + "usb_bridge": { + "anyOf": [ + { + "$ref": "#/$defs/UsbBridge" + }, + { + "type": "null" + } + ], + "description": "The USB-UART bridge the board's USB connector goes through — drives\nper-OS driver warnings (plan decision D5). Set only when verified;\nomitted = unknown." + } + }, + "required": [ + "board_id", + "display_name", + "manufacturer", + "soc", + "family", + "flash", + "price_usd", + "tier", + "blurb", + "hw" + ], + "title": "BoardDisplayFile", + "type": "object" +} diff --git a/schemas/history/v2/fixtures/blast.json b/schemas/history/v2/fixtures/blast.json new file mode 100644 index 000000000..419c4cbdc --- /dev/null +++ b/schemas/history/v2/fixtures/blast.json @@ -0,0 +1,30 @@ +{ + "kind": "Shader", + "source": "blast.glsl", + "render_order": 0, + "bindings": { + "progress": { + "source": "node:..#entry_progress" + }, + "time": { + "source": "node:..#entry_time" + } + }, + "float_mode": "fixed", + "consumed": { + "progress": { + "kind": "value", + "value": "f32", + "default": 0, + "label": "", + "description": "" + }, + "time": { + "kind": "value", + "value": "f32", + "default": 0, + "label": "", + "description": "" + } + } +} diff --git a/schemas/history/v2/fixtures/playlist.json b/schemas/history/v2/fixtures/playlist.json new file mode 100644 index 000000000..0c0d6797b --- /dev/null +++ b/schemas/history/v2/fixtures/playlist.json @@ -0,0 +1,34 @@ +{ + "kind": "Playlist", + "bindings": { + "time": { + "source": "bus:time" + }, + "trigger": { + "source": "bus:trigger" + } + }, + "time": 0, + "idle_entry": 1, + "default_fade": 0.35, + "entries": { + "1": { + "name": "idle", + "fade_after": 0.12, + "node": { + "ref": "./idle.json" + } + }, + "2": { + "name": "blast", + "trigger_ids": [ + 1 + ], + "duration": 10, + "fade_after": 2, + "node": { + "ref": "./blast.json" + } + } + } +} diff --git a/schemas/history/v2/fixtures/project.json b/schemas/history/v2/fixtures/project.json new file mode 100644 index 000000000..017345589 --- /dev/null +++ b/schemas/history/v2/fixtures/project.json @@ -0,0 +1,25 @@ +{ + "kind": "Module", + "format": 2, + "name": "fyeah-sign", + "nodes": { + "button": { + "ref": "./button.json" + }, + "clock": { + "ref": "./clock.json" + }, + "fixture": { + "ref": "./fixture.json" + }, + "output": { + "ref": "./output.json" + }, + "playlist": { + "ref": "./playlist.json" + }, + "radio": { + "ref": "./radio.json" + } + } +} diff --git a/schemas/history/v2/hardware.schema.json b/schemas/history/v2/hardware.schema.json new file mode 100644 index 000000000..f5b2a632c --- /dev/null +++ b/schemas/history/v2/hardware.schema.json @@ -0,0 +1,184 @@ +{ + "$defs": { + "HardwareBoardLabelFile": { + "description": "Board-silkscreen label discovered or recorded during board mapping.\n\nLabels are metadata for humans and tooling. They do not create claimable\nresources by themselves; resources come from [`HardwareResourceFile`].", + "properties": { + "gpio": { + "type": [ + "string", + "null" + ] + }, + "label": { + "type": "string" + }, + "note": { + "type": [ + "string", + "null" + ] + }, + "status": { + "anyOf": [ + { + "$ref": "#/$defs/HardwareBoardLabelStatus" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "label" + ], + "type": "object" + }, + "HardwareBoardLabelStatus": { + "description": "Verification status for a board label in a manifest file.", + "enum": [ + "unassigned", + "assigned", + "verified", + "not-found", + "skipped" + ], + "type": "string" + }, + "HardwareResourceFile": { + "description": "Serializable resource entry in a board manifest file.\n\nGPIO resources are often grouped under `gpio` in TOML for readability, while\nnon-GPIO resources live under `resource`; both convert to [`HwResource`].", + "properties": { + "address": { + "type": "string" + }, + "aliases": { + "items": { + "type": "string" + }, + "type": "array" + }, + "capabilities": { + "items": { + "$ref": "#/$defs/HwCapability" + }, + "type": "array" + }, + "display_label": { + "type": "string" + }, + "location": { + "type": [ + "string", + "null" + ] + }, + "reserved_reason": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "address", + "display_label", + "capabilities" + ], + "type": "object" + }, + "HardwareTarget": { + "description": "Build or runtime target that a board manifest describes.", + "enum": [ + "esp32c6", + "esp32s3", + "rv32imac_emu" + ], + "type": "string" + }, + "HwCapability": { + "description": "Capability advertised by a [`crate::HwResource`].\n\nDrivers check capabilities before claiming resources. A single resource may\nexpose multiple capabilities, such as a GPIO that can be used for input or\noutput.", + "oneOf": [ + { + "const": "gpio-output", + "description": "GPIO can drive an output level or waveform.", + "type": "string" + }, + { + "const": "gpio-input", + "description": "GPIO can be sampled as an input.", + "type": "string" + }, + { + "const": "ws281x-output", + "description": "Timing resource can drive WS281x-class LED protocols.", + "type": "string" + }, + { + "const": "rmt", + "description": "ESP-style remote-control timing peripheral.", + "type": "string" + }, + { + "const": "radio", + "description": "Packet radio peripheral.", + "type": "string" + } + ] + } + }, + "$id": "https://lightplayer.dev/schemas/hardware.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Serializable board manifest file (authored as JSON).\n\nThis is the serializable form checked into the repo for board profiles. Use\n[`HardwareManifestFile::to_manifest`] to validate and convert it into the\nruntime [`HwManifest`].", + "properties": { + "board_label": { + "items": { + "$ref": "#/$defs/HardwareBoardLabelFile" + }, + "type": "array" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "gpio": { + "items": { + "$ref": "#/$defs/HardwareResourceFile" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "product": { + "type": "string" + }, + "resource": { + "items": { + "$ref": "#/$defs/HardwareResourceFile" + }, + "type": "array" + }, + "target": { + "$ref": "#/$defs/HardwareTarget" + }, + "url": { + "type": [ + "string", + "null" + ] + }, + "vendor": { + "type": "string" + } + }, + "required": [ + "id", + "target", + "vendor", + "product" + ], + "title": "HardwareManifestFile", + "type": "object" +} diff --git a/schemas/history/v2/node.schema.json b/schemas/history/v2/node.schema.json new file mode 100644 index 000000000..c2562d08c --- /dev/null +++ b/schemas/history/v2/node.schema.json @@ -0,0 +1,1051 @@ +{ + "$defs": { + "LpAnyValue": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "items": { + "$ref": "#/$defs/LpAnyValue" + }, + "type": "array" + } + ], + "description": "Dynamically typed value: string, number, bool, or an array of these (objects and null are rejected by the reader)." + }, + "lp::control::Message": { + "additionalProperties": false, + "properties": { + "id": { + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + }, + "seq": { + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "id", + "seq" + ], + "type": "object" + }, + "lp::fluid::Emitter": { + "additionalProperties": false, + "properties": { + "color": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 3, + "type": "array" + }, + "dir": { + "items": { + "type": "number" + }, + "maxItems": 2, + "minItems": 2, + "type": "array" + }, + "id": { + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + }, + "intensity": { + "type": "number" + }, + "pos": { + "items": { + "type": "number" + }, + "maxItems": 2, + "minItems": 2, + "type": "array" + }, + "radius": { + "type": "number" + }, + "velocity": { + "type": "number" + } + }, + "required": [ + "id", + "pos", + "dir", + "radius", + "color", + "velocity", + "intensity" + ], + "type": "object" + }, + "lpc_model::binding::binding_def::BindingDef": { + "additionalProperties": false, + "properties": { + "source": { + "type": "string" + }, + "target": { + "type": "string" + }, + "value": { + "$ref": "#/$defs/LpAnyValue" + } + }, + "type": "object" + }, + "lpc_model::nodes::output::output_def::OutputDriverOptionsConfig": { + "additionalProperties": false, + "properties": { + "dithering_enabled": { + "type": "boolean" + }, + "interpolation_enabled": { + "type": "boolean" + }, + "lut_enabled": { + "type": "boolean" + }, + "white_point": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 3, + "type": "array" + } + }, + "type": "object" + }, + "lpc_model::nodes::playlist::playlist_entry::PlaylistEntry": { + "additionalProperties": false, + "properties": { + "duration": { + "description": "Intended non-negative float (not enforced on read).", + "type": "number" + }, + "fade_after": { + "description": "Intended non-negative float (not enforced on read).", + "type": "number" + }, + "name": { + "type": "string" + }, + "node": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "unset": { + "description": "Unit slot: any value is accepted and ignored on read; written as {}." + } + }, + "required": [ + "unset" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "ref": { + "description": "Path to an authored artifact file.", + "type": "string" + } + }, + "required": [ + "ref" + ], + "type": "object" + } + ] + }, + "trigger_ids": { + "items": { + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + }, + "type": "array" + } + }, + "type": "object" + }, + "lpc_model::nodes::shader::shader_param_def::ScalarHint": { + "additionalProperties": false, + "properties": { + "value": { + "description": "Intended non-negative float (not enforced on read).", + "type": "number" + } + }, + "type": "object" + }, + "lpc_model::nodes::shader::shader_param_def::ShaderParamDef": { + "additionalProperties": false, + "properties": { + "default": { + "description": "Ratio in the intended 0.0..=1.0 domain (not enforced on read).", + "type": "number" + }, + "description": { + "type": "string" + }, + "label": { + "type": "string" + }, + "min": { + "$ref": "#/$defs/lpc_model::nodes::shader::shader_param_def::ScalarHint" + }, + "value_type": { + "type": "string" + } + }, + "type": "object" + }, + "lpc_model::nodes::shader::shader_slot_def::ShaderSlotDef": { + "additionalProperties": false, + "properties": { + "default": { + "type": "number" + }, + "default_bind": { + "type": "string" + }, + "description": { + "type": "string" + }, + "key": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "label": { + "type": "string" + }, + "mapping": { + "$ref": "#/$defs/lpc_model::nodes::shader::shader_slot_mapping::ShaderSlotMappingDef" + }, + "max": { + "type": "number" + }, + "min": { + "type": "number" + }, + "panel": { + "type": "boolean" + }, + "step": { + "type": "number" + }, + "unit": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "type": "object" + }, + "lpc_model::nodes::shader::shader_slot_mapping::ShaderSlotMappingDef": { + "additionalProperties": false, + "properties": { + "empty_key": { + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + }, + "key": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "len": { + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + } + }, + "type": "object" + } + }, + "$id": "https://lightplayer.dev/schemas/node.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "format": { + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + }, + "kind": { + "const": "Module" + }, + "name": { + "type": "string" + }, + "nodes": { + "additionalProperties": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "unset": { + "description": "Unit slot: any value is accepted and ignored on read; written as {}." + } + }, + "required": [ + "unset" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "ref": { + "description": "Path to an authored artifact file.", + "type": "string" + } + }, + "required": [ + "ref" + ], + "type": "object" + } + ] + }, + "type": "object" + }, + "uid": { + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "bindings": { + "additionalProperties": { + "$ref": "#/$defs/lpc_model::binding::binding_def::BindingDef" + }, + "type": "object" + }, + "endpoint": { + "type": "string" + }, + "id": { + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + }, + "kind": { + "const": "Button" + }, + "stable_ms": { + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "bindings": { + "additionalProperties": { + "$ref": "#/$defs/lpc_model::binding::binding_def::BindingDef" + }, + "type": "object" + }, + "controls": { + "additionalProperties": false, + "properties": { + "rate": { + "type": "number" + }, + "running": { + "type": "boolean" + }, + "scrub_offset_seconds": { + "type": "number" + } + }, + "type": "object" + }, + "kind": { + "const": "Clock" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "bindings": { + "additionalProperties": { + "$ref": "#/$defs/lpc_model::binding::binding_def::BindingDef" + }, + "type": "object" + }, + "kind": { + "const": "Texture" + }, + "size": { + "additionalProperties": false, + "description": "Width/height in unsigned integer pixels or cells.", + "properties": { + "height": { + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + }, + "width": { + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "width", + "height" + ], + "type": "object" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "bindings": { + "additionalProperties": { + "$ref": "#/$defs/lpc_model::binding::binding_def::BindingDef" + }, + "type": "object" + }, + "consumed": { + "additionalProperties": { + "$ref": "#/$defs/lpc_model::nodes::shader::shader_slot_def::ShaderSlotDef" + }, + "type": "object" + }, + "float_mode": { + "type": "string" + }, + "kind": { + "const": "Shader" + }, + "param_defs": { + "additionalProperties": { + "$ref": "#/$defs/lpc_model::nodes::shader::shader_param_def::ShaderParamDef" + }, + "type": "object" + }, + "render_order": { + "description": "Render ordering value.", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, + "source": { + "anyOf": [ + { + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "$path": { + "type": "string" + } + }, + "required": [ + "$path" + ], + "type": "object" + } + ], + "description": "Asset reference: an artifact spec string, or { \"path\": }." + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "bindings": { + "additionalProperties": { + "$ref": "#/$defs/lpc_model::binding::binding_def::BindingDef" + }, + "type": "object" + }, + "consumed": { + "additionalProperties": { + "$ref": "#/$defs/lpc_model::nodes::shader::shader_slot_def::ShaderSlotDef" + }, + "type": "object" + }, + "float_mode": { + "type": "string" + }, + "kind": { + "const": "ComputeShader" + }, + "produced": { + "additionalProperties": { + "$ref": "#/$defs/lpc_model::nodes::shader::shader_slot_def::ShaderSlotDef" + }, + "type": "object" + }, + "source": { + "anyOf": [ + { + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "$path": { + "type": "string" + } + }, + "required": [ + "$path" + ], + "type": "object" + } + ], + "description": "Asset reference: an artifact spec string, or { \"path\": }." + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "bindings": { + "additionalProperties": { + "$ref": "#/$defs/lpc_model::binding::binding_def::BindingDef" + }, + "type": "object" + }, + "emitters": { + "additionalProperties": { + "$ref": "#/$defs/lp::fluid::Emitter" + }, + "propertyNames": { + "pattern": "^\\+?[0-9]+$" + }, + "type": "object" + }, + "fade_speed": { + "description": "Ratio in the intended 0.0..=1.0 domain (not enforced on read).", + "type": "number" + }, + "kind": { + "const": "Fluid" + }, + "size": { + "additionalProperties": false, + "description": "Width/height in unsigned integer pixels or cells.", + "properties": { + "height": { + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + }, + "width": { + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "width", + "height" + ], + "type": "object" + }, + "solver_iterations": { + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + }, + "step_hz": { + "description": "Intended non-negative float (not enforced on read).", + "type": "number" + }, + "time": { + "type": "number" + }, + "viscosity": { + "description": "Intended non-negative float (not enforced on read).", + "type": "number" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "bindings": { + "additionalProperties": { + "$ref": "#/$defs/lpc_model::binding::binding_def::BindingDef" + }, + "type": "object" + }, + "default_fade": { + "description": "Intended non-negative float (not enforced on read).", + "type": "number" + }, + "entries": { + "additionalProperties": { + "$ref": "#/$defs/lpc_model::nodes::playlist::playlist_entry::PlaylistEntry" + }, + "propertyNames": { + "pattern": "^\\+?[0-9]+$" + }, + "type": "object" + }, + "idle_entry": { + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + }, + "kind": { + "const": "Playlist" + }, + "time": { + "type": "number" + }, + "trigger": { + "additionalProperties": { + "$ref": "#/$defs/lp::control::Message" + }, + "propertyNames": { + "pattern": "^\\+?[0-9]+$" + }, + "type": "object" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "bindings": { + "additionalProperties": { + "$ref": "#/$defs/lpc_model::binding::binding_def::BindingDef" + }, + "type": "object" + }, + "channel": { + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + }, + "endpoint": { + "type": "string" + }, + "input": { + "additionalProperties": { + "$ref": "#/$defs/lp::control::Message" + }, + "propertyNames": { + "pattern": "^\\+?[0-9]+$" + }, + "type": "object" + }, + "kind": { + "const": "ControlRadio" + }, + "repeat_count": { + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + }, + "wifi_channel": { + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "bindings": { + "additionalProperties": { + "$ref": "#/$defs/lpc_model::binding::binding_def::BindingDef" + }, + "type": "object" + }, + "endpoint": { + "type": "string" + }, + "input": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "control" + }, + "node": { + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + }, + "output": { + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + }, + "preferred_extent": { + "additionalProperties": false, + "properties": { + "rows": { + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + }, + "samples_per_row": { + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "kind": { + "const": "Output" + }, + "options": { + "$ref": "#/$defs/lpc_model::nodes::output::output_def::OutputDriverOptionsConfig" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "bindings": { + "additionalProperties": { + "$ref": "#/$defs/lpc_model::binding::binding_def::BindingDef" + }, + "type": "object" + }, + "brightness": { + "description": "Fixture output brightness (0-255).", + "maximum": 4294967295, + "minimum": 0, + "title": "Brightness", + "type": "integer" + }, + "color_order": { + "type": "string" + }, + "diagnostic_mode": { + "type": "string" + }, + "gamma_correction": { + "type": "boolean" + }, + "input": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "visual" + }, + "node": { + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + }, + "output": { + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + }, + "preferred_extent": { + "additionalProperties": false, + "properties": { + "rows": { + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + }, + "samples_per_row": { + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "kind": { + "const": "Fixture" + }, + "mapping": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "Unset" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "PathPoints" + }, + "paths": { + "additionalProperties": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "first_channel": { + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + }, + "kind": { + "const": "PointList" + }, + "points": { + "additionalProperties": { + "description": "2D XY coordinate as [x, y].", + "items": { + "type": "number" + }, + "maxItems": 2, + "minItems": 2, + "type": "array" + }, + "propertyNames": { + "pattern": "^\\+?[0-9]+$" + }, + "type": "object" + } + }, + "required": [ + "kind" + ], + "type": "object" + } + ] + }, + "propertyNames": { + "pattern": "^\\+?[0-9]+$" + }, + "type": "object" + }, + "sample_diameter": { + "description": "Intended non-negative float (not enforced on read).", + "type": "number" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "Map2d" + }, + "source": { + "anyOf": [ + { + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "$path": { + "type": "string" + } + }, + "required": [ + "$path" + ], + "type": "object" + } + ], + "description": "Asset reference: an artifact spec string, or { \"path\": }." + } + }, + "required": [ + "kind" + ], + "type": "object" + } + ] + }, + "power": { + "additionalProperties": false, + "description": "Lamp type and supply budget used to limit output current.", + "properties": { + "budget_ma": { + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + }, + "lamp_type": { + "type": "string" + } + }, + "required": [ + "lamp_type", + "budget_ma" + ], + "title": "Power", + "type": "object" + }, + "render_size": { + "additionalProperties": false, + "description": "Width/height in unsigned integer pixels or cells.", + "properties": { + "height": { + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + }, + "width": { + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "width", + "height" + ], + "type": "object" + }, + "sampling": { + "type": "string" + }, + "transform": { + "description": "2D affine transform as a row-major 3x3 matrix; the bottom row must be (approximately) [0, 0, 1] — perspective matrices are rejected on read.", + "items": { + "items": { + "type": "number" + }, + "maxItems": 3, + "minItems": 3, + "type": "array" + }, + "maxItems": 3, + "minItems": 3, + "type": "array" + } + }, + "required": [ + "kind" + ], + "type": "object" + } + ] +} diff --git a/schemas/history/v2/project.schema.json b/schemas/history/v2/project.schema.json new file mode 100644 index 000000000..44fa882c4 --- /dev/null +++ b/schemas/history/v2/project.schema.json @@ -0,0 +1,56 @@ +{ + "$id": "https://lightplayer.dev/schemas/project.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "format": { + "const": 2 + }, + "kind": { + "const": "Module" + }, + "name": { + "type": "string" + }, + "nodes": { + "additionalProperties": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "unset": { + "description": "Unit slot: any value is accepted and ignored on read; written as {}." + } + }, + "required": [ + "unset" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "ref": { + "description": "Path to an authored artifact file.", + "type": "string" + } + }, + "required": [ + "ref" + ], + "type": "object" + } + ] + }, + "type": "object" + }, + "uid": { + "type": "string" + } + }, + "required": [ + "kind", + "format" + ], + "type": "object" +} diff --git a/schemas/history/v2/shapes/ArtifactPath.json b/schemas/history/v2/shapes/ArtifactPath.json new file mode 100644 index 000000000..d6275e680 --- /dev/null +++ b/schemas/history/v2/shapes/ArtifactPath.json @@ -0,0 +1,10 @@ +{ + "value": { + "shape": { + "editor": "path", + "id": 2244609540, + "meta": {}, + "ty": "string" + } + } +} diff --git a/schemas/history/v2/shapes/Dim2u.json b/schemas/history/v2/shapes/Dim2u.json new file mode 100644 index 000000000..e5c4507a4 --- /dev/null +++ b/schemas/history/v2/shapes/Dim2u.json @@ -0,0 +1,24 @@ +{ + "value": { + "shape": { + "editor": "dimensions", + "id": 2973013964, + "meta": {}, + "ty": { + "struct": { + "fields": [ + { + "name": "width", + "ty": "u32" + }, + { + "name": "height", + "ty": "u32" + } + ], + "name": "Dim2u" + } + } + } + } +} diff --git a/schemas/history/v2/shapes/PositiveF32.json b/schemas/history/v2/shapes/PositiveF32.json new file mode 100644 index 000000000..c1c0daff5 --- /dev/null +++ b/schemas/history/v2/shapes/PositiveF32.json @@ -0,0 +1,14 @@ +{ + "value": { + "shape": { + "editor": { + "number": { + "min": 0.0 + } + }, + "id": 2960302901, + "meta": {}, + "ty": "f32" + } + } +} diff --git a/schemas/history/v2/shapes/Ratio.json b/schemas/history/v2/shapes/Ratio.json new file mode 100644 index 000000000..f878a036b --- /dev/null +++ b/schemas/history/v2/shapes/Ratio.json @@ -0,0 +1,16 @@ +{ + "value": { + "shape": { + "editor": { + "slider": { + "max": 1.0, + "min": 0.0, + "step": 0.009999999776482582 + } + }, + "id": 1724747812, + "meta": {}, + "ty": "f32" + } + } +} diff --git a/schemas/history/v2/shapes/RenderOrder.json b/schemas/history/v2/shapes/RenderOrder.json new file mode 100644 index 000000000..d3533169f --- /dev/null +++ b/schemas/history/v2/shapes/RenderOrder.json @@ -0,0 +1,14 @@ +{ + "value": { + "shape": { + "editor": { + "number": { + "step": 1.0 + } + }, + "id": 1231123999, + "meta": {}, + "ty": "i32" + } + } +} diff --git a/schemas/history/v2/shapes/U32List.json b/schemas/history/v2/shapes/U32List.json new file mode 100644 index 000000000..ea2d295b4 --- /dev/null +++ b/schemas/history/v2/shapes/U32List.json @@ -0,0 +1,12 @@ +{ + "value": { + "shape": { + "editor": "plain", + "id": 1028734941, + "meta": {}, + "ty": { + "list": "u32" + } + } + } +} diff --git a/schemas/history/v2/shapes/Xy.json b/schemas/history/v2/shapes/Xy.json new file mode 100644 index 000000000..a1fc0c721 --- /dev/null +++ b/schemas/history/v2/shapes/Xy.json @@ -0,0 +1,10 @@ +{ + "value": { + "shape": { + "editor": "xy", + "id": 1477692922, + "meta": {}, + "ty": "vec2" + } + } +} diff --git a/schemas/history/v2/shapes/_index.json b/schemas/history/v2/shapes/_index.json new file mode 100644 index 000000000..c0de54e99 --- /dev/null +++ b/schemas/history/v2/shapes/_index.json @@ -0,0 +1,38 @@ +{ + "ArtifactPath": 2244609540, + "Dim2u": 2973013964, + "PositiveF32": 2960302901, + "Ratio": 1724747812, + "RenderOrder": 1231123999, + "U32List": 1028734941, + "Xy": 1477692922, + "lp::control::Message": 2014621053, + "lp::fluid::Emitter": 2405894887, + "lpc_model::binding::binding_def::BindingDef": 1885459118, + "lpc_model::nodes::button::button_def::ButtonDef": 1018556980, + "lpc_model::nodes::button::button_def::ButtonState": 4166558174, + "lpc_model::nodes::clock::clock_def::ClockDef": 520345680, + "lpc_model::nodes::clock::clock_state::ClockState": 3175756068, + "lpc_model::nodes::fixture::fixture_def::FixtureDef": 814168903, + "lpc_model::nodes::fixture::fixture_state::FixtureState": 1983594935, + "lpc_model::nodes::fluid::fluid_def::FluidDef": 2887292794, + "lpc_model::nodes::fluid::fluid_state::FluidState": 3376641154, + "lpc_model::nodes::module::module_def::ModuleDef": 3041613592, + "lpc_model::nodes::node_def::NodeArtifact": 3831631647, + "lpc_model::nodes::output::output_def::OutputDef": 770241759, + "lpc_model::nodes::output::output_def::OutputDriverOptionsConfig": 2660578566, + "lpc_model::nodes::playlist::playlist_def::PlaylistDef": 1520921198, + "lpc_model::nodes::playlist::playlist_entry::PlaylistEntry": 899201012, + "lpc_model::nodes::playlist::playlist_state::PlaylistState": 237636858, + "lpc_model::nodes::radio::control_radio_def::ControlRadioDef": 4099574392, + "lpc_model::nodes::radio::control_radio_def::ControlRadioState": 1057824914, + "lpc_model::nodes::shader::compute_shader_def::ComputeShaderDef": 3622644300, + "lpc_model::nodes::shader::shader_def::ShaderDef": 3733861171, + "lpc_model::nodes::shader::shader_param_def::ScalarHint": 3464771014, + "lpc_model::nodes::shader::shader_param_def::ShaderParamDef": 3712581622, + "lpc_model::nodes::shader::shader_slot_def::ShaderSlotDef": 90603770, + "lpc_model::nodes::shader::shader_slot_mapping::ShaderSlotMappingDef": 4227971207, + "lpc_model::nodes::shader::shader_state::ShaderState": 3824465175, + "lpc_model::nodes::texture::texture_def::TextureDef": 2579233885, + "lpc_model::nodes::texture::texture_state::TextureState": 4021413449 +} diff --git a/schemas/history/v2/shapes/lp.control.Message.json b/schemas/history/v2/shapes/lp.control.Message.json new file mode 100644 index 000000000..e463c7d18 --- /dev/null +++ b/schemas/history/v2/shapes/lp.control.Message.json @@ -0,0 +1,24 @@ +{ + "value": { + "shape": { + "editor": "plain", + "id": 2014621053, + "meta": {}, + "ty": { + "struct": { + "fields": [ + { + "name": "id", + "ty": "u32" + }, + { + "name": "seq", + "ty": "u32" + } + ], + "name": "ControlMessage" + } + } + } + } +} diff --git a/schemas/history/v2/shapes/lp.fluid.Emitter.json b/schemas/history/v2/shapes/lp.fluid.Emitter.json new file mode 100644 index 000000000..aab919490 --- /dev/null +++ b/schemas/history/v2/shapes/lp.fluid.Emitter.json @@ -0,0 +1,44 @@ +{ + "value": { + "shape": { + "editor": "plain", + "id": 2405894887, + "meta": {}, + "ty": { + "struct": { + "fields": [ + { + "name": "id", + "ty": "u32" + }, + { + "name": "pos", + "ty": "vec2" + }, + { + "name": "dir", + "ty": "vec2" + }, + { + "name": "radius", + "ty": "f32" + }, + { + "name": "color", + "ty": "vec3" + }, + { + "name": "velocity", + "ty": "f32" + }, + { + "name": "intensity", + "ty": "f32" + } + ], + "name": "FluidEmitter" + } + } + } + } +} diff --git a/schemas/history/v2/shapes/lpc_model.binding.binding_def.BindingDef.json b/schemas/history/v2/shapes/lpc_model.binding.binding_def.BindingDef.json new file mode 100644 index 000000000..8c527aecd --- /dev/null +++ b/schemas/history/v2/shapes/lpc_model.binding.binding_def.BindingDef.json @@ -0,0 +1,61 @@ +{ + "record": { + "fields": [ + { + "name": "value", + "shape": { + "option": { + "meta": {}, + "some": { + "value": { + "shape": { + "editor": "plain", + "id": 3678527952, + "meta": {}, + "ty": "any" + } + } + } + } + } + }, + { + "name": "source", + "shape": { + "option": { + "meta": {}, + "some": { + "value": { + "shape": { + "editor": "path", + "id": 1364391883, + "meta": {}, + "ty": "string" + } + } + } + } + } + }, + { + "name": "target", + "shape": { + "option": { + "meta": {}, + "some": { + "value": { + "shape": { + "editor": "path", + "id": 1364391883, + "meta": {}, + "ty": "string" + } + } + } + } + } + } + ], + "meta": {} + } +} diff --git a/schemas/history/v2/shapes/lpc_model.nodes.button.button_def.ButtonDef.json b/schemas/history/v2/shapes/lpc_model.nodes.button.button_def.ButtonDef.json new file mode 100644 index 000000000..55d9bd323 --- /dev/null +++ b/schemas/history/v2/shapes/lpc_model.nodes.button.button_def.ButtonDef.json @@ -0,0 +1,60 @@ +{ + "record": { + "fields": [ + { + "name": "bindings", + "shape": { + "map": { + "key": "string", + "meta": {}, + "value": { + "ref": { + "id": 1885459118 + } + } + } + } + }, + { + "name": "endpoint", + "shape": { + "value": { + "shape": { + "editor": "plain", + "id": 397552907, + "meta": {}, + "ty": "string" + } + } + } + }, + { + "name": "id", + "shape": { + "value": { + "shape": { + "editor": "plain", + "id": 3065358156, + "meta": {}, + "ty": "u32" + } + } + } + }, + { + "name": "stable_ms", + "shape": { + "value": { + "shape": { + "editor": "plain", + "id": 3065358156, + "meta": {}, + "ty": "u32" + } + } + } + } + ], + "meta": {} + } +} diff --git a/schemas/history/v2/shapes/lpc_model.nodes.button.button_def.ButtonState.json b/schemas/history/v2/shapes/lpc_model.nodes.button.button_def.ButtonState.json new file mode 100644 index 000000000..b5bc0af07 --- /dev/null +++ b/schemas/history/v2/shapes/lpc_model.nodes.button.button_def.ButtonState.json @@ -0,0 +1,70 @@ +{ + "record": { + "fields": [ + { + "name": "down", + "policy": { + "persistence": "transient", + "writable": false + }, + "semantics": { + "direction": "produced" + }, + "shape": { + "map": { + "key": "u32", + "meta": {}, + "value": { + "ref": { + "id": 2014621053 + } + } + } + } + }, + { + "name": "held", + "policy": { + "persistence": "transient", + "writable": false + }, + "semantics": { + "direction": "produced" + }, + "shape": { + "map": { + "key": "u32", + "meta": {}, + "value": { + "ref": { + "id": 2014621053 + } + } + } + } + }, + { + "name": "up", + "policy": { + "persistence": "transient", + "writable": false + }, + "semantics": { + "direction": "produced" + }, + "shape": { + "map": { + "key": "u32", + "meta": {}, + "value": { + "ref": { + "id": 2014621053 + } + } + } + } + } + ], + "meta": {} + } +} diff --git a/schemas/history/v2/shapes/lpc_model.nodes.clock.clock_def.ClockDef.json b/schemas/history/v2/shapes/lpc_model.nodes.clock.clock_def.ClockDef.json new file mode 100644 index 000000000..b494b16c0 --- /dev/null +++ b/schemas/history/v2/shapes/lpc_model.nodes.clock.clock_def.ClockDef.json @@ -0,0 +1,91 @@ +{ + "record": { + "fields": [ + { + "name": "bindings", + "shape": { + "map": { + "key": "string", + "meta": {}, + "value": { + "ref": { + "id": 1885459118 + } + } + } + } + }, + { + "name": "controls", + "shape": { + "record": { + "fields": [ + { + "name": "running", + "policy": { + "persistence": "transient" + }, + "shape": { + "value": { + "shape": { + "editor": "plain", + "id": 1196386242, + "meta": {}, + "ty": "bool" + } + } + } + }, + { + "name": "rate", + "policy": { + "persistence": "transient" + }, + "shape": { + "value": { + "shape": { + "editor": { + "slider": { + "max": 4.0, + "min": 0.0, + "step": 0.05000000074505806 + } + }, + "id": 885023043, + "meta": {}, + "ty": "f32" + } + } + } + }, + { + "name": "scrub_offset_seconds", + "policy": { + "persistence": "transient" + }, + "shape": { + "value": { + "shape": { + "editor": { + "slider": { + "max": 10.0, + "min": -10.0, + "step": 0.01666666753590107 + } + }, + "id": 3375024484, + "meta": {}, + "ty": "f32" + } + } + } + } + ], + "meta": {} + } + } + } + ], + "meta": {} + } +} diff --git a/schemas/history/v2/shapes/lpc_model.nodes.clock.clock_state.ClockState.json b/schemas/history/v2/shapes/lpc_model.nodes.clock.clock_state.ClockState.json new file mode 100644 index 000000000..a3f0bbbc9 --- /dev/null +++ b/schemas/history/v2/shapes/lpc_model.nodes.clock.clock_state.ClockState.json @@ -0,0 +1,48 @@ +{ + "record": { + "fields": [ + { + "default_bind": "bus:time", + "name": "seconds", + "policy": { + "persistence": "transient", + "writable": false + }, + "semantics": { + "direction": "produced" + }, + "shape": { + "value": { + "shape": { + "editor": "plain", + "id": 2605450937, + "meta": {}, + "ty": "f32" + } + } + } + }, + { + "name": "delta_seconds", + "policy": { + "persistence": "transient", + "writable": false + }, + "semantics": { + "direction": "produced" + }, + "shape": { + "value": { + "shape": { + "editor": "plain", + "id": 2605450937, + "meta": {}, + "ty": "f32" + } + } + } + } + ], + "meta": {} + } +} diff --git a/schemas/history/v2/shapes/lpc_model.nodes.fixture.fixture_def.FixtureDef.json b/schemas/history/v2/shapes/lpc_model.nodes.fixture.fixture_def.FixtureDef.json new file mode 100644 index 000000000..742a76898 --- /dev/null +++ b/schemas/history/v2/shapes/lpc_model.nodes.fixture.fixture_def.FixtureDef.json @@ -0,0 +1,386 @@ +{ + "record": { + "fields": [ + { + "default_bind": "bus:visual.out", + "name": "input", + "semantics": { + "direction": "consumed" + }, + "shape": { + "value": { + "shape": { + "editor": "visual_product", + "id": 689649576, + "meta": {}, + "ty": { + "product": "visual" + } + } + } + } + }, + { + "name": "render_size", + "shape": { + "value": { + "shape": { + "editor": "dimensions", + "id": 2973013964, + "meta": {}, + "ty": { + "struct": { + "fields": [ + { + "name": "width", + "ty": "u32" + }, + { + "name": "height", + "ty": "u32" + } + ], + "name": "Dim2u" + } + } + } + } + } + }, + { + "name": "bindings", + "shape": { + "map": { + "key": "string", + "meta": {}, + "value": { + "ref": { + "id": 1885459118 + } + } + } + } + }, + { + "name": "sampling", + "shape": { + "value": { + "shape": { + "editor": "plain", + "id": 1216569569, + "meta": {}, + "ty": "string" + } + } + } + }, + { + "name": "diagnostic_mode", + "shape": { + "value": { + "shape": { + "editor": { + "dropdown": { + "options": [ + { + "label": "Off", + "value": "off" + }, + { + "label": "LED index", + "value": "led_index" + }, + { + "label": "RGB groups of 10", + "value": "groups_10" + }, + { + "label": "Path colors", + "value": "path_colors" + }, + { + "label": "Chase", + "value": "chase" + } + ] + } + }, + "id": 4232169722, + "meta": {}, + "ty": "string" + } + } + } + }, + { + "name": "mapping", + "shape": { + "enum": { + "meta": {}, + "variants": [ + { + "name": "Unset", + "shape": { + "unit": { + "meta": {} + } + } + }, + { + "name": "PathPoints", + "shape": { + "record": { + "fields": [ + { + "name": "paths", + "shape": { + "map": { + "key": "u32", + "meta": {}, + "value": { + "enum": { + "meta": {}, + "variants": [ + { + "name": "PointList", + "shape": { + "record": { + "fields": [ + { + "name": "first_channel", + "shape": { + "value": { + "shape": { + "editor": "plain", + "id": 3065358156, + "meta": {}, + "ty": "u32" + } + } + } + }, + { + "name": "points", + "shape": { + "map": { + "key": "u32", + "meta": {}, + "value": { + "value": { + "shape": { + "editor": "xy", + "id": 1477692922, + "meta": {}, + "ty": "vec2" + } + } + } + } + } + } + ], + "meta": {} + } + } + } + ] + } + } + } + } + }, + { + "name": "sample_diameter", + "shape": { + "value": { + "shape": { + "editor": { + "number": { + "min": 0.0 + } + }, + "id": 2960302901, + "meta": {}, + "ty": "f32" + } + } + } + } + ], + "meta": {} + } + } + }, + { + "name": "Map2d", + "shape": { + "record": { + "fields": [ + { + "name": "source", + "shape": { + "custom": { + "codec": 2355797192, + "meta": {}, + "shape": { + "value": { + "shape": { + "editor": "plain", + "id": 177956922, + "meta": {}, + "ty": "string" + } + } + } + } + } + } + ], + "meta": {} + } + } + } + ] + } + } + }, + { + "name": "color_order", + "shape": { + "value": { + "shape": { + "editor": { + "dropdown": { + "options": [ + { + "label": "RGB", + "value": "rgb" + }, + { + "label": "GRB", + "value": "grb" + }, + { + "label": "RBG", + "value": "rbg" + }, + { + "label": "GBR", + "value": "gbr" + }, + { + "label": "BRG", + "value": "brg" + }, + { + "label": "BGR", + "value": "bgr" + } + ] + } + }, + "id": 1235394384, + "meta": {}, + "ty": "string" + } + } + } + }, + { + "name": "transform", + "shape": { + "value": { + "shape": { + "editor": "affine2d", + "id": 120141864, + "meta": {}, + "ty": "mat3x3" + } + } + } + }, + { + "name": "brightness", + "shape": { + "option": { + "meta": {}, + "some": { + "value": { + "shape": { + "editor": { + "slider": { + "max": 255.0, + "min": 0.0, + "step": 1.0 + } + }, + "id": 1575082113, + "meta": { + "description": "Fixture output brightness (0-255).", + "label": "Brightness", + "panel": true + }, + "ty": "u32" + } + } + } + } + } + }, + { + "name": "gamma_correction", + "shape": { + "option": { + "meta": {}, + "some": { + "value": { + "shape": { + "editor": "plain", + "id": 1196386242, + "meta": {}, + "ty": "bool" + } + } + } + } + } + }, + { + "name": "power", + "shape": { + "option": { + "meta": {}, + "some": { + "value": { + "shape": { + "editor": "plain", + "id": 1801252664, + "meta": { + "description": "Lamp type and supply budget used to limit output current.", + "label": "Power" + }, + "ty": { + "struct": { + "fields": [ + { + "name": "lamp_type", + "ty": "string" + }, + { + "name": "budget_ma", + "ty": "u32" + } + ], + "name": "FixturePower" + } + } + } + } + } + } + } + } + ], + "meta": {} + } +} diff --git a/schemas/history/v2/shapes/lpc_model.nodes.fixture.fixture_state.FixtureState.json b/schemas/history/v2/shapes/lpc_model.nodes.fixture.fixture_state.FixtureState.json new file mode 100644 index 000000000..d80af4db1 --- /dev/null +++ b/schemas/history/v2/shapes/lpc_model.nodes.fixture.fixture_state.FixtureState.json @@ -0,0 +1,90 @@ +{ + "record": { + "fields": [ + { + "default_bind": "bus:control.out", + "name": "output", + "policy": { + "persistence": "transient", + "writable": false + }, + "semantics": { + "direction": "produced" + }, + "shape": { + "value": { + "shape": { + "editor": "control_product", + "id": 4213724405, + "meta": {}, + "ty": { + "product": "control" + } + } + } + } + }, + { + "name": "estimated_draw_ma", + "policy": { + "persistence": "transient", + "writable": false + }, + "semantics": { + "direction": "produced" + }, + "shape": { + "value": { + "shape": { + "editor": "plain", + "id": 3065358156, + "meta": {}, + "ty": "u32" + } + } + } + }, + { + "name": "power_scale", + "policy": { + "persistence": "transient", + "writable": false + }, + "semantics": { + "direction": "produced" + }, + "shape": { + "value": { + "shape": { + "editor": "plain", + "id": 2605450937, + "meta": {}, + "ty": "f32" + } + } + } + }, + { + "name": "power_budget_ma", + "policy": { + "persistence": "transient", + "writable": false + }, + "semantics": { + "direction": "produced" + }, + "shape": { + "value": { + "shape": { + "editor": "plain", + "id": 3065358156, + "meta": {}, + "ty": "u32" + } + } + } + } + ], + "meta": {} + } +} diff --git a/schemas/history/v2/shapes/lpc_model.nodes.fluid.fluid_def.FluidDef.json b/schemas/history/v2/shapes/lpc_model.nodes.fluid.fluid_def.FluidDef.json new file mode 100644 index 000000000..6d9f71f55 --- /dev/null +++ b/schemas/history/v2/shapes/lpc_model.nodes.fluid.fluid_def.FluidDef.json @@ -0,0 +1,149 @@ +{ + "record": { + "fields": [ + { + "name": "bindings", + "shape": { + "map": { + "key": "string", + "meta": {}, + "value": { + "ref": { + "id": 1885459118 + } + } + } + } + }, + { + "name": "size", + "shape": { + "value": { + "shape": { + "editor": "dimensions", + "id": 2973013964, + "meta": {}, + "ty": { + "struct": { + "fields": [ + { + "name": "width", + "ty": "u32" + }, + { + "name": "height", + "ty": "u32" + } + ], + "name": "Dim2u" + } + } + } + } + } + }, + { + "name": "solver_iterations", + "shape": { + "value": { + "shape": { + "editor": "plain", + "id": 3065358156, + "meta": {}, + "ty": "u32" + } + } + } + }, + { + "name": "step_hz", + "shape": { + "value": { + "shape": { + "editor": { + "number": { + "min": 0.0 + } + }, + "id": 2960302901, + "meta": {}, + "ty": "f32" + } + } + } + }, + { + "name": "fade_speed", + "shape": { + "value": { + "shape": { + "editor": { + "slider": { + "max": 1.0, + "min": 0.0, + "step": 0.009999999776482582 + } + }, + "id": 1724747812, + "meta": {}, + "ty": "f32" + } + } + } + }, + { + "name": "viscosity", + "shape": { + "value": { + "shape": { + "editor": { + "number": { + "min": 0.0 + } + }, + "id": 2960302901, + "meta": {}, + "ty": "f32" + } + } + } + }, + { + "default_bind": "bus:time", + "name": "time", + "semantics": { + "direction": "consumed" + }, + "shape": { + "value": { + "shape": { + "editor": "plain", + "id": 2605450937, + "meta": {}, + "ty": "f32" + } + } + } + }, + { + "name": "emitters", + "semantics": { + "direction": "consumed", + "merge": "by_key" + }, + "shape": { + "map": { + "key": "u32", + "meta": {}, + "value": { + "ref": { + "id": 2405894887 + } + } + } + } + } + ], + "meta": {} + } +} diff --git a/schemas/history/v2/shapes/lpc_model.nodes.fluid.fluid_state.FluidState.json b/schemas/history/v2/shapes/lpc_model.nodes.fluid.fluid_state.FluidState.json new file mode 100644 index 000000000..2d1b30470 --- /dev/null +++ b/schemas/history/v2/shapes/lpc_model.nodes.fluid.fluid_state.FluidState.json @@ -0,0 +1,30 @@ +{ + "record": { + "fields": [ + { + "default_bind": "bus:visual.out", + "name": "output", + "policy": { + "persistence": "transient", + "writable": false + }, + "semantics": { + "direction": "produced" + }, + "shape": { + "value": { + "shape": { + "editor": "visual_product", + "id": 689649576, + "meta": {}, + "ty": { + "product": "visual" + } + } + } + } + } + ], + "meta": {} + } +} diff --git a/schemas/shapes/lpc_model.nodes.project.project_def.ProjectDef.json b/schemas/history/v2/shapes/lpc_model.nodes.module.module_def.ModuleDef.json similarity index 100% rename from schemas/shapes/lpc_model.nodes.project.project_def.ProjectDef.json rename to schemas/history/v2/shapes/lpc_model.nodes.module.module_def.ModuleDef.json diff --git a/schemas/history/v2/shapes/lpc_model.nodes.node_def.NodeArtifact.json b/schemas/history/v2/shapes/lpc_model.nodes.node_def.NodeArtifact.json new file mode 100644 index 000000000..5a7d68743 --- /dev/null +++ b/schemas/history/v2/shapes/lpc_model.nodes.node_def.NodeArtifact.json @@ -0,0 +1,95 @@ +{ + "enum": { + "meta": {}, + "variants": [ + { + "name": "Module", + "shape": { + "ref": { + "id": 3041613592 + } + } + }, + { + "name": "Button", + "shape": { + "ref": { + "id": 1018556980 + } + } + }, + { + "name": "Clock", + "shape": { + "ref": { + "id": 520345680 + } + } + }, + { + "name": "Texture", + "shape": { + "ref": { + "id": 2579233885 + } + } + }, + { + "name": "Shader", + "shape": { + "ref": { + "id": 3733861171 + } + } + }, + { + "name": "ComputeShader", + "shape": { + "ref": { + "id": 3622644300 + } + } + }, + { + "name": "Fluid", + "shape": { + "ref": { + "id": 2887292794 + } + } + }, + { + "name": "Playlist", + "shape": { + "ref": { + "id": 1520921198 + } + } + }, + { + "name": "ControlRadio", + "shape": { + "ref": { + "id": 4099574392 + } + } + }, + { + "name": "Output", + "shape": { + "ref": { + "id": 770241759 + } + } + }, + { + "name": "Fixture", + "shape": { + "ref": { + "id": 814168903 + } + } + } + ] + } +} diff --git a/schemas/history/v2/shapes/lpc_model.nodes.output.output_def.OutputDef.json b/schemas/history/v2/shapes/lpc_model.nodes.output.output_def.OutputDef.json new file mode 100644 index 000000000..b46400da2 --- /dev/null +++ b/schemas/history/v2/shapes/lpc_model.nodes.output.output_def.OutputDef.json @@ -0,0 +1,66 @@ +{ + "record": { + "fields": [ + { + "default_bind": "bus:control.out", + "name": "input", + "semantics": { + "direction": "consumed" + }, + "shape": { + "value": { + "shape": { + "editor": "control_product", + "id": 4213724405, + "meta": {}, + "ty": { + "product": "control" + } + } + } + } + }, + { + "name": "endpoint", + "shape": { + "value": { + "shape": { + "editor": "plain", + "id": 397552907, + "meta": {}, + "ty": "string" + } + } + } + }, + { + "name": "bindings", + "shape": { + "map": { + "key": "string", + "meta": {}, + "value": { + "ref": { + "id": 1885459118 + } + } + } + } + }, + { + "name": "options", + "shape": { + "option": { + "meta": {}, + "some": { + "ref": { + "id": 2660578566 + } + } + } + } + } + ], + "meta": {} + } +} diff --git a/schemas/history/v2/shapes/lpc_model.nodes.output.output_def.OutputDriverOptionsConfig.json b/schemas/history/v2/shapes/lpc_model.nodes.output.output_def.OutputDriverOptionsConfig.json new file mode 100644 index 000000000..fda362fb2 --- /dev/null +++ b/schemas/history/v2/shapes/lpc_model.nodes.output.output_def.OutputDriverOptionsConfig.json @@ -0,0 +1,59 @@ +{ + "record": { + "fields": [ + { + "name": "white_point", + "shape": { + "value": { + "shape": { + "editor": "plain", + "id": 2618928957, + "meta": {}, + "ty": "vec3" + } + } + } + }, + { + "name": "interpolation_enabled", + "shape": { + "value": { + "shape": { + "editor": "plain", + "id": 1196386242, + "meta": {}, + "ty": "bool" + } + } + } + }, + { + "name": "dithering_enabled", + "shape": { + "value": { + "shape": { + "editor": "plain", + "id": 1196386242, + "meta": {}, + "ty": "bool" + } + } + } + }, + { + "name": "lut_enabled", + "shape": { + "value": { + "shape": { + "editor": "plain", + "id": 1196386242, + "meta": {}, + "ty": "bool" + } + } + } + } + ], + "meta": {} + } +} diff --git a/schemas/history/v2/shapes/lpc_model.nodes.playlist.playlist_def.PlaylistDef.json b/schemas/history/v2/shapes/lpc_model.nodes.playlist.playlist_def.PlaylistDef.json new file mode 100644 index 000000000..9375ed8da --- /dev/null +++ b/schemas/history/v2/shapes/lpc_model.nodes.playlist.playlist_def.PlaylistDef.json @@ -0,0 +1,99 @@ +{ + "record": { + "fields": [ + { + "name": "bindings", + "shape": { + "map": { + "key": "string", + "meta": {}, + "value": { + "ref": { + "id": 1885459118 + } + } + } + } + }, + { + "name": "time", + "semantics": { + "direction": "consumed" + }, + "shape": { + "value": { + "shape": { + "editor": "plain", + "id": 2605450937, + "meta": {}, + "ty": "f32" + } + } + } + }, + { + "name": "trigger", + "semantics": { + "direction": "consumed", + "merge": "by_key" + }, + "shape": { + "map": { + "key": "u32", + "meta": {}, + "value": { + "ref": { + "id": 2014621053 + } + } + } + } + }, + { + "name": "idle_entry", + "shape": { + "value": { + "shape": { + "editor": "plain", + "id": 3065358156, + "meta": {}, + "ty": "u32" + } + } + } + }, + { + "name": "default_fade", + "shape": { + "value": { + "shape": { + "editor": { + "number": { + "min": 0.0 + } + }, + "id": 2960302901, + "meta": {}, + "ty": "f32" + } + } + } + }, + { + "name": "entries", + "shape": { + "map": { + "key": "u32", + "meta": {}, + "value": { + "ref": { + "id": 899201012 + } + } + } + } + } + ], + "meta": {} + } +} diff --git a/schemas/history/v2/shapes/lpc_model.nodes.playlist.playlist_entry.PlaylistEntry.json b/schemas/history/v2/shapes/lpc_model.nodes.playlist.playlist_entry.PlaylistEntry.json new file mode 100644 index 000000000..273dd376e --- /dev/null +++ b/schemas/history/v2/shapes/lpc_model.nodes.playlist.playlist_entry.PlaylistEntry.json @@ -0,0 +1,121 @@ +{ + "record": { + "fields": [ + { + "name": "name", + "shape": { + "option": { + "meta": {}, + "some": { + "value": { + "shape": { + "editor": "plain", + "id": 2612013983, + "meta": {}, + "ty": "string" + } + } + } + } + } + }, + { + "name": "trigger_ids", + "shape": { + "option": { + "meta": {}, + "some": { + "value": { + "shape": { + "editor": "plain", + "id": 1028734941, + "meta": {}, + "ty": { + "list": "u32" + } + } + } + } + } + } + }, + { + "name": "duration", + "shape": { + "option": { + "meta": {}, + "some": { + "value": { + "shape": { + "editor": { + "number": { + "min": 0.0 + } + }, + "id": 2960302901, + "meta": {}, + "ty": "f32" + } + } + } + } + } + }, + { + "name": "fade_after", + "shape": { + "option": { + "meta": {}, + "some": { + "value": { + "shape": { + "editor": { + "number": { + "min": 0.0 + } + }, + "id": 2960302901, + "meta": {}, + "ty": "f32" + } + } + } + } + } + }, + { + "name": "node", + "shape": { + "enum": { + "encoding": "external", + "meta": {}, + "variants": [ + { + "name": "unset", + "shape": { + "unit": { + "meta": {} + } + } + }, + { + "name": "ref", + "shape": { + "value": { + "shape": { + "editor": "path", + "id": 2244609540, + "meta": {}, + "ty": "string" + } + } + } + } + ] + } + } + } + ], + "meta": {} + } +} diff --git a/schemas/history/v2/shapes/lpc_model.nodes.playlist.playlist_state.PlaylistState.json b/schemas/history/v2/shapes/lpc_model.nodes.playlist.playlist_state.PlaylistState.json new file mode 100644 index 000000000..256cbfaf9 --- /dev/null +++ b/schemas/history/v2/shapes/lpc_model.nodes.playlist.playlist_state.PlaylistState.json @@ -0,0 +1,90 @@ +{ + "record": { + "fields": [ + { + "default_bind": "bus:visual.out", + "name": "output", + "policy": { + "persistence": "transient", + "writable": false + }, + "semantics": { + "direction": "produced" + }, + "shape": { + "value": { + "shape": { + "editor": "visual_product", + "id": 689649576, + "meta": {}, + "ty": { + "product": "visual" + } + } + } + } + }, + { + "name": "entry_time", + "policy": { + "persistence": "transient", + "writable": false + }, + "semantics": { + "direction": "produced" + }, + "shape": { + "value": { + "shape": { + "editor": "plain", + "id": 2605450937, + "meta": {}, + "ty": "f32" + } + } + } + }, + { + "name": "entry_progress", + "policy": { + "persistence": "transient", + "writable": false + }, + "semantics": { + "direction": "produced" + }, + "shape": { + "value": { + "shape": { + "editor": "plain", + "id": 2605450937, + "meta": {}, + "ty": "f32" + } + } + } + }, + { + "name": "active_entry", + "policy": { + "persistence": "transient", + "writable": false + }, + "semantics": { + "direction": "produced" + }, + "shape": { + "value": { + "shape": { + "editor": "plain", + "id": 3065358156, + "meta": {}, + "ty": "u32" + } + } + } + } + ], + "meta": {} + } +} diff --git a/schemas/history/v2/shapes/lpc_model.nodes.radio.control_radio_def.ControlRadioDef.json b/schemas/history/v2/shapes/lpc_model.nodes.radio.control_radio_def.ControlRadioDef.json new file mode 100644 index 000000000..b2fb49897 --- /dev/null +++ b/schemas/history/v2/shapes/lpc_model.nodes.radio.control_radio_def.ControlRadioDef.json @@ -0,0 +1,91 @@ +{ + "record": { + "fields": [ + { + "name": "bindings", + "shape": { + "map": { + "key": "string", + "meta": {}, + "value": { + "ref": { + "id": 1885459118 + } + } + } + } + }, + { + "name": "endpoint", + "shape": { + "value": { + "shape": { + "editor": "plain", + "id": 397552907, + "meta": {}, + "ty": "string" + } + } + } + }, + { + "name": "channel", + "shape": { + "value": { + "shape": { + "editor": "plain", + "id": 3065358156, + "meta": {}, + "ty": "u32" + } + } + } + }, + { + "name": "repeat_count", + "shape": { + "value": { + "shape": { + "editor": "plain", + "id": 3065358156, + "meta": {}, + "ty": "u32" + } + } + } + }, + { + "name": "wifi_channel", + "shape": { + "value": { + "shape": { + "editor": "plain", + "id": 3065358156, + "meta": {}, + "ty": "u32" + } + } + } + }, + { + "name": "input", + "semantics": { + "direction": "consumed", + "merge": "by_key" + }, + "shape": { + "map": { + "key": "u32", + "meta": {}, + "value": { + "ref": { + "id": 2014621053 + } + } + } + } + } + ], + "meta": {} + } +} diff --git a/schemas/history/v2/shapes/lpc_model.nodes.radio.control_radio_def.ControlRadioState.json b/schemas/history/v2/shapes/lpc_model.nodes.radio.control_radio_def.ControlRadioState.json new file mode 100644 index 000000000..e39877593 --- /dev/null +++ b/schemas/history/v2/shapes/lpc_model.nodes.radio.control_radio_def.ControlRadioState.json @@ -0,0 +1,28 @@ +{ + "record": { + "fields": [ + { + "name": "output", + "policy": { + "persistence": "transient", + "writable": false + }, + "semantics": { + "direction": "produced" + }, + "shape": { + "map": { + "key": "u32", + "meta": {}, + "value": { + "ref": { + "id": 2014621053 + } + } + } + } + } + ], + "meta": {} + } +} diff --git a/schemas/history/v2/shapes/lpc_model.nodes.shader.compute_shader_def.ComputeShaderDef.json b/schemas/history/v2/shapes/lpc_model.nodes.shader.compute_shader_def.ComputeShaderDef.json new file mode 100644 index 000000000..f3e921eff --- /dev/null +++ b/schemas/history/v2/shapes/lpc_model.nodes.shader.compute_shader_def.ComputeShaderDef.json @@ -0,0 +1,94 @@ +{ + "record": { + "fields": [ + { + "name": "source", + "shape": { + "custom": { + "codec": 2355797192, + "meta": {}, + "shape": { + "value": { + "shape": { + "editor": "plain", + "id": 177956922, + "meta": {}, + "ty": "string" + } + } + } + } + } + }, + { + "name": "bindings", + "shape": { + "map": { + "key": "string", + "meta": {}, + "value": { + "ref": { + "id": 1885459118 + } + } + } + } + }, + { + "name": "float_mode", + "shape": { + "value": { + "shape": { + "editor": { + "dropdown": { + "options": [ + { + "label": "Float", + "value": "float" + }, + { + "label": "Fixed", + "value": "fixed" + } + ] + } + }, + "id": 4255570002, + "meta": {}, + "ty": "string" + } + } + } + }, + { + "name": "consumed", + "shape": { + "map": { + "key": "string", + "meta": {}, + "value": { + "ref": { + "id": 90603770 + } + } + } + } + }, + { + "name": "produced", + "shape": { + "map": { + "key": "string", + "meta": {}, + "value": { + "ref": { + "id": 90603770 + } + } + } + } + } + ], + "meta": {} + } +} diff --git a/schemas/history/v2/shapes/lpc_model.nodes.shader.shader_def.ShaderDef.json b/schemas/history/v2/shapes/lpc_model.nodes.shader.shader_def.ShaderDef.json new file mode 100644 index 000000000..d18ef731f --- /dev/null +++ b/schemas/history/v2/shapes/lpc_model.nodes.shader.shader_def.ShaderDef.json @@ -0,0 +1,111 @@ +{ + "record": { + "fields": [ + { + "name": "source", + "shape": { + "custom": { + "codec": 2355797192, + "meta": {}, + "shape": { + "value": { + "shape": { + "editor": "plain", + "id": 177956922, + "meta": {}, + "ty": "string" + } + } + } + } + } + }, + { + "name": "render_order", + "shape": { + "value": { + "shape": { + "editor": { + "number": { + "step": 1.0 + } + }, + "id": 1231123999, + "meta": {}, + "ty": "i32" + } + } + } + }, + { + "name": "bindings", + "shape": { + "map": { + "key": "string", + "meta": {}, + "value": { + "ref": { + "id": 1885459118 + } + } + } + } + }, + { + "name": "float_mode", + "shape": { + "value": { + "shape": { + "editor": { + "dropdown": { + "options": [ + { + "label": "Float", + "value": "float" + }, + { + "label": "Fixed", + "value": "fixed" + } + ] + } + }, + "id": 4255570002, + "meta": {}, + "ty": "string" + } + } + } + }, + { + "name": "param_defs", + "shape": { + "map": { + "key": "string", + "meta": {}, + "value": { + "ref": { + "id": 3712581622 + } + } + } + } + }, + { + "name": "consumed", + "shape": { + "map": { + "key": "string", + "meta": {}, + "value": { + "ref": { + "id": 90603770 + } + } + } + } + } + ], + "meta": {} + } +} diff --git a/schemas/history/v2/shapes/lpc_model.nodes.shader.shader_param_def.ScalarHint.json b/schemas/history/v2/shapes/lpc_model.nodes.shader.shader_param_def.ScalarHint.json new file mode 100644 index 000000000..e30a7be4a --- /dev/null +++ b/schemas/history/v2/shapes/lpc_model.nodes.shader.shader_param_def.ScalarHint.json @@ -0,0 +1,24 @@ +{ + "record": { + "fields": [ + { + "name": "value", + "shape": { + "value": { + "shape": { + "editor": { + "number": { + "min": 0.0 + } + }, + "id": 2960302901, + "meta": {}, + "ty": "f32" + } + } + } + } + ], + "meta": {} + } +} diff --git a/schemas/history/v2/shapes/lpc_model.nodes.shader.shader_param_def.ShaderParamDef.json b/schemas/history/v2/shapes/lpc_model.nodes.shader.shader_param_def.ShaderParamDef.json new file mode 100644 index 000000000..b7f59e00a --- /dev/null +++ b/schemas/history/v2/shapes/lpc_model.nodes.shader.shader_param_def.ShaderParamDef.json @@ -0,0 +1,78 @@ +{ + "record": { + "fields": [ + { + "name": "label", + "shape": { + "value": { + "shape": { + "editor": "plain", + "id": 2612013983, + "meta": {}, + "ty": "string" + } + } + } + }, + { + "name": "description", + "shape": { + "value": { + "shape": { + "editor": "plain", + "id": 2612013983, + "meta": {}, + "ty": "string" + } + } + } + }, + { + "name": "value_type", + "shape": { + "value": { + "shape": { + "editor": "plain", + "id": 2612013983, + "meta": {}, + "ty": "string" + } + } + } + }, + { + "name": "default", + "shape": { + "value": { + "shape": { + "editor": { + "slider": { + "max": 1.0, + "min": 0.0, + "step": 0.009999999776482582 + } + }, + "id": 1724747812, + "meta": {}, + "ty": "f32" + } + } + } + }, + { + "name": "min", + "shape": { + "option": { + "meta": {}, + "some": { + "ref": { + "id": 3464771014 + } + } + } + } + } + ], + "meta": {} + } +} diff --git a/schemas/history/v2/shapes/lpc_model.nodes.shader.shader_slot_def.ShaderSlotDef.json b/schemas/history/v2/shapes/lpc_model.nodes.shader.shader_slot_def.ShaderSlotDef.json new file mode 100644 index 000000000..b6c2f9c5a --- /dev/null +++ b/schemas/history/v2/shapes/lpc_model.nodes.shader.shader_slot_def.ShaderSlotDef.json @@ -0,0 +1,216 @@ +{ + "record": { + "fields": [ + { + "name": "kind", + "shape": { + "value": { + "shape": { + "editor": "plain", + "id": 2435360456, + "meta": {}, + "ty": "string" + } + } + } + }, + { + "name": "value", + "shape": { + "value": { + "shape": { + "editor": "plain", + "id": 3864786694, + "meta": {}, + "ty": "string" + } + } + } + }, + { + "name": "key", + "shape": { + "option": { + "meta": {}, + "some": { + "value": { + "shape": { + "editor": "plain", + "id": 1124041669, + "meta": {}, + "ty": "string" + } + } + } + } + } + }, + { + "name": "default_bind", + "shape": { + "option": { + "meta": {}, + "some": { + "value": { + "shape": { + "editor": "path", + "id": 1364391883, + "meta": {}, + "ty": "string" + } + } + } + } + } + }, + { + "name": "default", + "shape": { + "option": { + "meta": {}, + "some": { + "value": { + "shape": { + "editor": "plain", + "id": 2605450937, + "meta": {}, + "ty": "f32" + } + } + } + } + } + }, + { + "name": "min", + "shape": { + "option": { + "meta": {}, + "some": { + "value": { + "shape": { + "editor": "plain", + "id": 2605450937, + "meta": {}, + "ty": "f32" + } + } + } + } + } + }, + { + "name": "max", + "shape": { + "option": { + "meta": {}, + "some": { + "value": { + "shape": { + "editor": "plain", + "id": 2605450937, + "meta": {}, + "ty": "f32" + } + } + } + } + } + }, + { + "name": "step", + "shape": { + "option": { + "meta": {}, + "some": { + "value": { + "shape": { + "editor": "plain", + "id": 2605450937, + "meta": {}, + "ty": "f32" + } + } + } + } + } + }, + { + "name": "mapping", + "shape": { + "option": { + "meta": {}, + "some": { + "ref": { + "id": 4227971207 + } + } + } + } + }, + { + "name": "label", + "shape": { + "value": { + "shape": { + "editor": "plain", + "id": 2612013983, + "meta": {}, + "ty": "string" + } + } + } + }, + { + "name": "description", + "shape": { + "value": { + "shape": { + "editor": "plain", + "id": 2612013983, + "meta": {}, + "ty": "string" + } + } + } + }, + { + "name": "panel", + "shape": { + "option": { + "meta": {}, + "some": { + "value": { + "shape": { + "editor": "plain", + "id": 1196386242, + "meta": {}, + "ty": "bool" + } + } + } + } + } + }, + { + "name": "unit", + "shape": { + "option": { + "meta": {}, + "some": { + "value": { + "shape": { + "editor": "plain", + "id": 2612013983, + "meta": {}, + "ty": "string" + } + } + } + } + } + } + ], + "meta": {} + } +} diff --git a/schemas/history/v2/shapes/lpc_model.nodes.shader.shader_slot_mapping.ShaderSlotMappingDef.json b/schemas/history/v2/shapes/lpc_model.nodes.shader.shader_slot_mapping.ShaderSlotMappingDef.json new file mode 100644 index 000000000..920778fc4 --- /dev/null +++ b/schemas/history/v2/shapes/lpc_model.nodes.shader.shader_slot_mapping.ShaderSlotMappingDef.json @@ -0,0 +1,59 @@ +{ + "record": { + "fields": [ + { + "name": "kind", + "shape": { + "value": { + "shape": { + "editor": "plain", + "id": 3102355672, + "meta": {}, + "ty": "string" + } + } + } + }, + { + "name": "len", + "shape": { + "value": { + "shape": { + "editor": "plain", + "id": 3065358156, + "meta": {}, + "ty": "u32" + } + } + } + }, + { + "name": "key", + "shape": { + "value": { + "shape": { + "editor": "plain", + "id": 2612013983, + "meta": {}, + "ty": "string" + } + } + } + }, + { + "name": "empty_key", + "shape": { + "value": { + "shape": { + "editor": "plain", + "id": 3065358156, + "meta": {}, + "ty": "u32" + } + } + } + } + ], + "meta": {} + } +} diff --git a/schemas/history/v2/shapes/lpc_model.nodes.shader.shader_state.ShaderState.json b/schemas/history/v2/shapes/lpc_model.nodes.shader.shader_state.ShaderState.json new file mode 100644 index 000000000..2d1b30470 --- /dev/null +++ b/schemas/history/v2/shapes/lpc_model.nodes.shader.shader_state.ShaderState.json @@ -0,0 +1,30 @@ +{ + "record": { + "fields": [ + { + "default_bind": "bus:visual.out", + "name": "output", + "policy": { + "persistence": "transient", + "writable": false + }, + "semantics": { + "direction": "produced" + }, + "shape": { + "value": { + "shape": { + "editor": "visual_product", + "id": 689649576, + "meta": {}, + "ty": { + "product": "visual" + } + } + } + } + } + ], + "meta": {} + } +} diff --git a/schemas/history/v2/shapes/lpc_model.nodes.texture.texture_def.TextureDef.json b/schemas/history/v2/shapes/lpc_model.nodes.texture.texture_def.TextureDef.json new file mode 100644 index 000000000..379473bcc --- /dev/null +++ b/schemas/history/v2/shapes/lpc_model.nodes.texture.texture_def.TextureDef.json @@ -0,0 +1,48 @@ +{ + "record": { + "fields": [ + { + "name": "size", + "shape": { + "value": { + "shape": { + "editor": "dimensions", + "id": 2973013964, + "meta": {}, + "ty": { + "struct": { + "fields": [ + { + "name": "width", + "ty": "u32" + }, + { + "name": "height", + "ty": "u32" + } + ], + "name": "Dim2u" + } + } + } + } + } + }, + { + "name": "bindings", + "shape": { + "map": { + "key": "string", + "meta": {}, + "value": { + "ref": { + "id": 1885459118 + } + } + } + } + } + ], + "meta": {} + } +} diff --git a/schemas/history/v2/shapes/lpc_model.nodes.texture.texture_state.TextureState.json b/schemas/history/v2/shapes/lpc_model.nodes.texture.texture_state.TextureState.json new file mode 100644 index 000000000..c783210d8 --- /dev/null +++ b/schemas/history/v2/shapes/lpc_model.nodes.texture.texture_state.TextureState.json @@ -0,0 +1,46 @@ +{ + "record": { + "fields": [ + { + "name": "width", + "shape": { + "value": { + "shape": { + "editor": "plain", + "id": 749747344, + "meta": {}, + "ty": "i32" + } + } + } + }, + { + "name": "height", + "shape": { + "value": { + "shape": { + "editor": "plain", + "id": 749747344, + "meta": {}, + "ty": "i32" + } + } + } + }, + { + "name": "format", + "shape": { + "value": { + "shape": { + "editor": "plain", + "id": 3065358156, + "meta": {}, + "ty": "u32" + } + } + } + } + ], + "meta": {} + } +} diff --git a/schemas/module.schema.json b/schemas/module.schema.json new file mode 100644 index 000000000..5734986a9 --- /dev/null +++ b/schemas/module.schema.json @@ -0,0 +1,140 @@ +{ + "$defs": { + "LpAnyValue": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "items": { + "$ref": "#/$defs/LpAnyValue" + }, + "type": "array" + } + ], + "description": "Dynamically typed value: string, number, bool, or an array of these (objects and null are rejected by the reader)." + }, + "lpc_model::binding::binding_def::BindingDef": { + "additionalProperties": false, + "properties": { + "source": { + "type": "string" + }, + "target": { + "type": "string" + }, + "value": { + "$ref": "#/$defs/LpAnyValue" + } + }, + "type": "object" + }, + "lpc_model::nodes::module::channel_meta_def::ChannelMetaDef": { + "additionalProperties": false, + "properties": { + "label": { + "type": "string" + }, + "max": { + "type": "number" + }, + "min": { + "type": "number" + }, + "unit": { + "type": "string" + } + }, + "type": "object" + }, + "lpc_model::nodes::provenance_def::ProvenanceDef": { + "additionalProperties": false, + "properties": { + "author": { + "type": "string" + }, + "created": { + "type": "string" + }, + "license": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "type": "object" + } + }, + "$id": "https://lightplayer.dev/schemas/module.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "bindings": { + "additionalProperties": { + "$ref": "#/$defs/lpc_model::binding::binding_def::BindingDef" + }, + "type": "object" + }, + "exports": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "kind": { + "const": "Module" + }, + "meta": { + "additionalProperties": { + "$ref": "#/$defs/lpc_model::nodes::module::channel_meta_def::ChannelMetaDef" + }, + "type": "object" + }, + "nodes": { + "additionalProperties": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "unset": { + "description": "Unit slot: any value is accepted and ignored on read; written as {}." + } + }, + "required": [ + "unset" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "ref": { + "description": "Path to an authored artifact file.", + "type": "string" + } + }, + "required": [ + "ref" + ], + "type": "object" + } + ] + }, + "type": "object" + }, + "provenance": { + "$ref": "#/$defs/lpc_model::nodes::provenance_def::ProvenanceDef" + } + }, + "required": [ + "kind" + ], + "type": "object" +} diff --git a/schemas/node.schema.json b/schemas/node.schema.json index 1bccc3f0c..a89b140df 100644 --- a/schemas/node.schema.json +++ b/schemas/node.schema.json @@ -108,6 +108,24 @@ }, "type": "object" }, + "lpc_model::nodes::module::channel_meta_def::ChannelMetaDef": { + "additionalProperties": false, + "properties": { + "label": { + "type": "string" + }, + "max": { + "type": "number" + }, + "min": { + "type": "number" + }, + "unit": { + "type": "string" + } + }, + "type": "object" + }, "lpc_model::nodes::output::output_def::OutputDriverOptionsConfig": { "additionalProperties": false, "properties": { @@ -185,6 +203,24 @@ }, "type": "object" }, + "lpc_model::nodes::provenance_def::ProvenanceDef": { + "additionalProperties": false, + "properties": { + "author": { + "type": "string" + }, + "created": { + "type": "string" + }, + "license": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "type": "object" + }, "lpc_model::nodes::shader::shader_param_def::ScalarHint": { "additionalProperties": false, "properties": { @@ -291,16 +327,26 @@ { "additionalProperties": false, "properties": { - "format": { - "maximum": 4294967295, - "minimum": 0, - "type": "integer" + "bindings": { + "additionalProperties": { + "$ref": "#/$defs/lpc_model::binding::binding_def::BindingDef" + }, + "type": "object" + }, + "exports": { + "additionalProperties": { + "type": "string" + }, + "type": "object" }, "kind": { - "const": "Project" + "const": "Module" }, - "name": { - "type": "string" + "meta": { + "additionalProperties": { + "$ref": "#/$defs/lpc_model::nodes::module::channel_meta_def::ChannelMetaDef" + }, + "type": "object" }, "nodes": { "additionalProperties": { @@ -334,8 +380,8 @@ }, "type": "object" }, - "uid": { - "type": "string" + "provenance": { + "$ref": "#/$defs/lpc_model::nodes::provenance_def::ProvenanceDef" } }, "required": [ diff --git a/schemas/project.schema.json b/schemas/project.schema.json index 8912ce475..90d948dfa 100644 --- a/schemas/project.schema.json +++ b/schemas/project.schema.json @@ -2,55 +2,24 @@ "$id": "https://lightplayer.dev/schemas/project.schema.json", "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, + "description": "Workspace identity of a project folder: format version, library uid, and display name. Not a node artifact — the root module node lives in module.json.", "properties": { "format": { - "const": 2 - }, - "kind": { - "const": "Project" + "const": 3, + "title": "Authored format version" }, "name": { + "title": "Human-readable project name", "type": "string" }, - "nodes": { - "additionalProperties": { - "oneOf": [ - { - "additionalProperties": false, - "properties": { - "unset": { - "description": "Unit slot: any value is accepted and ignored on read; written as {}." - } - }, - "required": [ - "unset" - ], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "ref": { - "description": "Path to an authored artifact file.", - "type": "string" - } - }, - "required": [ - "ref" - ], - "type": "object" - } - ] - }, - "type": "object" - }, "uid": { + "title": "Stable project identity (prj_…, base-62)", "type": "string" } }, "required": [ - "kind", "format" ], + "title": "LightPlayer project container manifest", "type": "object" } diff --git a/schemas/shapes/_index.json b/schemas/shapes/_index.json index 1dd97dd66..e13b93fc4 100644 --- a/schemas/shapes/_index.json +++ b/schemas/shapes/_index.json @@ -17,13 +17,15 @@ "lpc_model::nodes::fixture::fixture_state::FixtureState": 1983594935, "lpc_model::nodes::fluid::fluid_def::FluidDef": 2887292794, "lpc_model::nodes::fluid::fluid_state::FluidState": 3376641154, + "lpc_model::nodes::module::channel_meta_def::ChannelMetaDef": 1452279109, + "lpc_model::nodes::module::module_def::ModuleDef": 3041613592, "lpc_model::nodes::node_def::NodeArtifact": 3831631647, "lpc_model::nodes::output::output_def::OutputDef": 770241759, "lpc_model::nodes::output::output_def::OutputDriverOptionsConfig": 2660578566, "lpc_model::nodes::playlist::playlist_def::PlaylistDef": 1520921198, "lpc_model::nodes::playlist::playlist_entry::PlaylistEntry": 899201012, "lpc_model::nodes::playlist::playlist_state::PlaylistState": 237636858, - "lpc_model::nodes::project::project_def::ProjectDef": 2328756067, + "lpc_model::nodes::provenance_def::ProvenanceDef": 718754952, "lpc_model::nodes::radio::control_radio_def::ControlRadioDef": 4099574392, "lpc_model::nodes::radio::control_radio_def::ControlRadioState": 1057824914, "lpc_model::nodes::shader::compute_shader_def::ComputeShaderDef": 3622644300, diff --git a/schemas/shapes/lpc_model.nodes.module.channel_meta_def.ChannelMetaDef.json b/schemas/shapes/lpc_model.nodes.module.channel_meta_def.ChannelMetaDef.json new file mode 100644 index 000000000..72695304a --- /dev/null +++ b/schemas/shapes/lpc_model.nodes.module.channel_meta_def.ChannelMetaDef.json @@ -0,0 +1,79 @@ +{ + "record": { + "fields": [ + { + "name": "label", + "shape": { + "option": { + "meta": {}, + "some": { + "value": { + "shape": { + "editor": "plain", + "id": 2612013983, + "meta": {}, + "ty": "string" + } + } + } + } + } + }, + { + "name": "unit", + "shape": { + "option": { + "meta": {}, + "some": { + "value": { + "shape": { + "editor": "plain", + "id": 2612013983, + "meta": {}, + "ty": "string" + } + } + } + } + } + }, + { + "name": "min", + "shape": { + "option": { + "meta": {}, + "some": { + "value": { + "shape": { + "editor": "plain", + "id": 2605450937, + "meta": {}, + "ty": "f32" + } + } + } + } + } + }, + { + "name": "max", + "shape": { + "option": { + "meta": {}, + "some": { + "value": { + "shape": { + "editor": "plain", + "id": 2605450937, + "meta": {}, + "ty": "f32" + } + } + } + } + } + } + ], + "meta": {} + } +} diff --git a/schemas/shapes/lpc_model.nodes.module.module_def.ModuleDef.json b/schemas/shapes/lpc_model.nodes.module.module_def.ModuleDef.json new file mode 100644 index 000000000..bbfab0868 --- /dev/null +++ b/schemas/shapes/lpc_model.nodes.module.module_def.ModuleDef.json @@ -0,0 +1,108 @@ +{ + "record": { + "fields": [ + { + "name": "nodes", + "policy": { + "writable": false + }, + "shape": { + "map": { + "key": "string", + "meta": {}, + "value": { + "enum": { + "encoding": "external", + "meta": {}, + "variants": [ + { + "name": "unset", + "shape": { + "unit": { + "meta": {} + } + } + }, + { + "name": "ref", + "shape": { + "value": { + "shape": { + "editor": "path", + "id": 2244609540, + "meta": {}, + "ty": "string" + } + } + } + } + ] + } + } + } + } + }, + { + "name": "bindings", + "shape": { + "map": { + "key": "string", + "meta": {}, + "value": { + "ref": { + "id": 1885459118 + } + } + } + } + }, + { + "name": "exports", + "shape": { + "map": { + "key": "string", + "meta": {}, + "value": { + "value": { + "shape": { + "editor": "path", + "id": 1364391883, + "meta": {}, + "ty": "string" + } + } + } + } + } + }, + { + "name": "meta", + "shape": { + "map": { + "key": "string", + "meta": {}, + "value": { + "ref": { + "id": 1452279109 + } + } + } + } + }, + { + "name": "provenance", + "shape": { + "option": { + "meta": {}, + "some": { + "ref": { + "id": 718754952 + } + } + } + } + } + ], + "meta": {} + } +} diff --git a/schemas/shapes/lpc_model.nodes.node_def.NodeArtifact.json b/schemas/shapes/lpc_model.nodes.node_def.NodeArtifact.json index 82180f82f..5a7d68743 100644 --- a/schemas/shapes/lpc_model.nodes.node_def.NodeArtifact.json +++ b/schemas/shapes/lpc_model.nodes.node_def.NodeArtifact.json @@ -3,10 +3,10 @@ "meta": {}, "variants": [ { - "name": "Project", + "name": "Module", "shape": { "ref": { - "id": 2328756067 + "id": 3041613592 } } }, diff --git a/schemas/shapes/lpc_model.nodes.provenance_def.ProvenanceDef.json b/schemas/shapes/lpc_model.nodes.provenance_def.ProvenanceDef.json new file mode 100644 index 000000000..d6d065dae --- /dev/null +++ b/schemas/shapes/lpc_model.nodes.provenance_def.ProvenanceDef.json @@ -0,0 +1,79 @@ +{ + "record": { + "fields": [ + { + "name": "author", + "shape": { + "option": { + "meta": {}, + "some": { + "value": { + "shape": { + "editor": "plain", + "id": 2612013983, + "meta": {}, + "ty": "string" + } + } + } + } + } + }, + { + "name": "version", + "shape": { + "option": { + "meta": {}, + "some": { + "value": { + "shape": { + "editor": "plain", + "id": 2612013983, + "meta": {}, + "ty": "string" + } + } + } + } + } + }, + { + "name": "license", + "shape": { + "option": { + "meta": {}, + "some": { + "value": { + "shape": { + "editor": "plain", + "id": 2612013983, + "meta": {}, + "ty": "string" + } + } + } + } + } + }, + { + "name": "created", + "shape": { + "option": { + "meta": {}, + "some": { + "value": { + "shape": { + "editor": "plain", + "id": 2612013983, + "meta": {}, + "ty": "string" + } + } + } + } + } + } + ], + "meta": {} + } +}