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/adr/2026-08-02-panel-writers-and-state-persistence.md b/docs/adr/2026-08-02-panel-writers-and-state-persistence.md new file mode 100644 index 000000000..5ae110fe4 --- /dev/null +++ b/docs/adr/2026-08-02-panel-writers-and-state-persistence.md @@ -0,0 +1,142 @@ +# Panel writers: an unauthored runtime tier, persisted device-first in `/.lp/panel.json` + +- Status: accepted +- Date: 2026-08-02 +- Context: implements docs/design/panel.md P1–P4, P8–P11, P14 (ratified + alongside modules.md 2026-07-31). Builds on the scoped-bus engine + (`2026-08-01-scoped-bus-engine-architecture.md`), which supplies the + `(scope, channel)` identity this whole tier keys on, and follows the + runtime command-channel precedent from + `2026-07-27-runtime-node-command-channel.md`. +- Plan: `planning/2026-08-01-1003-modules-impl-roadmap` P8–P10. + +## Decision + +**A panel control is not an editor.** Turning a knob does not change the +project. It engages a *panel writer*: an unauthored, lazily materialized +runtime value source living on the Engine, keyed by `(scope, channel)`, +that outranks authored writers for that channel in that scope until +explicitly cleared. + +Four consequences follow, and each one is load-bearing: + +**The store is a side store on the Engine, not a binding.** +`apply_project_changes` rebuilds bindings from defs, so a panel writer +registered as a `BindingDraft` would be silently destroyed by the first +authoring edit — the user's dim would vanish because someone renamed a +node. `PanelWriterStore` therefore sits beside the binding set and +survives every edit that does not rebuild the Engine itself. A test pins +exactly that. + +**Writers are lazy.** A writer materializes on first touch, never at load. +Eager materialization would make every public parameter self-shadow at +boot, and an outer scope could then never drive an inner channel — +modules.md R10 would be dead on arrival. + +**Engagement REPLACES the scope's provider set, it does not join it.** +At the host seam, an engaged writer is returned alone. This is what makes +"panel wins" hold on `ByKey` merge channels too, where a merge would +otherwise blend the panel value with the authored ones and produce +something the user never asked for. + +**The wire ops are runtime pokes, never authored ops.** +`WireProjectCommand::PanelWrite` / `PanelClear` are project-level arms — +they address a scope, not a node — and touch no overlay, no `PendingEdit`, +and no dirty flag. That is not merely tidy: it is what sidesteps +`PendingEdit` value-shadowing, which would otherwise fight multi-client +convergence. Clients learn state through ordinary probe pulls, so two +phones on one device agree, and a knob fight resolves last-writer-wins at +the engine with no locks and no ownership. + +### Persistence is device-first, in the framework tier + +Panel state persists to `/.lp/panel.json` *inside the project's own +filesystem* — the framework-owned tier, never an authored artifact. Both +sim tiers run on `LpFsMemory`, so **sims stay ephemeral by construction** +(settled D-B); unit tests are the correctness story there, and the device +walk confirms it. Persistent sims are recorded future work. + +**Restore happens at Engine construction — before the first tick, and so +before the first render.** The requirement is verbatim from the design: +*4 a.m., Burning Man, LED scarf dimmed from a phone; unplug, replug — it +must come back dim, with not one bright frame.* A boot that renders even +one frame at authored brightness is non-conforming, which is why the seam +is `Project::new` (and `Project::reload`, which rebuilds the Engine) and +not a later ready-event. On device, `auto_load_project` runs before the +main loop, so this seam is the boot path. + +**Keys are `scope-path / channel`** from `ScopeRef::persist_path` — tree +paths and authored entry keys, never runtime ids or indices — so state +survives reload, reattach, and sibling reorder. A sink scope keys by the +ENTRY, not the entry's child: swapping which node a playlist entry plays +keeps the entry's panel state. **State follows the slot, not the +content.** An entry naming a scope this project no longer has is dropped +on load: vendoring and renames degrade gracefully rather than failing a +boot. + +**Version is bump-and-refuse.** An unknown version means the file is +ignored wholesale — no migration, matching the alpha posture everywhere +else in the format story (settles P-Q3). Losing panel state costs one +re-dim; a half-applied migration costs trust. + +**Writes are throttled to ~10 s and gated on a mutation counter**, with a +flush on clean shutdown. The counter matters more than it looks: a clear +followed by a re-write inside one window leaves an identically-shaped +map, so comparing `(len, newest revision)` would miss it and silently +lose the newer value. An idle project writes nothing at all, however long +it runs. + +**Momentary writers never persist** (P14). A gesture has no held value, +and a deadline that outlived a power cycle would be meaningless. + +### The prerequisite that makes persistence safe at all + +Writing inside the project filesystem fires an `FsEvent` back into +`Project::refresh_artifacts` → `apply_project_changes`. Without a filter, +**every ~10 s save would clear and re-register the whole binding graph, +and the rebuild would schedule the next save** — a permanent churn loop +costing the device its flash and its frame budget, triggered by nothing +more than leaving a knob engaged. + +So `refresh_artifacts` drops `/.lp/**` events *first*, before anything +reads the batch, and returns early when nothing authored remains. This is +the same boundary `lpc_history::is_hashed_path` draws for the canonical +package hash and `SnapshotStore` draws for device copies; panel state +inherits both, so a dimmed scarf can never read as a modified project or +show up as a device diff. `Project::applied_refresh_count` exists so this +is *observable* rather than asserted — a test proves an authored write +moves it and a `/.lp/` write does not. + +## Alternatives rejected + +- **Panel state as overlay slot edits** (the transient/`SlotRole::Debug` + tier used by clock rate/scrub). Rejected on identity: panel writers are + `(scope, channel)` command-channel state, not per-node slot edits. This + is the boundary the debug-slots-taxonomy ADR itself draws — events go to + the command channel, Debug goes to the overlay, panel state goes to + `.lp/panel.json`. Riding the overlay would also re-introduce the + dirty-flag and `PendingEdit` coupling the whole design exists to avoid. +- **Panel writers as authored bindings.** Destroyed by + `apply_project_changes`; see above. +- **Client-side panel state.** Breaks P9 outright: two phones would show + different values for one control, and a device rebooting alone (no + client attached) could not restore anything. +- **Writers that accumulate or generate** (`phase += speed·dt`). Rejected + by P3: that behavior belongs to a node. The supported idiom is + `speed` → phasor node → `phase` → consumer. +- **Slew in this phase.** Deferred deliberately (P-Q1 stays open): + emission is immediate, which is correct on its own rather than a + placeholder, and the seam when it arrives is writer-side shaping — the + writer holds the raw value and shapes only what it emits, so nothing + downstream changes. + +## Consequences + +- Any future input source — MIDI, OSC, hardware encoders, play mode, + phones — enters through these same two ops with the same identity, + latch semantics, and persistence rules. No second control path. +- A control whose backing slot has no bus channel behind it still edits + its authored default through the slot path; the two coexist, and the + control's `panel_target` is what selects between them. +- Turning auto-save off records itself in the file, so the choice + survives a reboot rather than quietly re-enabling overnight. diff --git a/docs/debt/README.md b/docs/debt/README.md index 443c62243..f0d3aecf4 100644 --- a/docs/debt/README.md +++ b/docs/debt/README.md @@ -70,6 +70,7 @@ stay in place when retired; the log is the history). | [s3-frame-cost-scales-per-fixture](s3-frame-cost-scales-per-fixture.md) | carried | 2026-07-31 | lpc-engine resolver + lpc-hardware registry | Frame cost is flat ~8.4 ms/fixture: per-frame dataflow re-resolution + per-frame endpoint-status recomputation; the shader JIT is ~1%, sends 11% | | [per-lamp-data-stored-three-times](per-lamp-data-stored-three-times.md) | carried | 2026-07-31 | lpc-model fixture mapping slots + lpc-engine fixture/output nodes | a lamp's position is stored three times and its colour twice — 31.6 of the classic's 89.5 B/LED; the big half is a wire-visible slot schema, so it did not land with the measurement | | [c6-on-legacy-ws281x-driver](c6-on-legacy-ws281x-driver.md) | retired | 2026-07-31 | lp-fw/fw-esp32c6/src/output | C6 ran its own single-channel WS281x driver, not `lp-ws281x`; retired 2026-08-01 when the C6 moved onto the shared core and gained its second channel | +| [panel-state-serde-flash-cost](panel-state-serde-flash-cost.md) | carried | 2026-08-02 | lpa-server/panel_state + serde surface | a SECOND LpValue JSON codec in the image costs 50,512 B of C6 flash; the first one is already there and unused by panel state | | [example-shaders-not-compile-gated](example-shaders-not-compile-gated.md) | carried | 2026-07-29 | examples GLSL + CI + lps-filetests | an example shader can compile on the host yet fail on 4 of 5 targets; the break surfaces only when a human opens it in Studio | | [clock-transport-has-no-transport-ui](clock-transport-has-no-transport-ui.md) | carried | 2026-05-12 | clock node + studio faces | scrubbing a show means typing seconds into a generic slider; the misfit also keeps the `Debug` name provisional | | [project-reload-drops-debug-silently](project-reload-drops-debug-silently.md) | carried | 2026-07-04 | lpa-server project lifecycle | the documented recovery path discards every pending edit and Debug override with no return value, event, or notice | diff --git a/docs/debt/lps-probe-perf-test-load-sensitive.md b/docs/debt/lps-probe-perf-test-load-sensitive.md index cf1daac2a..633bf2b73 100644 --- a/docs/debt/lps-probe-perf-test-load-sensitive.md +++ b/docs/debt/lps-probe-perf-test-load-sensitive.md @@ -39,6 +39,28 @@ already recorded in story-capture-pipeline.md). boards-catalog visual-gate session while the worktree dev server and a second agent session were active; passed clean between them with the server stopped. Filed this entry. +- 2026-08-02 — one full-gate failure during the modules-roadmap merge + session, with a firmware build and background cargo jobs sharing the + machine: **12.02 s against the 10 s bound**. Re-run alone on the same + tree: **8.42 s**. A 43% swing from load alone, on an assert with 20% + of headroom — the measurement is reporting the machine, not the code. +- 2026-08-02 (later, same session) — failed again at 10.88 s in the + gate and **11.56 s on a supposedly standalone re-run**. The re-run was + not standalone: `uptime` showed load average **143** and 49 cargo + processes, because a SIBLING agent session was running + `cargo clippy --workspace` on the same machine. The entry's own + premise — "re-run it alone and it passes" — is therefore not a + reliable workaround when you do not control the whole machine: there + is no way to tell flake from regression locally under a foreign + workload. That verdict had to be deferred to CI, which is the only + place with a dedicated runner. **Check `uptime` before believing any + measurement from this test**, and note the merge that session carried + main's lpvm changes (26 files, +1459 lines) — a real regression could + not be ruled out locally, only by CI. + **Resolved same day: `Validate (x64)` passed on CI** (run + 30766114996), so both local failures were load, not a regression. The + cost of the ambiguity was real even so — two gate cycles and a + firmware build spent deciding whether to believe a stopwatch. - 2026-08-02 — M5 provisioning-gate session: `perf_4096_render_evals_under_10s_debug` took **10.998 s** against the 10 s bound with the worktree dev server and a diff --git a/docs/debt/panel-state-serde-flash-cost.md b/docs/debt/panel-state-serde-flash-cost.md new file mode 100644 index 000000000..0a3477108 --- /dev/null +++ b/docs/debt/panel-state-serde-flash-cost.md @@ -0,0 +1,86 @@ +--- +status: carried +since: 2026-08-02 +logged: 2026-08-02 +area: lpa-server/panel_state + serde surface +related: + - docs/adr/2026-08-02-panel-writers-and-state-persistence.md + - docs/design/panel.md + - docs/debt/serde-surface-is-the-flash-lever (see memory note of the same name) +--- +# The firmware now carries TWO JSON codecs for `LpValue`, and the second one cost 50 KB + +**Shape** — `/.lp/panel.json` is a tiny document: a version, a bool, and +a list of `{ scope, channel, value }`. Persisting it cost **50,512 B of +ESP32-C6 flash** (measured 2026-08-02: headroom 202,640 B at the P9 head +→ 152,128 B with P10). Symbol-level attribution (ELF diff of the two +builds) says almost none of that is the file format and almost all of it +is a *duplicate*: + +| B | symbol | +|---|---| +| 10,332 | `LpValue as serde_core::de::Deserialize>::deserialize` @ `serde_json::Deserializer` — **new** | +| 3,720 | `LpValue as serde_core::ser::Serialize>::serialize` @ `serde_json::Serializer` — **new** | +| ~5,500 | `serde_json` `Deserializer::deserialize_*` plumbing for the new types | +| 3,308 | `Project::new` (restore inlined) | +| 1,868 | `Project::write_panel_state` | +| 1,888 | merged globals (log strings, JSON field names) | +| ~3,000 | knock-on: `NodeDef::clone`, `SlotShape`/`LpType` clones re-instantiated | + +The image already contained a hand-rolled streaming LpValue JSON reader +— `lpc_model::slot_codec::read_lp_value`, 13,594 B — +which is how project artifacts are parsed on device. Deriving +`Serialize`/`Deserialize` for a struct *containing* an `LpValue` and +handing it to `lpc_wire::json` (a `serde_json` facade) instantiated a +**second, complete** encode/decode path for the same type against a +different Deserializer/Serializer pair. The monomorphizations do not +share. + +This is the general serde-surface tax, but with a specific and cheaper +fix than "write less serde": *use the codec that is already linked*. + +**Carrying cost** — 50 KB off the C6's flash headroom on every image, on +our tightest target, permanently. Invisible at review time: a +`#[derive(Deserialize)]` looks free in a diff. Every future `/.lp/` +document that reaches for serde makes the same trade again. + +**Workarounds** — to measure any similar change, take a reading before +and after (stash the change between): + +```bash +just fw-esp32c6-size-check +``` + +To attribute a regression to symbols, diff the two ELFs +(`target/riscv32imac-unknown-none-elf/release-esp32/fw-esp32c6`) — there +is no RISC-V `nm` on this machine, but the ELF32 symbol table is a +20-line Python parse and that is what produced the table above. + +**Incident log** + +- 2026-08-02 — filed. P10 (panel persistence) measured at −50,512 B on + the C6. Accepted for now: the size gate passes with 2.3× its required + margin, and the fix was out of P10's scope. +- 2026-08-03 — **pressure released, duplication unchanged.** Main + flipped the ESP32 profile to `panic = "abort"` with `opt-level = "z"` + overrides; the C6 image went 3,003,104 B → 2,169,520 B and headroom + 142,624 B → **976,208 B**. Re-measured on the current image by symbol + class rather than by diff: the `serde_json` LpValue codec is + **24,912 B** across 14 symbols, the hand-rolled `slot_codec` beside it + is 15,970 B, and panel state's own code is only 5,694 B. So the + *shape* of the waste is confirmed — the expensive thing is the + duplicate codec, not the feature — while the urgency is much lower. + Note the earlier −50,512 B figure was a build-to-build diff under the + old profile and is not comparable to these absolute sums; both are + recorded rather than reconciled, because the actionable number is the + 24,912 B duplicate. + +**Exit criteria** — panel state encodes/decodes `LpValue` through +`slot_codec` (`read_lp_value` / `write_lp_value`), which is already in +the image, and the C6 recovers the bulk of the ~14 KB LpValue portion +plus most of the serde_json plumbing. One wrinkle to design around: +`read_lp_value` is *typed* — it takes the `LpType` it expects — whereas +the persisted value arrives untyped. The channel's declared `Kind` is +available at restore time from the scope's channel listing, so the read +can be typed by the channel rather than by the file; that is the piece +of design work, and it is small. Estimated paydown: half a day. diff --git a/docs/defects/2026-08-02-authored-source-bindings-silently-dropped.md b/docs/defects/2026-08-02-authored-source-bindings-silently-dropped.md new file mode 100644 index 000000000..32a8aa4b8 --- /dev/null +++ b/docs/defects/2026-08-02-authored-source-bindings-silently-dropped.md @@ -0,0 +1,61 @@ +--- +status: open +found: 2026-08-02 # how: P10 walk prep — authoring a consuming bus binding that never took effect +area: lpc-engine/project_loader (authored binding registration) +class: silent-drop +related: + - docs/design/panel.md + - docs/design/modules.md + - docs/adr/2026-08-02-panel-writers-and-state-persistence.md +--- +# An authored `source` binding on a slot the loader doesn't enumerate is dropped without a word + +**Symptom** — this clock authors a consuming binding, loads without error, +and behaves as if the binding were not there: + +```json +{ "kind": "Clock", + "bindings": { "controls.rate": { "source": "bus:rate" } }, + "controls": { "rate": 1.0 } } +``` + +The binding graph after load contains only the clock's default +`seconds → bus:time` publish. No `rate` channel lists, no consuming +binding exists, no warning is logged, and the project reports healthy. + +**Cause** — `ProjectLoader` registers authored `source` bindings by +*calling out slot names per node kind*: `register_optional_source_binding` +is invoked for `input` on a fixture, for each key of `consumed_slots` on a +shader/compute-shader, for `time`/`emitters` on other kinds. Any other key +in the `bindings` map is simply never looked up. `binding_source()` is a +map GET — a key nobody asks for is a key nobody misses. + +**Why it matters beyond the typo case** — it silently bounds which +controls can be panel controls at all. A panel control is a channel +presentation (panel.md P1), so a slot that cannot carry a consuming bus +binding can never be driven by a panel writer. Concretely today: + +- **Shader uniforms can** — every `consumed_slots` key is enumerated, so + `"bindings": { "glow": { "source": "bus:glow" } }` works. +- **Fixture `brightness` cannot** — only `input` is enumerated. That is + the scarf scenario's own control (panel.md P10), so the motivating + example of the whole panel-persistence design is currently unreachable + except through a shader uniform standing in for it. + +No shipped example authors such a binding, so nothing is *broken* today; +what is broken is the feedback when you try. + +**Fix directions** (not attempted here — out of P10's scope) + +1. **Loudest, cheapest:** after registering, diff the authored `bindings` + map against the keys actually consumed and log/report the leftovers. + A binding naming an unknown slot should surface as a node issue, the + way a bad slot path does. +2. **Structural:** drive registration from the def's slot shape + (which slots are `consumed`) instead of a hand-listed name set per + kind, so any consumed slot is bindable by construction. This is the + direction modules.md implies — publicity is a property of the slot, + not of a list in the loader. + +Either fix makes fixture brightness bindable, which is what lets the +panel own the control the design keeps using as its example. diff --git a/docs/defects/2026-08-03-fw-browser-smoke-throws-on-its-first-poll.md b/docs/defects/2026-08-03-fw-browser-smoke-throws-on-its-first-poll.md new file mode 100644 index 000000000..1035a6c04 --- /dev/null +++ b/docs/defects/2026-08-03-fw-browser-smoke-throws-on-its-first-poll.md @@ -0,0 +1,72 @@ +--- +status: open +found: 2026-08-03 # how: Validate Browser (x64) red on PR #255, green on rerun with no code change +area: lp-fw/fw-browser/scripts/fw-browser-smoke-check.mjs (CI harness) +class: test-harness-race +related: + - docs/debt/lps-probe-perf-test-load-sensitive.md +--- +# The fw-browser smoke check throws on its first poll instead of waiting + +**Symptom** — `Validate Browser (x64)` fails ~16 s in, before a single +`PASS` line, with no usable detail: + +``` +Error: page evaluation failed: Uncaught + at readPageState (fw-browser-smoke-check.mjs:184:11) + at async runSmoke (fw-browser-smoke-check.mjs:156:12) +``` + +The same commit passes locally (all nine stages, `frame=5`), main is +6/6 green on the same job, and **the job passed on rerun with no code +change** — which is what identifies it as a race rather than a +regression. + +**Cause** — `runSmoke` navigates and then polls: + +```js +await cdp.send("Page.navigate", { url: pageUrl }, sessionId); +const deadline = Date.now() + smokeTimeoutMs; +while (Date.now() < deadline) { + last = await readPageState(cdp, sessionId); // <- throws, ends the run + ... + await delay(250); +} +``` + +and `readPageState` turns any `exceptionDetails` into a thrown error. +The first `Runtime.evaluate` can land while the page's execution context +is still being torn down and replaced by the navigation. CDP reports that +as an exception whose `text` is the bare word `Uncaught` — an +app-thrown error would carry a message, which is the tell. A loaded CI +runner widens the window; a quiet laptop usually misses it. + +So the loop that exists precisely to *wait for the page to become +readable* aborts on the page not yet being readable. + +**Fix** — inside the polling window, treat an evaluation exception as +"not ready yet": keep the last exception text, `continue`, and only +raise if the deadline expires (report it as the timeout reason so a +genuinely broken page still prints evidence). Roughly: + +```js +let lastError = null; +while (Date.now() < deadline) { + try { last = await readPageState(cdp, sessionId); lastError = null; } + catch (err) { lastError = err; await delay(250); continue; } + if (last.smoke === "ok" || last.smoke === "error") return last; + await delay(250); +} +if (lastError) throw lastError; // never became readable +``` + +Not applied yet: found while merging main ahead of the G4 hardware walk, +and the branch was green and about to be used. The change is small and +belongs to whoever next touches this harness. + +**Why it matters beyond one red check** — this failure mode is +indistinguishable from a real browser-tier break at a glance, and the +error text carries no evidence, so the honest response to it is a rerun. +That trains rerun-on-red, which is exactly the habit that lets a real +failure through. Compare the load-sensitive `lps-probe` assert, where +the same ambiguity already cost a masked compile break. diff --git a/docs/design/modules.md b/docs/design/modules.md index 07e83b4bf..7944dadd1 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 @@ -238,7 +248,7 @@ restoring R5/R6 resolution and dropping the persisted entries. ### R13 — Persistence *(summary — normative: panel.md P10/P11)* -Panel state persists by default to `.lp/state.json` (§6) with throttled +Panel state persists by default to `.lp/panel.json` (§6) with throttled writes (≥ ~10 s, flash preservation), auto-save on by default, and restore **before first render** on boot. Never in authored artifacts. @@ -451,7 +461,7 @@ my-project/ │ ├─ module.json # (no project.json, no format — see below) │ └─ … └─ .lp/ # the ONE framework-owned dir: never authored, - └─ state.json # always safe to delete (panel state per R13; + └─ panel.json # always safe to delete (panel state per R13; # future caches/locks land here, never beside content) ``` @@ -470,7 +480,7 @@ my-project/ in-tree dir. - `.lp/` is a project-folder concept; the device keeps its own filesystem conventions and needs only the panel-state *data*, not the layout. -- `state.json` shape (proposed, Q3): a versioned map of +- `panel.json` shape (proposed, Q3): a versioned map of `scope-path / channel → { value, engaged }`. Scope paths are node paths, so vendoring/renames invalidate entries gracefully (unknown paths are dropped on load). @@ -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/panel.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/panel.md b/docs/design/panel.md index 2bc504604..131d1d4df 100644 --- a/docs/design/panel.md +++ b/docs/design/panel.md @@ -90,6 +90,10 @@ the same channel until cleared. Across scopes, ordinary writer-shadowing applies: an engaged writer in an inner scope shadows outer writers for that subtree — touching detaches, clearing re-attaches. +> Status: implemented 2026-08-02 (engine writer store at panel priority, +> replacing the scope's provider set — max-priority-wins holds on ByKey +> merge channels too; survives `apply_project_changes` by construction). + ### P5 — Takeover: jump, by default Grabbing a channel that authored dataflow is actively moving acquires it @@ -136,6 +140,16 @@ silently destroyed.) staged, nothing dirty, no overlay interaction. Studio, play mode, phones, and future hardware inputs all speak exactly these two ops. +> Status: implemented 2026-08-02. `WireProjectCommand::PanelWrite` / +> `PanelClear` (project-level arms — they address a scope, not a node), +> `PanelWriteOp` / `PanelClearOp` in Studio, coalesced per +> `(scope, channel)` in the actor's batch planner beside the slot-edit +> flood rule. The panel controls that already existed (shader uniform +> knobs, the fixture brightness fader) were re-pointed off +> `SlotEditOp::SetValue` onto this path wherever the backing slot +> consumes a bus channel; a control with no channel behind it still +> edits its authored default. + ### P9 — Multi-client: the engine is the authority Panel state lives in the engine (sim or device), never in a client. All @@ -154,9 +168,17 @@ must come back dim, with not one bright frame; next night, connect and reset.* A boot that renders even one frame at authored brightness before applying restored panel state is non-conforming. +> Status: implemented 2026-08-02. The seam is Engine construction — +> `Project::new`, and `Project::reload` because it rebuilds the Engine — +> so restore completes before the first tick and therefore before the +> first render. On device that is the boot path (`auto_load_project` runs +> ahead of the main loop). `apply_project_changes` does NOT rebuild the +> Engine, which is why an ordinary edit leaves engaged writers alone and +> touches no file. + ### P11 — Persistence -- Panel state persists to **`.lp/state.json`** in the project folder +- Panel state persists to **`.lp/panel.json`** in the project folder (the framework-owned tier — modules.md §6); on device, to the device's own filesystem. Never in authored artifacts. - Contents: a versioned map `scope-path / channel → { value }` — raw @@ -167,6 +189,28 @@ applying restored panel state is non-conforming. - **Auto-save is on by default** with a user toggle; Clear (P2) removes the corresponding persisted entries immediately. +> Status: implemented 2026-08-02 (`lpa-server/src/panel_state.rs`; +> ADR `2026-08-02-panel-writers-and-state-persistence.md`). +> +> **Device-first, per settled D-B**: both sim tiers run on `LpFsMemory`, +> so sims stay ephemeral by construction; the unit tests are the +> correctness story and the device walk confirms it. Persistent sims are +> recorded future work. +> +> The throttle gates on a writer-store mutation COUNTER, not on the +> writer set: a clear followed by a re-write inside one window leaves an +> identically-shaped map, so comparing size-and-revision would miss it. +> An idle project writes nothing at all. Turning auto-save off records +> itself in the file, so the choice survives a reboot instead of quietly +> re-enabling overnight. +> +> **Prerequisite that made this safe:** a write inside the project fs +> fires an FsEvent back at the artifact-refresh path, so `/.lp/**` is +> filtered out of project changes before anything reads the batch — +> otherwise every save would rebuild the binding graph and the rebuild +> would schedule the next save. `Project::applied_refresh_count` makes +> that observable rather than assumed. + ### P12 — Play mode Play mode renders **panels only** — the root module's panel, which @@ -205,6 +249,15 @@ mode switch. is domain logic and belongs to a node (a gate with a timeout param), never to writer resolution. +> Status: implemented 2026-08-02 (engine-side lifecycle). A momentary +> write carries a renewal deadline; the engine despawns the writer past +> it, in the tick. That makes despawn survive a dropped client — a +> gesture nobody is renewing releases on its own — and renewal is simply +> the next write. The wire shape is our own; PR #233's TTL/press_id +> direction was design reference only. Widget-side gesture classes (which +> channel kinds ARE momentary) arrive with the touch-set vocabulary, +> P-Q5. + ## 3. Worked walkthroughs - **The scarf**: boot → P10 restores `brightness` writer before frame 1 @@ -235,15 +288,33 @@ this document never specifies resolution. - **P-Q1:** slew defaults — which widget kinds slew at all (brightness fader: yes; stepped knob: no?), and is the time constant per-widget meta or one project default? + *Disposition 2026-08-02: still open, deliberately unimplemented.* + Emission is immediate (P5 takeover = jump), which is correct behavior + on its own and not a placeholder. The seam when slew arrives is + **writer-side shaping**: the panel writer holds the raw value (P7) and + what it emits is shaped on the way out, so nothing downstream of the + writer — resolution, persistence, identity — changes. - **P-Q2:** engaged-affordance treatment (distinct from bound-violet) — UX spike owns the visual; confirm the *requirement* that Read-following -automation, Read-at-default, and Latch are three visibly distinct states. -- **P-Q3:** `state.json` schema version field name/shape, and whether a - clean-shutdown flush is feasible on device (or throttle-only). -- **P-Q4:** does Clear-all also clear *sink-scope* (playlist entry) - state, or only visible panels? Lean: everything under the cleared - scope, sinks included — "reset means reset". +- **P-Q3:** ~~`panel.json` schema version field name/shape, and whether a + clean-shutdown flush is feasible on device (or throttle-only).~~ + **Settled 2026-08-02.** The file is + `{ version, auto_save, entries: [{ scope, channel, value }] }` with + `version: 1` and **bump-and-refuse** semantics — an unknown version is + ignored wholesale, never migrated, matching the alpha posture in the + rest of the format story. Losing panel state costs one re-dim; a + half-applied migration costs trust. A clean-shutdown flush IS included + (project unload flushes past the throttle); an unclean power cut simply + loses at most one throttle window, which is the trade the ~10 s + interval buys. +- **P-Q4:** ~~does Clear-all also clear *sink-scope* (playlist entry) + state, or only visible panels?~~ **Settled 2026-08-02 as leaned: + clear-all reaches sink scopes** — a playlist entry's latched value + clears with everything else. "Reset means reset"; a reset that leaves + values latched in scopes the user cannot currently see is a haunting, + not a safety feature. Implemented and pinned by test. - **P-Q5:** the touch-set value shape (per-touch id, position, pressure/z, velocity — carried or derived?) and the multi-XY pad widget spec. Prior art: old lightPlayer's `MultiTouchInput.Touch` 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/docs/glossary.md b/docs/glossary.md index 0eb317d00..05d5d7c30 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -74,7 +74,7 @@ of implementation — code can lag these names during the transition. - **Drawer** — a collapsible authoring surface below a face (code, advanced slots, bus wiring). - **Panel state** — unauthored runtime writer state per (scope, channel); - persisted to `.lp/state.json` with throttled writes; never dirties the + persisted to `.lp/panel.json` with throttled writes; never dirties the project. (module model) - **Engaged (Latch)** — a panel control whose lazy runtime writer has materialized (it was touched): it captures the channel, overriding 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/idle.json b/examples/fyeah-sign/idle.json index f477d5fea..c9929e51f 100644 --- a/examples/fyeah-sign/idle.json +++ b/examples/fyeah-sign/idle.json @@ -3,6 +3,11 @@ "source": "idle.glsl", "render_order": 0, "float_mode": "fixed", + "bindings": { + "glow": { + "source": "bus:glow" + } + }, "consumed": { "time": { "kind": "value", 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 79aa7c792..78255e86f 100644 --- a/justfile +++ b/justfile @@ -575,7 +575,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-boards/src/firmware_join.rs b/lp-app/lpa-boards/src/firmware_join.rs index 28a84f007..de239c3b8 100644 --- a/lp-app/lpa-boards/src/firmware_join.rs +++ b/lp-app/lpa-boards/src/firmware_join.rs @@ -511,7 +511,7 @@ pub fn feature_summary(build: &FirmwareBuild) -> Option { /// user-facing product names, not identifiers. 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", diff --git a/lp-app/lpa-client/src/client.rs b/lp-app/lpa-client/src/client.rs index eb7361441..10b4269f3 100644 --- a/lp-app/lpa-client/src/client.rs +++ b/lp-app/lpa-client/src/client.rs @@ -456,6 +456,48 @@ where } } + /// Engage (or update) a panel writer at `(scope, channel)` — the + /// runtime command channel, so nothing is staged and nothing turns + /// dirty. A stale gesture comes back as a normal `Rejected`. + pub async fn project_panel_write( + &mut self, + handle: WireProjectHandle, + request: lpc_wire::WirePanelWriteRequest, + ) -> ClientResult> { + let response = self + .project_command(handle, WireProjectCommand::PanelWrite { request }) + .await?; + match response.value { + WireProjectCommandResponse::PanelWrite { response: value } => { + Ok(ClientOutcome::new(value, response.events)) + } + other => Err(ClientError::unexpected_response( + "project.panel_write", + other, + )), + } + } + + /// Clear engaged panel writers: one control, one scope, or everything. + pub async fn project_panel_clear( + &mut self, + handle: WireProjectHandle, + request: lpc_wire::WirePanelClearRequest, + ) -> ClientResult> { + let response = self + .project_command(handle, WireProjectCommand::PanelClear { request }) + .await?; + match response.value { + WireProjectCommandResponse::PanelClear { response: value } => { + Ok(ClientOutcome::new(value, response.events)) + } + other => Err(ClientError::unexpected_response( + "project.panel_clear", + other, + )), + } + } + /// Dispatch a runtime node command (playlist activate-entry, future sim /// pokes) and return the server's accepted/rejected outcome. pub async fn project_node_command( 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 08188f8f3..0ea97fb0a 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/src/handlers.rs b/lp-app/lpa-server/src/handlers.rs index f0cabfc9e..8d5a0624e 100644 --- a/lp-app/lpa-server/src/handlers.rs +++ b/lp-app/lpa-server/src/handlers.rs @@ -150,6 +150,12 @@ fn handle_project_command( response: project.node_command(node, &command), }) } + WireProjectCommand::PanelWrite { request } => Ok(WireProjectCommandResponse::PanelWrite { + response: project.panel_write(&request), + }), + WireProjectCommand::PanelClear { request } => Ok(WireProjectCommandResponse::PanelClear { + response: project.panel_clear(&request), + }), } } diff --git a/lp-app/lpa-server/src/lib.rs b/lp-app/lpa-server/src/lib.rs index a62ffb9f8..0dae6885e 100644 --- a/lp-app/lpa-server/src/lib.rs +++ b/lp-app/lpa-server/src/lib.rs @@ -14,6 +14,7 @@ pub mod device_identity; pub mod error; pub mod file_sync; pub mod handlers; +pub mod panel_state; pub mod project; pub mod project_manager; mod project_read_source; diff --git a/lp-app/lpa-server/src/panel_state.rs b/lp-app/lpa-server/src/panel_state.rs new file mode 100644 index 000000000..e63d60c92 --- /dev/null +++ b/lp-app/lpa-server/src/panel_state.rs @@ -0,0 +1,180 @@ +//! Panel state persistence: `/.lp/panel.json` (panel.md P10/P11). +//! +//! Latched panel writers outlive a power cycle. The defining scenario is +//! the scarf: dimmed from a phone at 4 a.m., unplugged, replugged — it +//! must come back dim, with **not one bright frame**. That requirement is +//! what fixes the restore seam at project construction (before the first +//! tick, therefore before the first render), not at some later "ready" +//! event. +//! +//! The file lives in the framework-owned `/.lp/` tier inside the project's +//! own (chrooted) filesystem — never in authored artifacts. `/.lp/` is +//! already excluded from the canonical package hash +//! (`lpc_history::is_hashed_path`) and from snapshots, so panel state can +//! never destabilize a hash or show up as a device diff. +//! +//! Keys are `scope-path / channel` — the STABLE identity from +//! [`lpc_engine::node::ScopeRef::persist_path`] (tree paths and authored +//! entry keys, never runtime ids or indices), so state survives reload, +//! reattach, and sibling reorder. An unknown scope path on load is simply +//! dropped: vendoring and renames degrade gracefully rather than failing +//! the boot. +//! +//! Posture is the `/.lp/device.json` one — lenient load (missing, +//! unparseable, or unknown-version file → clean boot, no panic, no +//! migration; alpha bump-and-refuse) and best-effort write (a full flash +//! must never fail a frame). + +extern crate alloc; + +use alloc::string::String; +use alloc::vec::Vec; + +use lpc_engine::Engine; +use lpc_engine::node::ScopeRef; +use lpc_model::{AsLpPath, ChannelName, LpValue}; +use lpfs::LpFs; +use serde::{Deserialize, Serialize}; + +/// Path of the panel-state file inside the project's own filesystem. +pub const PANEL_STATE_PATH: &str = "/.lp/panel.json"; + +/// Format version of the panel-state file. Bump-and-refuse: a file +/// carrying any other version is ignored wholesale (alpha posture — we +/// never migrate panel state, and a dropped file costs one re-dim). +pub const PANEL_STATE_VERSION: u32 = 1; + +/// Minimum spacing between panel-state writes, for flash preservation +/// (panel.md P11): a knob wiggled for a minute writes ~6 times, not once +/// per input event. +pub const PANEL_STATE_WRITE_INTERVAL_MS: u32 = 10_000; + +/// The persisted file. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PanelStateFile { + pub version: u32, + /// Whether panel state keeps saving (panel.md P11 — on by default). + /// Persisted with the state so the choice itself survives a reboot. + #[serde(default = "auto_save_default")] + pub auto_save: bool, + /// One entry per engaged latching writer. Engagement is implied by + /// presence; momentary writers (P14) are never written here. + #[serde(default)] + pub entries: Vec, +} + +fn auto_save_default() -> bool { + true +} + +/// One persisted panel writer: `scope-path / channel → value`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PanelStateEntry { + /// The owning scope's stable persist path. + pub scope: String, + pub channel: String, + /// The RAW held value (panel.md P7 — emission clamps, storage never + /// does, so a range that shrinks and grows back restores exactly). + pub value: LpValue, +} + +/// Project the engine's engaged latching writers into a persistable file. +/// +/// Momentary writers are skipped by construction (P14: they despawn, and +/// a deadline that outlived a reboot would be meaningless). A writer whose +/// scope owner has vanished is skipped too — it has no stable key. +pub fn snapshot(engine: &Engine, auto_save: bool) -> PanelStateFile { + let mut entries: Vec = engine + .panel_writers() + .iter() + .filter(|(_, writer)| writer.expires_at_ms.is_none()) + .filter_map(|((scope, channel), writer)| { + Some(PanelStateEntry { + scope: engine.tree().scope_persist_path(*scope)?, + channel: channel.0.clone(), + value: writer.value.clone(), + }) + }) + .collect(); + // Deterministic on-disk order: the file is diffed by humans and + // compared by tests, and writer iteration order is an implementation + // detail of the store. + entries.sort_by(|a, b| (&a.scope, &a.channel).cmp(&(&b.scope, &b.channel))); + PanelStateFile { + version: PANEL_STATE_VERSION, + auto_save, + entries, + } +} + +/// Read the panel-state file. Missing, unparseable, or wrong-version → +/// `None` (boot clean). +pub fn read(fs: &dyn LpFs) -> Option { + let bytes = fs.read_file(PANEL_STATE_PATH.as_path()).ok()?; + let file = lpc_wire::json::from_slice::(&bytes).ok()?; + if file.version != PANEL_STATE_VERSION { + log::warn!( + "panel state: ignoring /.lp/panel.json with unknown version {} (expected {})", + file.version, + PANEL_STATE_VERSION + ); + return None; + } + Some(file) +} + +/// Write the panel-state file. Best-effort: a write failure is logged and +/// swallowed — persistence must never fail a frame or a shutdown. +pub fn write(fs: &dyn LpFs, file: &PanelStateFile) { + let json = match lpc_wire::json::to_string(file) { + Ok(json) => json, + Err(error) => { + log::warn!("panel state: failed to encode /.lp/panel.json: {error:?}"); + return; + } + }; + if let Err(error) = fs.write_file(PANEL_STATE_PATH.as_path(), json.as_bytes()) { + log::warn!("panel state: failed to write /.lp/panel.json: {error}"); + } +} + +/// Re-materialize persisted writers into the engine's store. +/// +/// Returns the restored `auto_save` preference (defaulting to on when +/// there is no usable file). Entries naming a scope this project no longer +/// has are dropped — that is the graceful-degradation rule, not an error. +pub fn restore(fs: &dyn LpFs, engine: &mut Engine) -> bool { + let Some(file) = read(fs) else { + return auto_save_default(); + }; + for entry in &file.entries { + let Some(scope) = scope_by_persist_path(engine, &entry.scope) else { + log::debug!( + "panel state: dropping entry for unknown scope {}", + entry.scope + ); + continue; + }; + engine.panel_write( + scope, + ChannelName(entry.channel.clone()), + entry.value.clone(), + None, + ); + } + log::info!( + "panel state: restored {} engaged control(s) (auto_save={})", + file.entries.len(), + file.auto_save + ); + file.auto_save +} + +/// The scope whose stable persist path is `path`, if this project has one. +fn scope_by_persist_path(engine: &Engine, path: &str) -> Option { + engine + .tree() + .scopes() + .into_iter() + .find(|scope| engine.tree().scope_persist_path(*scope).as_deref() == Some(path)) +} diff --git a/lp-app/lpa-server/src/project.rs b/lp-app/lpa-server/src/project.rs index fc7c1161f..b8e9297bd 100644 --- a/lp-app/lpa-server/src/project.rs +++ b/lp-app/lpa-server/src/project.rs @@ -3,8 +3,9 @@ extern crate alloc; use crate::error::ServerError; +use crate::panel_state::{self, PANEL_STATE_WRITE_INTERVAL_MS}; use crate::server::MemoryStatsFn; -use alloc::{boxed::Box, format, rc::Rc, string::String, sync::Arc}; +use alloc::{boxed::Box, format, rc::Rc, string::String, sync::Arc, vec::Vec}; use core::cell::RefCell; use lpc_engine::{ButtonService, Engine, EngineServices, LpGraphics, ProjectLoader, RadioService}; use lpc_hardware::HwEndpointSpec; @@ -47,6 +48,20 @@ pub struct Project { runtime: Option, /// Last filesystem version processed by this project last_fs_version: FsVersion, + /// How many fs-event batches actually reached + /// `apply_project_changes`. Framework-tier writes (`/.lp/**`) are + /// filtered out before that seam, so this is the diagnostic that says + /// whether a write cost a binding-graph rebuild. + applied_refreshes: u64, + /// Whether panel state keeps saving (panel.md P11 — on by default, + /// restored from the state file itself). + panel_auto_save: bool, + /// Engine-time since the last panel-state write, for the flash + /// preservation throttle. + panel_state_age_ms: u32, + /// Writer-store mutation count as of the last write, so an idle + /// project writes nothing however long it runs. + panel_state_saved_mutations: u64, } impl Project { @@ -92,7 +107,19 @@ impl Project { runtime.set_graphics(Some(graphics.clone())); log_memory(memory_stats, "project new after graphics"); + // Panel state comes back BEFORE the first tick, therefore before + // the first render (panel.md P10): the scarf that was dimmed from + // a phone must not flash bright on replug, not for one frame. + // This is the boot seam on device too — `auto_load_project` runs + // here, ahead of the main loop. + backtrace::set_oom_context("project new: restore panel state"); + let panel_auto_save = { + let fs_ref = fs.borrow(); + panel_state::restore(&*fs_ref, &mut runtime) + }; + backtrace::set_oom_context("project new: build wrapper"); + let panel_state_saved_mutations = runtime.panel_writers().mutations(); let project = Self { name, path: path.to_path_buf(), @@ -106,6 +133,10 @@ impl Project { registry, runtime: Some(runtime), last_fs_version: loaded_fs_version.next(), + applied_refreshes: 0, + panel_auto_save, + panel_state_age_ms: 0, + panel_state_saved_mutations, }; log_memory(memory_stats, "project new after wrapper"); backtrace::clear_oom_context(); @@ -161,9 +192,90 @@ impl Project { .runtime .as_mut() .expect("project runtime is only absent while reloading"); - runtime + let result = runtime .tick(registry, delta_ms) - .map_err(|e| ServerError::Core(format!("{e}"))) + .map_err(|e| ServerError::Core(format!("{e}"))); + // Persistence rides the tick, throttled — a failed frame still + // gets its panel state written, since a crash loop is exactly + // when losing the user's dim would hurt most. + self.persist_panel_state_if_due(delta_ms); + result + } + + /// Write panel state if it changed and the throttle window has + /// elapsed (panel.md P11: flash preservation — a knob wiggled for a + /// minute writes ~6 times, not once per input event). + fn persist_panel_state_if_due(&mut self, delta_ms: u32) { + self.panel_state_age_ms = self.panel_state_age_ms.saturating_add(delta_ms); + if !self.panel_auto_save || self.panel_state_age_ms < PANEL_STATE_WRITE_INTERVAL_MS { + return; + } + if self.panel_state_mutations() == self.panel_state_saved_mutations { + // Nothing engaged, cleared, or moved since the last write: + // an idle project never touches flash however long it runs. + self.panel_state_age_ms = 0; + return; + } + self.write_panel_state(); + } + + /// Write panel state now, regardless of the throttle — the + /// clean-shutdown flush (panel.md P11), so the last few seconds of a + /// gesture are not lost on an orderly stop. + pub fn flush_panel_state(&mut self) { + if !self.panel_auto_save || self.panel_state_mutations() == self.panel_state_saved_mutations + { + return; + } + self.write_panel_state(); + } + + fn write_panel_state(&mut self) { + let file = { + let runtime = self + .runtime + .as_ref() + .expect("project runtime is only absent while reloading"); + panel_state::snapshot(runtime, self.panel_auto_save) + }; + let fs_ref = self.fs.borrow(); + panel_state::write(&*fs_ref, &file); + self.panel_state_saved_mutations = self.panel_state_mutations(); + self.panel_state_age_ms = 0; + } + + fn panel_state_mutations(&self) -> u64 { + self.runtime + .as_ref() + .map(|runtime| runtime.panel_writers().mutations()) + .unwrap_or(self.panel_state_saved_mutations) + } + + /// Whether panel state is being saved (panel.md P11 — on by default). + pub fn panel_auto_save(&self) -> bool { + self.panel_auto_save + } + + /// Turn panel-state saving on or off. The choice is itself persisted, + /// so it survives a reboot; turning it OFF rewrites the file once so + /// the next boot knows, and turning it on flushes current state. + pub fn set_panel_auto_save(&mut self, auto_save: bool) { + if self.panel_auto_save == auto_save { + return; + } + self.panel_auto_save = auto_save; + let file = { + let runtime = self + .runtime + .as_ref() + .expect("project runtime is only absent while reloading"); + panel_state::snapshot(runtime, auto_save) + }; + let fs_ref = self.fs.borrow(); + panel_state::write(&*fs_ref, &file); + drop(fs_ref); + self.panel_state_saved_mutations = self.panel_state_mutations(); + self.panel_state_age_ms = 0; } /// Resolve the visual product handle currently carried by a bus channel. @@ -236,6 +348,57 @@ impl Project { } } + /// Dispatch a panel write (panel.md P8): engage/update the writer at + /// `(scope, channel)`. Runtime state only — no overlay, no dirty. + pub fn panel_write( + &mut self, + request: &lpc_wire::WirePanelWriteRequest, + ) -> lpc_wire::WirePanelCommandResponse { + let scope = lpc_engine::node::ScopeRef::from_wire(request.scope); + // A write to a scope no node introduces is a stale gesture from a + // client racing an edit — reject normally, never poison. + let engine = self.engine_mut(); + if engine.tree().get(scope.owner()).is_none() { + return lpc_wire::WirePanelCommandResponse::Rejected { + reason: alloc::format!("unknown scope owner {:?}", scope.owner()), + }; + } + engine.panel_write( + scope, + lpc_model::ChannelName(request.channel.clone()), + request.value.clone(), + request.ttl_ms, + ); + lpc_wire::WirePanelCommandResponse::Accepted { + engaged: engine.panel_writers().len() as u32, + } + } + + /// Dispatch a panel clear (panel.md P3; P-Q4: `All` reaches sink + /// scopes too). + pub fn panel_clear( + &mut self, + request: &lpc_wire::WirePanelClearRequest, + ) -> lpc_wire::WirePanelCommandResponse { + let engine = self.engine_mut(); + match request { + lpc_wire::WirePanelClearRequest::Channel { scope, channel } => { + let scope = lpc_engine::node::ScopeRef::from_wire(*scope); + engine.panel_clear(scope, &lpc_model::ChannelName(channel.clone())); + } + lpc_wire::WirePanelClearRequest::Scope { scope } => { + let scope = lpc_engine::node::ScopeRef::from_wire(*scope); + engine.panel_clear_scope(scope); + } + lpc_wire::WirePanelClearRequest::All => { + engine.panel_clear_all(); + } + } + lpc_wire::WirePanelCommandResponse::Accepted { + engaged: engine.panel_writers().len() as u32, + } + } + pub fn mutate_overlay( &mut self, request: WireOverlayMutationRequest, @@ -370,6 +533,22 @@ impl Project { } pub fn refresh_artifacts(&mut self, events: &[FsEvent]) -> Result<(), ServerError> { + // The framework tier is not authored content. Panel state + // (`/.lp/panel.json`) is written from inside the tick, and the + // write fires an FsEvent right back at us — without this filter + // every ~10s save would clear and re-register the whole binding + // graph, and a knob left engaged would rebuild the project + // forever. Filter FIRST, before anything reads the batch. + let events: Vec = events + .iter() + .filter(|event| is_project_artifact_path(&event.path)) + .cloned() + .collect(); + if events.is_empty() { + return Ok(()); + } + self.applied_refreshes = self.applied_refreshes.saturating_add(1); + let events = events.as_slice(); let frame = current_revision(); let shapes = self.engine().slot_shapes().clone(); let ctx = ParseCtx { shapes: &shapes }; @@ -422,6 +601,18 @@ impl Project { log_memory(self.memory_stats, "project reload after core project"); backtrace::set_oom_context("project reload: set graphics"); runtime.set_graphics(Some(self.graphics.clone())); + // Reload rebuilds the Engine — and with it an empty writer store — + // so panel state must be restored here too, on the same + // before-first-frame rule as `new()`. (`apply_project_changes` + // does NOT rebuild the Engine, which is why an ordinary edit + // leaves engaged writers alone and touches no file.) + backtrace::set_oom_context("project reload: restore panel state"); + self.panel_auto_save = { + let fs_ref = self.fs.borrow(); + panel_state::restore(&*fs_ref, &mut runtime) + }; + self.panel_state_saved_mutations = runtime.panel_writers().mutations(); + self.panel_state_age_ms = 0; self.registry = registry; self.runtime = Some(runtime); log_memory(self.memory_stats, "project reload after swap"); @@ -429,6 +620,12 @@ impl Project { Ok(()) } + /// How many fs-event batches actually rebuilt project state. A write + /// into the framework tier (`/.lp/**`) must never move this. + pub fn applied_refresh_count(&self) -> u64 { + self.applied_refreshes + } + /// Get the last filesystem version processed by this project pub fn last_fs_version(&self) -> FsVersion { self.last_fs_version @@ -497,6 +694,20 @@ impl OutputProvider for SharedOutputProvider { } } +/// Whether a project-relative fs path is authored content, as opposed to +/// the framework-owned `/.lp/` tier (panel state, meta) that the project +/// itself writes. +/// +/// This is the same boundary `lpc_history::is_hashed_path` draws for the +/// canonical package hash and `SnapshotStore` draws for device copies — +/// stated once more here because the artifact refresh path is a third +/// consumer of it, and the one where getting it wrong costs a rebuild +/// loop rather than a wrong hash. +fn is_project_artifact_path(path: &LpPath) -> bool { + let path = path.as_str(); + path != lpc_history::hash::hash_rules::RESERVED_META_DIR && !path.starts_with("/.lp/") +} + fn project_root_path(name: &str) -> Result { let mut sanitized = String::new(); for c in name.chars() { @@ -522,9 +733,27 @@ fn project_root_path(name: &str) -> Result { #[cfg(test)] mod tests { - use lpc_model::TreePath; + use lpc_model::{LpPath, TreePath}; - use super::project_root_path; + use super::{is_project_artifact_path, project_root_path}; + + #[test] + fn the_framework_tier_is_not_an_artifact_change() { + // Panel state is written from inside the tick; if these counted as + // artifact changes, each save would rebuild the binding graph and + // the rebuild would fire the next save. + assert!(!is_project_artifact_path(LpPath::new("/.lp"))); + assert!(!is_project_artifact_path(LpPath::new("/.lp/panel.json"))); + assert!(!is_project_artifact_path(LpPath::new("/.lp/meta.json"))); + assert!(!is_project_artifact_path(LpPath::new("/.lp/nested/x.json"))); + + assert!(is_project_artifact_path(LpPath::new("/project.json"))); + assert!(is_project_artifact_path(LpPath::new("/module.json"))); + assert!(is_project_artifact_path(LpPath::new("/shader.glsl"))); + // Not a prefix match on the name: a real artifact may start with + // the same letters. + assert!(is_project_artifact_path(LpPath::new("/.lponly.json"))); + } #[test] fn project_root_path_accepts_demo_folder_names() { diff --git a/lp-app/lpa-server/src/project_manager.rs b/lp-app/lpa-server/src/project_manager.rs index b3d91b384..f8f8e66e8 100644 --- a/lp-app/lpa-server/src/project_manager.rs +++ b/lp-app/lpa-server/src/project_manager.rs @@ -169,11 +169,15 @@ impl ProjectManager { /// Removes the project from memory but doesn't delete it from the filesystem. /// Output channels and other resources are freed before removal. pub fn unload_project(&mut self, handle: ProjectHandle) -> Result<(), ServerError> { - let project = self + let mut project = self .projects .remove(&handle) .ok_or_else(|| ServerError::ProjectNotFound(format!("handle {}", handle.id())))?; + // The clean-shutdown flush (panel.md P11): an orderly unload is + // the one moment we can beat the ~10 s throttle, so the last few + // seconds of a gesture are not lost. + project.flush_panel_state(); let name = project.name(); self.name_to_handle.remove(name); @@ -186,6 +190,9 @@ impl ProjectManager { /// Output channels and other resources are freed before removal. /// Note: next_handle_id is not reset - handles continue incrementing. pub fn unload_all_projects(&mut self) -> Result<(), ServerError> { + for project in self.projects.values_mut() { + project.flush_panel_state(); + } self.projects.clear(); self.name_to_handle.clear(); Ok(()) diff --git a/lp-app/lpa-server/tests/binding_apply_live.rs b/lp-app/lpa-server/tests/binding_apply_live.rs index e09a3b961..2a8730f70 100644 --- a/lp-app/lpa-server/tests/binding_apply_live.rs +++ b/lp-app/lpa-server/tests/binding_apply_live.rs @@ -90,7 +90,7 @@ fn authored_binding_to_the_default_channel_wins_over_the_default() { .filter(|binding| { matches!( &binding.endpoint, - WireBindingEndpoint::Bus { channel } if channel == "time" + WireBindingEndpoint::Bus { channel, .. } if channel == "time" ) && binding.direction == lpc_wire::WireBindingDirection::Publishes }) .collect(); @@ -161,7 +161,7 @@ fn assert_delta_binding(project: &mut Project, when: &str) { .find(|binding| { matches!( &binding.endpoint, - WireBindingEndpoint::Bus { channel } if channel == "delta-t" + WireBindingEndpoint::Bus { channel, .. } if channel == "delta-t" ) }) .unwrap_or_else(|| { @@ -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/panel_commands.rs b/lp-app/lpa-server/tests/panel_commands.rs new file mode 100644 index 000000000..6e9f15fd5 --- /dev/null +++ b/lp-app/lpa-server/tests/panel_commands.rs @@ -0,0 +1,371 @@ +//! Panel command round-trips (panel.md P8/P9, roadmap P9): `PanelWrite` / +//! `PanelClear` are runtime pokes on the playlist-activate pattern — +//! engage a writer at `(scope, channel)`, shadow the channel's authored +//! providers, touch NOTHING authored (no overlay entry, no dirty), and +//! converge across clients through ordinary probe pulls (last writer +//! wins at the engine). + +extern crate alloc; + +use alloc::boxed::Box; +use alloc::rc::Rc; +use alloc::string::ToString; +use alloc::sync::Arc; +use core::cell::RefCell; + +use lp_gfx_lpvm::TargetLpvmGraphics; +use lpa_server::{LpGraphics, LpServer, Project}; +use lpc_model::{AsLpPath, LpPathBuf, LpValue}; +use lpc_shared::output::MemoryOutputProvider; +use lpc_wire::{ + BindingGraphProbeRequest, BindingGraphProbeResult, WireBindingGraph, WireBindingOrigin, + WirePanelClearRequest, WirePanelCommandResponse, WirePanelWriteRequest, WireProjectHandle, + WireScopeRef, +}; +use lpfs::LpFsMemory; + +#[test] +fn panel_write_engages_shadows_and_clears_through_the_probe() { + let (mut server, project_path) = server_with_clock_project("panel-roundtrip"); + let handle = server.load_project(project_path.as_path()).expect("load"); + server.advance_frame(16).expect("tick"); + + let project = project_mut(&mut server, handle); + let (scope, authored_value) = time_channel(&probe(project)); + assert!( + authored_value.is_some(), + "clock publishes bus:time before any panel writer exists" + ); + + // Write: the writer engages and the channel resolves to the held value. + let response = project.panel_write(&WirePanelWriteRequest { + scope, + channel: "time".to_string(), + value: LpValue::F32(123.5), + ttl_ms: None, + }); + assert_eq!(response, WirePanelCommandResponse::Accepted { engaged: 1 }); + + let graph = probe(project); + let (_, held) = time_channel(&graph); + assert_eq!( + held, + Some(LpValue::F32(123.5)), + "the engaged writer shadows the clock's authored publish" + ); + assert!( + panel_provider_engaged(&graph), + "the probe surfaces the writer as a Panel-origin provider row" + ); + + // Clear: authored wiring returns, the Panel row is gone. + let response = project.panel_clear(&WirePanelClearRequest::Channel { + scope, + channel: "time".to_string(), + }); + assert_eq!(response, WirePanelCommandResponse::Accepted { engaged: 0 }); + + let graph = probe(project); + let (_, released) = time_channel(&graph); + assert_ne!( + released, + Some(LpValue::F32(123.5)), + "clearing releases the held value back to the clock" + ); + assert!( + !panel_provider_engaged(&graph), + "no Panel-origin provider row remains after clear" + ); +} + +#[test] +fn panel_writes_touch_nothing_authored() { + let (mut server, project_path) = server_with_clock_project("panel-no-dirty"); + let handle = server.load_project(project_path.as_path()).expect("load"); + server.advance_frame(16).expect("tick"); + + let project = project_mut(&mut server, handle); + let (scope, _) = time_channel(&probe(project)); + + project.panel_write(&WirePanelWriteRequest { + scope, + channel: "time".to_string(), + value: LpValue::F32(9.0), + ttl_ms: None, + }); + + let overlay = project.read_overlay(); + assert!( + overlay.overlay.is_empty(), + "a panel write stages no overlay entry (nothing dirty, nothing to save): {:?}", + overlay.overlay + ); +} + +#[test] +fn concurrent_writes_converge_on_the_last_writer() { + // Two clients fighting one knob (panel.md P9): both speak the same + // command channel, the engine keeps ONE writer per (scope, channel), + // and every probe pull reflects whoever moved last. + let (mut server, project_path) = server_with_clock_project("panel-last-writer"); + let handle = server.load_project(project_path.as_path()).expect("load"); + server.advance_frame(16).expect("tick"); + + let project = project_mut(&mut server, handle); + let (scope, _) = time_channel(&probe(project)); + + for value in [1.0_f32, 2.0, 7.75] { + let response = project.panel_write(&WirePanelWriteRequest { + scope, + channel: "time".to_string(), + value: LpValue::F32(value), + ttl_ms: None, + }); + assert_eq!( + response, + WirePanelCommandResponse::Accepted { engaged: 1 }, + "re-writes update the one writer, never stack a second" + ); + } + + let (_, held) = time_channel(&probe(project)); + assert_eq!(held, Some(LpValue::F32(7.75)), "last writer wins"); +} + +#[test] +fn a_stale_scope_rejects_normally() { + // A gesture racing a structural edit may name a scope whose owner is + // gone; that is a normal Rejected response, never a wire error. + let (mut server, project_path) = server_with_clock_project("panel-stale-scope"); + let handle = server.load_project(project_path.as_path()).expect("load"); + server.advance_frame(16).expect("tick"); + + let project = project_mut(&mut server, handle); + let response = project.panel_write(&WirePanelWriteRequest { + scope: WireScopeRef::Module { + owner: lpc_model::NodeId::new(9999), + }, + channel: "time".to_string(), + value: LpValue::F32(1.0), + ttl_ms: None, + }); + assert!( + matches!(response, WirePanelCommandResponse::Rejected { .. }), + "unknown scope owner rejects: {response:?}" + ); +} + +// --------------------------------------------------------------------------- + +fn probe(project: &mut Project) -> WireBindingGraph { + let (engine, registry) = project.runtime_read_parts(); + let result = engine.read_project_binding_graph_probe( + registry, + BindingGraphProbeRequest { + include_values: true, + }, + ); + let BindingGraphProbeResult::Graph(graph) = result else { + panic!("expected graph result"); + }; + graph +} + +/// The `time` channel's scope and current resolved value. +fn time_channel(graph: &WireBindingGraph) -> (WireScopeRef, Option) { + let channel = graph + .channels + .iter() + .find(|channel| channel.name == "time") + .expect("clock publishes bus:time"); + let scope = channel.scope.expect("channels list scoped"); + let value = channel.value.as_ref().and_then(|value| value.value.clone()); + (scope, value) +} + +/// Whether the `time` channel lists a Panel-origin provider row. +fn panel_provider_engaged(graph: &WireBindingGraph) -> bool { + graph + .channels + .iter() + .find(|channel| channel.name == "time") + .is_some_and(|channel| { + channel.providers.iter().any(|index| { + graph + .bindings + .get(*index as usize) + .is_some_and(|binding| binding.origin == WireBindingOrigin::Panel) + }) + }) +} + +fn project_mut(server: &mut LpServer, handle: WireProjectHandle) -> &mut Project { + server + .project_manager_mut() + .get_project_mut(handle) + .expect("loaded project") +} + +fn server_with_clock_project(name: &str) -> (LpServer, LpPathBuf) { + let output_provider = Rc::new(RefCell::new(MemoryOutputProvider::new())); + let graphics: Arc = + Arc::new(TargetLpvmGraphics::new(lpa_server::DEVICE_SHADER_FRONTEND)); + let mut server = LpServer::new( + output_provider, + Box::new(LpFsMemory::new()), + "projects".as_path(), + None, + None, + graphics, + ); + let project_path = LpPathBuf::from("/projects").join(name); + + server + .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": "Module", + "nodes": { + "clock": { + "ref": "./clock.json" + } + } +} +"#, + ) + .expect("write project"); + server + .base_fs_mut() + .write_file( + project_path.join("clock.json").as_path(), + br#" +{ + "kind": "Clock", + "controls": { + "rate": 1.0 + } +} +"#, + ) + .expect("write clock"); + + (server, project_path) +} + +// --------------------------------------------------------------------------- + +/// The full authored path a panel control depends on, which no other test +/// covers end to end: a shader uniform with `bindings: { glow: { source: +/// "bus:glow" } }` must reach the probe as a CONSUMING Bus endpoint that +/// carries a scope. That endpoint is the only thing Studio turns into a +/// `UiPanelTarget`, so if the scope is absent the knob silently stays an +/// ordinary slot editor and every panel behaviour downstream is untested. +#[test] +fn an_authored_bus_binding_on_a_uniform_reaches_the_probe_with_a_scope() { + let (mut server, project_path) = server_with_glow_shader_project("panel-authored-bind"); + let handle = server.load_project(project_path.as_path()).expect("load"); + server.advance_frame(16).expect("tick"); + + let project = project_mut(&mut server, handle); + let graph = probe(project); + + let consuming = graph + .bindings + .iter() + .find(|binding| { + binding.direction == lpc_wire::WireBindingDirection::Consumes + && matches!( + &binding.endpoint, + lpc_wire::WireBindingEndpoint::Bus { channel, .. } if channel == "glow" + ) + }) + .unwrap_or_else(|| { + panic!( + "no consuming binding to bus:glow; endpoints: {:?}", + graph + .bindings + .iter() + .map(|b| (&b.direction, &b.endpoint)) + .collect::>() + ) + }); + + let lpc_wire::WireBindingEndpoint::Bus { scope, .. } = &consuming.endpoint else { + panic!("checked above"); + }; + assert!( + scope.is_some(), + "the consuming endpoint carries its scope — Studio keys the panel \ + target on (scope, channel), so a None here means no panel control" + ); + + // ...and the channel lists in that scope, which is what makes a control + // appear at all (modules.md R6: an unwritten channel still lists). + assert!( + graph.channels.iter().any(|c| c.name == "glow"), + "bus:glow lists even with no writer: {:?}", + graph + .channels + .iter() + .map(|c| &c.name) + .collect::>() + ); +} + +/// One shader whose panel-flagged `glow` uniform consumes `bus:glow` — +/// the shape the G4 walk project uses. +fn server_with_glow_shader_project(name: &str) -> (LpServer, LpPathBuf) { + let (mut server, project_path) = server_with_clock_project(name); + server + .base_fs_mut() + .write_file( + project_path.join("module.json").as_path(), + br#" +{ + "kind": "Module", + "nodes": { + "clock": { "ref": "./clock.json" }, + "idle": { "ref": "./idle.json" } + } +} +"#, + ) + .expect("write module"); + server + .base_fs_mut() + .write_file( + project_path.join("idle.json").as_path(), + br#" +{ + "kind": "Shader", + "source": "idle.glsl", + "float_mode": "fixed", + "bindings": { "glow": { "source": "bus:glow" } }, + "consumed": { + "glow": { + "kind": "value", "value": "f32", "default": 0.5, + "min": 0, "max": 1, "label": "Glow", "panel": true + } + } +} +"#, + ) + .expect("write shader"); + server + .base_fs_mut() + .write_file( + project_path.join("idle.glsl").as_path(), + b"void main() { out_color = vec4(glow, glow, glow, 1.0); }", + ) + .expect("write glsl"); + (server, project_path) +} diff --git a/lp-app/lpa-server/tests/panel_persistence.rs b/lp-app/lpa-server/tests/panel_persistence.rs new file mode 100644 index 000000000..0da2ab376 --- /dev/null +++ b/lp-app/lpa-server/tests/panel_persistence.rs @@ -0,0 +1,477 @@ +//! Panel state persistence (panel.md P10/P11, roadmap P10). +//! +//! The requirement these tests exist for, verbatim from the design: *4 +//! a.m., Burning Man, LED scarf dimmed from a phone; unplug, replug — it +//! must come back dim, with not one bright frame.* So the interesting +//! assertion is never "the value came back eventually" but "the value was +//! already there before the first tick". +//! +//! Per settled D-B, persistence is device-first and both sim tiers run on +//! `LpFsMemory`; these unit tests ARE the correctness story, and the +//! device walk (G4) is the confirmation. + +extern crate alloc; + +use alloc::boxed::Box; +use alloc::rc::Rc; +use alloc::string::{String, ToString}; +use alloc::sync::Arc; +use core::cell::RefCell; + +use lp_gfx_lpvm::TargetLpvmGraphics; +use lpa_server::panel_state::{PANEL_STATE_PATH, PANEL_STATE_WRITE_INTERVAL_MS, PanelStateFile}; +use lpa_server::{LpGraphics, LpServer, Project}; +use lpc_model::{AsLpPath, LpPath, LpPathBuf, LpValue}; +use lpc_shared::output::MemoryOutputProvider; +use lpc_wire::{ + BindingGraphProbeRequest, BindingGraphProbeResult, WireBindingGraph, WirePanelClearRequest, + WirePanelWriteRequest, WireProjectHandle, WireScopeRef, +}; +use lpfs::{LpFs, LpFsMemory, LpFsView}; + +/// One tick shorter than the throttle window, so a test can step right up +/// to the boundary without crossing it. +const ALMOST_THE_WINDOW: u32 = PANEL_STATE_WRITE_INTERVAL_MS - 16; + +#[test] +fn a_latched_value_survives_a_reboot_and_is_there_before_the_first_frame() { + let mut harness = Harness::new("panel-persist-round-trip"); + harness.load(); + harness.write_panel("time", 42.5); + harness.advance(PANEL_STATE_WRITE_INTERVAL_MS); + + let saved = harness.state_file().expect("panel state written"); + assert_eq!(saved.entries.len(), 1, "one engaged control persisted"); + assert_eq!(saved.entries[0].channel, "time"); + assert_eq!(saved.entries[0].value, LpValue::F32(42.5)); + + // Reboot: a brand-new server over the SAME filesystem, exactly as a + // replug rebuilds everything from flash. + let mut rebooted = harness.reboot(); + // No tick yet — this is the pre-first-frame moment the scarf cares + // about. The value must already be resolving. + assert_eq!( + rebooted.channel_value("time"), + Some(LpValue::F32(42.5)), + "the held value resolves BEFORE the first frame, not after it" + ); +} + +#[test] +fn reload_restores_identically() { + // `reload()` rebuilds the Engine (and with it an empty writer store), + // so it needs the same restore as construction. + let mut harness = Harness::new("panel-persist-reload"); + harness.load(); + harness.write_panel("time", 7.25); + harness.advance(PANEL_STATE_WRITE_INTERVAL_MS); + + harness.project().reload().expect("reload"); + assert_eq!( + harness.channel_value("time"), + Some(LpValue::F32(7.25)), + "reload restores the held value" + ); +} + +#[test] +fn an_ordinary_edit_does_not_disturb_engaged_writers() { + // `apply_project_changes` does NOT rebuild the Engine, so the live + // store survives editing without a file round-trip. This is the + // reason panel writers are a side store rather than bindings. + let mut harness = Harness::new("panel-persist-edit"); + harness.load(); + harness.write_panel("time", 3.5); + + harness.touch_artifact(); + harness.advance(16); + + assert_eq!(harness.channel_value("time"), Some(LpValue::F32(3.5))); +} + +#[test] +fn writes_are_throttled_and_an_idle_project_writes_nothing() { + let mut harness = Harness::new("panel-persist-throttle"); + harness.load(); + + // A drag: many writes well inside one window. + for step in 0..20 { + harness.write_panel("time", step as f32); + harness.advance(16); + } + assert!( + harness.state_file().is_none(), + "no write before the throttle window elapses" + ); + + harness.advance(PANEL_STATE_WRITE_INTERVAL_MS); + let first = harness.state_file().expect("one write after the window"); + assert_eq!(first.entries[0].value, LpValue::F32(19.0), "latest value"); + + // Idle: the window elapses repeatedly with nothing engaged or moved. + // Flash must not be touched at all. + harness.delete_state_file(); + for _ in 0..5 { + harness.advance(PANEL_STATE_WRITE_INTERVAL_MS); + } + assert!( + harness.state_file().is_none(), + "an idle project never rewrites panel state" + ); +} + +#[test] +fn a_clean_shutdown_flushes_inside_the_window() { + let mut harness = Harness::new("panel-persist-flush"); + harness.load(); + harness.write_panel("time", 5.5); + harness.advance(ALMOST_THE_WINDOW); + assert!( + harness.state_file().is_none(), + "still inside the throttle window" + ); + + harness + .server + .project_manager_mut() + .unload_all_projects() + .expect("unload"); + + let flushed = harness.state_file().expect("shutdown flushes"); + assert_eq!(flushed.entries[0].value, LpValue::F32(5.5)); +} + +#[test] +fn auto_save_off_stops_writing_and_the_choice_itself_persists() { + let mut harness = Harness::new("panel-persist-auto-save"); + harness.load(); + harness.project().set_panel_auto_save(false); + let recorded = harness + .state_file() + .expect("turning it off records the choice"); + assert!(!recorded.auto_save); + + // With auto-save off, engaging a control changes nothing on disk — + // the file keeps the snapshot taken when it was switched off. + harness.write_panel("time", 9.0); + harness.advance(PANEL_STATE_WRITE_INTERVAL_MS * 3); + assert_eq!( + harness.state_file().expect("the file still exists"), + recorded, + "auto-save off writes nothing, however long it runs" + ); + + // And the preference outlives a reboot — otherwise "off" would silently + // turn itself back on overnight. + let rebooted = harness.reboot(); + assert!(!rebooted.project_ref().panel_auto_save()); +} + +#[test] +fn clearing_a_control_removes_its_persisted_entry() { + let mut harness = Harness::new("panel-persist-clear"); + harness.load(); + let scope = harness.scope(); + harness.write_panel("time", 4.0); + harness.advance(PANEL_STATE_WRITE_INTERVAL_MS); + assert_eq!(harness.state_file().expect("written").entries.len(), 1); + + harness + .project() + .panel_clear(&WirePanelClearRequest::Channel { + scope, + channel: "time".to_string(), + }); + harness.advance(PANEL_STATE_WRITE_INTERVAL_MS); + + assert!( + harness.state_file().expect("rewritten").entries.is_empty(), + "a cleared control leaves no persisted entry behind" + ); +} + +#[test] +fn momentary_writers_are_never_persisted() { + // P14: a gesture has no held value, and a deadline that outlived a + // reboot would be meaningless. + let mut harness = Harness::new("panel-persist-momentary"); + harness.load(); + let scope = harness.scope(); + harness.project().panel_write(&WirePanelWriteRequest { + scope, + channel: "time".to_string(), + value: LpValue::F32(1.0), + ttl_ms: Some(PANEL_STATE_WRITE_INTERVAL_MS * 10), + }); + harness.advance(PANEL_STATE_WRITE_INTERVAL_MS); + + let saved = harness.state_file().expect("a write happened"); + assert!( + saved.entries.is_empty(), + "the momentary writer is engaged but not persisted: {saved:?}" + ); +} + +#[test] +fn a_corrupt_or_wrong_version_file_boots_clean() { + for (label, body) in [ + ("corrupt", &b"{not json at all"[..]), + ("wrong version", br#"{"version":999,"auto_save":true,"entries":[]}"#), + ("empty", b""), + ( + "unknown scope", + br#"{"version":1,"auto_save":true,"entries":[{"scope":"/gone.show/nope","channel":"time","value":{"F32":1.0}}]}"#, + ), + ] { + let mut harness = Harness::new(&format!("panel-persist-lenient-{}", label.replace(' ', "-"))); + harness.write_state_file(body); + harness.load(); + // Booted, ticking, and showing authored behavior — no panic, and + // no half-applied state. + harness.advance(16); + assert!( + harness.channel_value("time").is_some(), + "{label}: the clock still drives bus:time" + ); + assert_ne!( + harness.channel_value("time"), + Some(LpValue::F32(1.0)), + "{label}: nothing was restored from an unusable file" + ); + } +} + +#[test] +fn writing_panel_state_does_not_rebuild_the_project() { + // THE prerequisite (roadmap P10): a write inside the project fs fires + // an FsEvent straight back at the refresh path. Without the framework + // -tier exclusion, every ~10 s save would clear and re-register the + // whole binding graph — and the rebuild would schedule the next save. + let mut harness = Harness::new("panel-persist-no-rebuild"); + harness.load(); + harness.advance(16); + let before = harness.project_ref().applied_refresh_count(); + + harness.write_panel("time", 2.0); + harness.advance(PANEL_STATE_WRITE_INTERVAL_MS); + assert!(harness.state_file().is_some(), "the state file was written"); + // Two more frames for the fs event to be picked up and routed. + harness.advance(16); + harness.advance(16); + + assert_eq!( + harness.project_ref().applied_refresh_count(), + before, + "a /.lp/ write must never reach apply_project_changes" + ); + + // ...while an authored write still does, so the exclusion is not just + // a broken refresh path. + harness.touch_artifact(); + harness.advance(16); + harness.advance(16); + assert!( + harness.project_ref().applied_refresh_count() > before, + "an authored artifact change still rebuilds" + ); +} + +#[test] +fn panel_state_inherits_the_framework_tier_exclusions() { + // `/.lp/` is already outside the canonical package hash and outside + // snapshots; panel.json must inherit both, or a dimmed scarf would + // read as a modified project and show up as a device diff. + assert!(!lpc_history::hash::is_hashed_path(LpPath::new( + PANEL_STATE_PATH + ))); + assert!(PANEL_STATE_PATH.starts_with("/.lp/")); +} + +// --------------------------------------------------------------------------- + +struct Harness { + server: LpServer, + project_path: LpPathBuf, + /// The one filesystem every server in a test shares — so a "reboot" + /// can be a brand-new server over the same bytes. + base_fs: Rc>, + handle: Option, + name: String, +} + +impl Harness { + fn new(name: &str) -> Self { + let base_fs: Rc> = Rc::new(RefCell::new(LpFsMemory::new())); + let server = build_server(base_fs.clone()); + let project_path = LpPathBuf::from("/projects").join(name); + let harness = Self { + server, + project_path, + base_fs, + handle: None, + name: name.to_string(), + }; + harness.write_project_files(); + harness + } + + /// A fresh server over the same filesystem — a power cycle. + fn reboot(&self) -> Harness { + let mut rebooted = Harness { + server: build_server(self.base_fs.clone()), + project_path: self.project_path.clone(), + base_fs: self.base_fs.clone(), + handle: None, + name: self.name.clone(), + }; + rebooted.load(); + rebooted + } + + fn load(&mut self) { + let handle = self + .server + .load_project(self.project_path.as_path()) + .expect("load"); + self.handle = Some(handle); + } + + fn advance(&mut self, delta_ms: u32) { + self.server.advance_frame(delta_ms).expect("tick"); + } + + fn project(&mut self) -> &mut Project { + let handle = self.handle.expect("loaded"); + self.server + .project_manager_mut() + .get_project_mut(handle) + .expect("loaded project") + } + + fn project_ref(&self) -> &Project { + let handle = self.handle.expect("loaded"); + self.server + .project_manager() + .get_project(handle) + .expect("loaded project") + } + + fn scope(&mut self) -> WireScopeRef { + let graph = self.probe(); + graph + .channels + .iter() + .find(|channel| channel.name == "time") + .expect("clock publishes bus:time") + .scope + .expect("channels are scoped") + } + + fn write_panel(&mut self, channel: &str, value: f32) { + let scope = self.scope(); + self.project().panel_write(&WirePanelWriteRequest { + scope, + channel: channel.to_string(), + value: LpValue::F32(value), + ttl_ms: None, + }); + } + + fn probe(&mut self) -> WireBindingGraph { + let (engine, registry) = self.project().runtime_read_parts(); + let result = engine.read_project_binding_graph_probe( + registry, + BindingGraphProbeRequest { + include_values: true, + }, + ); + let BindingGraphProbeResult::Graph(graph) = result else { + panic!("expected graph result"); + }; + graph + } + + fn channel_value(&mut self, channel: &str) -> Option { + self.probe() + .channels + .iter() + .find(|c| c.name == channel) + .and_then(|c| c.value.as_ref()) + .and_then(|value| value.value.clone()) + } + + fn state_path(&self) -> LpPathBuf { + self.project_path.join(".lp").join("panel.json") + } + + fn state_file(&self) -> Option { + let bytes = self + .base_fs + .borrow() + .read_file(self.state_path().as_path()) + .ok()?; + Some(lpc_wire::json::from_slice::(&bytes).expect("state file parses")) + } + + fn write_state_file(&self, body: &[u8]) { + self.base_fs + .borrow() + .write_file(self.state_path().as_path(), body) + .expect("write state file"); + } + + fn delete_state_file(&self) { + let _ = self + .base_fs + .borrow() + .delete_file(self.state_path().as_path()); + } + + /// Touch an authored artifact — a real project change, for contrast + /// with a framework-tier write. + fn touch_artifact(&self) { + self.base_fs + .borrow() + .write_file( + self.project_path.join("clock.json").as_path(), + br#"{"kind":"Clock","controls":{"rate":2.0}}"#, + ) + .expect("write clock"); + } + + fn write_project_files(&self) { + let fs = self.base_fs.borrow(); + fs.write_file( + self.project_path.join("project.json").as_path(), + b"{\n \"format\": 3\n}\n", + ) + .expect("write container manifest"); + fs.write_file( + self.project_path.join("module.json").as_path(), + br#"{"kind":"Module","nodes":{"clock":{"ref":"./clock.json"}}}"#, + ) + .expect("write module"); + fs.write_file( + self.project_path.join("clock.json").as_path(), + br#"{"kind":"Clock","controls":{"rate":1.0}}"#, + ) + .expect("write clock"); + } +} + +fn build_server(base_fs: Rc>) -> LpServer { + let output_provider = Rc::new(RefCell::new(MemoryOutputProvider::new())); + let graphics: Arc = + Arc::new(TargetLpvmGraphics::new(lpa_server::DEVICE_SHADER_FRONTEND)); + // The server owns its filesystem, but these tests need to read the + // same bytes afterwards and hand them to a second server — so each + // one gets a transparent shared VIEW rather than its own storage. + LpServer::new( + output_provider, + Box::new(LpFsView::new(base_fs, LpPath::new("/"))), + "projects".as_path(), + None, + None, + graphics, + ) +} diff --git a/lp-app/lpa-server/tests/project_fs_refresh.rs b/lp-app/lpa-server/tests/project_fs_refresh.rs index bc659afa5..42f080471 100644 --- a/lp-app/lpa-server/tests/project_fs_refresh.rs +++ b/lp-app/lpa-server/tests/project_fs_refresh.rs @@ -117,10 +117,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..28f5beaf3 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 @@ -21,6 +21,13 @@ impl UiBusView { /// One bus channel row. #[derive(Clone, Debug, PartialEq)] pub struct UiBusChannelView { + /// Structured scope this channel entry lists in — the Studio-side key + /// is `(scope, name)`; same-named channels in different scopes are + /// distinct rows. `None` for pre-scope snapshots/fixtures. + pub scope: Option, + /// Derived display label for the scope (owner node's label; empty for + /// the root scope). Presentation only — never used as a key. + pub scope_label: Option, /// Channel name (`time`, `trigger`, `visual.out`, …). pub name: String, /// Established semantic kind label, when known. @@ -140,6 +147,8 @@ mod tests { fn channel() -> UiBusChannelView { UiBusChannelView { + scope: None, + scope_label: None, name: "trigger".to_string(), kind: Some("Instant".to_string()), value: Some("msg 3".to_string()), @@ -189,7 +198,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 557793d6b..208ec69a6 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 @@ -790,7 +790,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(), @@ -817,7 +817,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/node/face/mod.rs b/lp-app/lpa-studio-core/src/app/node/face/mod.rs index f3c9520a1..4ad132883 100644 --- a/lp-app/lpa-studio-core/src/app/node/face/mod.rs +++ b/lp-app/lpa-studio-core/src/app/node/face/mod.rs @@ -24,7 +24,7 @@ mod ui_shader_face; pub use ui_fixture_face::UiFixtureFace; pub use ui_fixture_power::UiFixturePower; pub use ui_node_face::UiNodeFace; -pub use ui_panel_control::UiPanelControl; +pub use ui_panel_control::{UiPanelControl, UiPanelTarget}; pub use ui_panel_widget::UiPanelWidget; pub use ui_playlist_entry::UiPlaylistEntry; pub use ui_playlist_face::UiPlaylistFace; diff --git a/lp-app/lpa-studio-core/src/app/node/face/ui_panel_control.rs b/lp-app/lpa-studio-core/src/app/node/face/ui_panel_control.rs index ea644e253..d69ced665 100644 --- a/lp-app/lpa-studio-core/src/app/node/face/ui_panel_control.rs +++ b/lp-app/lpa-studio-core/src/app/node/face/ui_panel_control.rs @@ -5,12 +5,31 @@ use crate::{ UiSlotUnit, UiSlotValue, }; +/// The `(scope, channel)` a panel gesture writes (panel.md P1 identity), +/// carried when the control's backing slot consumes a bus channel. Present +/// = gestures dispatch `PanelWriteOp` down the runtime command channel (no +/// overlay, no dirty); absent = the control has no channel to write (an +/// unbound uniform) and keeps the authored-default slot-edit path. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UiPanelTarget { + /// The scope the write lands in (where the consuming binding reads). + pub scope: lpc_wire::WireScopeRef, + /// The bus channel the control drives. + pub channel: String, + /// Whether a panel writer is currently engaged for this target (a + /// Panel-origin provider row in the binding graph) — drives the + /// control's clear affordance. + pub engaged: bool, +} + /// A front-panel control projected from a panel-flagged slot. /// /// Panel controls sit directly on the card (no box-in-box) and open the SAME -/// detail popover as their slot row (hover-revealed corner ⓘ). Edits dispatch -/// through the standard slot write path (`SlotEditOp::SetValue` at -/// [`Self::address`]), so knob drags coalesce exactly like slot field floods. +/// detail popover as their slot row (hover-revealed corner ⓘ). A control +/// with a [`Self::panel_target`] dispatches gestures as panel writes (the +/// runtime command channel, panel.md P8); one without falls back to the +/// standard slot write path (`SlotEditOp::SetValue` at [`Self::address`]). +/// Both flood paths coalesce in the studio actor. #[derive(Clone, Debug, PartialEq)] pub struct UiPanelControl { /// Human-readable control label. @@ -28,6 +47,9 @@ pub struct UiPanelControl { /// (≤2 decimals) and absent for monotonic/time-kind channels — see /// [`crate::UiBindingEndpoint::live_value`], the source it mirrors. pub live_value: Option, + /// The `(scope, channel)` a gesture writes down the panel command + /// channel, present when the backing slot consumes a bus channel. + pub panel_target: Option, /// Optional display unit rendered near the value (e.g. "Hz", "%"). pub unit: Option, /// Interaction, dirty, bound/live, and validation state (violet when @@ -84,6 +106,7 @@ mod tests { }, value: UiSlotValue::f32(1.6), live_value: None, + panel_target: None, unit: None, state: UiSlotFieldState::editable(), aspects, diff --git a/lp-app/lpa-studio-core/src/app/node/mod.rs b/lp-app/lpa-studio-core/src/app/node/mod.rs index 8ff97e7f3..e88f25c5a 100644 --- a/lp-app/lpa-studio-core/src/app/node/mod.rs +++ b/lp-app/lpa-studio-core/src/app/node/mod.rs @@ -38,8 +38,8 @@ mod ui_slot_unit; mod ui_slot_value; pub use face::{ - UiFixtureFace, UiFixturePower, UiNodeFace, UiPanelControl, UiPanelWidget, UiPlaylistEntry, - UiPlaylistFace, UiShaderFace, + UiFixtureFace, UiFixturePower, UiNodeFace, UiPanelControl, UiPanelTarget, UiPanelWidget, + UiPlaylistEntry, UiPlaylistFace, UiShaderFace, }; pub use ui_asset_editor::UiAssetEditor; pub use ui_binding_authoring::{UiBindingAuthoring, UiBindingAuthoringDirection, UiChannelChoice}; diff --git a/lp-app/lpa-studio-core/src/app/node/ui_node_binding.rs b/lp-app/lpa-studio-core/src/app/node/ui_node_binding.rs index fc42d2b01..a932e4d5f 100644 --- a/lp-app/lpa-studio-core/src/app/node/ui_node_binding.rs +++ b/lp-app/lpa-studio-core/src/app/node/ui_node_binding.rs @@ -18,6 +18,10 @@ pub struct UiBindingEndpoint { /// enters the DTO and absent for monotonic/time-kind channels, so the /// whole-DTO change gate does not fire per engine tick (P6 item 1). pub live_value: Option, + /// The consumed bus channel's `(scope, channel)` panel-write target + /// (panel.md P1), present on Consumes-direction bus endpoints so the + /// panel control built over this wiring can dispatch panel commands. + pub panel_target: Option, } impl UiBindingEndpoint { @@ -28,6 +32,7 @@ impl UiBindingEndpoint { detail: None, default_origin: false, live_value: None, + panel_target: None, } } @@ -48,6 +53,12 @@ impl UiBindingEndpoint { self.live_value = Some(live_value.into()); self } + + /// Attach the consumed channel's panel-write target. + pub fn with_panel_target(mut self, panel_target: crate::UiPanelTarget) -> Self { + self.panel_target = Some(panel_target); + self + } } /// Binding state for a produced product or value. 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 3d8f5c41d..4f6f97a10 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 @@ -580,7 +580,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()), ] @@ -691,8 +691,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 f60f29727..c87a8fa99 100644 --- a/lp-app/lpa-studio-core/src/app/project/mod.rs +++ b/lp-app/lpa-studio-core/src/app/project/mod.rs @@ -58,9 +58,9 @@ pub use dirty_summary::DirtySummary; pub use loaded_project_choice::LoadedProjectChoice; pub use node::{ NodeClearDebugOp, NodeController, NodeControllerState, NodeCopyOp, NodeCreateOp, NodePasteOp, - NodeRemoveOp, NodeRevertOp, PlaylistActivateOp, ProjectNodeAddress, ProjectNodeTarget, - ProjectProductSubscriptionIntent, UiAddNodeMenu, UiAddNodeMenuEntry, UiAttachTarget, - UiNodeRemovePreflight, + NodeRemoveOp, NodeRevertOp, PanelClearOp, PanelWriteOp, PlaylistActivateOp, ProjectNodeAddress, + ProjectNodeTarget, ProjectProductSubscriptionIntent, UiAddNodeMenu, UiAddNodeMenuEntry, + UiAttachTarget, UiNodeRemovePreflight, }; pub use node_card_ui_state::{NodeCardDrawer, NodeCardUiState, NodeUiOp}; pub use project_connect_result::ProjectConnectResult; @@ -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/mod.rs b/lp-app/lpa-studio-core/src/app/project/node/mod.rs index 43c801b71..f9b31f72f 100644 --- a/lp-app/lpa-studio-core/src/app/project/node/mod.rs +++ b/lp-app/lpa-studio-core/src/app/project/node/mod.rs @@ -14,6 +14,7 @@ pub mod node_remove_op; pub mod node_remove_preflight; pub mod node_revert_op; pub mod node_share_op; +pub mod panel_write_op; pub mod playlist_activate_op; pub mod project_node_address; pub mod project_node_target; @@ -27,6 +28,7 @@ pub use node_remove_op::NodeRemoveOp; pub use node_remove_preflight::UiNodeRemovePreflight; pub use node_revert_op::NodeRevertOp; pub use node_share_op::{NodeCopyOp, NodePasteOp}; +pub use panel_write_op::{PanelClearOp, PanelWriteOp}; pub use playlist_activate_op::PlaylistActivateOp; pub use project_node_address::ProjectNodeAddress; pub use project_node_target::ProjectNodeTarget; diff --git a/lp-app/lpa-studio-core/src/app/project/node/node_clear_debug_op.rs b/lp-app/lpa-studio-core/src/app/project/node/node_clear_debug_op.rs index ef3efaa69..80be57477 100644 --- a/lp-app/lpa-studio-core/src/app/project/node/node_clear_debug_op.rs +++ b/lp-app/lpa-studio-core/src/app/project/node/node_clear_debug_op.rs @@ -65,7 +65,7 @@ mod tests { #[test] fn node_clear_debug_is_editor_foreground_class_with_clear_meta() { let op = NodeClearDebugOp { - node: ProjectNodeAddress::parse("/demo.project/clock.clock").unwrap(), + node: ProjectNodeAddress::parse("/demo.module/clock.clock").unwrap(), }; assert_eq!( 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 b6875b67a..09e9cb2f8 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 @@ -1016,7 +1016,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 f97f1c1cc..3d7924d3a 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 @@ -106,9 +106,41 @@ fn shader_face(sections: &[UiNodeSection]) -> Option { /// the generic sections. fn fixture_face(sections: &[UiNodeSection]) -> Option { let preview = product_of_kind(sections, UiProductKind::Control)?; - let brightness = config_rows(sections) - .into_iter() - .find_map(|slot| panel_control_from_row(slot, panel_widget(slot)?))?; + let rows = config_rows(sections); + let (key, mut brightness) = rows + .iter() + .find_map(|slot| { + Some(( + slot.key.clone(), + panel_control_from_row(slot, panel_widget(slot)?), + )) + }) + .and_then(|(key, control)| control.map(|control| (key, control)))?; + // Same rule the shader knobs follow: when the fader's slot is wired to + // a bus channel, the fader drives that channel (a panel write) rather + // than an authored default it can no longer affect. The wiring rides a + // separate binding-derived row — for a fixture it carries the SAME key + // as the authored row it decorates (unlike a shader uniform, whose + // entry key is `consumed[name]`), so this scans for whichever row + // actually carries the wiring rather than taking the first by key. + // + // NOTE (2026-08-02): no fixture can reach this yet. The project loader + // registers authored `source` bindings only for a fixed set of slot + // names per kind — for a fixture that is `input` alone — so + // `brightness` cannot currently be bound to a channel at all. This + // path exists so the fixture fader behaves like every other panel + // control the moment it can be, rather than silently lacking the + // feature; brightness is the scarf's own control and the two + // derivations must not diverge. + let field = key.rsplit('.').next().unwrap_or(&key).to_string(); + let field = field.split('[').next().unwrap_or(&field).to_string(); + let wired = || rows.iter().filter(|row| row.key == field); + if brightness.panel_target.is_none() { + brightness.panel_target = wired().find_map(|row| bound_panel_target(row)); + } + if brightness.live_value.is_none() { + brightness.live_value = wired().find_map(|row| bound_live_value(row)); + } Some(UiFixtureFace { preview, brightness, @@ -277,6 +309,15 @@ fn shader_uniform_control( .find(|row| row.key == name) .and_then(|row| bound_live_value(row)); } + // The panel-write target rides the same binding-derived row: a wired + // uniform's knob writes the consumed (scope, channel) down the command + // path; an unwired one keeps editing the authored default. + if control.panel_target.is_none() { + control.panel_target = top_rows + .iter() + .find(|row| row.key == name) + .and_then(|row| bound_panel_target(row)); + } Some(control) } @@ -309,6 +350,16 @@ fn bound_live_value(slot: &UiConfigSlot) -> Option { } } +/// A row's panel-write target: the `(scope, channel)` its bound source +/// endpoint consumes, so the control dispatches panel commands (panel.md +/// P8) instead of editing the authored default. +fn bound_panel_target(slot: &UiConfigSlot) -> Option { + match &slot.source { + UiSlotSourceState::Bound(endpoint) => endpoint.panel_target.clone(), + _ => None, + } +} + /// A map-entry row's key segment (the trailing bracket key of the row's /// key, e.g. `consumed[speed]` → `speed`, `entries[2]` → `2`). fn map_entry_name(entry: &UiConfigSlot) -> String { @@ -587,6 +638,7 @@ fn panel_control_from_row(slot: &UiConfigSlot, widget: UiPanelWidget) -> Option< widget, value: value.clone(), live_value: bound_live_value(slot), + panel_target: bound_panel_target(slot), unit: value.unit.clone(), state: slot.state.clone(), aspects: slot.visible_aspects(), @@ -643,7 +695,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] @@ -818,6 +870,65 @@ mod tests { ); } + #[test] + fn a_bus_wired_uniform_gets_a_panel_write_target() { + // panel.md P8: the knob for a uniform that CONSUMES a bus channel + // writes that (scope, channel) down the command channel instead of + // editing the authored default it can no longer affect. A uniform + // with nothing behind it keeps the slot-edit path, which is why + // the target is optional rather than assumed. + let mut sections = shader_sections(); + if let UiNodeSection::ConfigSlots(rows) = &mut sections[1] { + rows.push( + UiConfigSlot::empty("speed", "Speed").with_source(UiSlotSourceState::Bound( + UiBindingEndpoint::new("bus:glow").with_panel_target(crate::UiPanelTarget { + scope: lpc_wire::WireScopeRef::Module { + owner: lpc_model::NodeId::new(3), + }, + channel: "glow".to_string(), + engaged: false, + }), + )), + ); + } + + let Some(UiNodeFace::Shader(face)) = + kind_face("shader", &test_address(), §ions, &mut Vec::new()) + else { + panic!("expected a shader face"); + }; + let target = face.controls[0] + .panel_target + .as_ref() + .expect("a bus-wired uniform's knob targets its channel"); + assert_eq!(target.channel, "glow"); + assert_eq!( + target.scope, + lpc_wire::WireScopeRef::Module { + owner: lpc_model::NodeId::new(3) + } + ); + } + + #[test] + fn an_unwired_uniform_keeps_the_slot_edit_path() { + // No binding row at all: nothing to write, so the knob still edits + // `consumed.speed.default.some` exactly as it did before P9. + let Some(UiNodeFace::Shader(face)) = kind_face( + "shader", + &test_address(), + &shader_sections(), + &mut Vec::new(), + ) else { + panic!("expected a shader face"); + }; + assert!(face.controls[0].panel_target.is_none()); + assert!( + face.controls[0].address.is_some(), + "and it still has a slot address to edit" + ); + } + #[test] fn bound_uniform_mirrors_the_wired_rows_live_reading() { let mut sections = shader_sections(); @@ -1060,7 +1171,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,15 +1288,66 @@ 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 } + #[test] + fn a_bus_wired_brightness_fader_drives_the_channel() { + // The scarf's own control (panel.md P10): once brightness is wired + // to a channel, dimming it must engage a panel writer — that is + // what persists across a replug. The fixture path derives its + // target from the same binding-derived row the shader knobs use. + let mut sections = fixture_sections(); + if let UiNodeSection::ConfigSlots(rows) = &mut sections[1] { + rows.push(UiConfigSlot::empty("brightness", "Brightness").with_source( + UiSlotSourceState::Bound( + UiBindingEndpoint::new("bus:brightness").with_panel_target( + crate::UiPanelTarget { + scope: lpc_wire::WireScopeRef::Module { + owner: lpc_model::NodeId::new(1), + }, + channel: "brightness".to_string(), + engaged: true, + }, + ), + ), + )); + } + + let Some(UiNodeFace::Fixture(face)) = + kind_face("fixture", &test_address(), §ions, &mut Vec::new()) + else { + panic!("expected a fixture face"); + }; + let target = face + .brightness + .panel_target + .as_ref() + .expect("a wired brightness fader targets its channel"); + assert_eq!(target.channel, "brightness"); + assert!(target.engaged, "and reports the engaged writer"); + } + + #[test] + fn an_unwired_brightness_fader_still_edits_its_slot() { + let Some(UiNodeFace::Fixture(face)) = kind_face( + "fixture", + &test_address(), + &fixture_sections(), + &mut Vec::new(), + ) else { + panic!("expected a fixture face"); + }; + assert!(face.brightness.panel_target.is_none()); + assert!(face.brightness.address.is_some()); + } + fn fixture_sections() -> Vec { let brightness_value = UiSlotValue::u32(64) 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/panel_write_op.rs b/lp-app/lpa-studio-core/src/app/project/node/panel_write_op.rs new file mode 100644 index 000000000..116674909 --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/project/node/panel_write_op.rs @@ -0,0 +1,130 @@ +//! Panel write/clear operations (panel.md P8): the `(scope, channel)` +//! command-channel path for panel controls. +//! +//! A runtime poke, not an edit: nothing is staged in the overlay, nothing +//! shows in the Save panel, the project stays clean, and the effect lands +//! on the engine's next frame — engaged state follows through ordinary +//! probe pulls (which is also what makes multiple clients converge, +//! panel.md P9). + +use core::any::Any; + +use crate::{ + ActionClass, ActionMeta, ActionPriority, ControllerOp, PROJECT_EDITOR_ACTION_DEADLINE, +}; + +/// Engage (or update) the panel writer for `(scope, channel)`. +/// +/// Knob drags are a stream of these; the actor's batch planner COALESCES +/// per `(scope, channel)` — a newer write replaces a queued unsent one +/// (see `push_action_coalesced` in `studio_actor`), so a drag never +/// floods the wire. +#[derive(Clone, Debug, PartialEq)] +pub struct PanelWriteOp { + /// The scope the write lands in (the control's home scope). + pub scope: lpc_wire::WireScopeRef, + /// The channel the control drives. + pub channel: String, + /// The value to hold. + pub value: lpc_model::LpValue, + /// Momentary renewal deadline (panel.md P14); `None` = latching. + pub ttl_ms: Option, +} + +impl ControllerOp for PanelWriteOp { + fn default_action_meta(&self) -> ActionMeta { + ActionMeta::new( + "Set panel control", + "Hold this value on the channel until reset.", + ActionPriority::Primary, + ) + } + + fn action_class(&self) -> ActionClass { + ActionClass::Foreground { + deadline: PROJECT_EDITOR_ACTION_DEADLINE, + } + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } + + fn eq_op(&self, other: &dyn ControllerOp) -> bool { + other.as_any().downcast_ref::() == Some(self) + } + + fn as_any(&self) -> &dyn Any { + self + } + + fn into_any(self: Box) -> Box { + self + } +} + +/// Clear engaged panel writers: one control, one scope, or everything +/// (settled P-Q4: clear-all reaches sink scopes too). +#[derive(Clone, Debug, PartialEq)] +pub struct PanelClearOp { + pub request: lpc_wire::WirePanelClearRequest, +} + +impl ControllerOp for PanelClearOp { + fn default_action_meta(&self) -> ActionMeta { + ActionMeta::new( + "Reset panel control", + "Release the held value; authored wiring returns.", + ActionPriority::Primary, + ) + } + + fn action_class(&self) -> ActionClass { + ActionClass::Foreground { + deadline: PROJECT_EDITOR_ACTION_DEADLINE, + } + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } + + fn eq_op(&self, other: &dyn ControllerOp) -> bool { + other.as_any().downcast_ref::() == Some(self) + } + + fn as_any(&self) -> &dyn Any { + self + } + + fn into_any(self: Box) -> Box { + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn panel_ops_are_editor_foreground_class() { + let write = PanelWriteOp { + scope: lpc_wire::WireScopeRef::Module { + owner: lpc_model::NodeId::new(0), + }, + channel: "speed".to_string(), + value: lpc_model::LpValue::F32(0.5), + ttl_ms: None, + }; + assert_eq!( + write.action_class(), + ActionClass::Foreground { + deadline: PROJECT_EDITOR_ACTION_DEADLINE, + } + ); + let clear = PanelClearOp { + request: lpc_wire::WirePanelClearRequest::All, + }; + assert_eq!(clear.default_action_meta().label, "Reset panel control"); + } +} 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 3efb44a81..7e42f6969 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, @@ -57,7 +57,7 @@ pub struct UiAddNodeMenuEntry { pub unavailable: Option, } -/// 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, with every entry enabled. /// /// The device gate is applied afterwards by [`gate_add_node_menu`], once, @@ -123,11 +123,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"); @@ -138,7 +138,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 60bea44f5..330008753 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.** @@ -138,7 +138,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!( @@ -201,21 +201,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"); } } @@ -224,16 +224,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, }, ] @@ -245,11 +245,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 1a88faa20..d0a487be8 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 @@ -16,16 +16,17 @@ use crate::app::studio::refresh_cadence::{VERDICT_CHASE_INTERVAL, VERDICT_CHASE_ use crate::core::notice::UiNotices; use crate::{ AssetEditOp, Controller, ControllerId, DirtySummary, LoadedProjectChoice, MAX_ASSET_BODY_BYTES, - NodeCardUiState, NodeUiOp, PendingAssetEdit, PendingEdit, PendingEditOp, PendingEditPhase, - PlaylistActivateOp, ProgressState, ProjectConnectResult, ProjectEditorOp, ProjectEditorTarget, - ProjectEditorView, ProjectInventorySummary, ProjectNodeAddress, ProjectNodeStatusTone, - ProjectNodeTreeItem, ProjectNodeTreeView, ProjectOp, ProjectSlotAddress, ProjectSlotRoot, - ProjectSnapshot, ProjectState, ProjectSync, ProjectSyncPhase, ProjectSyncRun, - ProjectSyncSummary, SlotEditOp, StudioOverlayMutation, StudioProjectReadOutcome, - StudioServerClient, UiAction, UiAssetContent, UiAssetContentBody, UiAssetEditor, UiError, - UiIssue, UiLogDraft, UiLogLevel, UiLogOrigin, UiMetric, UiNodeView, UiNotice, UiPaneAction, - UiPaneView, UiPendingEdit, UiPendingEditKind, UiPendingEditPhase, UiProductRef, UiResult, - UiShaderError, UiShaderUniform, UiSlotAsset, UiStatus, UiViewContent, UxUpdateSink, + NodeCardUiState, NodeUiOp, PanelClearOp, PanelWriteOp, PendingAssetEdit, PendingEdit, + PendingEditOp, PendingEditPhase, PlaylistActivateOp, ProgressState, ProjectConnectResult, + ProjectEditorOp, ProjectEditorTarget, ProjectEditorView, ProjectInventorySummary, + ProjectNodeAddress, ProjectNodeStatusTone, ProjectNodeTreeItem, ProjectNodeTreeView, ProjectOp, + ProjectSlotAddress, ProjectSlotRoot, ProjectSnapshot, ProjectState, ProjectSync, + ProjectSyncPhase, ProjectSyncRun, ProjectSyncSummary, SlotEditOp, StudioOverlayMutation, + StudioProjectReadOutcome, StudioServerClient, UiAction, UiAssetContent, UiAssetContentBody, + UiAssetEditor, UiError, UiIssue, UiLogDraft, UiLogLevel, UiLogOrigin, UiMetric, UiNodeView, + UiNotice, UiPaneAction, UiPaneView, UiPendingEdit, UiPendingEditKind, UiPendingEditPhase, + UiProductRef, UiResult, UiShaderError, UiShaderUniform, UiSlotAsset, UiStatus, UiViewContent, + UxUpdateSink, }; use lpc_model::slot::SlotPersistence; use lpc_model::{ @@ -384,10 +385,27 @@ impl ProjectController { focus: node.map(node_focus_action), }) }; + let root_scope = graph + .channels + .iter() + .find(|channel| channel.primary_visual) + .and_then(|channel| channel.scope); let channels = graph .channels .iter() .map(|channel| crate::UiBusChannelView { + scope: channel.scope, + // Root-scope rows carry no label; embedded scopes label by + // their owner module's node label (display derivation is a + // client concern — the wire ships structure only). + scope_label: channel + .scope + .filter(|scope| Some(*scope) != root_scope) + .map(|scope| { + self.node_by_runtime_id(scope.owner()) + .map(|node| node.label().to_string()) + .unwrap_or_else(|| format!("node {}", scope.owner().0)) + }), name: channel.name.clone(), kind: channel.kind.map(|kind| format!("{kind:?}")), value: channel @@ -396,7 +414,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(), }) @@ -417,7 +435,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; @@ -585,11 +603,24 @@ impl ProjectController { // the authoring surface is built, so Retarget/Unbind state never // carries the churning value (P6 item 1). if binding.direction == lpc_wire::WireBindingDirection::Consumes - && let lpc_wire::WireBindingEndpoint::Bus { channel } = &binding.endpoint + && let lpc_wire::WireBindingEndpoint::Bus { channel, .. } = &binding.endpoint && let Some(live) = live_channel_value(graph, channel, binding.kind) { endpoint = endpoint.with_live_value(live); } + // A consumed bus channel is a panel-write target (panel.md P1): + // the control built over this wiring dispatches PanelWrite at + // this (scope, channel) instead of editing the authored default. + if binding.direction == lpc_wire::WireBindingDirection::Consumes + && let lpc_wire::WireBindingEndpoint::Bus { scope, channel } = &binding.endpoint + && let Some(scope) = scope + { + endpoint = endpoint.with_panel_target(crate::UiPanelTarget { + scope: *scope, + channel: channel.clone(), + engaged: panel_writer_engaged(graph, scope, channel), + }); + } let mut row = crate::UiConfigSlot::empty(name, human_field_label(name)) .with_description("Runtime slot wired by binding; it has no authored value here.") .with_state(crate::UiSlotFieldState::readonly()) @@ -612,7 +643,7 @@ impl ProjectController { endpoint: &lpc_wire::WireBindingEndpoint, ) -> crate::UiBindingEndpoint { match endpoint { - lpc_wire::WireBindingEndpoint::Bus { channel } => { + lpc_wire::WireBindingEndpoint::Bus { channel, .. } => { crate::UiBindingEndpoint::new(format!("bus:{channel}")) } lpc_wire::WireBindingEndpoint::NodeSlot { node, slot } => { @@ -1383,7 +1414,7 @@ impl ProjectController { if binding.direction != lpc_wire::WireBindingDirection::Consumes { continue; } - let lpc_wire::WireBindingEndpoint::Bus { channel } = &binding.endpoint else { + let lpc_wire::WireBindingEndpoint::Bus { channel, .. } = &binding.endpoint else { continue; }; let Some(slot) = binding.slot.as_ref() else { @@ -1628,6 +1659,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_debug_overrides(edits.debug_override_count()) @@ -2483,6 +2515,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 { @@ -2666,6 +2714,71 @@ impl ProjectController { }) } + /// Engage (or update) the panel writer for `(scope, channel)` via + /// `WireProjectCommand::PanelWrite` — the runtime command channel, so + /// no overlay entry, no dirty flag, no Save-panel row. Quiet on + /// acceptance — the engaged badge and live value follow through the + /// tightened refresh ticks; a rejection comes back as a warning notice. + pub async fn panel_write( + &mut self, + server: &mut StudioServerClient, + op: PanelWriteOp, + ) -> Result { + let handle_id = self.ready_handle_id()?; + let run = server + .panel_write( + handle_id, + lpc_wire::WirePanelWriteRequest { + scope: op.scope, + channel: op.channel.clone(), + value: op.value, + ttl_ms: op.ttl_ms, + }, + ) + .await?; + let notices = match run.response { + lpc_wire::WirePanelCommandResponse::Accepted { .. } => { + self.verdict_chase_ticks = VERDICT_CHASE_TICKS; + UiNotices::new() + } + lpc_wire::WirePanelCommandResponse::Rejected { reason } => UiNotices::new() + .with_notice(UiNotice::warning(format!( + "Couldn't set panel control {}: {reason}", + op.channel + ))), + }; + Ok(ProjectEditRun { + notices, + logs: run.logs, + }) + } + + /// Clear engaged panel writers (one control, one scope, or all) via + /// `WireProjectCommand::PanelClear`. Same runtime-command posture as + /// [`Self::panel_write`]. + pub async fn panel_clear( + &mut self, + server: &mut StudioServerClient, + op: PanelClearOp, + ) -> Result { + let handle_id = self.ready_handle_id()?; + let run = server.panel_clear(handle_id, op.request).await?; + let notices = match run.response { + lpc_wire::WirePanelCommandResponse::Accepted { .. } => { + self.verdict_chase_ticks = VERDICT_CHASE_TICKS; + UiNotices::new() + } + lpc_wire::WirePanelCommandResponse::Rejected { reason } => UiNotices::new() + .with_notice(UiNotice::warning(format!( + "Couldn't reset panel control: {reason}" + ))), + }; + Ok(ProjectEditRun { + notices, + logs: run.logs, + }) + } + /// Commit the pending-edit overlay (persisted edits are written back to /// def artifacts; Debug overrides stay pending) and re-sync the overlay /// mirror from a follow-up read. @@ -4511,6 +4624,28 @@ fn live_channel_value( crate::app::project::format_live_scalar(value) } +/// Whether a panel writer is engaged for `(scope, channel)`: the probe +/// surfaces one as a Panel-origin provider row on the scoped channel +/// listing, so engagement reads from the graph the UI already pulls. +fn panel_writer_engaged( + graph: &lpc_wire::WireBindingGraph, + scope: &lpc_wire::WireScopeRef, + channel_name: &str, +) -> bool { + graph + .channels + .iter() + .find(|channel| channel.scope.as_ref() == Some(scope) && channel.name == channel_name) + .is_some_and(|channel| { + channel.providers.iter().any(|index| { + graph + .bindings + .get(*index as usize) + .is_some_and(|binding| binding.origin == lpc_wire::WireBindingOrigin::Panel) + }) + }) +} + /// Collect `node` and every descendant controller (preorder) into `out`. fn collect_subtree_nodes<'a>(node: &'a NodeController, out: &mut Vec<&'a NodeController>) { out.push(node); @@ -5284,7 +5419,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; @@ -5293,7 +5428,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 @@ -5302,7 +5437,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)) @@ -5335,15 +5470,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(); @@ -5357,7 +5492,7 @@ mod tests { ); assert!( project - .node(&node_address("/demo.project/b.shader")) + .node(&node_address("/demo.module/b.shader")) .is_none() ); } @@ -5403,7 +5538,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(), @@ -5439,7 +5574,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() @@ -5453,7 +5588,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(), @@ -5485,7 +5620,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); @@ -5504,7 +5639,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(); @@ -5559,7 +5694,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; @@ -5567,8 +5702,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); @@ -5589,7 +5724,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()); } @@ -5599,7 +5734,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() @@ -5653,7 +5788,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), )) ); @@ -5688,7 +5823,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, @@ -5733,7 +5868,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, @@ -5780,7 +5915,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(), }, ); @@ -5801,7 +5936,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, }); @@ -5811,12 +5946,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); @@ -5965,6 +6100,7 @@ mod tests { slot: Some(SlotPath::parse("input").unwrap()), direction: lpc_wire::WireBindingDirection::Consumes, endpoint: lpc_wire::WireBindingEndpoint::Bus { + scope: None, channel: "visual.out".to_string(), }, origin: lpc_wire::WireBindingOrigin::Authored, @@ -5977,6 +6113,7 @@ mod tests { slot: Some(SlotPath::parse("output").unwrap()), direction: lpc_wire::WireBindingDirection::Publishes, endpoint: lpc_wire::WireBindingEndpoint::Bus { + scope: None, channel: "visual.out".to_string(), }, origin: lpc_wire::WireBindingOrigin::Default, @@ -5985,6 +6122,7 @@ mod tests { }, ], channels: vec![lpc_wire::WireBusChannel { + scope: None, name: "visual.out".to_string(), kind: Some(lpc_model::Kind::Color), providers: vec![1], @@ -5994,6 +6132,7 @@ mod tests { value: Some(LpValue::F32(0.5)), error: None, }), + primary_visual: true, }], }; project @@ -6036,6 +6175,7 @@ mod tests { slot: Some(SlotPath::parse("seconds").unwrap()), direction: lpc_wire::WireBindingDirection::Publishes, endpoint: lpc_wire::WireBindingEndpoint::Bus { + scope: None, channel: "time".to_string(), }, origin: lpc_wire::WireBindingOrigin::Default, @@ -6048,6 +6188,7 @@ mod tests { slot: Some(SlotPath::parse("time").unwrap()), direction: lpc_wire::WireBindingDirection::Consumes, endpoint: lpc_wire::WireBindingEndpoint::Bus { + scope: None, channel: "time".to_string(), }, origin: lpc_wire::WireBindingOrigin::Default, @@ -6116,6 +6257,7 @@ mod tests { slot: Some(SlotPath::parse("seconds").unwrap()), direction: lpc_wire::WireBindingDirection::Publishes, endpoint: lpc_wire::WireBindingEndpoint::Bus { + scope: None, channel: "other".to_string(), }, origin: lpc_wire::WireBindingOrigin::Default, @@ -6201,6 +6343,7 @@ mod tests { revision: Revision::new(2), bindings: Vec::new(), channels: vec![lpc_wire::WireBusChannel { + scope: None, name: lpc_model::PRIMARY_VISUAL_CHANNEL.to_string(), kind: Some(lpc_model::Kind::Color), providers: Vec::new(), @@ -6210,6 +6353,7 @@ mod tests { value, error: None, }), + primary_visual: true, }], } } @@ -6358,7 +6502,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() @@ -6384,6 +6528,7 @@ mod tests { slot: Some(SlotPath::parse("time").unwrap()), direction: lpc_wire::WireBindingDirection::Consumes, endpoint: lpc_wire::WireBindingEndpoint::Bus { + scope: None, channel: "time".to_string(), }, origin: lpc_wire::WireBindingOrigin::Default, @@ -6419,6 +6564,7 @@ mod tests { slot: Some(SlotPath::parse("time").unwrap()), direction: lpc_wire::WireBindingDirection::Consumes, endpoint: lpc_wire::WireBindingEndpoint::Bus { + scope: None, channel: "time".to_string(), }, origin: lpc_wire::WireBindingOrigin::Default, @@ -6431,7 +6577,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(), ) @@ -6565,18 +6711,22 @@ mod tests { bindings: Vec::new(), channels: vec![ lpc_wire::WireBusChannel { + scope: None, name: "time".to_string(), kind: Some(lpc_model::Kind::Instant), providers: vec![0], consumers: vec![1], value: None, + primary_visual: false, }, lpc_wire::WireBusChannel { + scope: None, name: "wobble".to_string(), kind: None, providers: vec![0], consumers: Vec::new(), value: None, + primary_visual: false, }, ], }; @@ -6619,6 +6769,7 @@ mod tests { slot: Some(SlotPath::parse(slot).unwrap()), direction, endpoint: lpc_wire::WireBindingEndpoint::Bus { + scope: None, channel: "visual.out".to_string(), }, origin: lpc_wire::WireBindingOrigin::Authored, @@ -6673,6 +6824,7 @@ mod tests { slot: Some(SlotPath::parse(slot).unwrap()), direction: lpc_wire::WireBindingDirection::Consumes, endpoint: lpc_wire::WireBindingEndpoint::Bus { + scope: None, channel: channel.to_string(), }, origin, @@ -6682,6 +6834,7 @@ mod tests { }; let channel = |name: &str, kind: lpc_model::Kind, value: f32| -> lpc_wire::WireBusChannel { lpc_wire::WireBusChannel { + scope: None, name: name.to_string(), kind: Some(kind), providers: Vec::new(), @@ -6691,6 +6844,7 @@ mod tests { value: Some(LpValue::F32(value)), error: None, }), + primary_visual: false, } }; project @@ -6757,17 +6911,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, )); @@ -6778,7 +6927,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) @@ -6801,7 +6950,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(), @@ -6827,7 +6976,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(); @@ -7038,7 +7187,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(), @@ -7136,18 +7285,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, )); @@ -7156,24 +7305,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, )); @@ -7182,18 +7331,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, )); @@ -7203,7 +7352,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 } @@ -8018,7 +8167,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(), ) @@ -8026,7 +8175,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(), ) @@ -8327,18 +8476,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"); } @@ -8408,7 +8557,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 @@ -8478,10 +8627,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 @@ -8490,7 +8637,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(), ); @@ -8559,7 +8706,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(); @@ -8582,7 +8729,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(); @@ -8611,7 +8758,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 @@ -8958,7 +9105,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(), ); @@ -9128,7 +9275,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(), ) @@ -9282,7 +9429,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(); @@ -9337,7 +9484,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(); @@ -9386,7 +9533,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"), }) ); } @@ -9497,8 +9644,7 @@ mod tests { let dirty_before = project.dirty_summary(); let run = block_on_ready( - project - .clear_node_debug_edits(&mut client, &node_address("/demo.project/orbit.shader")), + project.clear_node_debug_edits(&mut client, &node_address("/demo.module/orbit.shader")), ) .unwrap(); @@ -9557,8 +9703,7 @@ mod tests { ); let run = block_on_ready( - project - .clear_node_debug_edits(&mut client, &node_address("/demo.project/orbit.shader")), + project.clear_node_debug_edits(&mut client, &node_address("/demo.module/orbit.shader")), ) .unwrap(); @@ -9588,7 +9733,7 @@ mod tests { // A buffered (un-acked) debug edit joins the acked one in the sweep. project.insert_pending_edit_for_test( crate::ProjectSlotAddress::new( - node_address("/demo.project/orbit.shader"), + node_address("/demo.module/orbit.shader"), ProjectSlotRoot::def(), SlotPath::parse("rate").unwrap(), ), @@ -11068,7 +11213,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(), ), @@ -11300,12 +11445,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, ); @@ -11313,13 +11458,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 9224f19a5..dd272bcd4 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 @@ -84,6 +98,7 @@ impl ProjectEditorView { nodes, channel_choices: Vec::new(), root_slots: Vec::new(), + manifest: None, library_identity: None, dirty: DirtySummary::clean(), debug_overrides: 0, @@ -113,6 +128,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 a64a8dbd7..5e25b7c0a 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 @@ -1671,7 +1671,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 8e955a43e..2d6a28a63 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 @@ -443,7 +443,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 { @@ -569,7 +569,7 @@ mod tests { assert!(debug_entry.summary.is_clean()); 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" @@ -581,7 +581,7 @@ mod tests { // Two Debug overrides on this node, one persisted edit, one Debug // override on ANOTHER node: the debug channel counts three overall // and two here, while the dirty summary sees only the persisted one. - let other = ProjectNodeAddress::parse("/demo.project/clock.clock").unwrap(); + let other = ProjectNodeAddress::parse("/demo.module/clock.clock").unwrap(); let buffer = BTreeMap::from([(at("brightness"), PendingEdit::pending(LpValue::F32(0.9)))]); let overlay = BTreeMap::from([ ( @@ -676,7 +676,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 298629630..c6d0c488f 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 @@ -150,7 +150,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/server/studio_server_client.rs b/lp-app/lpa-studio-core/src/app/server/studio_server_client.rs index 38da0ca30..b63fa80e6 100644 --- a/lp-app/lpa-studio-core/src/app/server/studio_server_client.rs +++ b/lp-app/lpa-studio-core/src/app/server/studio_server_client.rs @@ -397,6 +397,12 @@ pub struct StudioNodeCommand { pub logs: Vec, } +/// A panel command's response plus any server logs it produced. +pub struct StudioPanelCommand { + pub response: lpc_wire::WirePanelCommandResponse, + pub logs: Vec, +} + /// Result of an overlay commit, with the post-commit overlay revision. pub struct StudioOverlayCommit { pub result: CommitResult, @@ -530,6 +536,42 @@ impl StudioServerClient { /// Dispatch a runtime node command (the non-overlay command channel; /// first consumer: playlist activate-entry). The server's rejection is /// data in the response, not a transport error. + pub async fn panel_write( + &mut self, + handle_id: u32, + request: lpc_wire::WirePanelWriteRequest, + ) -> Result { + let response = self + .client + .project_panel_write(WireProjectHandle::new(handle_id), request) + .await + .map_err(map_client_error)?; + let mut logs = map_client_events(response.events); + logs.extend(self.take_pending_logs()); + Ok(StudioPanelCommand { + response: response.value, + logs, + }) + } + + pub async fn panel_clear( + &mut self, + handle_id: u32, + request: lpc_wire::WirePanelClearRequest, + ) -> Result { + let response = self + .client + .project_panel_clear(WireProjectHandle::new(handle_id), request) + .await + .map_err(map_client_error)?; + let mut logs = map_client_events(response.events); + logs.extend(self.take_pending_logs()); + Ok(StudioPanelCommand { + response: response.value, + logs, + }) + } + pub async fn node_command( &mut self, handle_id: u32, 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.rs b/lp-app/lpa-studio-core/src/app/studio/studio_actor.rs index 80ad73dc9..6ca5b77cc 100644 --- a/lp-app/lpa-studio-core/src/app/studio/studio_actor.rs +++ b/lp-app/lpa-studio-core/src/app/studio/studio_actor.rs @@ -517,6 +517,26 @@ impl CommandPlan { /// change what a path *means*, so each queued gesture must reach the server /// in order. fn push_action_coalesced(actions: &mut Vec, action: UiAction) { + if let Some(op) = action.op_as::() { + // Panel writes coalesce per (scope, channel) with the same + // tail-scan rule: a knob-drag flood collapses to the newest value, + // and any non-PanelWrite action is a barrier (a queued clear must + // reach the server between two writes, in order). + let key = (op.scope, op.channel.clone()); + for queued in actions.iter_mut().rev() { + match queued.op_as::() { + Some(queued_op) => { + if (queued_op.scope, queued_op.channel.clone()) == key { + *queued = action; + return; + } + } + _ => break, + } + } + actions.push(action); + return; + } let Some(SlotEditOp::SetValue { address, .. }) = action.op_as::() else { actions.push(action); return; 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 a4650ce0c..1dce8f63e 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(), ) @@ -787,6 +787,103 @@ fn revert_between_set_values_is_a_coalescing_barrier() { ); } +// P9 (panel.md P8): per-(scope, channel) PanelWrite coalescing — a knob +// drag over the command path floods exactly like a SetValue drag and must +// bound the same way. +// --------------------------------------------------------------------------- + +fn panel_scope(owner: u32) -> lpc_wire::WireScopeRef { + lpc_wire::WireScopeRef::Module { + owner: lpc_model::NodeId::new(owner), + } +} + +fn panel_write_action(owner: u32, channel: &str, value: f32) -> StudioCommand { + StudioCommand::Action(UiAction::from_op( + ControllerId::new(ProjectController::NODE_ID), + crate::PanelWriteOp { + scope: panel_scope(owner), + channel: channel.to_string(), + value: LpValue::F32(value), + ttl_ms: None, + }, + )) +} + +fn planned_panel_writes(plan: &CommandPlan) -> Vec<(String, Option)> { + plan.actions + .iter() + .map(|action| match action.op_as::() { + Some(op) => ( + op.channel.clone(), + match &op.value { + LpValue::F32(value) => Some(*value), + _ => None, + }, + ), + None => ("other".to_string(), None), + }) + .collect() +} + +#[test] +fn rapid_panel_writes_for_one_target_coalesce_to_the_last() { + let plan = CommandPlan::from_batch(vec![ + panel_write_action(1, "speed", 1.0), + panel_write_action(1, "speed", 2.0), + panel_write_action(1, "speed", 3.0), + ]); + + assert_eq!( + planned_panel_writes(&plan), + vec![("speed".to_string(), Some(3.0))], + "a drag flood collapses to the newest value" + ); +} + +#[test] +fn panel_writes_coalesce_per_scope_and_channel_not_globally() { + let plan = CommandPlan::from_batch(vec![ + panel_write_action(1, "speed", 1.0), + panel_write_action(1, "hue", 0.2), + panel_write_action(2, "speed", 9.0), + panel_write_action(1, "speed", 2.0), + ]); + + assert_eq!( + planned_panel_writes(&plan), + vec![ + ("speed".to_string(), Some(2.0)), + ("hue".to_string(), Some(0.2)), + ("speed".to_string(), Some(9.0)), + ], + "same (scope, channel) coalesces; a different scope or channel never does" + ); +} + +#[test] +fn a_panel_clear_is_a_coalescing_barrier_between_writes() { + let plan = CommandPlan::from_batch(vec![ + panel_write_action(1, "speed", 1.0), + StudioCommand::Action(UiAction::from_op( + ControllerId::new(ProjectController::NODE_ID), + crate::PanelClearOp { + request: lpc_wire::WirePanelClearRequest::Channel { + scope: panel_scope(1), + channel: "speed".to_string(), + }, + }, + )), + panel_write_action(1, "speed", 2.0), + ]); + + assert_eq!( + plan.actions.len(), + 3, + "write → clear → write must reach the server in order" + ); +} + #[test] fn structural_ops_never_coalesce_even_for_one_address() { let plan = CommandPlan::from_batch(vec![ 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 1874e7796..ee1843cdb 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 @@ -23,12 +23,12 @@ use crate::core::notice::UiNotices; use crate::{ AssetContentFetchOp, AssetEditOp, ConnectFlowState, Controller, ControllerContext, DeviceController, DeviceOp, NodeClearDebugOp, NodeCopyOp, NodeCreateOp, NodePasteOp, - NodeRemoveOp, NodeRevertOp, PlaylistActivateOp, ProjectConnectResult, ProjectController, - ProjectEditRun, ProjectOp, ProjectRefreshOutcome, ProjectState, ProjectSyncRun, RuntimePayload, - RuntimePool, ServerFailureKind, ServerSnapshot, ServerState, SlotEditOp, StudioSnapshot, - UiAction, UiActions, UiActivityView, UiError, UiLogDraft, UiLogEntry, UiLogLevel, UiLogOrigin, - UiNotice, UiPaneView, UiResult, UiStatus, UiStudioView, UiViewContent, UxActivityTarget, - UxUpdate, UxUpdateSink, + NodeRemoveOp, NodeRevertOp, PanelClearOp, PanelWriteOp, PlaylistActivateOp, + ProjectConnectResult, ProjectController, ProjectEditRun, ProjectOp, ProjectRefreshOutcome, + ProjectState, ProjectSyncRun, RuntimePayload, RuntimePool, ServerFailureKind, ServerSnapshot, + ServerState, SlotEditOp, StudioSnapshot, UiAction, UiActions, UiActivityView, UiError, + UiLogDraft, UiLogEntry, UiLogLevel, UiLogOrigin, UiNotice, UiPaneView, UiResult, UiStatus, + UiStudioView, UiViewContent, UxActivityTarget, UxUpdate, UxUpdateSink, }; /// How often the quiet PortHeld retry re-attempts the granted attach @@ -2079,6 +2079,14 @@ impl StudioController { let op = action.into_op::()?; return self.execute_playlist_activate_op(op).await; } + if action.op_as::().is_some() { + let op = action.into_op::()?; + return self.execute_panel_write_op(op).await; + } + if action.op_as::().is_some() { + let op = action.into_op::()?; + return self.execute_panel_clear_op(op).await; + } if action.op_as::().is_some() { let op = action.into_op::()?; return self.execute_node_create_op(op).await; @@ -3235,6 +3243,24 @@ impl StudioController { self.record_project_edit_run(run) } + /// Panel-control gesture: dispatch the `(scope, channel)` panel write + /// down the runtime command channel (no overlay, no dirty flag). + async fn execute_panel_write_op(&mut self, op: PanelWriteOp) -> UiResult { + let run = { + let server = self.pool.lens_session_mut()?.client_mut()?; + self.project.panel_write(server, op).await + }; + self.record_project_edit_run(run) + } + + async fn execute_panel_clear_op(&mut self, op: PanelClearOp) -> UiResult { + let run = { + let server = self.pool.lens_session_mut()?.client_mut()?; + self.project.panel_clear(server, op).await + }; + self.record_project_edit_run(run) + } + async fn execute_node_create_op(&mut self, op: NodeCreateOp) -> UiResult { let run = { let server = self.pool.lens_session_mut()?.client_mut()?; @@ -6073,7 +6099,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); @@ -6332,7 +6358,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 f363dd3dc..2769c1f68 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 `Fixed` role - // on `format`/`nodes` intact; `name` stays editable. + // Post-mitosis the root module def carries ONLY `nodes` (role `Fixed`); + // 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" + ); // D3/D4 over the real wire: the clock's three `controls.*` Debug fields // land FLAT in the node's Debug section — no "Controls" record row in @@ -471,7 +470,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( @@ -589,7 +593,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( @@ -2339,9 +2348,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" }, @@ -2358,6 +2367,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), @@ -2417,11 +2427,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 15263045b..a4dd41501 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 @@ -2562,9 +2562,17 @@ fn backing_up_a_device_publishes_a_zip_of_its_files() { let script = FakeDeviceScript::new(FakeBootState::LightPlayer( FakeLightPlayerState::new() .with_project_files(vec![ + // Post-mitosis shape: the container manifest carries the + // format/name, the root MODULE carries the node map. A + // pre-mitosis single `project.json` is refused outright + // (D-A), which would leave the device unidentified here. ( "project.json".to_string(), - br#"{"kind":"Project","name":"sign","nodes":{}}"#.to_vec(), + br#"{"format":3,"name":"sign"}"#.to_vec(), + ), + ( + "module.json".to_string(), + br#"{"kind":"Module","nodes":{}}"#.to_vec(), ), ("shader.glsl".to_string(), b"void main() {}".to_vec()), ]) @@ -2703,8 +2711,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 b7efb6e85..28f757f8b 100644 --- a/lp-app/lpa-studio-core/src/lib.rs +++ b/lp-app/lpa-studio-core/src/lib.rs @@ -49,12 +49,12 @@ pub use app::node::{ UiBindingEndpoint, UiChannelChoice, UiConfigSlot, UiConfigSlotBody, UiControlProductPreview, UiControlSampleFormat, UiFixtureFace, UiFixturePower, UiNodeChild, UiNodeDirtyState, UiNodeFace, UiNodeHeader, UiNodeSection, UiNodeTab, UiNodeTabBody, UiNodeView, UiPanelControl, - UiPanelWidget, UiPlaylistEntry, UiPlaylistFace, UiProducedBinding, UiProducedBindings, - UiProducedProduct, UiProducedValue, UiProductKind, UiProductPreview, UiProductPreviewFrame, - UiProductRef, UiProductTrackingState, UiShaderFace, UiShaderUniform, UiSlotAffordance, - UiSlotAspect, UiSlotAspectKind, UiSlotAspectRow, UiSlotAsset, UiSlotComposite, - UiSlotEditorHint, UiSlotEnumComposite, UiSlotFieldState, UiSlotMapComposite, UiSlotMapKeyKind, - UiSlotOption, UiSlotOptionality, UiSlotRecord, UiSlotShape, UiSlotShapeField, + UiPanelTarget, UiPanelWidget, UiPlaylistEntry, UiPlaylistFace, UiProducedBinding, + UiProducedBindings, UiProducedProduct, UiProducedValue, UiProductKind, UiProductPreview, + UiProductPreviewFrame, UiProductRef, UiProductTrackingState, UiShaderFace, UiShaderUniform, + UiSlotAffordance, UiSlotAspect, UiSlotAspectKind, UiSlotAspectRow, UiSlotAsset, + UiSlotComposite, UiSlotEditorHint, UiSlotEnumComposite, UiSlotFieldState, UiSlotMapComposite, + UiSlotMapKeyKind, UiSlotOption, UiSlotOptionality, UiSlotRecord, UiSlotShape, UiSlotShapeField, UiSlotSourceState, UiSlotUnit, UiSlotValue, UiSlotValueKind, }; #[cfg(all(feature = "browser-worker", target_arch = "wasm32"))] @@ -67,16 +67,17 @@ pub use app::project::{ AgentEngineStatus, AssetContentFetchOp, AssetEditOp, DirtySummary, LoadedProjectChoice, MAX_ASSET_BODY_BYTES, NodeCardDrawer, NodeCardUiState, NodeClearDebugOp, NodeController, NodeControllerState, NodeCopyOp, NodeCreateOp, NodePasteOp, NodeRemoveOp, NodeRevertOp, - NodeUiOp, PendingAssetEdit, PendingEdit, PendingEditOp, PendingEditPhase, PlaylistActivateOp, - ProjectAssetContentRun, ProjectConnectResult, ProjectController, ProjectEditRun, - ProjectEditorOp, ProjectEditorTarget, ProjectEditorView, ProjectInventorySummary, - ProjectNodeAddress, ProjectNodeStatusTone, ProjectNodeStatusView, ProjectNodeTarget, - ProjectNodeTreeItem, ProjectNodeTreeView, ProjectOp, ProjectProductSubscriptionIntent, - ProjectRefreshOutcome, ProjectRuntimeSummary, ProjectSlotAddress, ProjectSlotRoot, - ProjectSnapshot, ProjectState, ProjectSync, ProjectSyncPhase, ProjectSyncRun, - ProjectSyncSummary, SlotController, SlotControllerState, SlotEditOp, SlotKind, UiAddNodeMenu, - UiAddNodeMenuEntry, UiAffordance, UiAssetContent, UiAssetContentBody, UiAttachTarget, - UiNodeRemovePreflight, UiPendingEdit, UiPendingEditKind, UiPendingEditPhase, UiShaderError, + NodeUiOp, PanelClearOp, PanelWriteOp, PendingAssetEdit, PendingEdit, PendingEditOp, + PendingEditPhase, PlaylistActivateOp, ProjectAssetContentRun, ProjectConnectResult, + ProjectController, ProjectEditRun, ProjectEditorOp, ProjectEditorTarget, ProjectEditorView, + ProjectInventorySummary, ProjectNodeAddress, ProjectNodeStatusTone, ProjectNodeStatusView, + ProjectNodeTarget, ProjectNodeTreeItem, ProjectNodeTreeView, ProjectOp, + ProjectProductSubscriptionIntent, ProjectRefreshOutcome, ProjectRuntimeSummary, + ProjectSlotAddress, ProjectSlotRoot, ProjectSnapshot, ProjectState, ProjectSync, + ProjectSyncPhase, ProjectSyncRun, ProjectSyncSummary, SlotController, SlotControllerState, + SlotEditOp, SlotKind, UiAddNodeMenu, UiAddNodeMenuEntry, UiAffordance, UiAssetContent, + UiAssetContentBody, UiAttachTarget, UiNodeRemovePreflight, UiPendingEdit, UiPendingEditKind, + 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.rs b/lp-app/lpa-studio-web/src/app/bus/bus_pane.rs index e538b22df..ac80a2944 100644 --- a/lp-app/lpa-studio-web/src/app/bus/bus_pane.rs +++ b/lp-app/lpa-studio-web/src/app/bus/bus_pane.rs @@ -52,16 +52,24 @@ pub(crate) fn BusChannelPane( let aspects = channel.visible_aspects(); let UiBusChannelView { name, + scope_label, kind, value, value_error, primary_visual, .. } = channel; + // Embedded-scope channels carry their module's label as a prefix so two + // same-named channels stay tellable apart; the proper grouped + // presentation is the module-face phase's job. + let title = match scope_label { + Some(scope_label) => format!("{scope_label} · {name}"), + None => name, + }; rsx! { SlotPane { - title: name, + title, aspects, initially_open, treatment: SlotPaneTreatment::Bound, 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..45c42baba 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, )), } @@ -26,6 +26,8 @@ fn fyeah_bus_view() -> UiBusView { UiBusView { channels: vec![ UiBusChannelView { + scope: None, + scope_label: None, name: "time".to_string(), kind: Some("Instant".to_string()), value: Some("3.333".to_string()), @@ -35,6 +37,8 @@ fn fyeah_bus_view() -> UiBusView { readers: vec![site("Playlist", Some("time"), false)], }, UiBusChannelView { + scope: None, + scope_label: None, name: "trigger".to_string(), kind: Some("Instant".to_string()), value: None, @@ -50,6 +54,8 @@ fn fyeah_bus_view() -> UiBusView { ], }, UiBusChannelView { + scope: None, + scope_label: None, name: "visual.out".to_string(), kind: Some("Color".to_string()), value: Some("visual product #5:0".to_string()), @@ -59,6 +65,8 @@ fn fyeah_bus_view() -> UiBusView { readers: vec![site("Fixture", Some("input"), false)], }, UiBusChannelView { + scope: None, + scope_label: None, name: "control.out".to_string(), kind: Some("Color".to_string()), value: None, 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 597ad1756..2c0551061 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 @@ -1744,7 +1744,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 5dce57372..563ae6747 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 3ce5b825e..2838ed928 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 a757c4ece..7a07aace2 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 @@ -71,6 +71,7 @@ pub(crate) fn knob_control_stepped( widget: UiPanelWidget::Knob { min, max, step }, value: slot_value, live_value: None, + panel_target: None, unit: None, state, aspects: aspect_slot.visible_aspects(), @@ -98,6 +99,7 @@ pub(crate) fn fader_control( }, value: slot_value, live_value: None, + panel_target: None, unit: None, state, aspects: aspect_slot.visible_aspects(), @@ -121,6 +123,7 @@ pub(crate) fn toggle_control( widget: UiPanelWidget::Toggle, value: slot_value, live_value: None, + panel_target: None, unit: None, state, aspects: aspect_slot.visible_aspects(), @@ -491,7 +494,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 74589d260..c5c4be371 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 @@ -12,7 +12,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 1d15c0274..5866816ed 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 @@ -118,7 +118,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"), @@ -142,7 +142,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"), @@ -393,7 +393,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/panel/h_fader_field.rs b/lp-app/lpa-studio-web/src/app/node/panel/h_fader_field.rs index 0a4267b1f..56a83b199 100644 --- a/lp-app/lpa-studio-web/src/app/node/panel/h_fader_field.rs +++ b/lp-app/lpa-studio-web/src/app/node/panel/h_fader_field.rs @@ -11,9 +11,9 @@ //! drag flood per address). use dioxus::prelude::*; -use lpa_studio_core::{ProjectSlotAddress, UiAction, UiSlotFieldState}; +use lpa_studio_core::{ProjectSlotAddress, UiAction, UiPanelTarget, UiSlotFieldState}; -use crate::app::node::slot_edit_actions::slot_set_value_action; +use crate::app::node::slot_edit_actions::panel_or_slot_action; use crate::app::node::slot_fields::field_wiring; use super::PanelEmit; @@ -36,6 +36,10 @@ pub fn HFaderField( #[props(default = false)] bound: bool, #[props(default = None)] address: Option, + /// Panel-write target: when present, gestures dispatch `PanelWriteOp` + /// at this `(scope, channel)` instead of editing the authored default. + #[props(default = None)] + panel_target: Option, /// Value family the drag dispatches (`F32` default; integer slots /// round). #[props(default)] @@ -80,7 +84,8 @@ pub fn HFaderField( if let (Some((address, handler)), Ok(next)) = (wired.clone(), event.value().parse::()) { - handler.call(slot_set_value_action(address, emit.lp_value(next))); + handler + .call(panel_or_slot_action(&panel_target, address, emit.lp_value(next))); } }, } diff --git a/lp-app/lpa-studio-web/src/app/node/panel/knob_field.rs b/lp-app/lpa-studio-web/src/app/node/panel/knob_field.rs index 38e6a4718..7b4886042 100644 --- a/lp-app/lpa-studio-web/src/app/node/panel/knob_field.rs +++ b/lp-app/lpa-studio-web/src/app/node/panel/knob_field.rs @@ -30,9 +30,9 @@ //! falls back to the continuous rendering. use dioxus::prelude::*; -use lpa_studio_core::{ProjectSlotAddress, UiAction, UiSlotFieldState}; +use lpa_studio_core::{ProjectSlotAddress, UiAction, UiPanelTarget, UiSlotFieldState}; -use crate::app::node::slot_edit_actions::slot_set_value_action; +use crate::app::node::slot_edit_actions::panel_or_slot_action; use crate::app::node::slot_fields::{capture_field_pointer, field_wiring}; use super::PanelEmit; @@ -62,6 +62,11 @@ pub fn KnobField( #[props(default = false)] bound: bool, #[props(default = None)] address: Option, + /// Panel-write target: when present, gestures dispatch `PanelWriteOp` + /// at this `(scope, channel)` (the runtime command channel) instead of + /// editing the authored default at `address`. + #[props(default = None)] + panel_target: Option, /// Value family the drag dispatches (`F32` default; integer slots /// round). #[props(default)] @@ -89,6 +94,8 @@ pub fn KnobField( let down_wiring = wired.clone(); let move_wiring = wired.clone(); let key_wiring = wired; + let move_target = panel_target.clone(); + let key_target = panel_target; // Drag anchor: pointer y and value at pointerdown; None while idle. let mut drag = use_signal(|| None::<(f64, f32)>); @@ -111,7 +118,7 @@ pub fn KnobField( return; }; event.prevent_default(); - handler.call(slot_set_value_action(address, emit.lp_value(next))); + handler.call(panel_or_slot_action(&key_target, address, emit.lp_value(next))); }, onpointerdown: move |event| { if down_wiring.is_none() { @@ -139,7 +146,7 @@ pub fn KnobField( max, step, ); - handler.call(slot_set_value_action(address, emit.lp_value(next))); + handler.call(panel_or_slot_action(&move_target, address, emit.lp_value(next))); }, onpointerup: move |_| drag.set(None), onpointercancel: move |_| drag.set(None), diff --git a/lp-app/lpa-studio-web/src/app/node/panel/panel_control.rs b/lp-app/lpa-studio-web/src/app/node/panel/panel_control.rs index 1f11b823d..be9e87742 100644 --- a/lp-app/lpa-studio-web/src/app/node/panel/panel_control.rs +++ b/lp-app/lpa-studio-web/src/app/node/panel/panel_control.rs @@ -27,6 +27,7 @@ use lpa_studio_core::{ }; use crate::app::node::slot_detail_button::primary_affordance; +use crate::app::node::slot_edit_actions::panel_clear_action; use crate::app::node::{SlotDetailButton, SlotUnitSuffix}; use crate::base::{PopoverPlacement, StudioIcon, StudioIconName}; @@ -124,6 +125,30 @@ fn PanelControlBody( #[props(default)] on_action: Option>, ) -> Element { let bound = control.bound(); + // A panel-targeted control with an ENGAGED writer gets the clear + // affordance (panel.md P2): a small ↺ beside the readout releasing the + // held value back to the authored wiring. + let clear = control + .panel_target + .as_ref() + .filter(|target| target.engaged) + .map(|target| { + let action = panel_clear_action(target); + rsx! { + button { + class: "tw:inline-flex tw:flex-none tw:cursor-pointer tw:appearance-none tw:items-center tw:border-0 tw:bg-transparent tw:p-0 tw:leading-none tw:text-status-bound-foreground tw:opacity-70 tw:hover:opacity-100", + r#type: "button", + title: "Release the held panel value", + onclick: move |event| { + event.stop_propagation(); + if let Some(handler) = on_action { + handler.call(action.clone()); + } + }, + StudioIcon { name: StudioIconName::Revert, size: 10 } + } + } + }); // The live bus reading (display-only; P6 item 1): the readout leads // with it in the bound-violet family while the authored default — still // the edit target — rides secondary in parentheses. @@ -134,12 +159,14 @@ fn PanelControlBody( span { class: "tw:text-status-bound-foreground", title: "live bus value", "{live}" } SlotUnitSuffix { unit: control.unit.clone(), reserve: false } span { class: "tw:text-dim-foreground", title: "authored default", "({control.value.display})" } + {clear} } }, None => rsx! { span { class: "tw:inline-flex tw:items-baseline tw:gap-1 tw:font-mono tw:text-[0.7rem] tw:text-muted-foreground", span { "{control.value.display}" } SlotUnitSuffix { unit: control.unit.clone(), reserve: false } + {clear} } }, }; @@ -159,6 +186,7 @@ fn PanelControlBody( state: control.state.clone(), bound, address: control.address.clone(), + panel_target: control.panel_target.clone(), emit, on_action, } @@ -184,6 +212,7 @@ fn PanelControlBody( state: control.state.clone(), bound, address: control.address.clone(), + panel_target: control.panel_target.clone(), emit, on_action, } @@ -203,6 +232,7 @@ fn PanelControlBody( state: control.state.clone(), bound, address: control.address.clone(), + panel_target: control.panel_target.clone(), on_action, } } @@ -336,6 +366,7 @@ mod tests { }, value, live_value: None, + panel_target: None, unit: None, state, aspects: aspect_slot.visible_aspects(), diff --git a/lp-app/lpa-studio-web/src/app/node/panel/toggle_field.rs b/lp-app/lpa-studio-web/src/app/node/panel/toggle_field.rs index aa99da85a..7e027a3e7 100644 --- a/lp-app/lpa-studio-web/src/app/node/panel/toggle_field.rs +++ b/lp-app/lpa-studio-web/src/app/node/panel/toggle_field.rs @@ -2,13 +2,14 @@ //! //! Good-green is worn ONLY by the on/valid state (Studio convention: green //! means good/valid — never selection, never binding). A bound toggle keeps -//! its violet ring on the pill regardless of on/off. Click dispatches -//! `SlotEditOp::SetValue` with the flipped value. +//! its violet ring on the pill regardless of on/off. Click dispatches the +//! flipped value — a panel write when the control targets a bus channel, +//! a slot edit otherwise. use dioxus::prelude::*; -use lpa_studio_core::{LpValue, ProjectSlotAddress, UiAction, UiSlotFieldState}; +use lpa_studio_core::{LpValue, ProjectSlotAddress, UiAction, UiPanelTarget, UiSlotFieldState}; -use crate::app::node::slot_edit_actions::slot_set_value_action; +use crate::app::node::slot_edit_actions::panel_or_slot_action; use crate::app::node::slot_fields::field_wiring; #[component] @@ -25,6 +26,11 @@ pub fn ToggleField( #[props(default = false)] bound: bool, #[props(default = None)] address: Option, + /// Panel-write target: when present, a click dispatches `PanelWriteOp` + /// flipping the SHOWN (live) state at this `(scope, channel)` instead + /// of editing the authored default. + #[props(default = None)] + panel_target: Option, #[props(default)] on_action: Option>, ) -> Element { let wired = field_wiring(&state, &address, on_action); @@ -45,7 +51,12 @@ pub fn ToggleField( onclick: move |event| { event.stop_propagation(); if let Some((address, handler)) = wired.clone() { - handler.call(slot_set_value_action(address, LpValue::Bool(!value))); + // A panel-targeted click flips the SHOWN state (the + // live value it drives); the slot path flips the + // authored default it edits. + let flip = if panel_target.is_some() { !shown } else { !value }; + handler + .call(panel_or_slot_action(&panel_target, address, LpValue::Bool(flip))); } }, span { class: "{thumb_class}" } diff --git a/lp-app/lpa-studio-web/src/app/node/slot_edit_actions.rs b/lp-app/lpa-studio-web/src/app/node/slot_edit_actions.rs index b56385f2e..fc184e0af 100644 --- a/lp-app/lpa-studio-web/src/app/node/slot_edit_actions.rs +++ b/lp-app/lpa-studio-web/src/app/node/slot_edit_actions.rs @@ -6,8 +6,8 @@ //! plus overlay mirror keep the DTO value stable until the server acks. use lpa_studio_core::{ - ControllerId, LpValue, NodeClearDebugOp, ProjectController, ProjectNodeAddress, - ProjectSlotAddress, SlotEditOp, SlotMapKey, UiAction, + ControllerId, LpValue, NodeClearDebugOp, PanelClearOp, PanelWriteOp, ProjectController, + ProjectNodeAddress, ProjectSlotAddress, SlotEditOp, SlotMapKey, UiAction, UiPanelTarget, }; /// Build the `SetValue` action a field dispatches on input. @@ -18,6 +18,43 @@ pub(crate) fn slot_set_value_action(address: ProjectSlotAddress, value: LpValue) ) } +/// Build the gesture action for a panel widget: a control with a panel +/// target writes its `(scope, channel)` down the runtime command channel +/// (panel.md P8 — nothing staged, nothing dirty, the actor coalesces per +/// target); one without edits the authored default through the slot path. +pub(crate) fn panel_or_slot_action( + panel_target: &Option, + address: ProjectSlotAddress, + value: LpValue, +) -> UiAction { + match panel_target { + Some(target) => UiAction::from_op( + ControllerId::new(ProjectController::NODE_ID), + PanelWriteOp { + scope: target.scope, + channel: target.channel.clone(), + value, + ttl_ms: None, + }, + ), + None => slot_set_value_action(address, value), + } +} + +/// Build the per-control clear (panel.md P2): release the engaged panel +/// writer and return the control to Read. +pub(crate) fn panel_clear_action(target: &UiPanelTarget) -> UiAction { + UiAction::from_op( + ControllerId::new(ProjectController::NODE_ID), + PanelClearOp { + request: lpc_wire::WirePanelClearRequest::Channel { + scope: target.scope, + channel: target.channel.clone(), + }, + }, + ) +} + /// Build the per-slot revert action for a persisted (unsaved) edit. pub(crate) fn slot_revert_action(address: ProjectSlotAddress) -> UiAction { UiAction::from_op( 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 757ca736b..355624031 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 @@ -514,7 +514,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 46fa3ea9f..7e8247eec 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 3c87be9e2..861d31641 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 @@ -178,7 +178,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 0cc49dbb0..b9e1709cd 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 @@ -322,7 +322,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 a7b1827f0..e41c9fa99 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 @@ -74,6 +74,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(); // D8 tier (a): the project-wide debug channel, deliberately outside the // dirty rollup that drives `chrome`/`affordance` (D7). @@ -100,6 +101,7 @@ pub fn ProjectPane( stats, pending_edits, root_slots, + manifest, library_identity, on_action, initially_open, @@ -191,6 +193,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)>, @@ -220,12 +225,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 67bf6e00d..99c883528 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 @@ -448,7 +448,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 a8388cbb6..02a00db79 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,59 @@ //! 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). +//! **Deliberately not the generic `SlotRecordEditor`.** The project's own +//! identity is not authoring, and the generic slot machinery dresses it as +//! such: 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. //! -//! So this section renders four purpose-built rows instead: +//! Post-mitosis the identity (`format` / `uid` / `name`) no longer lives in +//! a def at all: it is 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`). //! -//! - **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` all carry role `Fixed` in -//! `lpc_model::ProjectDef`, so the read-only presentation here agrees with -//! the model rather than merely hiding a writable slot. +//! - **Name / Format / UID** — read-only, from the manifest. UID keeps its +//! copy button (identity is the thing you actually want on your clipboard +//! when reporting a problem). +//! - **Nodes** — the one root-def row left, 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. It carries role `Fixed` in +//! `lpc_model::ModuleDef`, so the read-only presentation agrees with the +//! model rather than merely hiding a writable slot. 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 +95,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 +110,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 +121,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 8c51fb0a4..13e513c17 100644 --- a/lp-app/lpa-studio-web/src/app/readme_stories.rs +++ b/lp-app/lpa-studio-web/src/app/readme_stories.rs @@ -227,7 +227,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, @@ -241,7 +241,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()), @@ -252,7 +252,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()), @@ -304,7 +304,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 334993c25..a1314b27e 100644 --- a/lp-app/lpa-studio-web/src/app/story_fixtures.rs +++ b/lp-app/lpa-studio-web/src/app/story_fixtures.rs @@ -220,7 +220,7 @@ pub(crate) fn project_editor_fixture(phase: ProjectSyncPhase) -> ProjectEditorVi ); let project = tree_item( 1, - "/demo.project", + "/demo.module", "Demo", "Project", running.clone(), @@ -228,7 +228,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(), @@ -237,7 +237,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(), @@ -246,7 +246,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(), @@ -255,7 +255,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(), @@ -275,6 +275,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 { @@ -358,16 +359,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 `Fixed` role each carries on `lpc_model::ProjectDef` +/// the `Fixed` role 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", @@ -383,6 +379,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 { @@ -408,7 +413,7 @@ fn clock_node_child() -> UiNodeChild { node_child( "Clock", "Clock", - "/demo.project/clock.clock", + "/demo.module/clock.clock", UiStatus::good("Running"), ) .with_sections(vec![ @@ -431,7 +436,7 @@ fn orbit_shader_child() -> UiNodeChild { node_child( "Orbit shader", "Shader", - "/demo.project/orbit.shader", + "/demo.module/orbit.shader", UiStatus::good("Running"), ) .active("focused") @@ -473,7 +478,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![ @@ -503,7 +508,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 d13bdf84d..d9be8a8c4 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 } }, @@ -207,7 +207,7 @@ pub enum NodeKindIcon { Compute, Output, Playlist, - Project, + Module, Texture, Radio, Button, @@ -229,7 +229,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 f64f791cf..0ee13a0a2 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 bbebc35db..e4b2c8bbc 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 61b6abdd9..f440a3e63 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 94e075b6a..866a9ba66 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 2138809df..71472eb78 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 453417a3e..b007f55b1 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 0bad32aa3..cff7fd74e 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 3e9c73aab..b444419e4 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 3d0b80d39..8e1f910b6 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__debug-absent-from-save-panel__lg.png b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__debug-absent-from-save-panel__lg.png index 5a8b416a7..bd3876129 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__debug-absent-from-save-panel__lg.png and b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__debug-absent-from-save-panel__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__debug-absent-from-save-panel__md.png b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__debug-absent-from-save-panel__md.png index 8c9cf9ceb..064d8d758 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__debug-absent-from-save-panel__md.png and b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__debug-absent-from-save-panel__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__debug-absent-from-save-panel__sm.png b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__debug-absent-from-save-panel__sm.png index 5ea71ffc7..78af49e4b 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__debug-absent-from-save-panel__sm.png and b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__debug-absent-from-save-panel__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__debug-active-chip__sm.png b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__debug-active-chip__sm.png index e8b6123e9..41f9f5c2a 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__debug-active-chip__sm.png and b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__debug-active-chip__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__debug-active-with-unsaved__sm.png b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__debug-active-with-unsaved__sm.png index cfee3c540..0854440aa 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__debug-active-with-unsaved__sm.png and b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__debug-active-with-unsaved__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 41f603424..9674024d3 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 e2b6b8aad..ca6975058 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 2977ccc7c..fc6892e24 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__staged-node-removal__lg.png b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__staged-node-removal__lg.png index 2b371867e..13ddbdb5d 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 50c4bb2ed..477c05edb 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 90cb5c5cf..6116b535d 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-pane__unsupported-node-in-tree__sm.png b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__unsupported-node-in-tree__sm.png index 42f43ce10..1df62826d 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__unsupported-node-in-tree__sm.png and b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__unsupported-node-in-tree__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 e9190a261..e7bc8d4a5 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 e3c568676..0f0a79203 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", @@ -445,21 +461,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 8781e3981..d58c6d765 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/binding/binding_entry.rs b/lp-core/lpc-engine/src/dataflow/binding/binding_entry.rs index 6d21a92aa..ef0a0b20e 100644 --- a/lp-core/lpc-engine/src/dataflow/binding/binding_entry.rs +++ b/lp-core/lpc-engine/src/dataflow/binding/binding_entry.rs @@ -69,6 +69,13 @@ impl BindingPriority { Self(-1000) } + /// Engaged panel writers sit above authored bindings within their + /// scope (panel.md P4): the user's hand outranks the project's wiring + /// until an explicit clear. + pub fn panel() -> Self { + Self(1000) + } + pub fn as_i32(self) -> i32 { self.0 } diff --git a/lp-core/lpc-engine/src/dataflow/mod.rs b/lp-core/lpc-engine/src/dataflow/mod.rs index f9fb426a7..5b7307f12 100644 --- a/lp-core/lpc-engine/src/dataflow/mod.rs +++ b/lp-core/lpc-engine/src/dataflow/mod.rs @@ -6,4 +6,5 @@ pub mod binding; pub mod bus; +pub mod panel_writers; pub mod resolver; diff --git a/lp-core/lpc-engine/src/dataflow/panel_writers.rs b/lp-core/lpc-engine/src/dataflow/panel_writers.rs new file mode 100644 index 000000000..3e54cb7e5 --- /dev/null +++ b/lp-core/lpc-engine/src/dataflow/panel_writers.rs @@ -0,0 +1,157 @@ +//! Panel writer store: lazy, unauthored runtime writers per +//! `(scope, channel)` (panel.md P1–P4). +//! +//! Panel controls are stateful runtime data sources, never authored +//! bindings: a writer materializes only when a control is first touched +//! (lazy — otherwise every public param would self-shadow and outer scopes +//! could never drive inner channels), holds its value until an explicit +//! clear (latch, P2), and lives on the Engine so it survives +//! `apply_project_changes` (which rebuilds bindings from defs — a panel +//! writer registered as a binding would be silently destroyed by the first +//! authoring edit; that is why this is a side store, not a +//! `BindingDraft`). +//! +//! Firmware-safe by construction: `VecMap`, `no_std` + alloc. + +use lp_collection::VecMap; +use lpc_model::{ChannelName, LpValue, Revision}; + +use crate::node::ScopeRef; + +/// One engaged panel writer. +#[derive(Clone, Debug, PartialEq)] +pub struct PanelWriter { + /// The held value (latched until cleared, panel.md P2). + pub value: LpValue, + /// Engine revision of the most recent write (display/coalescing aid). + pub written_at: Revision, + /// Momentary liveness deadline (panel.md P14), in engine total-ms: + /// gesture channels write while active and DESPAWN past this instant — + /// the despawn IS the fallback mechanism. `None` = latching writer. + /// Renewal is simply another write. Momentary writers never persist. + pub expires_at_ms: Option, +} + +/// Engine-owned store of engaged panel writers, keyed by the scope the +/// write lands in plus the channel it drives. +#[derive(Debug, Default)] +pub struct PanelWriterStore { + writers: VecMap<(ScopeRef, ChannelName), PanelWriter>, + /// Monotonic count of mutations (set / clear / despawn). + /// + /// Persistence throttles on this rather than on the writer set + /// itself: a clear followed by a re-write inside one window leaves an + /// identical-looking map, and comparing `(len, newest revision)` gets + /// that wrong. A counter cannot. + mutations: u64, +} + +impl PanelWriterStore { + pub fn new() -> Self { + Self::default() + } + + /// Engage (or update) the writer for `(scope, channel)`. + pub fn set( + &mut self, + scope: ScopeRef, + channel: ChannelName, + value: LpValue, + at: Revision, + expires_at_ms: Option, + ) { + self.writers.insert( + (scope, channel), + PanelWriter { + value, + written_at: at, + expires_at_ms, + }, + ); + self.mutations = self.mutations.saturating_add(1); + } + + /// Despawn every momentary writer whose deadline has passed (a dropped + /// client must release its gesture). Returns the number despawned. + pub fn despawn_expired(&mut self, now_ms: u64) -> usize { + let expired: alloc::vec::Vec<(ScopeRef, ChannelName)> = self + .writers + .iter() + .filter(|(_, writer)| { + writer + .expires_at_ms + .is_some_and(|deadline| now_ms >= deadline) + }) + .map(|(key, _)| key.clone()) + .collect(); + let count = expired.len(); + for key in expired { + self.writers.remove(&key); + } + if count > 0 { + self.mutations = self.mutations.saturating_add(1); + } + count + } + + /// Clear EVERYTHING — including sink-scope writers (settled P-Q4: a + /// playlist entry's latched value clears too). Returns the count. + pub fn clear_all(&mut self) -> usize { + let count = self.writers.len(); + self.writers = VecMap::new(); + if count > 0 { + self.mutations = self.mutations.saturating_add(1); + } + count + } + + /// Clear one writer. Returns true when something was engaged. + pub fn clear(&mut self, scope: ScopeRef, channel: &ChannelName) -> bool { + let cleared = self.writers.remove(&(scope, channel.clone())).is_some(); + if cleared { + self.mutations = self.mutations.saturating_add(1); + } + cleared + } + + /// Clear every writer in `scope`. Returns the number cleared. + pub fn clear_scope(&mut self, scope: ScopeRef) -> usize { + let keys: alloc::vec::Vec<(ScopeRef, ChannelName)> = self + .writers + .iter() + .filter(|((s, _), _)| *s == scope) + .map(|(key, _)| key.clone()) + .collect(); + let cleared = keys.len(); + for key in keys { + self.writers.remove(&key); + } + if cleared > 0 { + self.mutations = self.mutations.saturating_add(1); + } + cleared + } + + /// The engaged writer for `(scope, channel)`, if any. + pub fn get(&self, scope: ScopeRef, channel: &ChannelName) -> Option<&PanelWriter> { + self.writers.get(&(scope, channel.clone())) + } + + /// Every engaged writer. + pub fn iter(&self) -> impl Iterator { + self.writers.iter() + } + + pub fn is_empty(&self) -> bool { + self.writers.is_empty() + } + + pub fn len(&self) -> usize { + self.writers.len() + } + + /// Mutations since construction — the persistence dirty signal. + pub fn mutations(&self) -> u64 { + self.mutations + } +} 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 a9e1665f7..d76d1e679 100644 --- a/lp-core/lpc-engine/src/dataflow/resolver/resolver_cache.rs +++ b/lp-core/lpc-engine/src/dataflow/resolver/resolver_cache.rs @@ -200,7 +200,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 4ae9ec167..a7587cf8a 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, @@ -61,6 +60,10 @@ pub struct Engine { services: EngineServices, demand_roots: Vec, graphics: Option>, + /// Engaged panel writers (panel.md P1–P4). A side store on purpose: + /// `apply_project_changes` rebuilds bindings from defs and must never + /// destroy an engaged control. + panel_writers: crate::dataflow::panel_writers::PanelWriterStore, /// Device-level safe-mode output ceiling, Q16 (`None` = no clamp). /// /// DEVICE state, not project data: set by the embedder (firmware, from a @@ -96,6 +99,7 @@ impl Engine { services, demand_roots: Vec::new(), graphics: None, + panel_writers: crate::dataflow::panel_writers::PanelWriterStore::new(), safe_output_clamp_q16: None, #[cfg(debug_assertions)] last_structural_check: None, @@ -222,8 +226,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))?; @@ -276,6 +279,62 @@ impl Engine { } /// Optional graphics backend for core shader nodes; clone is cheap (`Arc`). + /// Engage (or update) a panel writer (panel.md P1/P2): latched until + /// an explicit clear, landing in `scope` at panel priority. Changes + /// the winning provider set, so cached routes are invalidated. + pub fn panel_write( + &mut self, + scope: crate::node::ScopeRef, + channel: lpc_model::ChannelName, + value: lpc_model::LpValue, + ttl_ms: Option, + ) { + // Momentary liveness (panel.md P14): the deadline is an engine-time + // instant; renewal is just another write with a fresh TTL. + let expires_at_ms = ttl_ms.map(|ttl| self.frame_time.total_ms as u64 + u64::from(ttl)); + self.panel_writers + .set(scope, channel, value, self.revision, expires_at_ms); + self.resolver.invalidate_structure(); + } + + /// Clear every engaged writer everywhere — sink scopes included + /// (settled P-Q4). Returns the count. + pub fn panel_clear_all(&mut self) -> usize { + let cleared = self.panel_writers.clear_all(); + if cleared > 0 { + self.resolver.invalidate_structure(); + } + cleared + } + + /// Clear one engaged panel writer (panel.md P3). Returns whether + /// anything was engaged. + pub fn panel_clear( + &mut self, + scope: crate::node::ScopeRef, + channel: &lpc_model::ChannelName, + ) -> bool { + let cleared = self.panel_writers.clear(scope, channel); + if cleared { + self.resolver.invalidate_structure(); + } + cleared + } + + /// Clear every engaged writer in `scope`; returns the count. + pub fn panel_clear_scope(&mut self, scope: crate::node::ScopeRef) -> usize { + let cleared = self.panel_writers.clear_scope(scope); + if cleared > 0 { + self.resolver.invalidate_structure(); + } + cleared + } + + /// The engaged panel writers (probes, persistence). + pub fn panel_writers(&self) -> &crate::dataflow::panel_writers::PanelWriterStore { + &self.panel_writers + } + pub fn set_graphics(&mut self, graphics: Option>) { self.graphics = graphics; } @@ -396,16 +455,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() { @@ -476,6 +536,15 @@ impl Engine { self.revision = advance_revision(); self.frame_time = FrameTime::new(delta_ms, self.frame_time.total_ms.saturating_add(delta_ms)); + // Momentary panel writers despawn on expiry (panel.md P14) — the + // despawn IS the release fallback for a dropped client. + if self + .panel_writers + .despawn_expired(self.frame_time.total_ms as u64) + > 0 + { + self.resolver.invalidate_structure(); + } let mut resolver = core::mem::replace(&mut self.resolver, Resolver::new()); let trace = ResolveTrace::new(ResolveLogLevel::Off); @@ -489,6 +558,7 @@ impl Engine { let mut host = EngineResolveHost { tree: &mut self.tree, registry, + panel_writers: &self.panel_writers, producers_ticked: &mut producers_ticked, runtime_buffers: &mut self.runtime_buffers, slot_shapes: &self.slot_shapes, @@ -557,6 +627,7 @@ impl Engine { let mut host = EngineResolveHost { tree: &mut self.tree, registry, + panel_writers: &self.panel_writers, producers_ticked: &mut producers_ticked, runtime_buffers: &mut self.runtime_buffers, slot_shapes: &self.slot_shapes, @@ -583,7 +654,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 @@ -603,6 +677,7 @@ impl Engine { let mut host = EngineResolveHost { tree: &mut self.tree, registry, + panel_writers: &self.panel_writers, producers_ticked: &mut producers_ticked, runtime_buffers: &mut self.runtime_buffers, slot_shapes: &self.slot_shapes, @@ -656,6 +731,7 @@ impl Engine { let mut host = EngineResolveHost { tree: &mut self.tree, registry, + panel_writers: &self.panel_writers, producers_ticked: &mut producers_ticked, runtime_buffers: &mut self.runtime_buffers, slot_shapes: &self.slot_shapes, @@ -677,6 +753,7 @@ impl Engine { pub(crate) fn resolve_bus_channel_value( &mut self, registry: &ProjectRegistry, + scope: Option, channel: &ChannelName, ) -> Result { let mut resolver = core::mem::replace(&mut self.resolver, Resolver::new()); @@ -693,6 +770,7 @@ impl Engine { let mut host = EngineResolveHost { tree: &mut self.tree, registry, + panel_writers: &self.panel_writers, producers_ticked: &mut producers_ticked, runtime_buffers: &mut self.runtime_buffers, slot_shapes: &self.slot_shapes, @@ -703,7 +781,14 @@ impl Engine { frame_time_seconds: time_s, safe_output_clamp_q16: self.safe_output_clamp_q16, }; - let result = session.resolve(&mut host, &QueryKey::Bus(channel.clone())); + let scope = scope.or_else(|| host.tree.node_scope(host.tree.root())); + let result = session.resolve( + &mut host, + &QueryKey::Bus { + scope, + channel: channel.clone(), + }, + ); self.resolver = resolver; result } @@ -724,6 +809,7 @@ impl Engine { let mut host = EngineResolveHost { tree: &mut self.tree, registry, + panel_writers: &self.panel_writers, producers_ticked: &mut producers_ticked, runtime_buffers: &mut self.runtime_buffers, slot_shapes: &self.slot_shapes, @@ -739,9 +825,38 @@ impl Engine { } /// Host adapter with borrows disjoint from the [`Resolver`] handed to [`EngineSession`]. +/// The synthetic provider an engaged panel writer contributes: a literal +/// source at panel priority, owned by the scope's introducing node. The +/// binding ref uses a sentinel index — panel writers live in the side +/// store, never in any node's binding set. +fn panel_provider( + tree: &RuntimeNodeTree>, + scope: crate::node::ScopeRef, + channel: &lpc_model::ChannelName, + writer: &crate::dataflow::panel_writers::PanelWriter, +) -> (BindingRef, crate::dataflow::binding::BindingEntry) { + let kind = tree + .bus_channels() + .find(|(name, _)| *name == channel) + .map(|(_, kind)| kind) + .unwrap_or(lpc_model::Kind::Ratio); + ( + BindingRef::new(scope.owner(), usize::MAX), + crate::dataflow::binding::BindingEntry { + source: crate::dataflow::binding::BindingSource::Literal(writer.value.clone()), + target: crate::dataflow::binding::BindingTarget::BusChannel(channel.clone()), + priority: crate::dataflow::binding::BindingPriority::panel(), + kind, + version: writer.written_at, + owner: scope.owner(), + }, + ) +} + struct EngineResolveHost<'a> { tree: &'a mut RuntimeNodeTree>, registry: &'a ProjectRegistry, + panel_writers: &'a crate::dataflow::panel_writers::PanelWriterStore, producers_ticked: &'a mut VecSet, runtime_buffers: &'a mut RuntimeBufferStore, slot_shapes: &'a SlotShapeRegistry, @@ -960,7 +1075,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", )), } @@ -988,15 +1103,49 @@ impl ResolveHost for EngineResolveHost<'_> { .collect() } + fn node_scope(&self, node: NodeId) -> Option { + // Reading scope (R7 export semantics): introducers read inward, + // everyone else reads the scope they inhabit. Write-side provider + // classification stays on `NodeTree::node_scope` (R4). + self.tree.bus_read_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) - .into_iter() - .map(|(binding_ref, entry)| (binding_ref, entry.clone())) - .collect() + // The R5 walk with the panel overlay (panel.md P4): at each scope, + // an engaged panel writer REPLACES the scope's provider set — + // including for ByKey merge channels, where max priority wins + // rather than merging — and an engaged scope counts as "has a + // writer" for shadowing even when nothing authored writes there. + let Some(mut scope) = scope else { + return self + .tree + .providers_for_bus_read(None, channel) + .into_iter() + .map(|(binding_ref, entry)| (binding_ref, entry.clone())) + .collect(); + }; + loop { + if let Some(writer) = self.panel_writers.get(scope, channel) { + return alloc::vec![panel_provider(self.tree, scope, channel, writer)]; + } + let candidates: Vec<(BindingRef, crate::dataflow::binding::BindingEntry)> = self + .tree + .providers_for_bus_in_scope(scope, channel) + .into_iter() + .map(|(binding_ref, entry)| (binding_ref, entry.clone())) + .collect(); + if !candidates.is_empty() { + return candidates; + } + match self.tree.parent_scope(scope) { + Some(parent) => scope = parent, + None => return Vec::new(), + } + } } fn merge_policy_for_consumed_slot(&self, node: NodeId, slot: &SlotPath) -> SlotMerge { @@ -1962,6 +2111,7 @@ pub(crate) fn resolve_with_engine_host( let mut host = EngineResolveHost { tree: &mut eng.tree, registry, + panel_writers: &eng.panel_writers, producers_ticked: &mut producers_ticked, runtime_buffers: &mut eng.runtime_buffers, slot_shapes: &eng.slot_shapes, @@ -2001,6 +2151,7 @@ pub(super) fn resolve_twice_same_frame_with_engine_host( let mut host = EngineResolveHost { tree: &mut eng.tree, registry, + panel_writers: &eng.panel_writers, producers_ticked: &mut producers_ticked, runtime_buffers: &mut eng.runtime_buffers, slot_shapes: &eng.slot_shapes, @@ -2273,7 +2424,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( @@ -2328,14 +2482,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 0fe139998..1e440540c 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..64612db91 100644 --- a/lp-core/lpc-engine/src/engine/project_read_probes.rs +++ b/lp-core/lpc-engine/src/engine/project_read_probes.rs @@ -91,57 +91,143 @@ impl Engine { let mut bindings = Vec::new(); let mut wire_index: VecMap = VecMap::new(); + let mut scoped_rows = Vec::new(); for (binding_ref, entry) in self.tree().bindings_with_refs() { wire_index.insert(binding_ref, bindings.len() as u32); bindings.push(wire_effective_binding(entry)); + scoped_rows.push((entry.owner, entry.clone())); + } + // Endpoint scopes need tree lookups, which borrow-conflict with the + // iteration above — annotate in a second pass. + for (binding, (owner, entry)) in bindings.iter_mut().zip(scoped_rows.iter()) { + if let lpc_wire::WireBindingEndpoint::Bus { scope, .. } = &mut binding.endpoint { + *scope = match binding.direction { + lpc_wire::WireBindingDirection::Publishes => { + self.tree().node_scope(*owner).map(wire_scope_ref) + } + lpc_wire::WireBindingDirection::Consumes => { + self.tree().bus_read_scope(*owner).map(wire_scope_ref) + } + }; + } + let _ = entry; } - let channel_names: Vec<(ChannelName, Kind)> = self - .tree() - .bus_channels() - .map(|(name, kind)| (name.clone(), kind)) - .collect(); - - let mut channels = Vec::with_capacity(channel_names.len()); - for (name, kind) in channel_names { - let mut providers = self.tree().providers_for_bus(&name); - // Highest priority first; stable by binding ref within a priority. - providers.sort_by_key(|(binding_ref, entry)| { - (core::cmp::Reverse(entry.priority), *binding_ref) - }); - let providers = providers - .iter() - .filter_map(|(binding_ref, _)| wire_index.get(binding_ref).copied()) - .collect(); - let consumers = self + // Channels list PER SCOPE (wire 6): same-named channels in + // different scopes are distinct entries, and display strings are a + // client concern. Sink scopes never list — the modeled R2 property, + // honored here by enumerating non-sink scopes rather than by + // filtering rows out after the fact. A scope-less tree (test + // harness without assigned scopes) falls back to the flat listing. + let scopes: Vec> = { + let non_sink: Vec<_> = self .tree() - .consumers_for_bus(&name) + .scopes() + .into_iter() + .filter(|scope| !scope.is_sink()) + .map(Some) + .collect(); + if non_sink.is_empty() { + alloc::vec![None] + } else { + non_sink + } + }; + let root_scope = self.tree().node_scope(self.tree().root()); + + let mut channels = Vec::new(); + for scope in scopes { + let scope_channels: Vec<(ChannelName, Kind)> = match scope { + Some(scope) => self.tree().scope_channels(scope), + None => self + .tree() + .bus_channels() + .map(|(name, kind)| (name.clone(), kind)) + .collect(), + }; + for (name, kind) in scope_channels { + // An engaged panel writer surfaces as a provider row with + // Panel origin — synthesized here (it lives in the side + // store, not in any node's binding set) so the UI can + // render engaged state from the same graph it already + // reads. + let panel_row = scope.and_then(|scope| { + self.panel_writers() + .get(scope, &name) + .map(|writer| WireEffectiveBinding { + owner: scope.owner(), + node: scope.owner(), + slot: None, + direction: WireBindingDirection::Publishes, + endpoint: WireBindingEndpoint::Literal { + value: writer.value.clone(), + }, + origin: WireBindingOrigin::Panel, + priority: BindingPriority::panel().as_i32(), + kind, + }) + }); + let panel_index = panel_row.map(|row| { + let index = bindings.len() as u32; + bindings.push(row); + index + }); + let mut providers = match scope { + Some(scope) => self.tree().providers_for_bus_in_scope(scope, &name), + None => self.tree().providers_for_bus(&name), + }; + // Highest priority first; stable by binding ref within a + // priority. + providers.sort_by_key(|(binding_ref, entry)| { + (core::cmp::Reverse(entry.priority), *binding_ref) + }); + let providers: Vec = panel_index + .into_iter() + .chain( + providers + .iter() + .filter_map(|(binding_ref, _)| wire_index.get(binding_ref).copied()), + ) + .collect(); + let consumers = match scope { + Some(scope) => self.tree().consumers_for_bus_in_scope(scope, &name), + None => self.tree().consumers_for_bus(&name), + } .iter() .filter_map(|(binding_ref, _)| wire_index.get(binding_ref).copied()) .collect(); - let value = request.include_values.then(|| { - match self.resolve_bus_channel_value(registry, &name) { - Ok(production) => WireBusChannelValue { - revision, - value: production.value_leaf().map(|leaf| leaf.value().clone()), - error: None, - }, - Err(error) => WireBusChannelValue { - revision, - value: None, - error: Some(format!("{error:?}")), - }, - } - }); - - channels.push(WireBusChannel { - name: name.0.clone(), - kind: Some(kind), - providers, - consumers, - value, - }); + let value = request.include_values.then(|| { + match self.resolve_bus_channel_value(registry, scope, &name) { + Ok(production) => WireBusChannelValue { + revision, + value: production.value_leaf().map(|leaf| leaf.value().clone()), + error: None, + }, + Err(error) => WireBusChannelValue { + revision, + value: None, + error: Some(format!("{error:?}")), + }, + } + }); + + // Root-scope role, decided engine-side ONCE: the primary + // visual is the root scope's listing of the vocabulary + // channel the root module's mirror reads. The name + // comparison lives only here, next to the vocabulary. + let primary_visual = name.0 == lpc_model::PRIMARY_VISUAL_CHANNEL + && (scope == root_scope || root_scope.is_none()); + channels.push(WireBusChannel { + scope: scope.map(wire_scope_ref), + name: name.0.clone(), + kind: Some(kind), + providers, + consumers, + value, + primary_visual, + }); + } } BindingGraphProbeResult::Graph(WireBindingGraph { @@ -233,6 +319,16 @@ fn linear_unorm16_to_srgb8(value: u16) -> u8 { /// exists, otherwise the produced source feeding a bus channel. A binding /// with no local slot (literal or bus-to-bus bridge publishing to a /// channel) anchors to its owner with no slot path. +/// Project an engine scope into its wire form. +fn wire_scope_ref(scope: crate::node::ScopeRef) -> lpc_wire::WireScopeRef { + match scope { + crate::node::ScopeRef::Module { owner } => lpc_wire::WireScopeRef::Module { owner }, + crate::node::ScopeRef::Sink { owner, entry } => { + lpc_wire::WireScopeRef::Sink { owner, entry } + } + } +} + fn wire_effective_binding(entry: &BindingEntry) -> WireEffectiveBinding { let origin = wire_binding_origin(entry.priority); let (node, slot, direction, endpoint) = match (&entry.source, &entry.target) { @@ -247,6 +343,7 @@ fn wire_effective_binding(entry: &BindingEntry) -> WireEffectiveBinding { Some(slot.clone()), WireBindingDirection::Publishes, WireBindingEndpoint::Bus { + scope: None, channel: channel.0.clone(), }, ), @@ -258,6 +355,7 @@ fn wire_effective_binding(entry: &BindingEntry) -> WireEffectiveBinding { None, WireBindingDirection::Publishes, WireBindingEndpoint::Bus { + scope: None, channel: channel.0.clone(), }, ), @@ -279,6 +377,8 @@ fn wire_effective_binding(entry: &BindingEntry) -> WireEffectiveBinding { fn wire_binding_origin(priority: BindingPriority) -> WireBindingOrigin { if priority == BindingPriority::default_fallback() { WireBindingOrigin::Default + } else if priority == BindingPriority::panel() { + WireBindingOrigin::Panel } else { WireBindingOrigin::Authored } @@ -294,6 +394,7 @@ fn wire_endpoint_from_source(source: &BindingSource) -> WireBindingEndpoint { slot: slot.clone(), }, BindingSource::BusChannel(channel) => WireBindingEndpoint::Bus { + scope: None, channel: channel.0.clone(), }, } @@ -367,7 +468,7 @@ mod tests { assert_eq!(provider.origin, WireBindingOrigin::Authored); assert!(matches!( &provider.endpoint, - WireBindingEndpoint::Bus { channel } if channel == "video" + WireBindingEndpoint::Bus { channel, .. } if channel == "video" )); assert_eq!( provider.slot.as_ref(), @@ -379,7 +480,7 @@ mod tests { assert_eq!(consumer.direction, WireBindingDirection::Consumes); assert!(matches!( &consumer.endpoint, - WireBindingEndpoint::Bus { channel } if channel == "video" + WireBindingEndpoint::Bus { channel, .. } if channel == "video" )); let value = channel.value.as_ref().expect("value requested"); 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 be36e772e..bd042e88c 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]), @@ -819,21 +819,24 @@ mod tests { // Mutate the overlay at revision 9; the next read must report it. // `mutate` validates like the batch path (W9), so the edit must be a - // real one: load a minimal project root and stage a value on `name`, - // ProjectDef's one writable authored field. + // real one: load a minimal root MODULE and stage an authored export. + // Post-mitosis `nodes` is role `Fixed` (dedicated ops own it) and the + // identity fields live in the container manifest, so `exports` is the + // writable authored map this probe can touch. let mut fs = lpfs::LpFsMemory::new(); - fs.write_file_mut( - lpfs::LpPath::new("/project.json"), - br#"{"kind": "Project", "format": 2}"#, - ) - .expect("write project root"); + // Both halves: the container manifest gates the load (D-A refuses a + // project without one), the root module carries the def. + fs.write_file_mut(lpfs::LpPath::new("/project.json"), br#"{"format": 3}"#) + .expect("write container manifest"); + fs.write_file_mut(lpfs::LpPath::new("/module.json"), br#"{"kind": "Module"}"#) + .expect("write root module"); let ctx = lpc_registry::ParseCtx { shapes: h.engine.slot_shapes(), }; h.registry .load_root( &fs, - lpfs::LpPath::new("/project.json"), + lpfs::LpPath::new("/module.json"), Revision::new(8), &ctx, ) @@ -842,10 +845,10 @@ 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::assign_value( - lpc_model::SlotPath::parse("name.some").expect("slot path"), - lpc_model::LpValue::String(alloc::string::String::from("overlay probe")), + lpc_model::SlotPath::parse("exports[glow]").expect("slot path"), + lpc_model::LpValue::String(alloc::string::String::from("bus:glow")), ), }, Revision::new(9), @@ -1192,6 +1195,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 } }"#, @@ -1331,7 +1339,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(), ) } @@ -1450,7 +1458,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)); @@ -1493,7 +1501,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)); @@ -1539,7 +1547,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)); @@ -1562,7 +1570,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)); @@ -1598,7 +1606,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..6ef35dd56 --- /dev/null +++ b/lp-core/lpc-engine/src/engine/scoped_resolution_tests.rs @@ -0,0 +1,544 @@ +//! 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" + ); +} + +#[test] +fn probe_lists_same_named_channels_as_distinct_scoped_rows() { + // Wire 6: channels list per scope, structured — two scopes using the + // same channel name are distinct rows keyed by (scope, name), and the + // probe never flattens scope into a display string. + 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]); + introduce_module_scope(&mut h, holder_a, &[writer_a, out_a]); + introduce_module_scope(&mut h, holder_b, &[writer_b, out_b]); + + h.tick(16).expect("tick"); + 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 rows: alloc::vec::Vec<_> = graph + .channels + .iter() + .filter(|channel| channel.name == "chan") + .collect(); + assert_eq!(rows.len(), 2, "one row per scope: {rows:?}"); + let scopes: alloc::vec::Vec<_> = rows.iter().map(|row| row.scope).collect(); + assert!( + scopes.contains(&Some(lpc_wire::WireScopeRef::Module { owner: holder_a })) + && scopes.contains(&Some(lpc_wire::WireScopeRef::Module { owner: holder_b })), + "rows carry structured scopes: {scopes:?}" + ); + for row in rows { + let value = row.value.as_ref().expect("value requested"); + let expected = if row.scope == Some(lpc_wire::WireScopeRef::Module { owner: holder_a }) { + 1.0 + } else { + 9.0 + }; + assert_eq!( + value.value, + Some(lpc_model::LpValue::F32(expected)), + "each row resolves in ITS scope" + ); + } +} + +#[test] +fn panel_writer_engages_shadows_and_clears() { + // panel.md P1-P4 at the resolution seam: engage -> the in-scope + // consumer resolves the panel value while an outer-scope consumer is + // unaffected; an engaged OUTER scope is inherited by a writerless + // inner scope (P5 shadowing composes); clear -> authored wiring again. + let mut h = EngineTestBuilder::new() + .shader("holder", output("outputs[0]", 0.0)) + .shader("root_writer", output("outputs[0]", 0.25)) + .bind_bus("chan", produced_slot("root_writer", "outputs[0]")) + .output_node("root_reader") + .bind_demand_input("root_reader", bus("chan")) + .demand_root("root_reader") + .output_node("inner_reader") + .bind_demand_input("inner_reader", bus("chan")) + .demand_root("inner_reader") + .build(); + + let holder = h.node("holder"); + let root_writer = h.node("root_writer"); + let root_reader = h.node("root_reader"); + let inner_reader = h.node("inner_reader"); + let root_scope = assign_root_scope(&mut h, &[root_writer, root_reader]); + let inner_scope = introduce_module_scope(&mut h, holder, &[inner_reader]); + + // Untouched: authored wiring; the writerless inner scope inherits it. + h.tick(16).expect("tick"); + assert_eq!(h.output_f32("root_reader"), Some(0.25)); + assert_eq!(h.output_f32("inner_reader"), Some(0.25)); + + // Engage in the INNER scope: its reader detaches; root unaffected. + h.engine.panel_write( + inner_scope, + lpc_model::ChannelName(alloc::string::String::from("chan")), + lpc_model::LpValue::F32(0.9), + None, + ); + h.tick(16).expect("tick"); + assert_eq!( + h.output_f32("inner_reader"), + Some(0.9), + "engaged scope reads its panel writer" + ); + assert_eq!( + h.output_f32("root_reader"), + Some(0.25), + "outer scope unaffected by an inner engage" + ); + + // Engage ROOT instead: the writerless inner scope inherits the panel + // writer through the same shadowing walk. + assert!(h.engine.panel_clear( + inner_scope, + &lpc_model::ChannelName(alloc::string::String::from("chan")) + )); + h.engine.panel_write( + root_scope, + lpc_model::ChannelName(alloc::string::String::from("chan")), + lpc_model::LpValue::F32(0.6), + None, + ); + h.tick(16).expect("tick"); + assert_eq!(h.output_f32("root_reader"), Some(0.6)); + assert_eq!( + h.output_f32("inner_reader"), + Some(0.6), + "a writerless scope inherits the enclosing panel writer" + ); + + // Clear: authored wiring returns everywhere. + assert!(h.engine.panel_clear( + root_scope, + &lpc_model::ChannelName(alloc::string::String::from("chan")) + )); + h.tick(16).expect("tick"); + assert_eq!(h.output_f32("root_reader"), Some(0.25)); + assert_eq!(h.output_f32("inner_reader"), Some(0.25)); +} + +#[test] +fn engaged_panel_writer_replaces_the_scopes_provider_set() { + // The settled ByKey decision: an engaged panel writer REPLACES the + // scope's provider set (max priority wins) — pinned at the host seam + // both merge expansion and single-select read, so map-kinded (ByKey) + // channels shadow instead of merging the panel value into authored + // providers. + let mut h = EngineTestBuilder::new() + .shader("writer_a", output("outputs[0]", 0.25)) + .shader("writer_b", output("outputs[0]", 0.75)) + .bind_bus("chan", produced_slot("writer_a", "outputs[0]")) + .bind_bus("chan", produced_slot("writer_b", "outputs[0]")) + .output_node("reader") + .bind_demand_input("reader", bus("chan")) + .demand_root("reader") + .build(); + + let writer_a = h.node("writer_a"); + let writer_b = h.node("writer_b"); + let reader = h.node("reader"); + let root_scope = assign_root_scope(&mut h, &[writer_a, writer_b, reader]); + + h.engine.panel_write( + root_scope, + lpc_model::ChannelName(alloc::string::String::from("chan")), + lpc_model::LpValue::F32(0.5), + None, + ); + h.tick(16).expect("tick"); + // Two authored providers would be AMBIGUOUS (equal priority) — the + // panel writer replacing the set is what makes this resolve at all. + assert_eq!( + h.output_f32("reader"), + Some(0.5), + "the engaged writer replaces the provider set outright" + ); +} + +#[test] +fn probe_reports_engaged_writers_with_panel_origin() { + // The probe host (the site that is easy to miss) must see the panel + // overlay: the engaged value resolves in the probe's value read AND + // surfaces as a Panel-origin provider row for the UI's engaged state. + let mut h = EngineTestBuilder::new() + .shader("writer", output("outputs[0]", 0.25)) + .bind_bus("chan", produced_slot("writer", "outputs[0]")) + .output_node("reader") + .bind_demand_input("reader", bus("chan")) + .demand_root("reader") + .build(); + let writer = h.node("writer"); + let reader = h.node("reader"); + let root_scope = assign_root_scope(&mut h, &[writer, reader]); + h.engine.panel_write( + root_scope, + lpc_model::ChannelName(alloc::string::String::from("chan")), + lpc_model::LpValue::F32(0.9), + None, + ); + h.tick(16).expect("tick"); + + 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 == "chan") + .expect("channel listed"); + let value = channel.value.as_ref().expect("value requested"); + assert_eq!( + value.value, + Some(lpc_model::LpValue::F32(0.9)), + "the probe's value read sees the panel overlay" + ); + let first = &graph.bindings[channel.providers[0] as usize]; + assert_eq!( + first.origin, + lpc_wire::WireBindingOrigin::Panel, + "the engaged writer leads the provider list with Panel origin" + ); +} + +#[test] +fn momentary_writers_despawn_on_ttl_expiry() { + // panel.md P14: gesture channels write while active and despawn past + // their renewal deadline — the despawn IS the release fallback for a + // dropped client. Renewal is just another write. + let mut h = EngineTestBuilder::new() + .shader("writer", output("outputs[0]", 0.25)) + .bind_bus("chan", produced_slot("writer", "outputs[0]")) + .output_node("reader") + .bind_demand_input("reader", bus("chan")) + .demand_root("reader") + .build(); + let writer = h.node("writer"); + let reader = h.node("reader"); + let root_scope = assign_root_scope(&mut h, &[writer, reader]); + let channel = lpc_model::ChannelName(alloc::string::String::from("chan")); + + h.engine.panel_write( + root_scope, + channel.clone(), + lpc_model::LpValue::F32(0.9), + Some(40), + ); + h.tick(16).expect("tick"); + assert_eq!( + h.output_f32("reader"), + Some(0.9), + "gesture writes while live" + ); + + // Renewal keeps it alive past the original deadline… + h.tick(16).expect("tick"); + h.engine.panel_write( + root_scope, + channel.clone(), + lpc_model::LpValue::F32(0.8), + Some(40), + ); + h.tick(16).expect("tick"); + assert_eq!(h.output_f32("reader"), Some(0.8)); + + // …and silence despawns it: the authored writer returns. + h.tick(60).expect("tick"); + assert!( + h.engine.panel_writers().get(root_scope, &channel).is_none(), + "expired gesture despawns" + ); + h.tick(16).expect("tick"); + assert_eq!( + h.output_f32("reader"), + Some(0.25), + "despawn IS the fallback — authored wiring returns" + ); +} + +#[test] +fn clear_all_reaches_sink_scopes() { + // Settled P-Q4: clear-all clears EVERYTHING, including a playlist + // entry's sink-scope latched value. + let mut h = EngineTestBuilder::new() + .shader("standin", output("outputs[0]", 0.0)) + .shader("writer", output("outputs[0]", 0.25)) + .bind_bus("chan", produced_slot("writer", "outputs[0]")) + .output_node("reader") + .bind_demand_input("reader", bus("chan")) + .demand_root("reader") + .build(); + let standin = h.node("standin"); + let writer = h.node("writer"); + let reader = h.node("reader"); + let root_scope = assign_root_scope(&mut h, &[standin, writer, reader]); + let sink = ScopeRef::Sink { + owner: standin, + entry: 3, + }; + let channel = lpc_model::ChannelName(alloc::string::String::from("chan")); + h.engine.panel_write( + root_scope, + channel.clone(), + lpc_model::LpValue::F32(0.9), + None, + ); + h.engine + .panel_write(sink, channel.clone(), lpc_model::LpValue::F32(0.7), None); + assert_eq!(h.engine.panel_writers().len(), 2); + + assert_eq!( + h.engine.panel_clear_all(), + 2, + "sink-scope writers clear too" + ); + assert!(h.engine.panel_writers().is_empty()); +} 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 e9eddbb8d..3f45b1a57 100644 --- a/lp-core/lpc-engine/src/features.rs +++ b/lp-core/lpc-engine/src/features.rs @@ -148,7 +148,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 a42744871..762a020b6 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; @@ -611,19 +609,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, } } @@ -636,27 +632,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, } } @@ -669,11 +658,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)] @@ -691,6 +675,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)] #[slot(default_role = "state")] @@ -736,6 +721,7 @@ mod tests { fn providers_for_bus( &self, + _scope: Option, channel: &lpc_model::ChannelName, ) -> Vec<(BindingRef, BindingEntry)> { self.entries @@ -774,9 +760,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) } } @@ -838,7 +825,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))); } @@ -971,17 +961,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(()) } @@ -1009,7 +996,10 @@ mod tests { let slot_shapes = SlotShapeRegistry::default(); let mut node = QueryResolvingNode { - query: QueryKey::Bus(channel), + query: QueryKey::Bus { + scope: None, + channel, + }, resolved_value: None, }; @@ -1069,55 +1059,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..66ee30797 100644 --- a/lp-core/lpc-engine/src/node/node_tree.rs +++ b/lp-core/lpc-engine/src/node/node_tree.rs @@ -306,6 +306,178 @@ 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 scope a node's bus READS resolve from: scope INTRODUCERS read + /// inward (R7 — 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. + pub fn bus_read_scope(&self, node: NodeId) -> Option { + self.scope_introduced_by(node) + .or_else(|| self.node_scope(node)) + } + + /// The channels listed in `scope` (R3 — a public slot's channel exists + /// in the slot's scope): a publishing endpoint lists where the write + /// lands (the owner's inhabited scope, R4), a consuming endpoint where + /// its resolution starts (the owner's read scope). Listing only — + /// resolution is the scoped-key walk. + 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 let crate::dataflow::binding::BindingTarget::BusChannel(channel) = &binding.target { + if self.node_scope(binding.owner) == Some(scope) + && !channels.iter().any(|(existing, _)| existing == channel) + { + channels.push((channel.clone(), binding.kind)); + } + } + if let crate::dataflow::binding::BindingSource::BusChannel(channel) = &binding.source { + if self.bus_read_scope(binding.owner) == Some(scope) + && !channels.iter().any(|(existing, _)| existing == channel) + { + channels.push((channel.clone(), binding.kind)); + } + } + } + channels.sort_by(|(a, _), (b, _)| a.cmp(b)); + channels + } + + /// Providers of `channel` whose write lands in `scope` — the LISTING + /// counterpart of the shadowing walk, for probes showing one scope's + /// wiring. + pub fn providers_for_bus_in_scope( + &self, + scope: crate::node::ScopeRef, + channel: &ChannelName, + ) -> Vec<(BindingRef, &BindingEntry)> { + self.providers_for_bus(channel) + .into_iter() + .filter(|(_, entry)| self.node_scope(entry.owner) == Some(scope)) + .collect() + } + + /// Consumers of `channel` whose read starts in `scope`. + pub fn consumers_for_bus_in_scope( + &self, + scope: crate::node::ScopeRef, + channel: &ChannelName, + ) -> Vec<(BindingRef, &BindingEntry)> { + self.consumers_for_bus(channel) + .into_iter() + .filter(|(_, entry)| self.bus_read_scope(entry.owner) == Some(scope)) + .collect() + } + + /// 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..e287ae2d9 --- /dev/null +++ b/lp-core/lpc-engine/src/node/scope.rs @@ -0,0 +1,81 @@ +//! 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 { .. }) + } + + /// Wire form of this scope. + pub fn to_wire(self) -> lpc_wire::WireScopeRef { + match self { + Self::Module { owner } => lpc_wire::WireScopeRef::Module { owner }, + Self::Sink { owner, entry } => lpc_wire::WireScopeRef::Sink { owner, entry }, + } + } + + /// Engine form of a wire scope reference. + pub fn from_wire(scope: lpc_wire::WireScopeRef) -> Self { + match scope { + lpc_wire::WireScopeRef::Module { owner } => Self::Module { owner }, + lpc_wire::WireScopeRef::Sink { owner, entry } => Self::Sink { owner, entry }, + } + } + + /// 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 0532ec517..41b21a560 100644 --- a/lp-core/lpc-engine/src/nodes/fixture/fixture_node.rs +++ b/lp-core/lpc-engine/src/nodes/fixture/fixture_node.rs @@ -395,14 +395,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; @@ -1558,14 +1558,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(()) } @@ -1641,14 +1641,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..ecc115fc4 --- /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_role = "state")] +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 9e8197ffe..6a639d764 100644 --- a/lp-core/lpc-engine/src/nodes/output/output_node.rs +++ b/lp-core/lpc-engine/src/nodes/output/output_node.rs @@ -187,14 +187,14 @@ impl NodeRuntime for OutputNode { self.publish_channel_buffer(ctx) } - 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 1ced92532..dfc89b08a 100644 --- a/lp-core/lpc-engine/src/nodes/placeholder/mod.rs +++ b/lp-core/lpc-engine/src/nodes/placeholder/mod.rs @@ -36,9 +36,9 @@ impl NodeRuntime for CorePlaceholderNode { /// A placeholder standing in for a GATEABLE kind reports the build gap. /// /// Two other placeholder shapes must stay silent: the synthetic spine - /// folder (`kind: None`), and the project ROOT — spelled - /// `new_leaf(NodeKind::Project)`, an always-on kind whose placeholder - /// means "the root has no behavior", never "this build lacks Project". + /// folder (`kind: None`), and the root MODULE — spelled + /// `new_leaf(NodeKind::Module)`, an always-on kind whose placeholder + /// means "the root has no behavior", never "this build lacks Module". /// [`LpFeature::for_node_kind`] draws exactly that line: a kind with no /// feature can never be gated out. fn runtime_status(&self) -> Option { @@ -47,14 +47,14 @@ impl NodeRuntime for CorePlaceholderNode { .map(|_| NodeRuntimeStatus::Unsupported(Self::unsupported_kind_message(kind))) } - 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 d4f859475..695e63579 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 @@ -320,14 +320,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 5b569dcd5..488526164 100644 --- a/lp-core/lpc-engine/src/nodes/shader/shader_node.rs +++ b/lp-core/lpc-engine/src/nodes/shader/shader_node.rs @@ -359,14 +359,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..893d8c887 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, + }, 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..8e8cdabd4 --- /dev/null +++ b/lp-core/lpc-engine/tests/structural_scope.rs @@ -0,0 +1,592 @@ +//! 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" + ); +} + +#[test] +fn panel_writer_survives_apply_project_changes() { + // The side-store's reason to exist: apply_project_changes rebuilds + // ALL bindings from defs (clear + re-register), and an engaged panel + // writer must ride through untouched — still engaged, still winning. + let fs = project_fs(); + let rt = load(&fs); + let (mut engine, mut registry) = rt.into_parts(); + let root_scope = engine + .tree() + .node_scope(engine.tree().root()) + .expect("root scope"); + let channel = lpc_model::ChannelName(String::from("time")); + engine.panel_write( + root_scope, + channel.clone(), + lpc_model::LpValue::F32(42.0), + None, + ); + + 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!( + engine.panel_writers().get(root_scope, &channel).is_some(), + "the engaged writer survives the binding rebuild" + ); + let winners = engine + .tree() + .providers_for_bus_in_scope(root_scope, &channel); + // The authored clock target still exists in the tree… + assert!(!winners.is_empty()); + // …but the probe's value read (the resolve path) still answers with + // the engaged value. + let result = engine.read_project_binding_graph_probe( + ®istry, + lpc_wire::BindingGraphProbeRequest { + include_values: true, + }, + ); + let lpc_wire::BindingGraphProbeResult::Graph(graph) = result else { + panic!("expected graph result"); + }; + let row = graph + .channels + .iter() + .find(|row| row.name == "time" && !row.scope.is_none()) + .expect("scoped time row"); + assert_eq!( + row.value.as_ref().and_then(|value| value.value.clone()), + Some(lpc_model::LpValue::F32(42.0)), + "the panel value still wins after apply" + ); +} 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 b200aca4c..b96912b81 100644 --- a/lp-core/lpc-model/src/lib.rs +++ b/lp-core/lpc-model/src/lib.rs @@ -110,22 +110,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, - FixturePower, FixtureSamplingConfig, FixtureState, FixtureStateView, FloatMode, FluidDef, - FluidDefView, FluidEmitter, FluidState, InvocationSite, LampType, 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, FixturePower, FixtureSamplingConfig, FixtureState, FixtureStateView, + FloatMode, FluidDef, FluidDefView, FluidEmitter, FluidState, InvocationSite, LampType, + 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, @@ -141,9 +140,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 831a301fa..6229e7428 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, @@ -24,7 +24,7 @@ impl NodeKind { /// variant without extending it is caught by /// [`tests::all_is_total_and_in_declaration_order`]. pub const ALL: [NodeKind; 11] = [ - NodeKind::Project, + NodeKind::Module, NodeKind::Button, NodeKind::Clock, NodeKind::Texture, @@ -49,7 +49,7 @@ mod tests { fn all_is_total_and_in_declaration_order() { const fn index_in_all(kind: NodeKind) -> usize { match kind { - NodeKind::Project => 0, + NodeKind::Module => 0, NodeKind::Button => 1, NodeKind::Clock => 2, NodeKind::Texture => 3, diff --git a/lp-core/lpc-model/src/nodes/mod.rs b/lp-core/lpc-model/src/nodes/mod.rs index 88b1043d0..0b87e2453 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, LampType, 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..452dad1c7 --- /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(role = "fixed")] + 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_fixed() { + use crate::{SlotRole, 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.role, SlotRole::Fixed); + } + + #[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 986618be2..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(role = "fixed")] - 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(role = "fixed")] - 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(role = "fixed")] - 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_fixed_name_is_setting() { - use crate::{SlotRole, SlotShape, StaticSlotShape}; - - let SlotShape::Record { fields, .. } = crate::ProjectDef::slot_shape() else { - panic!("project def shape must be a record"); - }; - let role = |name: &str| { - fields - .iter() - .find(|field| field.name.as_str() == name) - .unwrap_or_else(|| panic!("{name} field")) - .role - }; - assert_eq!(role("format"), SlotRole::Fixed); - assert_eq!(role("nodes"), SlotRole::Fixed); - assert_eq!(role("name"), SlotRole::Setting); - } - - 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 57787e857..baf426742 100644 --- a/lp-core/lpc-model/src/slot/slot_shape_registry.rs +++ b/lp-core/lpc-model/src/slot/slot_shape_registry.rs @@ -899,7 +899,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 221db8080..99006f40d 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` //! `Fixed` role promises: node creation and removal never //! arrive as raw slot edits, they arrive here. Both address the node through //! a [`NodeAttachSite`] — either the role-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(), @@ -545,7 +545,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(), @@ -829,7 +829,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 @@ -839,7 +839,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!( @@ -848,7 +848,7 @@ mod tests { "attachment rewrite must be canonical-writer output" ); let invocation = project - .as_project() + .as_module() .unwrap() .nodes .entries @@ -858,7 +858,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") ); @@ -951,25 +951,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, @@ -986,24 +989,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")); } @@ -1035,7 +1036,7 @@ mod tests { .stage_dedicated_op( &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), @@ -1094,7 +1095,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, @@ -1126,7 +1127,7 @@ mod tests { body.as_bytes(), &[], &NodeAttachSite::Slot { - artifact: ArtifactLocation::file("/project.json"), + artifact: ArtifactLocation::file("/module.json"), path: SlotPath::parse("nodes[new]").unwrap(), }, ) @@ -1266,7 +1267,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!( @@ -1382,13 +1383,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()); } @@ -1460,7 +1461,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(), }, }]; @@ -1538,7 +1539,7 @@ mod tests { &mut registry, &shapes, &NodeAttachSite::Slot { - artifact: ArtifactLocation::file("/project.json"), + artifact: ArtifactLocation::file("/module.json"), path: SlotPath::parse("nodes[clock]").unwrap(), }, ) @@ -1579,12 +1580,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" } } @@ -1604,12 +1605,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" } } @@ -1641,12 +1642,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" } } @@ -1671,12 +1672,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" } @@ -1704,12 +1705,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" } } @@ -1762,7 +1763,7 @@ mod tests { registry .load_root( fs, - LpPath::new("/project.json"), + LpPath::new("/module.json"), Revision::new(1), &ParseCtx { shapes }, ) @@ -1818,11 +1819,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 70ecbe0a1..934e09d35 100644 --- a/lp-core/lpc-registry/src/registry/project_registry.rs +++ b/lp-core/lpc-registry/src/registry/project_registry.rs @@ -9,11 +9,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, SlotRoleResolution, SlotShapeLookup, SlotShapeView, StaticSlotShape, StoredSlotEdit, WithRevision, - lookup_slot_data, lp_value_matches_type, read_project_format_json, resolve_slot_role, - slot::SlotPersistence, + lookup_slot_data, lp_value_matches_type, resolve_slot_role, slot::SlotPersistence, }; use lpfs::{FsEvent, FsEventKind, LpFs, LpPath}; @@ -35,6 +34,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(), @@ -51,9 +57,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()); @@ -64,32 +70,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, }) } } @@ -1645,15 +1658,16 @@ 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 - // role `Fixed` — only the loader format gate / future upgrader - // (format) and dedicated project ops (nodes, Studio authoring M2) - // own them. The role 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: `ModuleDef.nodes` carries role `Fixed` — only + // dedicated project ops own it, and the role inherits into the + // subtree (map entries, option interior). 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 role 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, @@ -1661,55 +1675,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")); } @@ -2216,7 +2228,7 @@ mod tests { .stage_dedicated_op( &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), @@ -2327,7 +2339,7 @@ mod tests { .stage_dedicated_op( &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), @@ -3681,12 +3693,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" } } @@ -3705,7 +3717,7 @@ mod tests { registry .load_root( &fs, - lpfs::LpPath::new("/project.json"), + lpfs::LpPath::new("/module.json"), Revision::new(1), &ParseCtx { shapes }, ) @@ -3722,12 +3734,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" } } @@ -3747,7 +3759,7 @@ mod tests { registry .load_root( &fs, - lpfs::LpPath::new("/project.json"), + lpfs::LpPath::new("/module.json"), Revision::new(1), &ParseCtx { shapes }, ) @@ -3759,12 +3771,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" } } @@ -3783,7 +3795,7 @@ mod tests { registry .load_root( &fs, - lpfs::LpPath::new("/project.json"), + lpfs::LpPath::new("/module.json"), Revision::new(1), &ParseCtx { shapes }, ) @@ -3801,12 +3813,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" } } @@ -3840,7 +3852,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 e51e63b03..5d4d99b1d 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) { @@ -516,20 +519,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/lib.rs b/lp-core/lpc-wire/src/lib.rs index 48d5eef29..14e3fc3bf 100644 --- a/lp-core/lpc-wire/src/lib.rs +++ b/lp-core/lpc-wire/src/lib.rs @@ -36,7 +36,7 @@ pub use messages::{ ResourcePayloadRead, ResourceReadQuery, ResourceReadResult, RuntimeReadQuery, RuntimeReadResult, ServerRuntimeStatus, ShapeReadQuery, ShapeReadResult, WireBindingDirection, WireBindingEndpoint, WireBindingGraph, WireBindingOrigin, WireBusChannel, WireBusChannelValue, - WireEffectiveBinding, + WireEffectiveBinding, WireScopeRef, }; pub use messages::{ClientMessage, ClientRequest, Message, ServerMessage}; pub use project::{ @@ -47,7 +47,8 @@ pub use project::{ }; pub use project_command::{ WireCreateNodeRequest, WireCreateNodeResponse, WireNodeCommand, WireNodeCommandResponse, - WireProjectCommand, WireProjectCommandResponse, WireRemoveNodeRequest, WireRemoveNodeResponse, + WirePanelClearRequest, WirePanelCommandResponse, WirePanelWriteRequest, WireProjectCommand, + WireProjectCommandResponse, WireRemoveNodeRequest, WireRemoveNodeResponse, }; pub use project_inventory::{ WireProjectInventoryReadRequest, WireProjectInventoryReadResponse, diff --git a/lp-core/lpc-wire/src/messages/mod.rs b/lp-core/lpc-wire/src/messages/mod.rs index 2faf40264..8442b9820 100644 --- a/lp-core/lpc-wire/src/messages/mod.rs +++ b/lp-core/lpc-wire/src/messages/mod.rs @@ -17,5 +17,5 @@ pub use project_read::{ ResourcePayloadRead, ResourceReadQuery, ResourceReadResult, RuntimeReadQuery, RuntimeReadResult, ServerRuntimeStatus, ShapeReadQuery, ShapeReadResult, WireBindingDirection, WireBindingEndpoint, WireBindingGraph, WireBindingOrigin, WireBusChannel, WireBusChannelValue, - WireEffectiveBinding, + WireEffectiveBinding, WireScopeRef, }; diff --git a/lp-core/lpc-wire/src/messages/project_read/mod.rs b/lp-core/lpc-wire/src/messages/project_read/mod.rs index 1d7f9e825..cd94e0c41 100644 --- a/lp-core/lpc-wire/src/messages/project_read/mod.rs +++ b/lp-core/lpc-wire/src/messages/project_read/mod.rs @@ -32,7 +32,7 @@ pub use probe::{ ControlProductProbeResultHeader, ProjectProbeRequest, ProjectProbeResult, ProjectProbeResultHeader, RenderProductProbeRequest, RenderProductProbeResult, RenderProductProbeResultHeader, WireBindingDirection, WireBindingEndpoint, WireBindingGraph, - WireBindingOrigin, WireBusChannel, WireBusChannelValue, WireEffectiveBinding, + WireBindingOrigin, WireBusChannel, WireBusChannelValue, WireEffectiveBinding, WireScopeRef, }; pub use project_read_event::{ ProjectReadEvent, ProjectReadNodeEvent, ProjectReadProbeEvent, ProjectReadQueryEvent, 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..3cc6ee609 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 @@ -83,13 +83,49 @@ pub enum WireBindingDirection { Publishes, } +/// Structured identity of one bus scope on the wire (modules.md R1/R2). +/// +/// Owners are runtime node ids from the same tree the probe ships, so +/// clients key scoped channels structurally and derive any display string +/// themselves (the engine never flattens scope into a label). +#[derive( + Debug, Clone, Copy, Eq, PartialEq, PartialOrd, Ord, serde::Serialize, serde::Deserialize, +)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +#[serde(rename_all = "snake_case", tag = "kind")] +pub enum WireScopeRef { + /// The scope a module node introduces around its project children. + Module { owner: NodeId }, + /// The anonymous sink scope one playlist entry wraps its child in. + /// Sink channels never surface in probe listings; the variant exists + /// so endpoints can still name one structurally. + Sink { owner: NodeId, entry: u32 }, +} + +impl WireScopeRef { + pub fn owner(&self) -> NodeId { + match self { + Self::Module { owner } | Self::Sink { owner, .. } => *owner, + } + } + + pub fn is_sink(&self) -> bool { + matches!(self, Self::Sink { .. }) + } +} + /// The remote side of an effective binding. #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum WireBindingEndpoint { - /// A bus channel by name. - Bus { channel: String }, + /// A bus channel, scoped: for a publishing endpoint the scope the + /// write lands in; for a consuming endpoint the scope its resolution + /// starts from (writer-shadowing walks outward from there, R5). + Bus { + scope: Option, + channel: String, + }, /// Another node's slot. NodeSlot { node: NodeId, slot: SlotPath }, /// An authored literal value. @@ -103,6 +139,8 @@ pub enum WireBindingEndpoint { pub enum WireBindingOrigin { /// Authored in project data. Authored, + /// An engaged panel writer (lazy runtime state, never authored). + Panel, /// Materialized from default binding policy (fallback priority). Default, } @@ -111,6 +149,11 @@ pub enum WireBindingOrigin { #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] pub struct WireBusChannel { + /// The scope this channel entry lists in (R3: a channel exists in the + /// scope of the slots bound to it). Same-named channels in different + /// scopes are distinct entries. `None` only from scope-less engines + /// (test fakes). + pub scope: Option, /// Channel name (`time`, `trigger`, `visual.out`, …). pub name: String, /// Established channel kind, when any binding declared one. @@ -123,6 +166,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 @@ -155,6 +202,9 @@ mod tests { slot: Some(SlotPath::parse("trigger").unwrap()), direction: WireBindingDirection::Consumes, endpoint: WireBindingEndpoint::Bus { + scope: Some(WireScopeRef::Module { + owner: NodeId::new(0), + }), channel: "trigger".to_string(), }, origin: WireBindingOrigin::Authored, @@ -162,6 +212,9 @@ mod tests { kind: Kind::Instant, }], channels: vec![WireBusChannel { + scope: Some(WireScopeRef::Module { + owner: NodeId::new(0), + }), name: "trigger".to_string(), kind: Some(Kind::Instant), providers: vec![], @@ -171,6 +224,7 @@ mod tests { value: None, error: None, }), + primary_visual: false, }], }; let result = BindingGraphProbeResult::Graph(graph.clone()); @@ -185,6 +239,10 @@ mod tests { fn endpoint_variants_round_trip() { for endpoint in [ WireBindingEndpoint::Bus { + scope: Some(WireScopeRef::Sink { + owner: NodeId::new(3), + entry: 7, + }), channel: "visual.out".to_string(), }, WireBindingEndpoint::NodeSlot { diff --git a/lp-core/lpc-wire/src/messages/project_read/probe/mod.rs b/lp-core/lpc-wire/src/messages/project_read/probe/mod.rs index 3c1489c00..0c93364c5 100644 --- a/lp-core/lpc-wire/src/messages/project_read/probe/mod.rs +++ b/lp-core/lpc-wire/src/messages/project_read/probe/mod.rs @@ -8,6 +8,7 @@ mod render_product_probe; pub use binding_graph_probe::{ BindingGraphProbeRequest, BindingGraphProbeResult, WireBindingDirection, WireBindingEndpoint, WireBindingGraph, WireBindingOrigin, WireBusChannel, WireBusChannelValue, WireEffectiveBinding, + WireScopeRef, }; pub use control_product_probe::{ ControlDisplayLayoutProbeResult, ControlDisplayLayoutRead, ControlProductProbeRequest, 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/project_command/mod.rs b/lp-core/lpc-wire/src/project_command/mod.rs index c6ac1e8f1..2c1f35803 100644 --- a/lp-core/lpc-wire/src/project_command/mod.rs +++ b/lp-core/lpc-wire/src/project_command/mod.rs @@ -2,10 +2,12 @@ mod create_node; mod node_command; +mod panel_command; mod project_command; mod remove_node; pub use create_node::{WireCreateNodeRequest, WireCreateNodeResponse}; pub use node_command::{WireNodeCommand, WireNodeCommandResponse}; +pub use panel_command::{WirePanelClearRequest, WirePanelCommandResponse, WirePanelWriteRequest}; pub use project_command::{WireProjectCommand, WireProjectCommandResponse}; pub use remove_node::{WireRemoveNodeRequest, WireRemoveNodeResponse}; diff --git a/lp-core/lpc-wire/src/project_command/panel_command.rs b/lp-core/lpc-wire/src/project_command/panel_command.rs new file mode 100644 index 000000000..29a1a5c79 --- /dev/null +++ b/lp-core/lpc-wire/src/project_command/panel_command.rs @@ -0,0 +1,109 @@ +//! Panel commands: the `(scope, channel)` write surface for panel +//! controls (panel.md P8). +//! +//! Panel writes are runtime state, never authoring: like the node command +//! channel (`docs/adr/2026-07-27-runtime-node-command-channel.md`) they +//! touch no overlay, no pending edit, and no dirty flag — which is also +//! what sidesteps `PendingEdit` value-shadowing and lets multiple clients +//! converge through ordinary probe pulls (last writer wins, panel.md P9). +//! Unlike node commands they address a SCOPE, not a node, so they are +//! project-level arms. + +use alloc::string::String; +use lpc_model::LpValue; + +use crate::WireScopeRef; + +/// Engage (or update) the panel writer for `(scope, channel)`. +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct WirePanelWriteRequest { + /// The scope the write lands in (the control's home scope). + pub scope: WireScopeRef, + /// The channel the control drives. + pub channel: String, + /// The value to hold. + pub value: LpValue, + /// Momentary liveness (panel.md P14): when present, the writer + /// DESPAWNS unless renewed within this many milliseconds — renewal is + /// simply another write. Absent = latching writer (holds until an + /// explicit clear). The TTL/renewal shape follows the design reference + /// in the punted node-actions direction, not its wire form. + pub ttl_ms: Option, +} + +/// Clear engaged panel writers (panel.md P3). +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WirePanelClearRequest { + /// Clear one control. + Channel { + scope: WireScopeRef, + channel: String, + }, + /// Clear every engaged control in one scope. + Scope { scope: WireScopeRef }, + /// Clear everything — sink scopes included (settled P-Q4: a playlist + /// entry's latched value clears too). + All, +} + +/// Outcome of a panel command. Rejection is a NORMAL response (unknown +/// scope, dead runtime): a stale gesture must never poison the connection. +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WirePanelCommandResponse { + /// Accepted; `engaged` is the store's total engaged-writer count after + /// the command (drives the ↺ superscript without another read). + Accepted { + engaged: u32, + }, + Rejected { + reason: String, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::string::ToString; + use lpc_model::NodeId; + + #[test] + fn panel_write_round_trips() { + let request = WirePanelWriteRequest { + scope: WireScopeRef::Module { + owner: NodeId::new(4), + }, + channel: "speed".to_string(), + value: LpValue::F32(0.5), + ttl_ms: Some(1000), + }; + let json = serde_json::to_string(&request).unwrap(); + let back: WirePanelWriteRequest = serde_json::from_str(&json).unwrap(); + assert_eq!(back, request); + } + + #[test] + fn panel_clear_variants_round_trip() { + for request in [ + WirePanelClearRequest::Channel { + scope: WireScopeRef::Sink { + owner: NodeId::new(2), + entry: 7, + }, + channel: "speed".to_string(), + }, + WirePanelClearRequest::Scope { + scope: WireScopeRef::Module { + owner: NodeId::new(0), + }, + }, + WirePanelClearRequest::All, + ] { + let json = serde_json::to_string(&request).unwrap(); + let back: WirePanelClearRequest = serde_json::from_str(&json).unwrap(); + assert_eq!(back, request); + } + } +} diff --git a/lp-core/lpc-wire/src/project_command/project_command.rs b/lp-core/lpc-wire/src/project_command/project_command.rs index 860caebfe..46696d099 100644 --- a/lp-core/lpc-wire/src/project_command/project_command.rs +++ b/lp-core/lpc-wire/src/project_command/project_command.rs @@ -39,6 +39,15 @@ pub enum WireProjectCommand { node: NodeId, command: WireNodeCommand, }, + /// Engage/update a panel writer at `(scope, channel)` — runtime state, + /// no overlay, no dirty (see [`crate::WirePanelWriteRequest`]). + PanelWrite { + request: crate::WirePanelWriteRequest, + }, + /// Clear engaged panel writers (see [`crate::WirePanelClearRequest`]). + PanelClear { + request: crate::WirePanelClearRequest, + }, } /// Project command response. @@ -66,6 +75,12 @@ pub enum WireProjectCommandResponse { NodeCommand { response: WireNodeCommandResponse, }, + PanelWrite { + response: crate::WirePanelCommandResponse, + }, + PanelClear { + response: crate::WirePanelCommandResponse, + }, } #[cfg(test)] diff --git a/lp-core/lpc-wire/src/server/hello.rs b/lp-core/lpc-wire/src/server/hello.rs index 3d706169c..040f1c906 100644 --- a/lp-core/lpc-wire/src/server/hello.rs +++ b/lp-core/lpc-wire/src/server/hello.rs @@ -33,6 +33,15 @@ use serde::{Deserialize, Serialize}; /// /// # History /// +/// - 7: structured scope on the probe surface — `WireBusChannel` gains +/// `scope` (channels list per scope) and `WireBindingEndpoint::Bus` +/// carries the endpoint's scope; display strings become a client +/// concern. Same train: `WireProjectCommand::PanelWrite`/`PanelClear` +/// (the `(scope, channel)` panel command surface) and +/// `WireBindingOrigin::Panel`. +/// - 6: node kind `project` renamed to `module` — `NodeKind::Module` +/// serializes as `"Module"` in inventory frames, and `TreePath` +/// segments carry `.module` instead of `.project`. /// - 5: `ServerHello` reshaped into distinct build and hardware facts. /// `fw: FwProvenance` is gone: its four provenance fields moved onto /// `build: BuildFacts` alongside the build's `LpFeature` list, and @@ -50,7 +59,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 = 5; +pub const WIRE_PROTO_VERSION: u32 = 7; /// Unsolicited/boot-time server identity, version, and capability 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-emu/manifest-core.expected.json b/lp-fw/fw-emu/manifest-core.expected.json index f9c618a0e..f997c316a 100644 --- a/lp-fw/fw-emu/manifest-core.expected.json +++ b/lp-fw/fw-emu/manifest-core.expected.json @@ -18,5 +18,5 @@ "gfx.lpvm" ], "limits": {}, - "wireProto": 5 + "wireProto": 7 } diff --git a/lp-fw/fw-esp32c6/manifest-core.expected.json b/lp-fw/fw-esp32c6/manifest-core.expected.json index 5def6bca8..ba340c55d 100644 --- a/lp-fw/fw-esp32c6/manifest-core.expected.json +++ b/lp-fw/fw-esp32c6/manifest-core.expected.json @@ -22,5 +22,5 @@ "limits": { "flashAppBytes": 3145728 }, - "wireProto": 5 + "wireProto": 7 } diff --git a/lp-fw/fw-esp32s3/manifest-core.expected.json b/lp-fw/fw-esp32s3/manifest-core.expected.json index 03a584de0..0d8ee070d 100644 --- a/lp-fw/fw-esp32s3/manifest-core.expected.json +++ b/lp-fw/fw-esp32s3/manifest-core.expected.json @@ -22,5 +22,5 @@ "limits": { "flashAppBytes": 6291456 }, - "wireProto": 5 + "wireProto": 7 } diff --git a/lp-fw/fw-esp32v3/manifest-core.expected.json b/lp-fw/fw-esp32v3/manifest-core.expected.json index 7d1910eff..557a136bc 100644 --- a/lp-fw/fw-esp32v3/manifest-core.expected.json +++ b/lp-fw/fw-esp32v3/manifest-core.expected.json @@ -14,5 +14,5 @@ "limits": { "flashAppBytes": 3145728 }, - "wireProto": 5 + "wireProto": 7 } diff --git a/lp-fw/fw-host/src/host_runtime.rs b/lp-fw/fw-host/src/host_runtime.rs index 4e770b6fd..a81783462 100644 --- a/lp-fw/fw-host/src/host_runtime.rs +++ b/lp-fw/fw-host/src/host_runtime.rs @@ -206,11 +206,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-equal100-v3/module.json b/projects/test/quad-equal100-v3/module.json new file mode 100644 index 000000000..2d6711830 --- /dev/null +++ b/projects/test/quad-equal100-v3/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-equal100-v3/project.json b/projects/test/quad-equal100-v3/project.json index 78f43f3fb..11e06821c 100644 --- a/projects/test/quad-equal100-v3/project.json +++ b/projects/test/quad-equal100-v3/project.json @@ -1,37 +1,4 @@ { - "kind": "Project", - "format": 2, - "name": "Quad equal 100 v3", - "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" - } - } -} \ No newline at end of file + "format": 3, + "name": "Quad equal 100 v3" +} diff --git a/projects/test/quad-gamma-full/module.json b/projects/test/quad-gamma-full/module.json new file mode 100644 index 000000000..2d6711830 --- /dev/null +++ b/projects/test/quad-gamma-full/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-gamma-full/project.json b/projects/test/quad-gamma-full/project.json index e2d9ce235..9717d0161 100644 --- a/projects/test/quad-gamma-full/project.json +++ b/projects/test/quad-gamma-full/project.json @@ -1,37 +1,4 @@ { - "kind": "Project", - "format": 2, - "name": "Quad gamma full", - "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" - } - } -} \ No newline at end of file + "format": 3, + "name": "Quad gamma full" +} diff --git a/projects/test/quad-gamma-v3/module.json b/projects/test/quad-gamma-v3/module.json new file mode 100644 index 000000000..2d6711830 --- /dev/null +++ b/projects/test/quad-gamma-v3/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-gamma-v3/project.json b/projects/test/quad-gamma-v3/project.json index 342792735..e07dcbaa0 100644 --- a/projects/test/quad-gamma-v3/project.json +++ b/projects/test/quad-gamma-v3/project.json @@ -1,37 +1,4 @@ { - "kind": "Project", - "format": 2, - "name": "Quad gamma v3", - "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" - } - } -} \ No newline at end of file + "format": 3, + "name": "Quad gamma v3" +} 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-v3/module.json b/projects/test/quad-strips-v3/module.json new file mode 100644 index 000000000..2d6711830 --- /dev/null +++ b/projects/test/quad-strips-v3/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-v3/project.json b/projects/test/quad-strips-v3/project.json index 503e83d2e..f3f53015d 100644 --- a/projects/test/quad-strips-v3/project.json +++ b/projects/test/quad-strips-v3/project.json @@ -1,37 +1,4 @@ { - "kind": "Project", - "format": 2, - "name": "Quad strips v3", - "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 v3" } 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/projects/test/quad60-v3/module.json b/projects/test/quad60-v3/module.json new file mode 100644 index 000000000..2d6711830 --- /dev/null +++ b/projects/test/quad60-v3/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/quad60-v3/project.json b/projects/test/quad60-v3/project.json index afd4330a9..579831abe 100644 --- a/projects/test/quad60-v3/project.json +++ b/projects/test/quad60-v3/project.json @@ -1,37 +1,4 @@ { - "kind": "Project", - "format": 2, - "name": "Quad 60 v3", - "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" - } - } -} \ No newline at end of file + "format": 3, + "name": "Quad 60 v3" +} diff --git a/schemas/README.md b/schemas/README.md index 5b8b870f7..2e24519f6 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. | @@ -56,7 +57,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 646202c8d..dffaee6ae 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..ed17c4515 --- /dev/null +++ b/schemas/shapes/lpc_model.nodes.module.module_def.ModuleDef.json @@ -0,0 +1,106 @@ +{ + "record": { + "fields": [ + { + "name": "nodes", + "role": "fixed", + "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": {} + } +}